likes
comments
collection
share

面试官:聊聊ESModule的工作流程和实现原理

作者站长头像
站长
· 阅读数 2

概述

对于 ESModule的工作流程主要包含以下三个步骤:

  • 构造(Construction) — 找到、下载并解析所有文件为模块记录。
  • 实例化(Instantiation) — 在内存中找到位置用于存放所有的导出值,但是不用实际值来填充它们。然后让导出和导入都指向内存中的这些位置。这被称为链接(linking)。
  • 评估(Evaluation) — 运行代码以真实值填充这些位置。

下面我们结合源码和构建工具处理的逻辑,来理解这三个步骤

构造

这个过程涉及找到,并下载必要的模块文件。一旦找到这些文件,JavaScript 会解析这些文件并将它们转换为模块记录。模块记录是包含有关模块所有导出、导入以及源代码的信息的数据结构。

// 导入模块
import { a } from './test.js';

实例化

在这个阶段,JavaScript 引擎会为所有导出的变量找到内存位置,并且原始的导入和导出声明都会被替换为这些内存位置的引用。这个过程称为链接,是实现模块间的交互的关键步骤。

下面我们通过源码来理解:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用

CommonJS

CommonJS源码:

// index.js
const { a } = require('./test')
console.log(a)
setTimeout(() => {
  console.log(a)
}, 500)

// test.js
let a = 0
setTimeout(() => {
  a = 1
}, 500)
module.exports = {
  a: a
}

CommonJS 构建后简化代码

let a = 0
const module = {
  exports: {
    a
  },
};

function test(module, exports) {
  console.log(exports.a)
  setTimeout(() => {
    console.log(exports.a)
  }, 500)
}
setTimeout(() => {
  a = 1
}, 500)
test(module, module.exports)
// 打印结果: 0 0

ESModule

ESmodule规范实现跟上面一样逻辑的代码

// index.js
import a from './test'
console.log(a)
setTimeout(() => {
  console.log(a)
}, 500)

// test.js
export let a = 0
setTimeout(() => {
  a = 1
}, 500)

ESModule 构建后简化代码

__webpack_require__ = {}
// 判断是否已经有该私有属性
__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], // 通过get方法巧妙返回值的引用
      });
    }
  }
}
let a = 0
const module = {
  exports: {},
};
__webpack_require__.d(module.exports, {a: () => (a)})

function test(module, exports) {
  console.log(exports.a)
  setTimeout(() => {
    console.log(exports.a)
  }, 500)
}
setTimeout(() => {
  a = 1
}, 500)

test(module, module.exports)
// 打印结果: 0 1

defineProperty

可以对比打印结果,我们可以感受到值的拷贝和值的引用的区别,而对于值的引用的关键实现就是:

Object.defineProperty(exports, key, {
  enumerable: true,
  get: definition[key], // 通过get方法巧妙返回值的引用
});

使用Object.defineProperty能提供更高层次的控制,可以控制属性是否可枚举、可写、可配置,并且可以提供getter和setter方法,使得在访问和设置属性时可以执行自定义的行为。但是,如果不需要这些额外的控制,那么使用普通的赋值方式(exports[key]=definition[key])会更简单、清晰。

这里我们get: definition[key]来实现值的引用

评估

在这个过程中,JavaScript 引擎会实际运行模块的代码。所有的实际值(如函数和变量)都会被保存在之前实例化阶段建立的内存位置中。

tree-shaking

ESModule 尽量静态化处理,特点:

  • import 只能作为模块顶层的语句出现
  • import 的模块名是静态固定的
  • import 绑定不变

所以模块依赖关系是确认的,和运行时的状态无关,所以在运行前可以进行静态分析,删除无用代码,使得最终的打包文件更小

不过要注意,tree-shaking 不会影响到那些有副作用的模块。即使模块没有被明确导入,如果它在顶层范围有副作用(例如调用一个函数,修改一个全局变量),那么它就不会被剔除。因为这些副作用可能会影响到程序的运行。

  • 正常情况下:
// index.js
import { a } from "./test.js";
console.log(a);

// test.js
export let a = 1;
export let b = 3;

// 打包产物
(() => {
  "use strict";
  console.log(1);
})();
  • 增加有副作用的模块
// index.js
import { a } from "./test.js";
console.log(a);

// test.js
export let a = 1;
export let b = 3;
setTimeout(() => {
  b = 2;
}, 500);

// 打包产物
(() => {
  "use strict";
  let e = 3;
  setTimeout(() => {
    e = 2;
  }, 500),
    console.log(1);
})();

动态引入 import()

前面讲到 import 模块名是固定的,绑定也是不变的。所以下面的代码会报错:

// 报错
if (a === 1) {
  import module from './module';
}

上面代码中,引擎处理 import 语句是在编译时,这时不会去分析或执行 if 语句,所以 import 语句放在 if 代码块之中毫无意义,因此会报句法错误,而不是执行时错误.

ES2020 提案 引入 import()函数,支持动态加载模块

if (a === 1) {
  import('./module').then((module) => {
    // todo module
  })
}

而对于动态 import()的引入,如不会影响其他模块静态解析,而对于这部分动态模块的引入,打包工具主要做了以下事项:

  1. 将异步模块单独打包到一个文件
  2. 当运行到对应代码时,在 Promise 内创建一个 script 标签加载异步文件
  3. 监听 script 的 onload、onerror,进行加载成功或者失败时的处理

参考文献

hacks.mozilla.org/2018/03/es-… es6.ruanyifeng.com/#docs/modul…