深入分析JavaScript模块循环引用

为了方便说明,本文把 JS 代码的运行大致分为预处理和执行两个阶段,注意,官方并没有这种说法。下面进行更细致的分析。

CommonJS 模块

===========

在 Node.js 中,CommonJS 模块[2]由 cjs/loader.js[3] 实现加载逻辑。其中,模块包装器是一个比较巧妙的设计。

在浏览器中,CommonJS 模块一般由包管理器提供的运行时实现,整体逻辑和 Node.js 的模块运行时类似,也使用了模块包装器。以下分析都以 Node.js 为例。

模块使用报错

======

CommonJS 模块使用不当时,由 cjs/loader.js 抛出错误。比如:

// Node.js

internal/modules/cjs/loader.js:905

throw err;

^

Error: Cannot find module ‘./none_existed.js’

Require stack:

  • /Users/wuliang/Documents/code/demo_module/index.js

可以看到,错误是通过 throw 语句抛出的。

模块执行顺序

======

CommonJS 模块是顺序执行的,遇到 require 时,加载并执行对应模块的代码,然后再回来执行当前模块的代码。

如图 3 所示,模块 A 依赖模块 B 和 C,模块 A 被 2 个 require 语句从上往下分为 3 段,记为 A1、A2、A3。

如图 4 所示,代码块执行顺序为:A1 -> B -> A2 -> C -> A3。

模块循环引用

======

从 cjs/loader.js 的 L765、L772 和 L784 行代码可以看到,在模块执行前就会创建好对应的模块对象,并进行缓存。模块执行的过程实际是在给该模块对象计算需要导出的变量属性。因此,CommonJS 模块在启动执行时,就已经处于可以被获取的状态,这个特点可以很好地解决模块循环引用的问题。

如图 5 所示,模块 A 依赖模块 B,模块 B 又依赖模块 A,模块 A 和 B 分别被 require 语句从上往下分为 2 段,记为 A1、A2、B1、B2。

如图 6 所示,代码块的执行顺序为:A1 -> B1 -> B2 -> A2。

使用不当的问题

=======

如果 B2 使用了 A2 导出的变量会怎么样呢?模块 A 的模块对象上不存在该变量对应的属性,获取的值为 undefined。获得 undefined 虽然不符合预期,但一般不会造成 JS 错误。

可以看到,由于 require 语句直接分割了执行的代码块,CommonJS 模块的导入导出语句的位置会影响模块代码语句的执行结果。

ES6 模块

======

ES6 模块[4]借助 JS 引擎实现。JS 引擎实现了 ES6 模块的底层核心逻辑,JS 运行时需要在上层做适配。适配工作量还不小,比如实现文件的加载,具体可以看一下我发起的一个讨论[5]。

模块使用报错

======

ES6 模块使用不当时,由 JS 引擎或 JS 运行时的适配层抛出错误。比如:

// Node.js 中报错

internal/process/esm_loader.js:74

internalBinding(‘errors’).triggerUncaughtException

^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module

// 浏览器中报错

Uncaught SyntaxError: The requested module ‘./child.js’ does not provide an export named ‘b’

第一个是 Node.js 适配层触发的内部错误(不是通过 throw 抛出的),第二个是浏览器抛出的 JS 引擎级别的语法错误。

模块执行顺序

======

ES6 模块有 5 种状态,分别为 unlinked、linking、linked、evaluating 和 evaluated,用循环模块记录(Module Environment Records)[6]的 Status 字段表示。ES6 模块的处理包括连接(link)和评估(evaluate)两步。连接成功之后才能进行评估。

连接主要由函数 InnerModuleLinking[7] 实现。函数 InnerModuleLinking 会调用函数 InitializeEnvironment[8],该函数会初始化模块的环境记录(Environment Records)[9],主要包括创建模块的执行上下文(Execution Contexts)[10]、给导入模块变量创建绑定[11]并初始化[12]为子模块的对应变量,给 var 变量创建绑定并初始化为 undefined、给函数声明变量创建绑定并初始化为函数体的实例化[13]值、给其他变量创建绑定但不进行初始化。

对于图 3 的模块关系,连接过程如图 7 所示。连接阶段采用深度优先遍历,通过函数 HostResolveImportedModule[14] 获取子模块。完成核心操作的函数 InitializeEnvironment 是后置执行的,所以从效果上看,子模块先于父模块被初始化。

