深入理解ES modules
参考
背景
如果不了解分期乐和乐卡借钱的同学,可以做以下类比:
- 分期乐:类似支付宝
- 乐卡借钱:类似借呗
有商务需求的可以点击:乐信开放平台 (fenqile.com)
这个平台现在是我在维护,哈哈哈
免责声明:文章中涉及乐信、乐卡借钱等部分均是我虚构出来,具体内容请联系相关人员
Read-only live bindings
CommonJS
我们思考以下代码:main.js
和counter.js
执行mian.js
第9行:count = count + 2
后,
后续的log3
和log4
分别输出了:2和1
这是为什么呢?
这个问题问得很傻,哈哈哈哈。
我们在学习CommonJS
(后面简称为:CMJ
)的时候说过,它的实现主要是基于一个The module wrapper的立即执行函数来实现的:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
于是我们可以把CMJ
的问题转换成一个立即执行函数的问题:
根据文档Function declarations,我们知道:
Primitive parameters
(such as a number) are passed to functions by value; the value is passed to the function, but if the function changes the value of the parameter, this change is not reflected globally or in the calling function.
If you pass an object (i.e. a non-primitive value
, such as Array
or a user-defined object) as a parameter and the function changes the object's properties, that change is visible outside the function.
总的来说就是,==当我们传递的是一个primitive values
时,函数域里使用的值和传递进来的值,他们指向的不是同一块内存地址了==。
通过函数参数,主要是为了引入
按值传递(pass-by-value)
概念,实际上在整个JS中,都是遵循这种传递方式的,当然在后面我们会在EMS中讲到别的方式。具体的讨论可以看 Is JavaScript a pass-by-reference or pass-by-value language?
就像下面这种图一样:
所以我们可以回答:
Q:执行mian.js
第9行:count = count + 2
后,后续的log3
和log4
为什么分别输出了:2和1
A:因为mian.js
和counter.js
里的count
只是初始值是一样的,但是内存引用是不一致的,所以两者的更改是互不影响的。
ESMAScript moduels
和上面的CMJ
事例中最大的差异有以下两点:
count = count + 2
报错log3
打印出的值是11
我们刚刚在前面CMJ
说到mian.js
和counter.js
里的count
只是初始值是一样的,但是内存引用是不一致的,所以两者的更改是互不影响的。
但是这里我们却发现:当counter.js
修改自身的count
值时
setTimeout(() => {
count = count + 10;
}, 0);
在main.js
输出counter
时却明明受到影响了呀!因为输出了11
setTimeout(() => {
console.log(`=========3=====>count`);
console.log(count);
}, 10);
那因为ESM
使用的不是按值传递的方式进行模块的数据传递,而是通过Read-only live bindings
的方式来传递数据,所谓的live bindings
就是==传递过来的值和使用的值使用的是同一块内存地址==。
live binddings
的本质就是pass-by-reference
既然引用的是同一块内存中的值,那么counter.js
里更改count
时,当然也是会影响到main.js
的count
,如图所示:
剩下的Read-only
就很字面意思了,就只别人只能读不能改,要改的话就通过调用该模块的方法去改,就前端单向数据流内味。
上面说得那么抽象,小朋友你是不是有许多问号?
- 明明已经统一使用了
pass-by-value
这种方式,为什么还要增加Read-only live binddings
这种pass-by-reference
来提高理解成本呢? CMJ
和ESM
到底有个锤子的区别呢?
我们以消费方式来类比两种模块管理
- 传统消费:
CMJ
- 乐卡借钱:
ESM
胡说八道正式开始!
传统消费
在传统消费中,会有以下这种场景:
我想买个笔记本电脑,但是我的钱不够😭
-
电脑需要花费10k
-
每个月工资10k
-
我每个月能存1k来购买电脑
所以我要存够10个月后才能去买电脑。
转换成编程理解的话,就有
- 我需要一个电脑模块
- 电脑模块需要金额模块
- 金额模块需要我存够钱才能返回
这个时候,我们回到CMJ,我们说CMJ的本质是一个函数,根据依赖的模块是函数参数传递过来的,
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
在进行值传递前,我们有个前提:这个值已经计算完毕,即代码已经执行返回了一个结果。
就像传统消费中,我需要存够10个月的钱,才有钱去买电脑一样。
这种按值传递的方式在循环引用中,就会有一些问题:
在循环引用时被依赖模块会返==回局部的代码执行结果==,又因为是按值传递的,即这种交付是一次性的,所以就算被依赖模块把剩下的代码执行完了,原本返回的结果也不会更新。
举个简单的例子:
这以上的代码中,我们可以看出main.js
和counter.js
行成了一种循环依赖关系
根据CMJ的加载方式,我们可以画出以下时序图:
title:simplified example
main.js->counter.js:require("./counter.js").count
counter.js->main.js:require("./main.js").message
main.js->counter.js:返回一个局部执行的结果
Note left of counter.js: 此时的message在main.js并未执行,所以是个undefined
Note right of counter.js: exports.count = 5;
Note right of counter.js: setTimeout(fn,0);
counter.js->main.js:加载结束
Note right of main.js:得到counter.js返回的count,值为5;
Note right of main.js:exports.message = "Eval complted";
Note right of counter.js: console.log(message) // undefined
这里的关键是在
counter.js
依赖于main.js
时,main.js
返回了一个局部的执行结果,实际代码就是给以下的执行函数,返回了一个Module Object
作为参数:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
因为此时的main.js
代码并为执行完毕,即还没有给exports.message
赋值这个步骤,那么作为参数传递进来 的Module Object
里的message
还是undefined
,又因为我们知道CMJ是按值传递的,所以此时的内存状态就是
那么在执行后续的main.js
执行完export.message = "Eval complete“
时,就会有这种情况:
所以尽管是在mian.js
做完了赋值的操作后,setTimeout
打印出的的值还是undefined
聪明的你们一定想到了,他们都指向同一块内存地址不就完事了吗???比如:
太聪明了,ESM也是通过这样来解决循环依赖的问题的。
那ESM加载模块的具体的流程是怎么样的呢?
这个时候就要来讲下乐卡借钱了。
乐卡借钱
这个时候Jay
和我说:小伙子,分期乐了解一下!
Jay
说你算下你银行卡A下个月会进帐多少- A卡下个月发工资10K
- B卡呢?
- B卡下月发奖金10K(搓搓小手,期待ing)
- 那你把这两张绑定在分期乐上,然后再去开通乐卡借钱,根据你刚刚提供的信息,乐花卡对你的额度进行分析,得出可以借20K给你
- 于是我就拿着20K去买了笔记本电脑了
- 后面等到工资钱到了,乐卡借钱就会在我绑定的银行卡上进行按序扣款
而不是用像以前那样要存十个月才买买到电脑了。
在上面的流程中,主要有三个流程需要注意
- 额度分析
- 绑定银行卡
- 扣款
而在ESM中也遵循以下流程:
Parsing
:额度分析Instantiation
:绑定银行卡Evaluation
:扣款
额度分析(Parsing)
在使用乐卡借钱前,分期乐会做一个额度分析,预估我们以后会赚多少钱,计算出可以借多少钱给我,比如我的收入来源
-
工资A卡下月进帐10K
-
奖金B卡下月进帐10K
乐卡借钱就预估着可以借我20K,这个预估,我的工资和奖金并未真正拿到手的;
对于ESM来说,这个额度分析就是Parsing
,所谓的钱没有真正拿到手,就像代码没有真正执行一样,这个Parsing
只行分析下哪里会导出值,就乐卡借钱分析我下个月的偿还能力一样。
我们举个稍微复杂点循环依赖的例子:
// main.js
import * as a from './a.js'
import * as b from './b.js'
console.log('a ->', a)
console.log('b ->', b)
// a.js
import * as bModule from './b.js'
export let loaded = false
export const b = bModule
loaded = true
// b.js
import * as aModule from './a.js'
export let loaded = false
export const a = aModule
loaded = true
假设mian.js
就是我的总资产,总资产的钱来自于a.js
和b.js
即A银行卡和B银行卡。那我的总资产应该就是:总资产 = A卡余额 + B卡余额。
这里的重点在于3
和4
;作为基本的正常人,我们在预估我们一个月赚多少的时候,是不会做重复进帐来欺骗自己的;ECM也是。ECM会在main.js
的Moudle Record
小帐本中来记录。
- 依赖
a.js
,把a.js
记录进Module Record
中 - 依赖
b.js
,把b.js
记录进Module Record
中 - 依赖
a.js
,这个依赖已经记载过来,跳过 - 依赖
b.js
,这个依赖已经记载过来,跳过
最后得出main.js
的依赖是a.js
和b.js
,因为有跳过的操作避免了循环依赖的问题,我们就可以画出个这样的有向图。
绑定银行卡(Instantiation)
在前面我们只是算出我们后续可以有多少钱,我们还需要把这两张卡都绑定到分期乐中,以便后续乐卡借钱可以扣款。
不然呢?不然白给吗?钱不是凭空出来的,只是提前消费而已。
分期虽快乐,消费仍需节制!
在使用乐卡借钱时,会以下两个概念:
- 绑卡(绑定银行卡)
- 扣款顺序
绑卡
绑卡的话,就是我们刚刚说的我们要去绑定A卡和B卡。
具体到在代码中,就是a.js
和b.js
把导出的值的位置记录出来
注意这里说的是值的位置,而不是具体的值,因为此时内存中全部的值还没进行初始化。
就像我们不是直接打钱到分期乐让乐卡借钱来扣款,而是绑定银行卡,等到后续工资和奖金到账后,乐卡借钱才来进行扣款操作。
- 银行卡:值的位置、内存地址、引用
- 钱:具体的值
这里会有个细节问题:就是在函数声明(Function Declaration)时,函数本身会作为一个值存储在变量中,就是把函数自身当作一个字符串存储在变量中。
具体的可以看 Function expressions中对比函数表示式和函数声明两者的区别介绍。
可以理解成我们在执行代码
let name = 'wcdaren'
实际上是有两个东西的
- 声明
- 定义
这两个动作是分开的,比如现在我们这里的
undefined
就是声明了一变量,但是我们还给他定义。但是函数的声明:是把声明和定义绑定在一起的
function sayHi() { alert( "Hello" ); }
一旦声明了一个函数,就已经完成了声明+定义。
一般这个绑卡操作,会分为了个步骤进行操作。
- 银行卡绑定在分期乐中
- 乐卡借钱去分期乐中拉取银行列表
先把银行卡绑定在分期乐中,即wire up exports
。
接着就是乐卡借钱去分期乐中拉银行卡列表了,即wire up imports
扣款顺序
很多人使用乐卡借钱时,很少会注意到扣款顺序这东西,因为为了减少用户操作成本,一般都会把==绑卡的顺序当作扣款顺序==。
在Parsing
时,我们画出了以下的依赖图:
ESM在这个时候选择的是一种深度优先后遍历(depth first post-order traversal)方式。那么最先执行的应该就是最后一个,比如上面图中的b.js
,这么做是因为最底下的模块就不会再依赖另一模块了,这样代码执行起来就很方便了。
我们刚刚说了绑卡的顺序当作扣款顺序,那么前面的绑卡过程就应该是
- 先在分期乐中绑
B
卡 - 然后在分期乐中绑
A
卡
那乐卡借钱在支付包拉到的绑卡顺序就是
B
卡A
卡
所以在乐卡借钱中扣款顺序就是:B --> A
到期扣款(Evaluation)
所谓的扣款就是执行代码了,在前面我们已经确定完了口款顺序,现在我们只需要按着扣款顺序去扣款即可。
以上就是ESM的全部内容。