在最近的 Supercharged Livestream 中,我们实现了代码拆分和基于路由的分块。 随着 HTTP/2 和原生 ES6 模块的出现,这些技术将成为实现脚本资源的有效加载和缓存的关键。
这一集中的其他提示和技巧
asyncFunction().catch()
与error.stack
:9:55<script>
标记上的模块和nomodule
属性:7:30- 节点 8 中的
promisify()
:17:20
要点
如何通过基于路由的分块进行代码拆分:
- 获取入口点列表。
- 提取所有这些入口点的模块依赖项。
- 查找所有入口点之间的共享依赖项。
- 捆绑共享依赖项。
- 重写入口点。
代码分块与基于路由的分块
代码拆分和基于路由的分块密切相关,并且通常可互换使用。这导致了一些混淆。我们来澄清一下:
- 代码拆分:代码拆分是指将代码拆分为多个软件包的过程。如果您未将包含所有 JavaScript 的大软件包发送给客户端,您就是在进行代码拆分。拆分代码的一种具体方法是使用基于路线的分块。
- 基于路由的分块:基于路由的分块会创建与应用的路由相关的软件包。通过分析您的路线及其依赖项,我们可以更改哪些模块要添加到哪个软件包中。
为什么要进行代码拆分?
松散模块
借助原生 ES6 模块,每个 JavaScript 模块都可以导入自己的依赖项。当浏览器收到一个模块时,所有 import
语句都会触发额外提取来获取运行代码所需的模块。不过,所有这些模块都可以拥有自己的依赖项。危险在于,浏览器最终会进行一系列提取,这些提取会持续多次往返,然后才能最终执行代码。
分类显示
捆绑(即将所有模块内嵌到单个 bundle 中)可确保浏览器在 1 次往返后拥有所需的所有代码,并能够更快地开始运行代码。但是,这会迫使用户下载大量不需要的代码,从而浪费带宽和时间。此外,对原始模块中的任何更改都会导致软件包发生更改,从而使软件包的任何缓存版本失效。用户将不得不重新下载整个内容。
代码分块
中间层是代码拆分。我们愿意投资额外的往返,通过只下载所需的内容来提高网络效率,并通过大幅减少每个软件包的模块数来提高缓存效率。如果捆绑正确完成,往返次数总数将远低于使用松散模块时的往返次数。最后,如果需要,我们可以利用 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);
}
结束
这真是一段不平凡的经历,对吗?请注意,本课程的目标是介绍和阐明代码分块。结果有效,但仅适用于我们的演示网站,在一般情况下会严重失败。对于生产环境,我建议使用 WebPack、RollUp 等成熟工具。
您可以在 GitHub 代码库中找到我们的代码。
再见!