评估主要由函数 InnerModuleEvaluation[15] 实现。函数 InnerModuleEvaluation 会调用函数 ExecuteModule[16],该函数会评估模块代码(evaluating module.[[ECMAScriptCode]])。ES6 规范并没有明确说明这里的评估模块代码具体指什么。我把 ES6 规范的相关部分反复看了至少十余遍,才得出一个比较合理的解释。这里的评估模块代码应该指根据代码语句顺序执行条款 13[17]、条款 14[18] 和 条款 15[19] 内的对应小节的“运行时语义:评估(Runtime Semantics: Evaluation)”。ScriptEvaluation[20] 中的评估脚本(evaluating scriptBody)应该也是这个意思。可以看到,ES6 规范虽然做了很多设计并且逻辑清晰和自洽,但仍有一些模棱两可的地方,没有达到一种绝对完善和无懈可击的状态。

对于图 3 的模块关系,评估过程如图 8 所示。和连接阶段类似,评估阶段也采用深度优先遍历,通过函数 HostResolveImportedModule 获取子模块。完成核心操作的函数 ExecuteModule 是后置执行的,所以从效果上看,子模块先于父模块被执行。

由于连接阶段会给导入模块变量创建绑定并初始化为子模块的对应变量,子模块的对应变量在评估阶段会先被赋值,所以导入模块变量获得了和函数声明变量一样的提升效果。例如,代码 1 是能正常运行的。因此,ES6 模块的导入导出语句的位置不影响模块代码语句的执行结果。

console.log(a) // 正常打印 a 的值

import { a } from ‘./child.js’

代码 1

模块循环引用

======

对于循环引用的场景,会先对子模块进行预处理和执行。连接阶段除了分析模块依赖关系,还会创建执行上下文和初始化变量,所以连接阶段主要包括分析模块依赖关系和对模块进行预处理。如图 9 所示,对于图 5 的模块关系,处理顺序为:预处理 B -> 预处理 A -> 执行 B -> 执行 A。

使用不当的问题

=======

由于子模块先于父模块被执行,子模块直接执行从父模块导入的变量会导致 JS 错误。

// 文件 parent.js

import {} from ‘./child.js’;

export const parent = ‘parent’;

// 文件 child.js

import { parent } from ‘./parent.js’;

console.log(parent); // 报错

代码 2

如代码 2 所示,child.js 中的导入变量 parent 被绑定为 parent.js 的导出变量 parent,当执行 child.js 的最后一行代码时,parent.js 还没有被执行,parent.js 的导出变量 parent 未被初始化,所以 child.js 中的导入变量 parent 也就没有被初始化,会导致 JS 错误。注意,本文说的变量是统称,包含 var、let、const、function 等关键字声明的变量。

console.log(parent)

^

ReferenceError: Cannot access ‘parent’ before initialization

如果是异步执行,则没问题,因为异步执行的时候父模块已经被执行了。例如,代码 3 是能正常运行的。

// parent.js

import {} from ‘./child.js’;

export const parent = ‘parent’;

// child.js

import { parent } from ‘./parent.js’;

setTimeout(() => {

console.log(parent) // 输出 ‘parent’

}, 0);

代码 3

纠正教程观点

======

《ECMAScript 6 入门教程》一书说的三个重大差异如下:

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

  3. CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段。

总结

三套“算法宝典”

28天读完349页,这份阿里面试通关手册,助我闯进字节跳动

算法刷题LeetCode中文版(为例)

人与人存在很大的不同,我们都拥有各自的目标,在一线城市漂泊的我偶尔也会羡慕在老家踏踏实实开开心心养老的人,但是我深刻知道自己想要的是一年比一年有进步。

最后,我想说的是,无论你现在什么年龄,位于什么城市,拥有什么背景或学历,跟你比较的人永远都是你自己,所以明年的你看看与今年的你是否有差距,不想做咸鱼的人,只能用尽全力去跳跃。祝愿,明年的你会更好!

由于篇幅有限,下篇的面试技术攻克篇只能够展示出部分的面试题,详细完整版以及答案解析,有需要的可以关注

  • 26
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值