likes
comments
collection
share

一文读懂 webpack 架构设计

作者站长头像
站长
· 阅读数 2

背景

历史

传统的前端开发模式是基于 JavaScript(ES5)、HTML、CSS,最终将 JS、CSS、图片等静态资源通过 script、link 等标签插入到 HTML 中进行部署。随着应用的复杂度提升就愈发难以维护,标签的插入位置、顺序,如果保证代码的复用性,如何删除未使用的代码,还需要担心变量的污染等问题。

随着 NodeJS 的提出,加上 RequireJS、UMD 等方案的完善,模块化的开发概念开始逐渐流行,我们可以将代码拆分为数个模块开发,最终在生产环境时将其组合起来即可。而后,Babel、TypeScript、CoffeeScript 等工具的出现可以协助我们在开发阶段绕过 ES5 自身的缺陷编写出高质量的代码,Less、Sass、Stylus 等工具,为页面样式开发引入逻辑运算、数学运算、嵌套、继承等结构化语言特性,等等。

这些工程化工具能不同程度地弥补浏览器、语言、规范本身的设计缺陷,但随着工具的发展也出现一个问题:如何管理这些工具与工程背后的工程化逻辑?我们需要一套足够开放,能融合诸多工程化工具,彻底抹平开发与生产环境差异的一体化工程方案,这也正是 Webpack 需要解决的问题。

Webpack 做了什么?

将具有依赖关系的模块合并打包为 JS、CSS 等浏览器兼容的静态资源:

一文读懂 webpack 架构设计

在 Webpack 概念中,所有资源文件都被统一视为模块(Module) ,以相同的的加载、解析、管理、合并流程处理,借助 Loader、Plugin 将资源的差异处理逻辑抛出交由社区实现。凭借设计上的强开放性,可以轻松接入社区的一系列工程化工具,这些工程又补充了 Webpack 的工程能力使其成为了一个一大统的资源处理框架,满足现代 Web 工程在效率、质量、性能等多方面的诉求,也能应用于小程序、客户端、SSR 等场景,即使在当前众多构建方案内卷的时代,依旧是使用最广泛的构建工具之一。

简易打包器

webpack的核心是现代 JavaScript 应用程序的静态模块打包器。 当 webpack 处理您的应用程序时,它会在内部从一个或多个入口点构建一个依赖关系图,然后将您的项目需要的每个模块组合到一个或多个bundle中,这些 bundle 是为您的内容提供服务的静态资产。

一文读懂 webpack 架构设计

Webpack 就可以理解为一个模块打包器,一个最简单的打包器流程为:

  1. 从入口模块分析依赖。
  2. 构造模块依赖图。
  3. 把所有代码合并。

我们可以按照这个流程实现一个:

从入口模块分析依赖

借助 babel 生成的 ast 结构,可以很方便地获取到引入的文件。

Webpack 使用 acorn 分析的 AST 结构。

acorn 是基于 estree 标准实现的底层 JS Parser 并提供了插件机制,espree(eslint)、babel parser 都是基于 acorn 做的扩展

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')

function step1(filename) {
  // 读入文件
  const content = fs.readFileSync(filename, 'utf-8');
  // 生成 AST
  const ast = parser.parse(content, {
    sourceType: 'unambiguous',
  });
  const dependencies = {};

  // 遍历AST抽象语法树
  traverse(ast, {
    // 获取通过import引入的模块
    ImportDeclaration({ node }) {
      // 处理引入的模块路径为绝对路径
      const importFilePath = path.resolve(path.dirname(filename), node.source.value);

      dependencies[node.source.value] = importFilePath;
    },
  });

  // 通过@babel/core和@babel/preset-env进行代码的转换
  const { code } = babel.transformFromAst(ast, null, {
    presets: ['@babel/preset-env'],
  });

  return {
    filename, // 该文件名
    dependencies, // 该文件所依赖的模块集合(键值对存储)
    code, // 转换后的代码
  };
}

一文读懂 webpack 架构设计

循环遍历,构造模块依赖图

function step2(entry) {
  // 入口文件的依赖关系
  const entryModule = step1(entry);

  // 所有模块的依赖关系
  const graphArray = [entryModule];

  for (const module of graphArray) {
    const { dependencies } = module;

    for (const dependency in dependencies) {
      // 将入口模块及其所有相关的模块添加到数组中,进行后续遍历
      graphArray.push(step1(dependency));
    }
  }

  // 接下来生成图谱
  const graph = {};

  graphArray.forEach(item => {
    graph[item.filename] = {
      dependencies: item.dependencies,
      code: item.code,
    };
  });
  return graph;
}

一文读懂 webpack 架构设计

合并输出产物

function step3(entry) {
  const graph = JSON.stringify(step2(entry));

  return `
      (function(graph) {
          function require(module) {
              function localRequire(relativePath) {
                  return require(graph[module].dependencies[relativePath]);
              }
              var exports = {};
              (function(require, exports, code) {
                  eval(code);
              })(localRequire, exports, graph[module].code);
              return exports;
          }
          require('${entry}')
      })(${graph})`;
}

Loader

所有资源文件统一视为模块,但 Webpack 本身不能处理非 JS 文件。

