从 Nodejs 如何解决模块循环依赖问题来一点关于模块的发散思考

·  阅读 1561

什么是模块循环依赖

所谓循环依赖就如字面理解的那样, 众所周知 Nodejs 对模块的解析是会提前加载到内存中, 当所有模块加载完才从入口文件开始 run 整个应用, 如果所有的模块之间都是按照顺序串行依赖, 就跟贪吃蛇一样是没什问题, 无非就是依赖链长一点, 不过要是贪吃蛇不小心咬到了身体或者尾巴, 就会行程环, 在代码中就好比

//a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');

//b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');

//main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
复制代码

这是一个 Nodejs 官网的示例, 正如示例中的那样, a.js 巴拉巴拉, 读到 require b.js, 然后跑去 b.js, b.js 巴拉巴拉 又读到 require a.js, 然后跑去 a.js, 这时候我们不妨想想第二次读取 a.js 的时候 Nodejs 该怎么处理呢, 如果像浏览器一样的话, 应该重头再解析一遍, 毕竟哪有半途而废的道理嘛, 于是从头再读...巴拉巴拉 又读到 require b.js 跑进去再重来, 等等这不是又回来了!?

这就是模块循环依赖问题

要解决这个问题, 说起来也很简单, 只要把重来一遍变成中断就可以了, Nodejs 称为 unfinished copy, 第二进入 a.js 模块的时候, 从require b.js 的后面继续往下读取, 这样就将环解开又回到了原先串行的解析方式, 代价就是你得知道 require 前后都写了什么, 尤其是涉及 exports 出去的值, 因为执行顺序问题, 两次的值并不相同. 不过如果我们不想中断, 就想从头到尾读呢?

那我们就需要将模块解析和模块加载分开处理, 比如我们 ES6 Module 带来的 import

//a.js
import b from 'b.js'
import c from 'c.js'

...coding...
//b.js
import a from 'a.js'

...coding...
复制代码

因为代码的执行分成了两个阶段, 意味着无论你 import 写在哪都会比其他代码先被读取, 这也就解决了 require 前后代码执行顺序导致 exports 值不一致的问题, 所以为啥说要 import 写在顶上, 那是为了符合直觉, 因为顺序就是从 import 先开始的, 所以从这个角度看如果 require 的设计撇开 module 这个概念, 比如 require 的不是 module 而是嗯片段, 就比较符合直觉, 因为是个超级大的 script , 但是如果加上 module 就有点反直觉, 因为我们都会觉得 module 和 module 之间是隔离的, 一个 module 的加载如果因为 require 导致被割裂...就感觉这种设计好残疾, 与其说是 unfinished copy 不如说是 unfinished module. 所以用 copy 代替 module, 叫 CommonJS Copy 可能会更符合实际的设计.

回到正题, 通过解析加载分离, 我们就可以先解析模块的依赖关系, 而避免去读取实际的模块代码, 在 a.js 中我们读取到 import 'b.js', 这时候我们可以给 b.js 添加一个 State, 并将其值设为 'Linked', 表示 b.js 已经被读取了, 然后从 b.js 读取到 import a.js, 我们再将 a.js 的 State 设为 'Linked', 然后又回到 a.js, 发现 b.js 已经是 Linked 了, 跳过, 继续读取 c.js , 通过状态标记就避免了循环依赖

Import 带来的礼物 Tree-Shaking

Tree-Shaking 是 rollup 提出的一个移除 dead code 的方案, 这里的摇树的概念应该是源自于 rollup 的具体处理方式, rollup 将代码转换成一颗 AST 然后摇啊摇, 其实就是不断的移除那些 unuesed 的变量/函数/类等等, 所以叫树摇...吧, 而之所以能这么做的前提其实就是基于 Import 的静态分析, 基于静态分析, 就可以先分析出 module 本身导出的对象, 和所有被其他 module 引入的对象, 找出那些导出未使用的变量, 然后再从 module 自身分析下是否有使用这些变量, 如果都没有, 那就摇掉吧...而且摇树背后其实意有所指, 比如当我们使劲摇树的时候会掉什么!?

自然是 ---- 叶子...

在 AST 中掉下来的叶子就是那些 node 节点, 所以摇树, 或者树摇是不是很形象 😁

不过话说回来, 实现 Tree-Shaking 其实用到了两种基本的数据解构, 图和树, 所以学好数据结构还是很有必要的 😀 (说到这里, 我已经回去重修了... 😢)

CommonJS -> ES6 Module

Import 的设计比 require 更进一步, 不仅解决了问题还引入可优化的方案, 在 Import 的基础上得以发展出 Tree-Shaking 这样的技术来进一步优化构建, 这是技术进步的意义所在, 而且 Import 还为远程模块留下了扩展支持, 像 Deno 那样 Import 一个远程模块, 如果发生在运行时那估计会比较糟糕, 如果加载的某个模块不可用, 会影响关联的所有模块, 对于大型应用而言这几乎是在灾难性的, 因为网络环境本身是不稳定的, 加上网络环境的不可知性, 会让你的模块系统陷入不可用的窘境, 但如果是静态分析, 我们可以提前测试模块的可用性, 网络的介入多少会让问题变复杂, 而依赖分析和加载的解耦对于这种场景也能够很好的去支持, 所以 Import 基于静态分析的规范设计其实是非常棒的, 像这种支持未来的设计都是很棒的设计!? (所以值得我们好好学习, 陷入深深的思考)

后话

ES6 Module 是很棒的设计, 不过写这篇文章的时候不免稍稍有点伤感, 因为这类关于底层的标准设计大多数都是外国人, 嗯基本上是外国人, 在国内其实很少有碰到关于这类底层标准/规范设计的讨论, 比如 require 的实现或者说 CommonJS Module 的设计有缺陷, 但其实鲜有人关注, 或者有人关注了但很少引发讨论, 我们似乎已经习惯了接受某种嗯广为人知的设计或者接受某种已经存在的标准, 而很少独立思考, 甚至去质疑, 质疑为什么这么设计

有时候我会觉得前端圈像个投资圈, 我们似乎再不停的追逐一个又一个技术风口, 以至于诞生了有名的调侃 "学不动了" 其实这种调侃背后是我们对不断追逐技术风口感到疲惫, 因为大多数人并没有因此受益, 就好像投资圈赚钱的永远是那几个大佬, 普通老百姓都是韭菜, 不信? 你看看股市里的币圈里的韭菜们就知道了

或许我们可以停下来, 思考一些东西, 质疑一些东西, 而不仅仅是追逐.

分类:
前端
标签:
分类:
前端
标签: