在最近的超级直播中,我们实现了代码拆分和基于路由的分块。借助 HTTP/2 和原生 ES6 模块,这些技术对于实现高效加载和缓存脚本资源至关重要。
此剧集中的其他提示和技巧
asyncFunction().catch()
与error.stack
:9:55<script>
标记的模块和nomodule
属性:7:30- 节点 8 中的
promisify()
:17:20
要点
如何通过基于路由的分块进行代码拆分:
- 获取入口点列表。
- 提取所有这些入口点的模块依赖项。
- 查找所有入口点之间的共享依赖项。
- 捆绑共享依赖项。
- 重写入口点。
代码拆分与基于路由的分块
代码拆分和基于路由的分块密切相关,且通常可互换使用。这引起了一些混淆。我们来试着解决这个问题:
- 代码拆分:代码拆分是指将代码拆分为多个软件包的过程。如果您没有将一个包含所有 JavaScript 的大软件包交付到客户端,那么您正在进行代码拆分。拆分代码的一种具体方法是使用基于路由的分块。
- 基于路由的分块:基于路由的分块会创建与应用的路由相关的软件包。通过分析您的路由及其依赖项,我们可以更改要归入哪个软件包的模块。
为什么进行代码拆分?
松散模块
使用原生 ES6 模块,每个 JavaScript 模块都可以导入自己的依赖项。当浏览器收到模块时,所有 import
语句都会触发额外的提取操作,以获取运行代码所需的模块。不过,所有这些模块都可以拥有自己的依赖项。其危险在于,浏览器最终会进行层叠提取,持续进行多次往返,之后才最终能够执行代码。
捆绑
捆绑功能(即将所有模块内嵌到一个 bundle 中)可确保浏览器在 1 次往返后拥有所需的全部代码,并且可以更快地开始运行代码。但是,这会强制用户下载大量不需要的代码,浪费了带宽和时间。此外,对我们的一个原始模块进行的每次更改都会导致 bundle 发生变化,进而使 bundle 的任何缓存版本失效。用户必须重新下载整个内容。
代码拆分
代码拆分是中间点。我们愿意投入额外的往返时间来仅下载所需的内容,从而提高网络效率,并尽可能减少每个 bundle 的模块数量,从而提高缓存效率。如果捆绑正确,总往返次数会比松散模块低得多。最后,我们可以利用 link[rel=preload]
之类的预加载机制,根据需要节省额外的轮三轮测试时间。
第 1 步:获取入口点列表
这只是众多方法中的一种,但在本节中,我们解析了网站的 sitemap.xml
,以获取网站的入口点。通常使用列出所有入口点的专用 JSON 文件。
使用 babel 处理 JavaScript
Babel 通常用于“转译”:使用最新的 JavaScript 代码并将其转换为旧版 JavaScript,以便更多浏览器能够执行代码。第一步是使用解析器(Babel 使用 babylon)使用解析器解析新的 JavaScript,该解析器会将代码转换成所谓的“抽象语法树”(AST)。生成 AST 后,会一系列插件分析和破坏 AST。
我们将大量使用 babel 来检测(稍后会操纵)JavaScript 模块的导入。您可能很想使用正则表达式,但正则表达式的功能不足,无法正确解析语言,而且难以维护。依靠 Babel 等久经考验的工具可以省却很多麻烦。
下面是一个使用自定义插件运行 Babel 的简单示例:
const plugin = {
visitor: {
ImportDeclaration(decl) {
/* ... */
}
}
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});
插件可以提供 visitor
对象。访问者包含插件要处理的任何节点类型的函数。在遍历 AST 时遇到该类型的节点时,系统将以该节点作为参数调用 visitor
对象中的相应函数。在上面的示例中,系统将对文件中的每个 import
声明调用 ImportDeclaration()
方法。如需详细了解节点类型和 AST,请查看 astexplorer.net。
第 2 步:提取模块依赖项
为了构建模块的依赖项树,我们需要解析该模块,并创建其导入的所有模块的列表。我们还需要解析这些依赖项,因为它们可能也有依赖项。这是一个典型的递归案例!
async function buildDependencyTree(file) {
let code = await readFile(file);
code = code.toString('utf-8');
// `dep` will collect all dependencies of `file`
let dep = [];
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
// Recursion: Push an array of the dependency’s dependencies onto the list
dep.push((async function() {
return await buildDependencyTree(`./app/${importedFile}`);
})());
// Push the dependency itself onto the list
dep.push(importedFile);
}
}
}
// Run the plugin
babel.transform(code, {plugins: [plugin]});
// Wait for all promises to resolve and then flatten the array
return flatten(await Promise.all(dep));
}
第 3 步:查找所有入口点之间的共享依赖项
由于我们有一组依存关系树(如果需要的话就是一个依赖关系林),因此我们可以通过查找每个树中出现的节点来查找共享的依赖关系。我们将扁平化森林并删除重复信息,并进行过滤以仅保留所有树中出现的元素。
function findCommonDeps(depTrees) {
const depSet = new Set();
// Flatten
depTrees.forEach(depTree => {
depTree.forEach(dep => depSet.add(dep));
});
// Filter
return Array.from(depSet)
.filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}
第 4 步:捆绑共享依赖项
要捆绑我们的一组共享依赖项,我们只需串联所有模块文件即可。使用这种方法时会出现两个问题:第一个问题是 bundle 仍会包含 import
语句,这会导致浏览器尝试提取资源。第二个问题是依赖项的依赖项尚未捆绑。由于之前已经完成过此步骤,因此我们将要编写另一个 babel 插件。
代码与我们的第一个插件非常相似,但我们将移除导入内容,并插入导入文件的捆绑版本:
async function bundle(oldCode) {
// `newCode` will be filled with code fragments that eventually form the bundle.
let newCode = [];
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
newCode.push((async function() {
// Bundle the imported file and add it to the output.
return await bundle(await readFile(`./app/${importedFile}`));
})());
// Remove the import declaration from the AST.
decl.remove();
}
}
};
// Save the stringified, transformed AST. This code is the same as `oldCode`
// but without any import statements.
const {code} = babel.transform(oldCode, {plugins: [plugin]});
newCode.push(code);
// `newCode` contains all the bundled dependencies as well as the
// import-less version of the code itself. Concatenate to generate the code
// for the bundle.
return flatten(await Promise.all(newCode)).join('\n');
}
第 5 步:重写入口点
在最后一步中,我们将编写另一个 Babel 插件。它的作用是移除共享软件包中所有导入的模块。
async function rewrite(section, sharedBundle) {
let oldCode = await readFile(`./app/static/${section}.js`);
oldCode = oldCode.toString('utf-8');
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
// If this import statement imports a file that is in the shared bundle, remove it.
if(sharedBundle.includes(importedFile))
decl.remove();
}
}
};
let {code} = babel.transform(oldCode, {plugins: [plugin]});
// Prepend an import statement for the shared bundle.
code = `import '/static/_shared.js';\n${code}`;
await writeFile(`./app/static/_${section}.js`, code);
}
End
这次的行程很精彩,对吧?请谨记,我们本集的目标是解释和清楚阐明代码拆分。结果可以运行,但它特定于我们的演示网站,在一般情况下会严重失败。对于生产环境,我建议依赖于 WebPack、RollUp 等成熟工具。
您可以在 GitHub 代码库中找到我们的代码。
再见!