前端模块化

149 阅读12分钟

从 commonjs -> AMD、CMD -> ES Module 等模块化方案等发展,前端构建工具也发生了很大等变化。毕竟都2022年了,基于 esm 的 snowpack、vite 都流行起来了,大有取代 Webpack 的趋势。那么抛开这些构建工具不谈,是否了解这些前面提到的模块化方案呢?

其实不管是 commonjs 还是 ES Module,其本质都是为了更好的管理变量,但不同平台的实现不同,下面让我们一起深入 ♂ 了解这些模块化的原理和实现。

commonjs

CommonJS 是以在浏览器环境之外构建 JavaScript 生态系统为目标而产生的项目,比如node

特点

  1. CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作
  2. 每个文件就是一个模块,有自己的作用域,所有代码都运行在模块作用域,不会污染全局作用域。
  3. 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  4. 模块加载的顺序,按照其在代码中出现的顺序。
  5. 输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值(对象除外)
demo1
// index.js
console.log('index.js')
const {add} = require('./add');
let a = add(1,2)
console.log(a)

// add.js
console.log('add.js');
exports.add = (a, b) => a+ b;

输出

index.js
add.js
3
demo2
// a.js
var b = require('./b');
console.log(b.sayFoo());
setTimeout(() => {
  console.log(b.sayFoo());
  console.log(require('./b').sayFoo());
}, 1000);

// b.js
let foo = 1;
setTimeout(() => {
  foo = 2;
}, 500);

module.exports = {
  foo,
  sayFoo: () => {
    return foo;
  },
};

执行:node a.js

// 执行结果:
1 1
2 1
2 1

注意 b.foo 的值一直都是 1

commonjs 的 module对象

Node内部提供一个Module构建函数。所有模块都是Module的实例。

属性

  1. module.id 模块的识别符,通常是带有绝对路径的模块文件名
  2. module.filename 模块的文件名,带有绝对路径。
  3. module.loaded 返回一个布尔值,表示模块是否已经完成加载
  4. module.parent 返回一个对象,表示调用该模块的模块
  5. module.children 返回一个数组,表示该模块要用到的其他模块
  6. module.exports 表示模块对外输出的值。

exports 变量:指向 module.exports,相当于每个模块顶部都一句 var exports = module.exports;

实现原理

这个过程比较简单,可以参考 CommonJS 模块化简易实现,本文的重点是浏览器方案。

大致过程

  1. require -> (调用)Module._load
  2. 检查 Module._cache,是否缓存之中有指定模块
  3. 如果缓存之中没有,就创建一个新的Module实例
  4. 将它保存到缓存 Module._cache
  5. 使用 module.load() 加载指定的模块文件,读取文件内容
  6. 调用 module.compile(),传入(exports, require, module)等参数,在沙箱中执行代码
  7. 如果加载/解析过程报错,就从缓存删除该模块
  8. 返回该模块的 module.exports

浏览器方案

AMD

Asynchronous Module Definition,异步模块定义,AMD是 RequireJS 在推广过程中对模块定义的规范化产出。是在浏览器端模块化开发的方案。

特点
  1. AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块
  2. 模块就在完成后就会立即执行主逻辑,获取导出的数据。(所以依赖中那个先加载完就先执行,但是主模块一定在依赖执行完后执行)
说明

规范只定义了一个函数 "define",它是全局变量。函数的描述为:

define(id?, dependencies?, factory);
  • id: 模块名,默认为请求时指定的脚本名称
  • dependencies: 需要的依赖
  • factory: 模块的内容,函数or对象
用法
define("alpha", ["require", "exports", "beta"], function (require, exports, beta) {
  exports.verb = function() {
    return beta.verb();
    // or:
    return require("beta").verb();
  }
});

require规范的说明参考

require.js

遵守AMD规范的客户端模块管理库

主要解决两个问题

  1. 多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
  2. js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长

说明

实现了define和require两个定义模块、调用模块的方法

// index.js
require.config({}) // 初始化配置

require(['a.js', 'b.js'], function (a,  b) { ... })

// a.js
defind(['c.js'], function () { ... })

实现一个简单demo

require源码流程分析

简单流程图如下

  1. require([xx], function)开始,首先会创建模块对象module,包含 id,depends,factory,exprots 等信息,用于管理模块的依赖,以及加载和执行。同时这个模块会注册在 enabledRegistry 全局变量中,表示注册但还未激活的模块(重点,后面会用到)
  2. 接着初始化模块代码
  3. 如果有依赖且依赖没加载完,会通过 script 标签去加载依赖模块,然后会等待依赖加载完成(通过订阅-发布模式,事件通过模块的id订阅)在继续执行模块代码
  4. script 加载完依赖代码,通过 defind 记录的模块信息,在 onScriptLoad 的回调中注册这个模块,然后同样初始化模块代码,如果有依赖,则重复3去循环加载依赖
  5. 如果没有依赖,那么就跳过步骤3,直接执行模块的代码,并将输出保存到 module.exports 中,并且将执行完的模块保存到全局变量 defined 中,然后从 enabledRegistry 中删除自己,然后以模块 id 为事件名发布这个模块已经完成的事件
  6. 父级模块收到子模块加载完成的事件后,会判断所有依赖 depends 是否都加载完(根据id在 defined 查找),如果没加载完就继续等待,否则执行函数的方法。这样一层层往上执行,直到所有模块加载并执行完,就会执行最初 require 中的那个 function
循环依赖处理

如果加载的某个子模块,又引用了父级模块,如

// a.js
define(['modules/b.js'], function (b) {    
  console.log('a.js: b is ', b);   
  return {        
    a: 1    
  }
})


// b.js
define(['modules/a.js'], function (a) {    
  console.log('b.js: a is ', a);   
  return {        
    b: 'b.js'   
  }
})


// index.js 
require(['modules/a.js', 'modules/b.js'], function (a, b) { 
  console.log('index.js: a is ', a, ' b is ', b)
})

当最低层的依赖 b.js 加载完后,判断依赖 a.js 还没执行完,也会继续等待(此时 a.js 还在 enabledRegistry 中,而不再 registry 中),于是所有函数就都不会执行。

require.js中的处理方式是

  1. 在每个模块加载完成后( onScriptLoad 中的最后)都会调用 checkLoaded 函数。
  2. 其中会从 enabledRegistry 中取出所有 require 方式定义的模块,然后进行依赖递归检查,递归的时候,会记录下之前递归的所有依赖
  3. 当递归到 b.js 的时候,此时已经记录了 [main.js, a,js],但发现 b.js 的依赖 a.js 已经被递归过了,但没激活,那么直接取 a.js.exports 的值给 b.js 去执行(由于未初始化完成 a.js.exportsundefined ),接着回到步骤5,最终的输出结果:

image.png

CMD

Common Module Definition,通用模块定义,CMD是 SeaJS 在推广过程中对模块定义的规范化产出(参考)。与require.js解决的问题一样,只不过定义方式和模块加载时机上不同

特点
  1. CMD推崇依赖就近,所以一般不在define的参数中写依赖,在factory中写
  2. 一个文件一个模块,所以经常就用文件名作为模块id
  3. 惰性执行,与commonjs一样,执行主模块的时候,遇到require语句才执行对应的模块
说明
  1. 模块定义

    define(factory); // factory的参数为 (require, exports, module)
    
  2. 模块加载

    require(id) 
    require.async(id, callback?) // 模块内部异步加载模块
    
例子
// add.js
define(function (require, exports) {
  exports.add = function (a, b) {
    return a +b;
  }
})

// index.js
seajs.use('./add.js', function (a) {
  var sum = a.add(1,2); // 3
})

seajs

遵循CMD规范的模块加载化开发库

注: 2.2.1的时候还未解决循环依赖

主要实现两个基本功能

  1. 实现模块定义规范(define)
  2. 模块系统的启动与运行(seajs.use)

实现一个简单demo

源码分析

相比require.js, seajs稍微好理解点,代码两也少一些,写起来比较像在写node

