Skip to content

JS 模块化原理

JavaScript 在设计之初并没有想到会用于实现复杂的功能,所以没有提供模块化功能。但在逐步的发展中,没有模块化暴露出了很严重的问题:

  1. 命名冲突

HTML中通过script标签加载每个脚本,并按顺序执行。所以需要十分小心脚本中的变量名是否与其他脚本冲突

  1. 不利于代码拆分

当代码量增加时,拆分文件很有必要。但script标签加载的方式要求每个脚本的书写顺序必须正确,一旦脚本数量增加,会带来很重的心智负担


为了解决这些问题,涌现过一系列的模块化方案,可以阅读The Evolution of JavaScript Modularity译文了解 JS 模块化发展历史。本文仅介绍立即执行函数和如今使用的CommonJSESModule

立即执行函数

英文全称Immediately Invoked Function Expression,简称IIFE

通过 JS 的函数作用域实现模块化是早期最为流行的一种方案,这也是为什么面试几乎必问闭包的原因,算是一种历史传承

js
;(function () {
  // 脚本逻辑
  // 函数内的变量在其他脚本中无法访问,不会造成作用域污染
})()
// 或者
var someMethod = (function () {})()
;(function () {
  // 脚本逻辑
  // 函数内的变量在其他脚本中无法访问,不会造成作用域污染
})()
// 或者
var someMethod = (function () {})()

这就实现了最为经典的模块化方案,其中第一个分号是因为通过script标签加载多个脚本时,前面的脚本可能没有写分号结尾,这就会导致 JS 解析为(第一个脚本)()(第二个脚本)的格式,也就是说第二个脚本的括号被当作了函数调用的括号

后来这种以分号开头,结尾不写分号的立即执行函数格式成了很多程序员默认的规范写法

CommonJS(CJS)