Loader 的作用就是预处理文件,将文件的内容转译之后输出标准的 JS 文本,Webpack 通过 acorn 就可以解析并生成 AST,进而分析此模块的依赖关系。

比如图片资源,最终需要被处理为 export default "data:image/png;base64,xxx"export default "https://xxx"

Plugin

Webpack 整个编译流程通过两个对象维护:

  • Compiler:负责整体的编译流程,只存在一个。
  • Compilation:负责单次的编译流程,当文件发生变更需要重新编译时,会创建一个新的 Compilation 对象。

Webpack 使用 Tapable(github.com/webpack/tap…

Tapabel 内部定义了如下钩子:

const {
        SyncHook,
        SyncBailHook,
        SyncWaterfallHook,
        SyncLoopHook,
        AsyncParallelHook,
        AsyncParallelBailHook,
        AsyncSeriesHook,
        AsyncSeriesBailHook,
        AsyncSeriesWaterfallHook
 } = require("tapable");

Webpack 抛出 hooks 按照编译流程注册了对应的钩子:

一文读懂 webpack 架构设计

Compiler 完整 hooks:webpack.js.org/api/compile…

Compilation 完整 hooks:webpack.js.org/api/compila…

插件本质上就是一个实现了 apply 方法的类:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, compilation => {
      console.log('webpack 构建正在启动!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

构建流程

  1. 初始化阶段
    • 通过 CLI 或读取 webpack.config.js 文件生成相应的配置,创建 Compiler 实例。
    • 遍历用户传入的 plugins,执行 apply 方法。
    • 触发 WebpackOptionsApply,根据 webpack 配置加载内部插件。
    • 环境初始化
    • 执行 compiler.run / compiler.watch 方法。
const createCompiler = rawOptions => {
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);
  // 创建 Compiler 实例
  const compiler = new Compiler(options.context, options);
  // 初始化
  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging
  }).apply(compiler);
  // 遍历用户传入的插件并执行 apply 方法
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === "function") {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }
  applyWebpackOptionsDefaults(options);
  compiler.hooks.environment.call();
  compiler.hooks.afterEnvironment.call();
  // 加载内部插件
  new WebpackOptionsApply().process(options, compiler);
  compiler.hooks.initialize.call();
  return compiler;
};
  1. 构建阶段
    • 从入口文件开始,
    • 调用 NormalModule.build() 方法,其中会执行对应的 loader,将原始代码进行 loader 进行转义,通常是 JS 文本。
    • 调用 parser.pase ,使用 acorn 将 JS 文本解析为 AST。
    • 通过 AST 结构分析依赖,对依赖的模块进行信息收集。
    • 递归收集所有依赖的模块信息,维护依赖关系图 moduleGraph
  2. 生成代码
    • 调用 compilation.seal() 方法

    • 根据 moduleGraph 生成 ChunkGraph 实例。

    • 将 modules 按照配置组合成 chunks,chunk 与最终的输出资源一一对应。(默认情况下一个入口对应一个资源,通过动态引入的模块单独为一个资源)

    • 调用 renderBootstrap 方法生成最终的代码,所有代码包裹在一个 IIFE 中。

思考

Webpack 为什么那么慢?

  1. JS 语言劣势,单线程开发,没有高效利用多核
  2. 代码构建
    • AST 未能高效利用:构建依赖关系图时使用 acorn,转译代码时使用的是 loader:babel-loader、ts-loader...,重复生成 AST 结构
  3. 代码压缩
    • terser 速度比较慢

如何提高构建速度?

Webpack5 给出的答案:

  1. 本地持久化缓存:webpack.js.org/configurati…
  2. 模块联邦:webpack.js.org/concepts/mo…
  3. 延迟编译(实验功能):webpack.js.org/configurati…
  4. ESM 格式产物(实验功能):webpack.js.org/configurati…
    • 第三方包资源编译并部署在 CDN 侧,某些第三方服务平台: esm.sh/#docs
    • 构建时将 externals 第三方包排除打包范围,改为远端资源链接
    • 只编译业务代码,第三方包资源在运行时加载,省去第三方包编译的时间
  5. 使用 esbuild-loader、swc-loader 替代 babel-loader、ts-loader 转译 JS、TS 代码
  6. 使用 esbuild、swc 压缩(实验阶段)

未来构建方向

No bundle

将文件打包成一个 bundle 需要等待比较长时间的资源合并阶段,随着项目复杂度的增大,冷启动和热更新的速度都会比较慢。

Vite 一类的工具在开发阶段采取 no bundle 不打包的形式,资源模块之间通过原生 es module 加载,并且基于 esbuild 进行资源的预构建,整体启动、更新耗时比 Webpack 少很多。

存在的问题:

  1. 尽管原生 ESM 得到了广泛支持,但嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 页面性能影响严重(即使使用 HTTP / 2)。
  2. 开发、生产环境不一致。由于 esbuild 针对构建的关键能力仍未成熟(代码分隔、CSS 处理),vite 生产模式基于 rollup 打包,而开发模式是基于 esbuild,没有抹平开发与生产环境的差异。

Bundle

工具链使用 Rust 编写:turbo.build/pack/docs

优势:速度快

劣势:

  1. 生态差
  2. 插件系统不完善,与 webpack 不兼容

参考链接: