Skip to content

Module 的加载实现

浏览器加载

传统方法

浏览器通过<script>标签加载 JavaScript 脚本

html
<!-- 页面内嵌的脚本 -->
<script type="application/javascript">
  // module code
</script>

<!-- 外部脚本 -->
<script type="application/javascript" src="path/to/myModule.js"></script>

默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。

问题:脚本体积过大,会导致浏览器堵塞

浏览器允许脚本异步加载,以下是两种异步加载语法

html
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>

defer 与 async 的区别:defer 是“渲染完再执行”,async 是“下载完就执行”

defer:等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;

async:一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。

多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的

加载规则

浏览器加载 ES6 模块,需要加入 type="module"属性

html
<script type="module" src="./foo.js"></script>

<script type="module">为异步加载,不会造成堵塞浏览器

html
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>

多个 <script type="module"> ,会按照在页面出现的顺序依次执行。可以开启 async 属性,但是使用后,就不会按照页面出现顺序执行。

html
<script type="module" src="./foo.js" async></script>

ES6 模块也允许内嵌在网页中,语法行为与加载外部脚本完全一致

js
<script type="module">import utils from "./utils.js"; // other code</script>

对于外部的模块脚本,有几点需要注意点

  1. 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
  2. 模块脚本自动采用严格模式
  3. 使用 import 命令加载其他模块(.js 后缀不可省略,需要提供绝对 URL 或相对 URL),使用 export 命令输出对外接口
  4. 模块之中,顶层 this 关键字返回 undefined,而不是指向 window
  5. 同一个模块如果加载多次,将只执行一次

通过第 4 点,检测当前代码是否在 ES6 模块

js
const isNotModuleScript = this !== undefined

ES6 Module 与 CommonJS 差异

ES6 模块与 CommonJS 模块三个重大差异

CommonJSES6 Module
输出值输出的是一个值的拷贝输出的是值的引用
加载时机运行时加载编译时加载
加载类型require()是同步加载模块import 命令是异步加载

输出值差异分析

CommonJS,一旦输出一个值,模块内部的变化就影响不到这个值。

ES6 Module 在 JS 引擎对脚本静态分析时,加载一个命令 import,就会生成一个只读引用。代码执行时,通过引用取值。

js
// lib.js
var counter = 3
function incCounter() {
  counter++
}
module.exports = {
  counter: counter,
  incCounter: incCounter
}

// main.js
var mod = require('./lib')

console.log(mod.counter) // 3
mod.incCounter()
console.log(mod.counter) // 3

ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。变量是只读的,对它进行重新赋值会报错。

js
// lib.js
export let counter = 3
export function incCounter() {
  counter++
}

// main.js
import { counter, incCounter } from './lib'
console.log(counter) // 3
incCounter()
console.log(counter) // 4
js
// m1.js
export var foo = 'bar'
setTimeout(() => (foo = 'baz'), 500)

// m2.js
import { foo } from './m1.js'
console.log(foo)
setTimeout(() => console.log(foo), 500)

export 输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

js
// mod.js
function C() {
  this.sum = 0
  this.add = function () {
    this.sum += 1
  }
  this.show = function () {
    console.log(this.sum)
  }
}

export let c = new C()
js
// x.js
import { c } from './mod'
c.add()

// y.js
import { c } from './mod'
c.show()

// main.js
import './x'
import './y'

加载时机差异分析

因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成

Node.js 的模块加载方法

概述

JavaScript 现在有两种模块。一种是 ES6 模块,简称 ESM;另一种是 CommonJS 模块,简称 CJS。

CommonJS 模块使用 require()和 module.exports,ES6 模块使用 import 和 export

CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。

从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持

Node.js 要求 ES6 模块采用.mjs 后缀文件名。Node.js 遇到.mjs 文件,标识为 es6 模块。

不希望将后缀名改成.mjs,可在 package.json 进行如下配置,设置后,项目 JS 脚本,都被解释成 ES6 模块

js
{
   "type": "module"
}

总结:.mjs以 ES6 模块加载,.cjs以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置

package.json 的 main

package.json 中有两个字段指定模块入口文件:main 和 exports。

json
// ./node_modules/es-module-package/package.json
{
  "type": "module",
  "main": "./src/index.js"
}

import 命令就可以加载这个模块

js
// ./my-app.mjs

import { something } from 'es-module-package'
// 实际加载的是 ./node_modules/es-module-package/src/index.js

注: CommonJS 模块的 require()命令去加载 es-module-package 模块会报错,因为 CommonJS 模块不能处理 export 命令

package.json 的 exports

exports 优先级高于 main

  1. 子目录别名

package.json 文件通过 exports 指定脚本或子目录的别名

json
// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./submodule": "./src/submodule.js"
  }
}
js
import submodule from 'es-module-package/submodule'
// 加载 ./node_modules/es-module-package/src/submodule.js

未指定别名,就不能用“模块+脚本名”这种形式加载脚本

js
// 报错
import submodule from 'es-module-package/private-module.js'

// 不报错
import submodule from './node_modules/es-module-package/private-module.js'
  1. main 的别名

别名是.,代表模块主入口。

json
{
  "exports": {
    ".": "./main.js"
  }
}

// 等同于
{
  "exports": "./main.js"
}

polyfill

json
{
  "main": "./main-legacy.cjs",
  "exports": {
    ".": "./main-modern.cjs"
  }
}
  1. 条件加载

利用.别名,可为 ES6 模块和 CommonJS 指定不同入口。但要在 Node.js 运行的时候,打开--experimental-conditional-exports 标志

json
{
  "type": "module",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "default": "./main.js"
    }
  }
}

简写

json
{
  "exports": {
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

同时有其他别名,就不能采用简写,否则会报错

json
{
  // 报错
  "exports": {
    "./feature": "./lib/feature.js",
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

CommonJS 模块加载 ES6 模块

问题:CommonJS 的 require()命令不能加载 ES6 模块,会报错。

原因(之一):require() 是同步加载, ES6 模块可以使用顶层 await,导致无法被同步加载

解决:使用 import()方法加载。

js
;(async () => {
  await import('./my-app.mjs')
})()

ES6 模块加载 CommonJS 模块

ES6 模块的 import 命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项

js
// 正确
import packageMain from 'commonjs-package'

// 报错
import { method } from 'commonjs-package'

ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是 module.exports,是一个对象,无法被静态分析,所以只能整体加载

变通加载方法:使用 Node.js 内置的 module.createRequire()方法

js
// cjs.cjs
module.exports = 'cjs'

// esm.mjs
import { createRequire } from 'module'

const require = createRequire(import.meta.url)

const cjs = require('./cjs.cjs')
cjs === 'cjs' // true

支持两种格式模块

原始模块 ES6,需要给出整体输出接口

js
export default obj

原始 CommonJS ,加一个包装层进行转发

js
import cjsModule from '../index.js'
export const foo = cjsModule.foo

两种格式文件输出写法

方法一:文件的后缀名改为.mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的 package.json 文件,指明{ type: "module" }

方法二:利用 exports 指定不同 加载入口

js
"exports":{
  "require": "./index.js"
  "import": "./esm/wrapper.js"
}

Node.js 内置模块

Node.js 的内置模块可以整体加载,也可以加载指定的输出项

js
// 整体加载
import EventEmitter from 'events'
const e = new EventEmitter()

// 加载指定的输出项
import { readFile } from 'fs'
readFile('./foo.txt', (err, source) => {
  if (err) {
    console.error(err)
  } else {
    console.log(source)
  }
})

加载路径

ES6 模块加载路径:必须给出脚本的完整路径,不能省略脚本的后缀名。import 命令和 package.json 文件的 main 字段如果省略脚本的后缀名,会报错

js
// ES6 模块中将报错
import { something } from './index'

为了与浏览器的 import 加载规则相同,Node.js 的.mjs 文件支持 URL 路径

js
import './foo.mjs?query=1' // 加载 ./foo 传入参数 ?query=1

同一个脚本只要参数不同,就会被按照 URL 规则进行解析。会被加载多次,并且保存成不同的缓存。文件名中含有: % # ?等特殊字符,最好对这些字符进行转义

Node.js 的 import 命令只支持加载本地模块(file:协议)和 data:协议,不支持加载远程模块。脚本路径只支持相对路径,不支持绝对路径(即以///开头的路径)。

内部变量

ES6 模块应该是通用,同一个模块不用修改,就可以用在浏览器环境和服务器环境。Node.js 规定 ES6 模块之中不能使用 CommonJS 模块的特有的一些内部变量

以下顶层变量在 ES6 中不存在

  • arguments
  • require
  • module
  • exports
  • __filenam
  • __dirname

循环加载

循环加载(circular dependency):a 脚本的执行依赖 b 脚本,而 b 脚本的执行又依赖 a 脚本

补充:CommonJS 模块的加载原理

require 第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

  • id:模块名
  • exports:模块输出接口
  • loaded:脚本是否执行完毕
js
{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

CommonJS 无论加载多少次,都只会在第一次加载时运行一次。以后加载直接读取第一次结果,除非手动清除缓存。

CommonJS 模块的循环加载

CommonJS 模块的重要特性:加载时执行,require 时代码会全部执行。

js
// 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 执行完毕')
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. a.js 执行到第二行,会去加载 b.js。

  2. 进入 b.js 执行到第二行,会去加载 a.js,发生循环加载。a.js 还未加载完毕,只能取回部分执行,所以 b.done 为 false。

  3. b.js 继续执行,执行完后,回到 a.js 执行

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)
/*
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
*/

由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。

js
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) // 使用的是一个部分加载时的值
}

分析:发生循环加载, require('a').foo 可能会被改写

ES6 模块的循环加载

ES6 模块是动态引用,使用 import 从一个模块加载变量,此变量是引用而不是缓存

js
// 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'

执行结果:ReferenceError: foo is not defined

分析:执行 a.mjs 后,发现依赖 b.mjs,回去执行 b.mjs。执行 b.mjs 时,发现依赖 a.mjs,此时默认 a.mjs 已经存在,不会执行 a.mjs,而是向下执行。

解决:写成函数形式

js
// 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 }

分析:利用函数提升,import {bar} from './b'; 时,函数 foo 已经定义

ES6 模块加载器 SystemJS 给出的一个例子

js
// 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)
}
js
$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17

如果是 commonJS,将无法执行

js
// 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)
}
js
$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function

分析:引入 event.js 后,第一行加载 odd.js。在 odd.js,运行 require('./even').even,会去获取 even.js 已执行部分,但没有结果,所以 even 是 undefined,向下执行,发现不是函数。

相关链接

[-] Module 的加载实现