CommonJSNodeJS采用的模块化规范(现在也支持ESModule

语法

js
module.exports = {
  name: 'value'
}

// 也可以直接使用exports
exports.name = 'value'
module.exports = {
  name: 'value'
}

// 也可以直接使用exports
exports.name = 'value'
js
const lib = require('./lib.js')
console.log(lib.name) // 打印 value

// 对于不需要接收值的模块,可以只导入
require('./lib.js')
const lib = require('./lib.js')
console.log(lib.name) // 打印 value

// 对于不需要接收值的模块,可以只导入
require('./lib.js')

原理

下面模拟一下CommonJS实现模块化的大致原理(仅帮助理解执行流程,与真正的实现方式有差异)。假设拥有如下两个模块:

js
const a = require('./a.js')

console.log(a)
const a = require('./a.js')

console.log(a)
js
module.exports = 'a'
module.exports = 'a'

可以将CommonJS看作是一个构建插件,会将上面的两个模块处理成如下的格式:

js
// Module类
function Module() {
  this.exports = {}
  // 省略类初始化参数及其他初始化属性
}
// 设置缓存对象
Module._cache = {}
// 挂载原型方法require
Module.prototype.require = function (path) {
  // 1.计算绝对路径
  var filename = 计算绝对路径
  // 2.判断是否有缓存
  var cache = Module._cache[filename]
  if (cache) {
    return cache
  }
  // 3.判断是否内置模块
  if (内置模块中存在filename) {
    return 内置模块
  }
  // 4.生成模块实例,存入缓存
  var module = new Module()
  Module._cache[filename] = module
  // 创建module.exports的引用,用于提供exports.key=value的简化写法
  var exports = module.exports
  // 5.加载模块
  var content = 读取脚本内容(
    // 6.执行模块
    function (content, exports, require, module) {
      // 模块代码被包装到拥有exports、require、module的函数中执行
      // 所以模块中能够直接使用这三个变量
      // 通过module.exports=导出的对象也就存储到了该模块实例的exports属性中
      // 其他模块再require该模块时,即可从第2步返回的缓存实例对象中获取exports
      // eval是执行代码字符串的方法,参考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval
      eval(content)
    }
  )(content, module.exports, Module.prototype.require, module)
}
// 调用入口模块
Module.prototype.require('./index.js')
// 运行到入口中的require('./a.js')时,又会构建a.js的Module实例,并执行a.js代码
// 整个过程是逐行的方式同步执行全部脚本
// Module类
function Module() {
  this.exports = {}
  // 省略类初始化参数及其他初始化属性
}
// 设置缓存对象
Module._cache = {}
// 挂载原型方法require
Module.prototype.require = function (path) {
  // 1.计算绝对路径
  var filename = 计算绝对路径
  // 2.判断是否有缓存
  var cache = Module._cache[filename]
  if (cache) {
    return cache
  }
  // 3.判断是否内置模块
  if (内置模块中存在filename) {
    return 内置模块
  }
  // 4.生成模块实例,存入缓存
  var module = new Module()
  Module._cache[filename] = module
  // 创建module.exports的引用,用于提供exports.key=value的简化写法
  var exports = module.exports
  // 5.加载模块
  var content = 读取脚本内容(
    // 6.执行模块
    function (content, exports, require, module) {
      // 模块代码被包装到拥有exports、require、module的函数中执行
      // 所以模块中能够直接使用这三个变量
      // 通过module.exports=导出的对象也就存储到了该模块实例的exports属性中
      // 其他模块再require该模块时,即可从第2步返回的缓存实例对象中获取exports
      // eval是执行代码字符串的方法,参考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval
      eval(content)
    }
  )(content, module.exports, Module.prototype.require, module)
}
// 调用入口模块
Module.prototype.require('./index.js')
// 运行到入口中的require('./a.js')时,又会构建a.js的Module实例,并执行a.js代码
// 整个过程是逐行的方式同步执行全部脚本

从上面的伪代码可以观察到CommonJS的几个特点:

  1. require加载模块是同步执行的(运行到require那一行才会执行对应的模块)
  2. 模块只有在第一次被加载时才会执行,后续加载都是读取的缓存
  3. 通过模块路径读取缓存,如果路径不同会重新加载(NodeJS中支持URL格式的路径,所以添加?key=value等符号会使同一个模块重新加载)
  4. 如果未导出任何值,require返回的是初始化的exports空对象
  5. module.exports导出的就是等号接收的值(浅拷贝),对象类型的值各模块可以通过引用共享,简单值则不会互相影响
  6. 虽然exportsmodule.exports是同一个对象,但不推荐直接使用exports。因为exports只能用挂载属性的方式导出:exports.key = value,如果误写为exports = value的形式,只是改写了exports变量的值,而真正的module.exports属性并未接收到值

模块匹配规则

CommonJS导入JSON文件会将文件中的内容解析后导入

js
// 例如json文件内容为:{"a":"a"},被导入时就相当于
module.exports = { a: 'a' }
// 例如json文件内容为:{"a":"a"},被导入时就相当于
module.exports = { a: 'a' }

CommonJS模块解析策略中除了.js.json.node之外的扩展名,其他文件均会视为js文件进行处理

未找到与传入路径完全匹配的模块时,会依次尝试添加.js.json.node扩展名进行匹配

CommonJS 详细的加载顺序为:

使用相对或绝对路径导入时

  1. 判断是否有完全匹配路径的文件
  2. 依次尝试.js.json.node扩展名进行匹配(路径无扩展名则添加扩展名,路径本身有扩展名则修改扩展名)
  3. 尝试寻找同名文件夹(去掉扩展名)
  4. 如果有同名文件夹,且内部有package.json文件和正确的exports字段,尝试解析其指定规则的文件(没有与规则路径完全匹配的文件时,前面的规则仍适用)
  5. 如果没有正确的exports字段,但有正确的main字段,尝试加载main字段对应的文件(没有与规则路径完全匹配的文件时,前面的规则仍适用)
  6. 如果有同名文件夹,但没有package.jsonexportsmain字段均不正确,在文件夹中按第二条规则尝试查找index文件

直接通过包名导入时(require('package')),会查找当前目录或最近上级目录中的node_modules文件夹,然后再根据上诉规则在node_modules文件夹中查找

如果均匹配失败,报错Cannot find module

循环加载规则

js
var b = require('./b.js')
console.log(b) // b
module.exports = 'a'
var b = require('./b.js')
console.log(b) // b
module.exports = 'a'
js
var a = require('./a.js')
console.log(a) // {}
module.exports = 'b'
var a = require('./a.js')
console.log(a) // {}
module.exports = 'b'

当出现如上的循环引用时,CommonJS的处理比较特殊:

a执行时,第一行是加载b,此时a会停住,开始执行b

b又导入了a,但是a不会被重复加载,因为模块判断a已经被执行过了,缓存中a的值是已被执行的部分,也就是没有导出,所以打印为module.exports初始的空对象{}

b执行完毕,再将执行权交回到a,这时导入的b取到了值,所以打印就为b

但需要注意的是,语言特性中的函数提升会早于require读取,所以循环引用中写在require后的function声明还是会先被加载var也会提升,但未赋值前都是undefined所以没什么影响)

ES Module

全称EcmaScript Module,是官方在ES6中推出的标准模块化方案,无论是Node端还是浏览器端,基本已全面支持

语法

导出:

js
// 直接导出变量、函数,let、var同理
export const key = value
export function func() {}

// 先声明变量再导出
const key = value
function func() {}
export { key, func }

// 导出重命名
export { key as otherKey, func as otherFunc }

// 默认导出,一个模块只能有一个默认导出,具名导出可以有多个,且可以和默认导出同时拥有
export default value
// 直接导出变量、函数,let、var同理
export const key = value
export function func() {}

// 先声明变量再导出
const key = value
function func() {}
export { key, func }

// 导出重命名
export { key as otherKey, func as otherFunc }

// 默认导出,一个模块只能有一个默认导出,具名导出可以有多个,且可以和默认导出同时拥有
export default value

导入:

js
// 导入非默认导出的变量
import { key, func } from 'module'

// 导入重命名
import { key as otherKey, func as otherFunc } from 'module'

// 整体导入,通过obj.key、obj.func调用
import * as obj from 'module'

// 导入默认导出的数据,name可以任意取
import name from 'module'

// 重命名默认导出
import { default as otherName } from 'module'

// 导入同时有默认导出和具名导出的数据
import name, { key, func } from 'module'

// 仅执行模块,而不获取任何变量
import 'module'
// 导入非默认导出的变量
import { key, func } from 'module'

// 导入重命名
import { key as otherKey, func as otherFunc } from 'module'

// 整体导入,通过obj.key、obj.func调用
import * as obj from 'module'

// 导入默认导出的数据,name可以任意取
import name from 'module'

// 重命名默认导出
import { default as otherName } from 'module'

// 导入同时有默认导出和具名导出的数据
import name, { key, func } from 'module'

// 仅执行模块,而不获取任何变量
import 'module'

导出导入也支持复合写法,可以简化某些情况的书写:

js
export { foo, bar } from 'module'
// 可以简单理解为
import { foo, bar } from 'module'
export { foo, bar }

// 同样支持重命名和整体再导出
export { foo as myFoo } from 'module'
export * from 'module'
export * as newName from 'module'

// 默认导出的再导出语法为
export { default } from 'module'
export { default as newName } from 'module'
export { foo, bar } from 'module'
// 可以简单理解为
import { foo, bar } from 'module'
export { foo, bar }

// 同样支持重命名和整体再导出
export { foo as myFoo } from 'module'
export * from 'module'
export * as newName from 'module'

// 默认导出的再导出语法为
export { default } from 'module'
export { default as newName } from 'module'

浏览器中也已支持ESModule模块,采用异步加载的方式,等同于script添加了defer关键字,多个ESModule script标签同样会按照书写顺序加载和执行。也可以添加async关键字,这时候模块会在加载完成时立即执行

html
<script type="module" src=""></script>
<!-- 等同于 -->
<script type="module" src="" defer></script>
<!-- 添加async时,脚本加载完成时便会立即执行 -->
<script type="module" src="" async></script>
<script type="module" src=""></script>
<!-- 等同于 -->
<script type="module" src="" defer></script>
<!-- 添加async时,脚本加载完成时便会立即执行 -->
<script type="module" src="" async></script>

deferasync的区别是:

defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

原理

ES6模块化的思想是静态化,使编译时就能够确定模块间依赖关系,以及输入输出的变量,不同于CommonJS需要实际运行到那一行才能确定这些东西。静态化带来的好处是:

  • 静态分析可以帮助实现类型检验、TreeShaking等实用功能
  • 将来官方扩充 API 时,就不在必须做成全局属性,而可以通过提供模块的方式

浏览器中的ES模块,通常采用只用script标签加载一个入口模块的方式,再通过模块间的依赖解析依次加载需要的模块。下面用一个简单的Vue项目例子,帮助理解ES模块解析的过程:

html
<!-- 省略其他代码 -->
<html>
  <body>
    <div id="app"></div>
    <script type="module" src="./main.js"></script>
  </body>
</html>
<!-- 省略其他代码 -->
<html>
  <body>
    <div id="app"></div>
    <script type="module" src="./main.js"></script>
  </body>
</html>
js
import { createApp } from 'vue'
import App from './App.vue'

createApp(App)
import { createApp } from 'vue'
import App from './App.vue'