简单流程图如下

  1. seajs.use('xx.js', function (x) {}) ,首先也会创建模块的实例module,并创建一个回调函数 callback(入口module,后面用 entry 指代他,调用 module.load
  2. 接着调用 module.pass ,更根据路径实例化子模块,并将 entry 保存在依赖实例 _entry 中,每次传递完都清空自身的 _entry ,每次传递都会将待加载的模块数量累加到 entry.remain ,然后 fetch 去加载依赖的代码
  3. 加载完成,执行define,define中会调用 parseDependencies 收集代码中 require 的依赖 depends ,保存在全局变量 anonymousMeta ,一起保存的还有执行函数 facroty
  4. 执行script回调,将 facrotydepends 一起保存在之前的模块实例中,然后执行 module.load
  5. 如果有依赖,则执行步骤2
  6. 没有依赖,则2中的 _entry 没有被清空,就会调用 _entry 中保存的模块入口 entry.onload
  7. onload中,--entry.remain ,判断依赖是否都加载完成(entry.remain === 0),是则执行callback ,否则等待其他依赖调用
  8. callback 按顺序调用依赖的 exec ,然后执行入口的 function
循环依赖处理

看demo

// a.js
define(function (require, exports, module) {    
  console.log('a.js loader');   
  exports.a = 'a.js not ready yet';    
  let b = require('./b.js');    // 这里引用了b
  let c = require('./c.js');
  console.log('a.js: b is', b, ' - c is ', c);
  exports.a = 'a.js';
})

// b.js
define(function (require, exports, module) {    
  console.log('b.js loader');    
  let a = require('./a.js');   // 此处a还没加载完成
  console.log('b.js: a is ', a);    
  module.exports = {        
    b: 'b.js'    
  }
})

// c.js
define(function (require, exports, module) {    
  console.log('c.js loader');    
  module.exports = {        
    c: 'c.js'    
  }
})


//index.js
seajs.use('./modules/a.js', function (a) {    
  console.log('index.js: a ', a)
});

seajs的主要处理逻辑都在Mdoule.prototype.pass中,在pass中,会将(起始use中定义的模块(entry)传递到子依赖中,当模块module在pass中遍历子依赖时,会发生一下几种情况:

  1. 子依赖还没加载,则保存到子依赖的_entry中,并且entry.histroy中记录下子依赖的id,并将记录了entry的子模块数量累加到entry.remain中(此时remain还要减1,表示当前模块module已经加载了),接着清空模块module._entry,所以此时外面不会进入onload,即对应上面的a.js,此时的一些数据如下

    entry.remian = 2
    entry.history = {
      'a.js': true,
      'b.js': true,
      'c.js': true
    }
    
    a._entry = []
    b._entry = [entry]
    c._entry = [entry]
    
  2. 如果模块没有子依赖,也就不会执行步骤1中的场景,所以外面判断_entry存在则调用onload,即对应demo中的c.js,执行完onload之后,此时的一些数据如下

    entry.remian = 1
    entry.history = {
      'a.js': true,
      'b.js': true,
      'c.js': true
    }
    
    a._entry = []
    b._entry = [entry] // 等待b.js处理
    c._entry = []
    
  3. 如果子模块依赖了父依赖(循环引用),也就时在entry.histroy中记录了,也不会执行步骤1,所以外面也会执行onload,即对应上面demo中的b.js的场景,执行完onload之后,此时的一些数据如下

    entry.remian = 1
    entry.history = {
      'a.js': true,
      'b.js': true,
      'c.js': true
    }
    
    a._entry = []
    b._entry = []
    c._entry = [entry] // 等待c.js执行
    
  4. 所以当第2或第3部执行完后,只要等另外一个依赖加载完,remain就能置为0,然后调用entry.callback()

结果如下

总结

到最后其实可以看出来sea.js和require.js的实现还有有些不同的,require.js通过发布-订阅的方式,通知父模块它的子依赖加载完成,这样父模块才能执行其factory,获取exports,然后一层层的往上通知,最后传递到入口(require(xxx, function))。

而seajs由于不需要执行父模块,最终需要执行的就只有入口的那个函数(seajs.use(xxx, function)),所以通过将回调传递到未加载的子依赖中,等子依赖执行完了直接调用即可。 很明显,seajs也可以用过发布-订阅的方式去实现,只要require.js中不执行父依赖的factory,等使用到的时候在执行就行了。

当然,require.js也可以改造成sea.js的方式加载依赖,但不能直接传递入口函数了,而是要封装一下, 感觉实现起来就没那么优雅了。

Browserify

browserify,用于改写现有的CommonJS模块,使得浏览器端也可以使用这些模块

特点

见阮一峰老师的文章,这里简单概括下

  1. 将CommonJS模块转成客户端也能使用的模块
  2. 可以实时生成,也就是可以在发送请求的时候才生成模块代码

现在主要是用webpack做构建了,这东西也没怎么接触,不详细讲

es module

es6在语言层面上实现的模块化方案,前面提到的commonjs和AMD、CMD都是社区制定了一些模块加载方案。

特点

  1. 编译时确定依赖关系,所以不能这么写import a from 'a' + '.js',但commonjs可以
  2. 模块脚本自动采用严格模式,不管有没有声明use strict
  3. 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
  4. 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
  5. 同一个模块如果加载多次,将只执行一次。
  6. ES6 模块输出的是值的引用,导出都是以 "活动绑定" 的方式处理(这一点对于循环依赖的模块的执行很有帮助)
  7. 编译时,就执行模块获取输出

例子

//a.mjs
import {bar} from './b.mjs';
console.log('a.js');
console.log(bar);
export let foo = 'foo'

// b.mjs
import {foo} from './a.mjs';
console.log('b.js');
// console.log('a, foo:', foo); 
// 报错, 初始化完成前不能使用 Cannot access 'foo' before initialization
setTimeout(() => {    
  console.log('a, foo:', foo); // 正常输出
})
export let bar = 'bar';

具体解析过程参考:深入理解 ES Modules

扩展阅读

参考资料