前端模块化原理
前言
本文会从前端模块化的演进历史,来逐个分析不同的模块化规范的特性与实现原理,希望通过本文的学习,大家能够彻底弄懂前端模块化的实现原理。看完本文可以掌握,以下几个方面:
- 为什么需要模块化,什么是模块化。
- CommonJS规范的实现原理与加载原理
- ESModule 实现原理与加载原理
混乱的前端时期
早期在没有打包工具的情况下,很多情况下大家会直接在对应的文件下申明变量与引入依赖,从而造成一些变量污染和依赖引用混乱的问题,线上也会出现一些神奇的 bug。比如
var name = 'this is index.js';
var outPutMyName = () => {
console.log(name);
}
var name = 'this is home.js';
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>test page</title>
</head>
<body>
<script src="./index.js"></script>
<script src="./home.js"></script>
<script>
// outputMyName 不仅可以调用,还会输出 this is home.js
outputMyName();
</script>
</body>
</html>
随着前端项目的复杂度越来越高,从早期的 100 行的脚本变成成千上万行脚本,这种变量污染与依赖引用混乱的问题极大的制约了前端开发的效率,从而前端的模块化开发方案应运而生。
什么是模块化
模块化就是将一个复杂的程序依据一定的规则或者说是规范,将其封装成几个单独的块(这里的块指的就是文件),在使用的时候将其组合在一起。
块内部的数据是私有的,只是向外部暴露一些接口或者说是一些方法,让其与其他模块进行通信。
由于在早期社区各个大佬的思路各不统一,先先后后出现了不少模块化的解决方案,这里我们讲一下几个使用比较广泛的模块化规范
CommonJS
CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS规范。Node 是 CommonJS在服务器端一个具有代表性的实现。正是因为Node中对CommonJS进行了支持和实现,所以它具备以下几个特点:
- 在 commonjs 中每一个 js 文件都是一个单独的模块,我们可以称之为 module;
- 该模块中,包含CommonJS规范的核心变量: exports、module.exports、require
- exports 和 module.exports 可以负责对模块中的内容进行导出;
- require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;
简单使用
// 文件 name.js
const name = 'this is index.js'
const outputName = () => {
console.log(name);
}
module.exports = {
outputName,
}
// 文件 index.js
const { outputName } = require('./name.js');
const main = () => {
console.log('this is index.js');
outputName();
}
exports = main;
那么它是如何解决变量污染的问题的?module、exports、require 又是如何工作的呢?
实现原理
细心的同学已经发现,在每一个文件中 module、exports、require 是没有经过定义的,按照 JS 的执行逻辑,未定义的变量会直接报错,那么为什么在 Node 的 CommonJS 中可以直接使用呢?因为在实际的编译过程中,实际 Commonjs 对 js 的代码块进行了首尾包装,以我们上面的代码为例,实际的效果是这样的
(function(exports,require,module){
const { outputName } = require('name.js');
const main = () => {
console.log('this is index.js');
outputName();
}
exports = main;
})
到这里我们就解答了第一个问题,commonJS 是如何解决变量污染的问题。在 JS 中,函数在执行的过程中会创建自己的私有作用域,外部是无法访问的(这也是我们常说的 JS 闭包),所以就不会存在文章最开始的不同文件加载,导致同一个变量被污染。在 Node 实际的运行过程中,读取到的一个文件只是一个字符串,那么我们还需要运行当前的字符串,所以在函数包装的本质是会有一个 wrapper 函数进行统一的包装处理
function wrapper (script) {
return '(function (exports, require, module) {' +
script +
'\n})'
}
对应到上面的代码,实际的执行效果就是
const moduleFunctionStr = wrapper(`
const { outputName } = require('./name.js');
const main = () => {
console.log('this is index.js');
outputName();
}
exports = main;
`);
// 实际的情况并不是简单的 eval
eval(moduleFunction)(module.exports, require, module)
require 工作机制
通过上面的原理分析,我们知道 module、exports、require 是被注入进来的函数。其中 require 函数接收一个变量,返回值为 module.exports 的值。那么我们就可以简单的设计一下 require 函数
const require = (id) => {
// id 拿到的可能是相对路径,也有可能是node_modules的依赖,也有可能是 node 的基础模块、根据不同的情况读取到不同的地址ID,确保唯一性
const realId = formatIdToPath(id);
const fileStr = getFileStr(realId);
const modlue = {
exports: {};
};
wrapper(fileStr)(module.exports, require, module);
return modlue.exports;
}
简单来说,就是读取到对应的代码,通过上诉的 wrapper 包装一下,并且用我们申明好的 module 与 require 注入,利用引用数据类型的特性将执行后的返回值返回。但是这里有一个明显的问题,文件会被反复读取,这样会造成性能的极大浪费,那不妨我们再加一层缓存,在上诉代码基础上稍作改动。
const Module = {
cache: {},
}
const require = (id) => {
// id 拿到的可能是相对路径,也有可能是node_modules的依赖,也有可能是 node 的基础模块、根据不同的情况读取到不同的地址ID,确保唯一性
const realId = formatIdToPath(id);
if (Module.cache[id]) {
return Module.cache[id].exports;
}
const fileStr = getFileStr(realId);
const modlue = {
exports: {};
};
Module.cache[id] = module;
wrapper(fileStr)(module.exports, require, module);
return modlue.exports;
}
现在我们来总结一下 require 的工作流
- require 会接收一个参数——文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,加下来会从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。
- 如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件,加载完文件。
- 模块导出就是 return 这个变量的其实跟 a = b 赋值一样, 基本类型导出的是值, 引用类型导出的是引用地址。
- exports 和 module.exports 持有相同引用,因为最后导出的是 module.exports, 所以对 exports 进行赋值会导致 exports 操作的不再是 module.exports 的引用。
exports 和 module.exports
通过上面的分析,我们可以知道 exports 其实就是 module 上面的一个属性,且 exports 会被初始化为一个空对象。下面简单列一下二者的使用场景
// module.exports
module.exports = function() { ... }
module.exports = {
name: '张三',
say: () => { ... }
}
// exports
exports.name = '张三';
exports.say = () => {}
那么问题来了?既然有了 exports,为何又出了 module.exports?
如果我们不想在 commonjs 中导出对象,而是只导出一个类或者一个函数再或者其他属性的情况,那么 module.exports 就更方便了,如上我们知道 exports 会被初始化成一个对象,也就是我们只能在对象上绑定属性,但是我们可以通过 module.exports 自定义导出出对象外的其他类型元素。且 exports 是没法像 module.exports 直接导出一个对象的。比如
// a.js
exports = {
x: 1,
}
// b.js
var a = require('./a');
console.log(a); // 输出 {}
ESmodule
Nodejs 借鉴了 Commonjs 实现了模块化 (Node v13.2.0 起开始正式支持 ES Modules 特性),虽然我们可以通过各种构建工具,将 CommonJS 的模块化方案应用到 web 端,但 CommonJS 并不是 javaScript 官方的模块化规范,直到 ES6 的出现,JavaScript 才真正意义上有自己的模块化规范:ESmodule。
ESmodule 的出发点与 CommonJS 有一些不同,它除了提供模块化的解决方案外,更注重代码的静态分析能力,毕竟在 web 端 JS Bundle 的体积会直接影响页面访问速度。所以 ESmodule 相对 CommonJS 有很多优势,比如
- 借助 ESmodule 的静态导入导出的优势,能够很方便的实现 tree shaking。
- 现代浏览器可以直接支持ESmodule代码(Vite 就是利用了一特性)
简单使用
export const count = 1;
export const name = '张三';
const age = 12;
export { age };
export default () => {
console.log(cont);
}
export * from 'module' // 将module 中的内容导出,但是不包含 default
import consoleCount, { count, name as homeName, age } from './home.js'
import * as homeData from './home.js';
整体来说 ESmodule 具有以下几个特点
-
静态语法
ES6 module 的引入和导出是静态的,import 会自动提升到代码的顶层 ,import , export 不能放在块级作用域或条件语句中。这种静态语法,在编译过程中确定了导入和导出的关系,所以更方便去查找依赖,更方便去 tree shaking (摇树) , 可以使用 lint 工具对模块依赖进行检查,可以对导入导出加上类型信息进行静态的类型检查。
-
执行特性
ES6 module 和 Common.js 一样,对于相同的 js 文件,会保存静态属性。但是与 Common.js 不同的是 ,CommonJS 模块同步加载并执行模块文件,ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历,执行顺序是子 -> 父。
// main.js console.log('main.js开始执行') import say from './a' import say1 from './b' console.log('main.js执行完毕') // a.js import b from './b' console.log('a模块加载') export default function say (){ console.log('hello , world') } // b.js console.log('b模块加载') export default function sayhello(){ console.log('hello,world') }
执行 main.js 会输出
b模块加载 a模块加载 main.js开始执行 main.js执行完毕
相同的代码,我们用 CommonJS 实现
// main.js console.log('main.js开始执行') const a = require('./a'); const b = require('./b'); console.log('main.js执行完毕') // a.js require('./b') console.log('a模块加载') // b.js console.log('b模块加载')
执行 main 会输出
main.js开始执行 b模块加载 a模块加载 main.js执行完毕
-
只读属性
import { num , addNumber } from './a' num = 2 // 报错,导入变量是只读的
但是如果是引用数据类型,其实是可以改变其属性,并且会影响所有引用的地方。但是:千万别做么做!!!
// a.js export const obj = { x: 1} // b.js import obj from './a.js'; obj.x = 2; // main.js import { obj } from './a.js'; import './b.js' console.log(obj); // 输出 { x: 2 }
-
属性绑定
ESmodule 中则是值的动态映射,并且这个映射是只读的。这与CommonJS 值拷贝是不一样的。比如
// a.js export var count = 0; export default () => { count += 1; }; // main.js import addCount, { count } from './a' console.log(count); // 0 addCount(); console.log(count); // 1
同样的代码我们拿 CommonJS 实现一遍
// a.js var count = 0; module.exports = { count, addCount: () => { count += 1; } } // main.js const { count, addCount } require('./a') console.log(count); // 0 addCount(); console.log(count); // 0
实现原理
通过上面的分析我们简单总结一下 ESmodule 的特性
- 使用 import 被导入的模块运行在严格模式下。
- 使用 import 被导入的变量是只读的,可以理解默认为 const 装饰,无法被赋值
- 使用 import 被导入的变量是与原变量绑定/引用的,可以理解为 import 导入的变量无论是否为基本类型都是引用传递。
那如果由我们设计这样的模块化方案,我们应该怎么设计呢 ?我们可以通过 webpack 的产物来窥探其中的秘密。
首先,我们可以用 webpack 搭建一个简单的项目。
// a.js
console.log('加载a 文件');
export const data = 'aaa';
export default function () {
console.log(data);
}
// index.js
console.log('开始加载主入口');
import sayData, { data } from './modules/a'
sayData()
console.log(data);
console.log('结束加载主入口');
我们通过上面的分析,可以知道处理 ESmodule 其实是分了两步,第一步是预处理阶段,静态分析依赖编译,第二步是执行阶段。通过编译上诉的代码,在bundle 里面的表现为
(() => {
var __webpack_modules__ = ({
"./src/index.js": ((module, __webpack_exports__, __webpack_require__) => {
// 这里是通过引用调用与 CommonJS 的差异点就在这里
var _modules_a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modules/a */ "./src/modules/a.js");
console.log('开始加载主入口');
(0,_modules_a__WEBPACK_IMPORTED_MODULE_0__["default"])();
console.log(_modules_a__WEBPACK_IMPORTED_MODULE_0__.data);
console.log('结束加载主入口');
}),
"./src/modules/a.js": ((module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.d(__webpack_exports__, {
"data": () => (/* binding */ data),
"default": () => (/* export default binding */ __WEBPACK_DEFAULT_EXPORT__)
});
console.log('加载a 文件');
const data = 'aaa';
function __WEBPACK_DEFAULT_EXPORT__() {
console.log(data);
}
}),
})
})
将代码简化后,我们可以了解到,文件的内容都存储在 webpack_modules_ 这个对象上,文件的引用通过 webpack_require 导入,文件导出通过 webpack_require.d 导出,那么我们接下来分析一下 webpack_require 这个函数的实现。
__webpack_require__与 CommonJS 中的 require 实现类似,同样接收一个id,返回这个id 对应内容的导出内容。其实内部的实现也是类似的
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
if (cachedModule.error !== undefined) throw cachedModule.error;
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
// Execute the module function
try {
// 这里被我修改了,实际的运行要比这个复杂的多
var factory = __webpack_modules__[moduleId];
factory(module, module.exports, __webpack_require__)
} catch(e) {
module.error = e;
throw e;
}
// Return the exports of the module
return module.exports;
}
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
__webpack_require__.d = (exports, definition) => {
for(var key in definition) {
if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
// 设置其不可改属性
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};
总结
现在我们再来一起回顾一下 CommonJS 与 ESmodule 的一些特性。
-
CommonJS
- CommonJS 模块由 JS 运行时实现。
- CommonJs 是单个值导出,本质上导出的就是 exports 属性。
- CommonJS 是可以动态加载的,对每一个加载都存在缓存,可以有效的解决循环引用问题。
- CommonJS 模块同步加载并执行模块文件。
- CommonJS 导出是浅拷贝赋值,无法读取到修改后的值。但是可异步获取。
-
ESmodule
- ES6 Module 静态的,不能放在块级作用域内,代码发生在编译时。
- ES6 Module 的值是动态映射的,可以通过导出方法修改,可以直接访问修改结果。
- ES6 Module 可以导出多个属性和方法,可以单个导入导出,混合导入导出。
- ES6 模块提前加载并执行模块文件,采用深度优先遍历,执行顺序是子 -> 父。
- ES6 Module 导入模块在严格模式下。
- ES6 Module 的特性可以很容易实现 Tree Shaking 和 Code Splitting。
其它模块化规范
-
AMD
AMD是英文Asynchronous Module Definition(异步模块定义)的缩写,它是由JavaScript社区提出的专注于支持浏览器端模块化的标准。从名字就可以看出它与CommonJS和ES6 Module最大的区别在于它加载模块的方式是异步的。下面的例子展示了如何定义一个AMD模块。
define('getSum', ['calculator'], function(math) { return function(a, b) { console.log('sum' + calculator.add(a, b)) } })
-
UMD
严格来说,UMD并不能说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境(当时ES6 Module还未被提出)。
(function(global, main) { // 根据当前环境采取不同的导出方式 if (typeof define === 'function' && defind.amd) { // AMD define(...) } else if (typeof exports === 'object') { // CommonJS module.exports = ... } else { global.add = ... } })(this, function() { // 定义模块主体 return {...} })
-
CMD
CMD(Common Module Definition - 通用模块定义)规范主要是Sea.js推广中形成的,一个文件就是一个模块,可以像Node.js一般书写模块代码。主要在浏览器中运行,当然也可以在Node.js中运行。
它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。
// model1.js define(function (require, exports, module) { console.log('model1 entry'); exports.getHello = function () { return 'model1'; } }); // model2.js define(function (require, exports, module) { console.log('model2 entry'); exports.getHello = function () { return 'model2'; } }); // main.js define(function(require, exports, module) { var model1 = require('./model1'); //在需要时申明 console.log(model1.getHello()); var model2 = require('./model2'); //在需要时申明 console.log(model2.getHello()); }); <script src="https://cdn.bootcss.com/seajs/3.0.3/sea.js"></script> <script> seajs.use('./main.js') </script> // 输出 // model1 entry // model1 // model2 entry // model2
参考文档
转载自:https://juejin.cn/post/7172433802877206541