在现代化ES中提供了新的组织代码的工具“模块”,而新的工具总会伴随着兼容性等问题,所以有了babel和webpack等工具帮开发者抹平现实和理想(新特性)之间的差距。而对于ESM来说webpack是怎么兼容的,这是本文要探寻的问题。
ESM是什么以及存在的疑惑点
模块是代码组织的工具。我们可以将所有代码都放在全局环境下,也可以将代码分割到各个函数中,现在还可以将代码按模块分割,这里说的模块就是ESM。
模块是一个环境,这个环境有入口和出口,对应ESM中的import
和export
命令(这么看真的很像函数,函数也有入口和出口,对应入参和返回值,并且函数也有自己的环境)。
// src/sync_module.js
import React from 'react'
let syncModuleValue = 1
export {
syncModuleValue
}
如上代码中import React from 'react'
即可得到React对象,这个对象定有一个存储的地方,这个地方是哪里?export { syncModuleValue }
将变量导出,导出到哪里去了?
以上两个问题是webpack在实现ESM时必须解决的问题之二。
webpack眼中的ESM
webpack将模块实现成一个对象,而导出则是模块对象的一个名为exports
的属性,该属性值也是一个对象,因为导出的变量会有多个。除了导出对应模块的export功能,webpack提供了运行时的__webpack_require__
方法对应ESM的import命令,用于导入依赖模块。
"use strict"
// ./src/sync_module.js
function (__webpack_module__, __webpack_exports__, __webpack_require__) {
let React = __webpack_require__('./node_modules/react/index.js')
let syncModuleValue = 1
Object.defineProperty(
__webpack_exports__,
'syncModuleValue',
{ enumerable: true, get: () => syncModuleValue }
)
}
以上是一个通过webpack打包后的ESM,该模块在运行时被实现成一个函数,该函数有3个入参,分别是模块对象,模块导出对象,和依赖模块导入方法。模块函数本身在哪里调用和这3个函数是哪里来的?这些问题的答案在模块运行时中可以找到,也就是webpack runtime。
webpack对ESM运行时的实现
"use strict"
var __webpack_modules__ = {
"./src/sync_module.js": function (__webpack_module__, __webpack_exports__, __webpack_require__) {
// 这里先去掉,让整段代码能跑起来
// let React = __webpack_require__('./node_modules/react/index.js')
let syncModuleValue = 1
Object.defineProperty(
__webpack_exports__,
'syncModuleValue',
{ enumerable: true, get: () => syncModuleValue }
)
}
}
var __webpack_module_cache__ = {}
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId]
if (cachedModule !== undefined) {
return cachedModule.exports
}
var module = __webpack_module_cache__[moduleId] = {exports: {}}
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)
return module.exports
}
// 入口
__webpack_require__('./src/sync_module.js')
默认情况下模块对应的代码字符串会被编译到__webpack_modules__
对应的对象中,用文件地址作为值的key(模块ID)。而__webpack_require__
函数使用模块ID作为入参获取对应的模块函数并执行,执行对应的模块函数后得到模块导出并存在模块缓存中后返回模块导出,下次__webpack_require__
查找同样的模块则直接根据模块ID从模块缓存中返回模块对应导出。
webpack对ESM导出变量更新后导入变量自动更新的实现
ESM和CommonJS等模块的一个不同点是对于导出导入的实现。在ESM中,变量a被导出后在依赖该变量的模块中依然能感知到变量a的变动。
// ./src/modulea.js
let a = 1
function seta() {a = a + 1}
export {a, seta}
// ./src/moduleb.js
import {a, seta} from "./modulea"
seta()
console.log(a) // 2
以上ESM代码被编译成
"use strict"
const __webpack_modules__ = {
// ./src/modulea.js
'./src/modulea.js': function (__webpack_module__, __webpack_exports__, __webpack_require__) {
let a = 1
function seta() {a = a + 1}
Object.defineProperty(
__webpack_exports__,
'a',
{ enumerable: true, get: () => a }
)
Object.defineProperty(
__webpack_exports__,
'seta',
{ enumerable: true, get: () => seta }
)
},
// ./src/moduleb.js
'./src/moduleb.js': function (__webpack_module__, __webpack_exports__, __webpack_require__) {
var moduleb = __webpack_require('./src/modulea.js')
moduleb.seta()
console.log(moduleb.a)
}
}
如上案例可运行资源见附件
为了做到导入的变量a
跟着导出的变量a
的变动而变动,编译后的a
是导出对象的一个存取器属性,当访问a
属性时会执行取函数获取到导出模块环境中最新的变量a
的值。而变量的使用也会被编译成属性的获取,达到触发存取器的目的,而不是直接访问变量的值。导出导入两方配合完成了值的动态获取。
webpack中的模块加载方式
这部分和webpack对于模块的理解没啥关系,但是在研究webpack打包产物时发现webpack对于模块文件的组织和下载实现让人记忆犹新,所以在此继续记录。
ESM中一个模块是一个文件,而webpack对于ESM的实现并不没有遵从该规则。在webpack的理解中多个module会组成chunk,chunk会组成bundle后输出,这个bundle则是最终的产物,而大部分情况下chunk和bundle是一一对应的。在下面的介绍中统一使用chunk作为webpack的产物,方便理解。
所以一个chunk对应一个文件,而一个chunk中有很多模块。例如我们会将项目依赖的所有三方库打在一个chunk里面生成一个稳定的文件,不会随着业务的迭代重新打包使缓存失效。
那也就是说入口和依赖的包可能不在一个文件中,那么webpack是如何下载依赖之后组装起来并正常运行则是问题的核心。
从从面的介绍中知道__webpack_require__
函数本身不关注模块来源,执行时直接从__webpack_modules__
中根据模块id获取对应模块即可,也就是说__webpack_require__
请求对应模块之前该模块一定完成了安装,即使该模块是通过网络另外获取的。
将入口文件放在依赖之后加载,让依赖先加载之后再加载入口文件并执行,即可解决入口和依赖不在一个文件内的问题。
所以关注的点变成了分开加载的模块是怎么安装到webpack运行时的__webpack_modules__
对象上的。
// src_modulea_js.js
"use strict"
(self["webpackChunklearnwebpack"] = self["webpackChunklearnwebpack"] || []).push([["chunkname"], {
"./src/module_id.js": (__webpack_module__, __webpack_exports__, __webpack_require__) => {
// ...
}
}]);
对于被分割出去的chunk文件内容如上。该文件会在self上寻找属性webpackChunklearnwebpack如果没有就创建一个,其值是一个数组。之后将内容push到数组中。内容是一个数组,数组第一项是chunkId组成的数组(通常只有一个chunkId),第二项是该文件包含的所有模块源码。
webpack runtime加载chunk的核心代码如下:
// object to store loaded and loading chunks
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
var installedChunks = {}
var __webpack_modules__ = {}
function webpackJsonpCallback(parentChunkLoadingFunction, data) {
var [chunkIds, moreModules] = data;
// 安装模块
for(moduleId in moreModules) __webpack_modules__.m[moduleId] = moreModules[moduleId]
// 标识chunk对应chunk已经被加载
for (var i = 0; i < chunkIds.length; i++) installedChunks[chunkId] = 0
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data)
}
var chunkLoadingGlobal = self["webpackChunklearnwebpack"] = self["webpackChunklearnwebpack"] || []
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0))
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))
其中的灵魂是chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
- 所有chunk加载完成后都会被存在数组
self["webpackChunklearnwebpack"]
中 - webpack运行时执行时会拿到该数组并使用数组中的每一项执行函数
webpackJsonpCallback
,将模块安装到__webpack_modules__
中 - 在webpack运行时之前加载的chunk中的模块都完成了安装
到这里正常流程可以走通,那灵魂一行的作用是什么?继续之前,先解析这行代码实现的功能。
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))
稍微拆解一下让其更加语义化
const rawPush = chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
const nowPush = webpackJsonpCallback.bind(null, rawPush)
chunkLoadingGlobal.push = nowPush
当向chunkLoadingGlobal
数组中push内容时实际上调用的是函数nowPush
,也就是函数webpackJsonpCallback
(该函数用于安装chunk中的模块到运行时中),区别是这个函数的形参parentChunkLoadingFunction
绑定的值是rawPush
也就是原来的chunkLoadingGlobal.push
可以先简单看做是数组的push
方法。
向chunkLoadingGlobal中push一个值会经历如下流程:
首先会调用当前webpack运行时的webpackJsonpCallback
函数,将对应值中的模块安装到__webpack_modules__
中,然后再调用rawPush
将对应值push到chunkLoadingGlobal
数组中。
所以简单解释一下,数组的push方法被劫持了,添加值时执行完自定义逻辑之后才会将值添加到目标数组中。
这么做解决了动态模块的加载问题,因为并不是所有chunk的加载都在入口模块(webpack运行时)执行之前。webpack运行时执行完成之后加载的chunk也一定会触发chunkLoadingGlobal
的push方法(参考上面编译生成的chunk),这样自动就会将对应的模块添加到运行时的__webpack_modules__
中,完成动态模块的安装。
到这里解释了代码中对chunkLoadingGlobal
的push方法劫持后执行自定义逻辑的行为做出了解释,但是为什么还要将之后添加的模块再通过rawPush
添加到原数组中就又是一个新的问题。
这是因为webpack打包的应用虽然入口大部分情况下只有一个但并不代表只能有一个。换句话说,同一个应用的webpack运行时可能有两个或者多个,其取决于entry
的配置。再明确一点chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))
这行影响全局行为的代码在有多个入口的情况下会被执行多次,rawPush
可能不是数组的push方法,而是上一个入口的nowPush
方法。
如果不将之后动态加载的chunk添加到原来的数组中,那么就会导致添加行为只能被添加到最后一个运行时内,即使触发加载行为的可能是第一个运行时。