超级直播博客 - 代码拆分

在最近的 Supercharged Livestream 中,我们实现了代码拆分和基于路由的分块。 随着 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 次往返后拥有所需的所有代码,并能够更快地开始运行代码。但是,这会迫使用户下载大量不需要的代码,从而浪费带宽和时间。此外,对原始模块中的任何更改都会导致软件包发生更改,从而使软件包的任何缓存版本失效。用户将不得不重新下载整个内容。

代码分块

中间层是代码拆分。我们愿意投资额外的往返,通过只下载所需的内容来提高网络效率,并通过大幅减少每个软件包的模块数来提高缓存效率。如果捆绑正确完成,往返次数总数将远低于使用松散模块时的往返次数。最后,如果需要,我们可以利用 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 代码库中找到我们的代码。

再见!