createApp(App)
js
import { h } from 'vue'
import str from './config.js'
// template模板形式的vue文件,实际上也是被解析为类似的render函数形式
export default {
  render() {
    return h('div', str)
  }
}
import { h } from 'vue'
import str from './config.js'
// template模板形式的vue文件,实际上也是被解析为类似的render函数形式
export default {
  render() {
    return h('div', str)
  }
}
js
export default 'xxx'
export default 'xxx'

HTML代码解析完毕后,因为识别到type="module",所以引擎会采用异步加载的方式,直接开始下载main.js文件

在文件下载结束,且div#app被渲染完毕后main.js便会被引擎中的ES模块解析器解析。解析器会从入口开始下载并解析后续的每一个模块,并收集所有的importexport,构建模块依赖图(这是在引擎解析过程中完成的,代码还未转换为机器码执行)

收集export的同时,会为这些导出的变量开辟存储空间。在所有模块全部解析完成后,会从最后一个完成解析的最底层模块开始以深度优先后续遍历的方式层层往上,将每一个importexport链接到对应的内存地址中,称为软链接(linking

完成上诉解析步骤后,引擎已经有了完整的模块依赖图,可以开始执行代码

第一句import { createApp } from 'vue'会让浏览器开始执行vue相关的代码。执行过程中会对createApp这个导出变量进行求值,并填充到对应内存地址,因为import已链接这个地址,所以模块中能正确获取到createApp方法

第二句import App from './App.vue'会开始执行App.vue文件(正式环境中是被解析后的js文件,而不是.vue),App.vue内部又从vue中引入了h函数,因为vue模块在模块依赖图中标记已被执行,所以会直接取得h函数的内存地址,然后开始执行config.js

config.js中对导出变量进行了赋值,回到App.vue中执行,又对render函数进行了赋值。最后回到main.js开始执行createApp函数,createApp内部会调用render函数,返回执行h函数生成的虚拟DOM,再由createApp挂载到div#app上,这就完成了整个应用的执行流程


因为ESModule是在执行前被JS引擎静态分析,所以无法在导入中使用变量,或将导入导出放在逻辑之中:

js
// 报错,因为if逻辑只在运行中才能确定
if (x === 2) {
  import module from 'module.js';
}
// 导入无法使用变量,只能将导入放在模块最外层,规范为模块顶部
let varName = 'xxx'
import { `${varName}Module.js` } from 'module'
// 报错,因为if逻辑只在运行中才能确定
if (x === 2) {
  import module from 'module.js';
}
// 导入无法使用变量,只能将导入放在模块最外层,规范为模块顶部
let varName = 'xxx'
import { `${varName}Module.js` } from 'module'

ES模块中还有一些特性:

  1. ES模块文件中会自动采用严格模式,严格模式相关规则查看MDN-严格模式
  2. ES模块支持顶层await(也就是代码最外层,非函数内部也可以使用,会断住模块主流程)
  3. 模块中顶层thisundefined,而不是window,可以用这个特性区分模块类型
  4. 导入json文件需要做断言import name from './xxx.json' assert { type: 'json' },只能使用默认导入的方式,导入数据为json解析后的对象

模块匹配规则

ES模块相比于CommonJS,只支持导入.js.mjs.cjs.json格式的文件,且模块路径必须完全匹配,包括扩展名。所以ES模块匹配规则直接在CommonJS上进行精简即可:

使用相对或绝对路径导入时,直接尝试查找完全匹配路径的文件

通过包名导入时(import 'package'),会查找当前目录或最近上级目录中的node_modules文件夹。如果node_modules中有同名文件夹,且内部有package.json文件和正确的exports字段,尝试解析其指定规则的文件

否则匹配失败,报错Cannot find package(在webpack等打包器中,会扩展ESModule支持CommonJS的解析规则,要注意并不是ES模块的原生解析规则)

循环加载规则

ESModule处理循环加载与CommonJS有本质的不同。CommonJS使用拷贝值,而ESModule通过软链接到导出变量的内存地址

js
// a.mjs
import b from './b.mjs';
console.log('a');
console.log(b);
export default 'a';

// b.mjs
import a from './a.mjs';
console.log('b');
console.log(a);
export default 'b';

// 执行a.mjs的打印结果:
// b
// ReferenceError: a is not defined

// 如果是CommonJS的话,可以结合上文推算下,结果是:
// b
// {}
// a
// b
// a.mjs
import b from './b.mjs';
console.log('a');
console.log(b);
export default 'a';

// b.mjs
import a from './a.mjs';
console.log('b');
console.log(a);
export default 'b';

// 执行a.mjs的打印结果:
// b
// ReferenceError: a is not defined

// 如果是CommonJS的话,可以结合上文推算下,结果是:
// b
// {}
// a
// b

在上面的例子中,a.js首先引用了b.js,会马上开始执行b.js。而b.js第一行又是引用a.js,这时模块依赖关系中判断a已经处理过,所以不会重复执行

然后打印'b',之后再执行打印a,因为此时的a.js模块中导出语句还未执行,也就是软链接地址中还未赋值,所以无法找到a,便会报错(只开辟了空间但未赋值,不同于空间中赋值为undefined

但与CommonJS一样,函数的提升还是会早于import的执行,所以如果把上面的导出换成函数,结果便会不一样

js
// a.mjs
import b from './b.mjs';
console.log('a');
console.log(b);
export default function(){ return 'a' };

// b.js
import a from './a.mjs';
console.log('b');
console.log(a);
export default function(){ return 'a' };

// 执行a.js的打印结果:
// b
// [Function: default]
// a
// [Function: default]
// a.mjs
import b from './b.mjs';
console.log('a');
console.log(b);
export default function(){ return 'a' };

// b.js
import a from './a.mjs';
console.log('b');
console.log(a);
export default function(){ return 'a' };

// 执行a.js的打印结果:
// b
// [Function: default]
// a
// [Function: default]

ES 模块中加载 CommonJS 模块

因为CommonJS是同步加载的,而ES模块内部支持顶层await导致不能被同步加载,所以CommonJS并不支持混用ES模块。但反过来同步的CommonJS代码是能被ESModule加载的

因为需要执行才能确定导出值,CommonJS模块无法被ES模块解析器静态分析,所以只能被整体加载:

js
// 报错
import { method } from 'commonjs-package'
// 正确
import packageMain from 'commonjs-package'
const { method } = packageMain
// 也可以再次导出,使支持ES模块单个加载
export { method }
// 报错
import { method } from 'commonjs-package'
// 正确
import packageMain from 'commonjs-package'
const { method } = packageMain
// 也可以再次导出,使支持ES模块单个加载
export { method }

NodeJS的内置模块是支持指定加载的:

js
// 整体加载
import EventEmitter from 'events'
// 加载指定的输出项
import { readFile } from 'fs'
// 整体加载
import EventEmitter from 'events'
// 加载指定的输出项
import { readFile } from 'fs'

动态加载函数-import()

因为ES模块采用软链接的方式,所以这些内存地址是只读的,也就是说无法直接修改导入的变量:

js
import { a } from 'a.js'
a = 2 // Uncaught TypeError: Assignment to constant variable.
import { a } from 'a.js'
a = 2 // Uncaught TypeError: Assignment to constant variable.

为了弥补这个缺陷,在ES2020提案中引入了import()函数,支持动态加载模块,语法为:

js
import('./module.js')
// 上面提到的例子也就支持了
if (x === 2) {
  import('module')
}
let varName = 'xxx'
import(`${varName}Module.js`)
import('./module.js')
// 上面提到的例子也就支持了
if (x === 2) {
  import('module')
}
let varName = 'xxx'
import(`${varName}Module.js`)

能看出来,vue中的路由懒加载与vite中的模块动态加载便是用的import函数语法

import函数会返回一个Promise对象,可以用在任何地方,包括CommonJS模块中。因为动态加载的原因,所以import函数与被加载模块没有静态链接关系,这点与import语句不同

js
const name = require('./b.js')

;(async function () {
  const value = await import(`./${name}.mjs`)
  console.log(value)
})()
const name = require('./b.js')

;(async function () {
  const value = await import(`./${name}.mjs`)
  console.log(value)
})()
js
module.exports = 'c'
module.exports = 'c'
js
export default 'ccccccc'
export default 'ccccccc'

执行node a.js后会打印[Module: null prototype] { default: 'ccccccc' }

ES2020中还为import属性添加一个元属性import.meta,返回当前模块的元信息。具体返回哪些属性,标准没有规定,但至少包含下面两个属性:

  1. import.meta.url:返回当前模块的url路径,如https://foo.com/main.js
  2. import.meta.scriptElement:是浏览器特有的原属性会返回加载模块的script标签元素

import.meta是可扩展的,通过import.meta.key = value的形式扩展属性,整个项目其他ES模块中也能访问,例如vite中扩展了import.meta.env