超级直播博客 - 代码拆分

在最近的超级直播中,我们实现了代码拆分和基于路由的分块。借助 HTTP/2 和原生 ES6 模块,这些技术对于实现高效加载和缓存脚本资源至关重要。

此剧集中的其他提示和技巧

  • asyncFunction().catch()error.stack9:55
  • <script> 标记的模块和 nomodule 属性:7:30
  • 节点 8 中的 promisify()17:20

要点

如何通过基于路由的分块进行代码拆分:

  1. 获取入口点列表。
  2. 提取所有这些入口点的模块依赖项。
  3. 查找所有入口点之间的共享依赖项。
  4. 捆绑共享依赖项。
  5. 重写入口点。

代码拆分与基于路由的分块

代码拆分和基于路由的分块密切相关,且通常可互换使用。这引起了一些混淆。我们来试着解决这个问题:

  • 代码拆分:代码拆分是指将代码拆分为多个软件包的过程。如果您没有将一个包含所有 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 代码库中找到我们的代码。

再见!