模块化

JS


# CommonJS

NodeJS 实现了 CommonJS 规范,以 NodeJS 为例来看 CommonJS 规范的实现原理

# 基本用法

// a.js 模块导出
let a = 1
let add = function () {
  a++
}
module.exports = {
  a,
  add,
}

// b.js 模块引入
const { a, add } = require('./a')
console.log(a) // 1
add()
console.log(a) // 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 特点

  • 利用 module.exports 或者 exports 来导出模块内部的接口
    • exports === module.exports
    • 模块内部不能使用 exports = 'abc' 的方式导出接口,这样做会导致失去原有引用
  • 利用 require 方法来引入导出的模块接口
    • require 是同步导入模块的
    • require 是动态导入,该语句可以出现在模块内部的任何位置
    • require 引入的模块会被缓存,多次导入相同模块,模块代码只会在第一次加载时执行一次,所以导入之后,后续即便模块内部导出的值发生了改变,导入的值也不会改变;想要更新值,必须重新导入
  • Module
    • 每个模块内部都有__dirname__filenamemodulerequireexports几个顶层变量
    • 模块内部的 module 属性是一个对象,指向当前模块自身

# Module

Module 的实现 (opens new window)

根据规范,每个文件就是一个模块,每个模块都会有一个 module 对象,这个对象指向当前模块自身,它们都是 Module 的实例。

function Module(id = '', parent) {
  this.id = id // 当前模块的id
  this.path = path.dirname(id)
  this.exports = {} // 表示当前模块暴露给外部的值
  // 缓存引入当前的父模块,即引入当前模块的模块
  // 比如 a 引入了 b,那 a 就是 b 模块的父模块
  moduleParentCache.set(this, parent)
  updateChildren(parent, this, false)
  this.filename = null // 模块的绝对路径
  this.loaded = false // 一个布尔值,表示当前模块是否已经被完全加载
  this.children = [] // 是一个对象,表示当前模块调用的模块
}
1
2
3
4
5
6
7
8
9
10
11
12

# require

用于引入模块的方法

Module.prototype.require = function (id) {
  // 当前模块去require其他模块的时候,parent就指向当前模块
  return Module._load(id, this, false)
}
1
2
3
4

# Module._load 静态方法

该方法在 require 内部进行调用,用于查找并加载模块,并返回模块的 exports 对象

Module._load = function (request, parent, isMain) {
  // 1. 如果module在缓存中已经存在,就直接返回它的exports属性
  const cachedModule = Module._cache[filename]
  return cachedModule.exports

  // 2. 如果module是一个原生模块
  const filename = Module._resolveFilename(request, parent, isMain) // 模块查找
  if (StringPrototypeStartsWith(filename, 'node:')) {
    const id = StringPrototypeSlice(filename, 5)
    const module = loadNativeModule(id, request)
    return module.exports
  }

  // 3. 否则,就创建一个新的module并缓存起来,并在模块load之后再返回
  const cachedModule = Module._cache[filename]
  const module = cachedModule || new Module(filename, parent)
  // 调用原型上的load方法去加载模块
  module.load(filename)
  return module.exports
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# load

load 完成之后,会将实例的 load 属性置为 true,表示模块已成功加载完成

此时模块实例的 exports 属性上就有了值

Module.prototype.load = function (content, filename) {
  this.filename = filename
  this.paths = Module._nodeModulePaths(path.dirname(filename))

  const extension = findLongestRegisteredExtension(filename)
  // 根据扩展名来加载模块
  Module._extensions[extension](this, filename)
  this.loaded = true
}
1
2
3
4
5
6
7
8
9

# 处理不同扩展名的文件

这里只关注针对.js文件的处理,内部调用了原型上的_compile 方法

// .js模块
Module.prototype._extensions['.js'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8')
  module._compile(content, filename)
}
// .json模块
Module.prototype._extensions['.json'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8')
  module.exports = JSONParse(stripBOM(content))
}
// .node
Module.prototype._extensions['.node'] = function (module, filename) {}
1
2
3
4
5
6
7
8
9
10
11
12

# _compile

接收模块内容和模块路径两个参数,并通过vm.runInThisContext (opens new window)vm.compileFunction (opens new window)保证在正确的 scope 或沙箱中运行模块内的代码

并将辅助变量 require、module、exports、__dirname、__filename 暴露给模块文件。

Module.prototype._compile = function (content, filename) {
  const compiledWrapper = wrapSafe(filename, content, this)

  const dirname = path.dirname(filename)
  const require = makeRequireFunction(this, redirects)
  let result
  const exports = this.exports
  const thisValue = exports
  const module = this
  // 这里相当于 compiledWrapper.apply(thisValue, [exports, require, module, filename, dirname])
  result = ReflectApply(compiledWrapper, thisValue, [
    exports,
    require,
    module,
    filename,
    dirname,
  ])
  return result
}

function wrapSafe(filename, content, cjsModuleInstance) {
  if (patched) {
    // 这里的wrapper其实就是
    // '(function (exports, require, module, __filename, __dirname) {' + content + '\n})'
    const wrapper = Module.wrap(content)

    return vm.runInThisContext(wrapper, {
      filename,
      lineOffset: 0,
      displayErrors: true,
      importModuleDynamically: async (specifier) => {
        const loader = asyncESM.ESMLoader
        return loader.import(specifier, normalizeReferrerURL(filename))
      },
    })
  }
  return vm.compileFunction(
    content,
    ['exports', 'require', 'module', '__filename', '__dirname'],
    {
      filename,
      importModuleDynamically(specifier) {
        const loader = asyncESM.ESMLoader
        return loader.import(specifier, normalizeReferrerURL(filename))
      },
    }
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

所以模块加载的本质,其实就是

  • 读取文件的内容(字符串)
  • 然后和包装器 wrapper 进行字符串拼接后,得到如下形式的一串字符:
'(function (exports, require, module, __filename, __dirname) {' +
  模块源码 +
  '\n})'
1
2
3
  • 然后将其通过 vm 模块的runInThisContext或者compileFunction方法去执行,执行结果类似于我们用 Fucntion 构造器去创建一个函数实例,最终就将拼接的字符串生成为了一个函数
  • 最后通过调用这个生成的函数,并将所需要的参数依次传入,就得到最终的导出结果 -exports对象

# ESM

# 基本用法

// a.js
export let a = 1
export let add = function() {
  a++
}

export default {
  name: 'a'
}

// b.js
import moduleA, { a, add } from './a
console.log(a) // 1
add()
console.log(a) // 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 特点

  • 利用 export 或 export default 来导出内部接口
    • export 语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值
    • export 可以出现在模块内的任何位置,只要是在最外层就可以,不能是代码块中
  • 利用 import 来导入模块接口
    • import 是静态导入,导入的代码是在编译期间完成的,而不是在运行时
    • import 语句也只能放到模块的最外层位置
    • import 语句会执行所加载的模块
    • 重复引入某个相同的模块时,模块只会执行一次

# import()

ES2020 提案 (opens new window) 引入 import()函数,支持在运行时动态加载模块。

import() 返回一个 Promise 对象

// 使用示例
import(`./section-modules/${someVariable}.js`)
  .then((module) => {
    module.loadPageInto(main)
  })
  .catch((err) => {
    main.textContent = err.message
  })
1
2
3
4
5
6
7
8

# 循环依赖

以下内容引自《ECMAScript6 入门》阮一峰 (opens new window)

# CommonJS 模块的循环加载

CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

让我们来看,Node 官方文档里面的例子。脚本文件 a.js 代码如下。

exports.done = false
var b = require('./b.js')
console.log('在 a.js 之中,b.done = %j', b.done)
exports.done = true
console.log('a.js 执行完毕')
1
2
3
4
5

上面代码之中,a.js 脚本先输出一个 done 变量,然后加载另一个脚本文件 b.js。注意,此时 a.js 代码就停在这里,等待 b.js 执行完毕,再往下执行。

再看 b.js 的代码。

exports.done = false
var a = require('./a.js')
console.log('在 b.js 之中,a.done = %j', a.done)
exports.done = true
console.log('b.js 执行完毕')
1
2
3
4
5

上面代码之中,b.js 执行到第二行,就会去加载 a.js,这时,就发生了“循环加载”。系统会去 a.js 模块对应对象的 exports 属性取值,可是因为 a.js 还没有执行完,从 exports 属性只能取回已经执行的部分,而不是最后的值。

a.js 已经执行的部分,只有一行。

exports.done = false
1

因此,对于 b.js 来说,它从 a.js 只输入一个变量 done,值为 false。

然后,b.js 接着往下执行,等到全部执行完毕,再把执行权交还给 a.js。于是,a.js 接着往下执行,直到执行完毕。我们写一个脚本 main.js,验证这个过程。

var a = require('./a.js')
var b = require('./b.js')
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done)
1
2
3

执行 main.js,

$ node main.js
1

运行结果如下

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
1
2
3
4
5

上面的代码证明了两件事。

  • 在 b.js 之中,a.js 没有执行完毕,只执行了第一行。
  • main.js 执行到第二行时,不会再次执行 b.js,而是输出缓存的 b.js 的执行结果,即它的第四行。
exports.done = true
1

总之,CommonJS 输入的是被输出值的拷贝,不是引用。

另外,由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。所以,输入变量的时候,必须非常小心。

var a = require('a') // 安全的写法
var foo = require('a').foo // 危险的写法

exports.good = function (arg) {
  return a.foo('good', arg) // 使用的是 a.foo 的最新值
}

exports.bad = function (arg) {
  return foo('bad', arg) // 使用的是一个部分加载时的值
}
1
2
3
4
5
6
7
8
9
10

上面代码中,如果发生循环加载,require('a').foo 的值很可能后面会被改写,改用 require('a')会更保险一点。

# ES6 模块的循环加载

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用 import 从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

请看下面这个例子。

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

// b.mjs
import { foo } from './a'
console.log('b.mjs')
console.log(foo)
export let bar = 'bar'
1
2
3
4
5
6
7
8
9
10
11

上面代码中,a.mjs 加载 b.mjs,b.mjs 又加载 a.mjs,构成循环加载。执行 a.mjs,结果如下。

$ node --experimental-modules a.mjs
# 结果
b.mjs
ReferenceError: foo is not defined
1
2
3
4

上面代码中,执行 a.mjs 以后会报错,foo 变量未定义,这是为什么?

让我们一行行来看,ES6 循环加载是怎么处理的。

  • 首先,执行 a.mjs 以后,引擎发现它加载了 b.mjs,因此会优先执行 b.mjs,然后再执行 a.mjs。
  • 接着,执行 b.mjs 的时候,已知它从 a.mjs 输入了 foo 接口,这时不会去执行 a.mjs,而是认为这个接口已经存在了,继续往下执行。
  • 执行到第三行 console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让 b.mjs 运行的时候,foo 已经有定义了。这可以通过将 foo 写成函数来解决。

// a.mjs
import { bar } from './b'
console.log('a.mjs')
console.log(bar())
function foo() {
  return 'foo'
}
export { foo }

// b.mjs
import { foo } from './a'
console.log('b.mjs')
console.log(foo())
function bar() {
  return 'bar'
}
export { bar }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这时再执行 a.mjs 就可以得到预期结果。

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar
1
2
3
4
5

这是因为函数具有提升作用,在执行 import {bar} from './b'时,函数 foo 就已经有定义了,所以 b.mjs 加载的时候不会报错。这也意味着,如果把函数 foo 改写成函数表达式,也会报错。

// a.mjs
import { bar } from './b'
console.log('a.mjs')
console.log(bar())
const foo = () => 'foo'
export { foo }
1
2
3
4
5
6

上面代码的第四行,改成了函数表达式,就不具有提升作用,执行就会报错。

我们再来看 ES6 模块加载器 SystemJS 给出的一个例子。

// even.js
import { odd } from './odd'
export var counter = 0
export function even(n) {
  counter++
  return n === 0 || odd(n - 1)
}

// odd.js
import { even } from './even'
export function odd(n) {
  return n !== 0 && even(n - 1)
}
1
2
3
4
5
6
7
8
9
10
11
12
13

上面代码中,even.js 里面的函数 even 有一个参数 n,只要不等于 0,就会减去 1,传入加载的 odd()。odd.js 也会做类似操作。

运行上面这段代码,结果如下。

$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17
1
2
3
4
5
6
7
8
9
10

上面代码中,参数 n 从 10 变为 0 的过程中,even()一共会执行 6 次,所以变量 counter 等于 6。第二次调用 even()时,参数 n 从 20 变为 0,even()一共会执行 11 次,加上前面的 6 次,所以变量 counter 等于 17。

这个例子要是改写成 CommonJS,就根本无法执行,会报错。

// even.js
var odd = require('./odd')
var counter = 0
exports.counter = counter
exports.even = function (n) {
  counter++
  return n == 0 || odd(n - 1)
}

// odd.js
var even = require('./even').even
module.exports = function (n) {
  return n != 0 && even(n - 1)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上面代码中,even.js 加载 odd.js,而 odd.js 又去加载 even.js,形成“循环加载”。这时,执行引擎就会输出 even.js 已经执行的部分(不存在任何结果),所以在 odd.js 之中,变量 even 等于 undefined,等到后面调用 even(n - 1)就会报错。

$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function
1
2
3
4
Last Updated: 10/21/2024, 4:15:17 PM