为什么他的 Webpack 这么快?

一、Webpack 现状

最初的 Webpack 主打 Bundle 并且支持「Code Splitting」;在经过一段爆发式增长后,最有用的功能之一:热更新 Hot Module Replacement (HMR)出现(你还可以在这里 Stack Overflow – What exactly is Hot Module Replacement in Webpack? 看到 2014 年 Dan 的提问及 Tobias 的回答);经过多年的发展到现在,强大的 Webpack 有了一个新的痛点,那就是「慢」。

为什么慢

通常,一个中大型项目的依赖会非常多,除了逐渐增长的项目体积,以及各种 Loader 与 Plugin 也会拖慢整体的构建速度。 其中最耗时应该属于压缩阶段一般会用到的 Terser ,其次是构建阶段的 Babel ,除此之外,Webpack 去构建 ModuleGraph 的过程也会导致性能瓶颈,而现在多数浏览器已经支持 ESM ,其它的 Vite/Snowpack/wmr 等构建工具使用 ESM 的 Bundleless 方案将这部分依赖分析的工作交由浏览器完成,速度当然就会快很多。

持久缓存

空间换时间是见效非常明显的提速方案,在 Webpack5 中通过配置 cache: 'filesystem' 来开启持久缓存,开启后命中缓存的情况下会直接反序列化缓存文件并跳过构建流程。

{
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename, path.resolve('package.json')]
    },
  },
}

同样 babel-loader 也可以开启持久缓存:

module: {
  rules: [{
      test: /.(js|jsx|ts|tsx)$/,
      use: {
        loader: 'babel-loader',
        options: {
          cacheDirectory: true,
          ...
        },
      },
      ...
    },
  ],
}

命中缓存后,原本构建需要 16.63s 的项目只需要 2.65s :

持久缓存效果非常好,但是对于首次启动的项目来说没有任何提升。那么有没有办法直接提升首次构建速度?

ESBuild / SWC

使用 swc-loader / esbuild-loader 替换 babel-loader 也可以一定程度上减少构建耗时。下面是使用了一个拥有 5 个页面(包含 10+ 个 ArcoDesign 组件)的普通中后台项目构建测试的结果,虽然还远达不到中大型应用的标准,但也能明显体现出替换后的效果:

可以看到,即使替换成 ESBuild 后,依然会有 10s+ 的耗时,当引入的依赖模块过多时,即使没有配置任何 Loader 处理模块,直接加载 Vanilla JS 也会耗时较长。

对于不太追求压缩率的项目可以使用 ESBuild 替换 Terser 做压缩,但在开发阶段启动一般不会启用,这块不会影响到项目启动的构建耗时。

https://github.com/privatenumber/minification-benchmarks

ESBuild/SWC 效果也非常不错,但对于部分项目来说无法完全脱离 Babel ,即使使用了 ESBuild 或 SWC 后也会受到来自 node_modules 模块数量过多的影响无法达到更快的构建速度。

二、按需编译(懒编译)

For Development Only

对于部分中大型应用来说,完整启动一次的时间太长了,可能出门 CTRL 玩一圈回来还没编译完。那么为了提升首次构建的速度,早在 Next.js 8.x 版本中就出现了按需编译:当应用启动时不会编译所有页面 ,而是在访问时即时编译。这样做的好处就是,在开发阶段减少了不必要模块的构建,无论应用后续增长多大,都能保持相对稳定的启动速度,但对于小型应用来说,体感上不会那么明显。

Next.js 的按需编译

https://nextjs.org/blog/next-8#improved-on-demand-entries

在 Next.js 中基于 Webpack 实现了按需构建的能力,同时使用了 SWC 替换 Babel 实现 17 倍的速度提升(来自 Next.js 官方文档),使得 Next.js 在中大型应用开发中也能保持很可观的构建速度。

  1. 自定义 Loader 动态加载 Pages

通过自定义 Loader 劫持入口 ,对于不需要构建的模块返回劫持后的内容,该内容中的代码被运行时会触发一段激活逻辑,当前页面/模块会被标记为活跃状态。如下,window.__NEXT_P 记录了当前活跃页面:

import { stringifyRequest } from '../stringify-request'

export type ClientPagesLoaderOptions = {
  absolutePagePath: string
  page: string
  isServerComponent?: boolean
}

// this parameter: https://www.typescriptlang.org/docs/handbook/functions.html#this-parameters
function nextClientPagesLoader(this: any) {
  const pagesLoaderSpan = this.currentTraceSpan.traceChild(
    'next-client-pages-loader'
  )

  return pagesLoaderSpan.traceFn(() => {
    const { absolutePagePath, page, isServerComponent } =
      this.getOptions() as ClientPagesLoaderOptions

    pagesLoaderSpan.setAttribute('absolutePagePath', absolutePagePath)

    const stringifiedPageRequest = isServerComponent
      ? JSON.stringify(absolutePagePath + '!')
      : stringifyRequest(this, absolutePagePath)
    const stringifiedPage = JSON.stringify(page)

    // 记录当前页面到 window.__NEXT_P 中
    return `
    (window.__NEXT_P = window.__NEXT_P || []).push([
      ${stringifiedPage},
      function () {
        return require(${stringifiedPageRequest});
      }
    ]);
    if(module.hot) {
      module.hot.dispose(function () {
        window.__NEXT_P.push([${stringifiedPage}])
      });
    }
  `
  })
}

export default nextClientPagesLoader
  1. 转发至 Dev Server

上述的 window.__NEXT_P.push 方法在调用前已经被覆写,所以这里会通过对应的 routeLoader 触发对应页面的加载,如 /_next/static/chunks/pages/index.js 会被传递给 Dev Server 。


  ...
  pageLoader = new PageLoader(initialData.buildId, prefix)
  
  const register: RegisterFn = ([r, f]) =>
    pageLoader.routeLoader.onEntrypoint(r, f)
  if (window.__NEXT_P) {
    // Defer page registration for another tick. This will increase the overall
    // latency in hydrating the page, but reduce the total blocking time.
    window.__NEXT_P.map((p) => setTimeout(() => register(p), 0))
  }
  window.__NEXT_P = []
  ;(window.__NEXT_P as any).push = register
  ...
  1. 判断是否需要触发构建

Dev Server 最后将路径交由 onDemandEntryHandler 判断是否需要构建,该模块未被记录则会被添加到 entries ,并触发 Webpack 构建(call invalidate())。

这里还包括一些优化逻辑,比如 25s 内没有使用的页面会被回收以减少内存占用。

  1. 构建完成返回页面

从下图中可以看出每当访问新的路由时,会触发构建并打印提示,编译完成后可访问。其次,与 SWC 深度融合也是 Next.js 构建这么快的原因之一。

Webpack 的按需编译

https://webpack.js.org/configuration/experiments/#experimentslazycompilation

现在,我们可以直接使用 Webpack5 的实验特性 lazyCompilation 开启 Webpack 的按需编译。

某 Webpack5 项目开启后,本地首次启动耗时减少 100%+

Webpack 的实现与 Next.js 大同小异,也是劫持模块的加载并记录活跃状态,判断是否需要构建以返回对应模块真实内容。只不过他们实现的维度不同,Webpack 的 lazyCompilation 是以 Webpack 插件的形式的实现,并使用 LazyCompilationProxyModule 来代理原来的模块,当其 build 方法触发时,会判断当前的 active 状态,如果需要被构建,则返回 originalModule 即原有的模块。

...
    /**
     * @param {WebpackOptions} options webpack options
     * @param {Compilation} compilation the compilation
     * @param {ResolverWithOptions} resolver the resolver
     * @param {InputFileSystem} fs the file system
     * @param {function(WebpackError=): void} callback callback function
     * @returns {void}
     */
    build(options, compilation, resolver, fs, callback) {
        this.buildInfo = {
            active: this.active
        };
        /** @type {BuildMeta} */
        this.buildMeta = {};
        this.clearDependenciesAndBlocks();
        const dep = new CommonJsRequireDependency(this.client);
        this.addDependency(dep);
        if (this.active) {
            const dep = new LazyCompilationDependency(this);
            const block = new AsyncDependenciesBlock({});
            block.addDependency(dep);
            this.addBlock(block);
        }
        callback();
    }
…

具体流程不再赘述,可以查看 Github – Webpack: lib/hmr/LazyCompilationPlugin.js

三、依赖预构建 + Module Federation

For Development Only

既然在多数项目中,Webpack 在处理依赖模块(node_modules)的耗时远大于项目代码,那么我们可以将来自 node_modules 的模块使用 ESBuild/SWC 等巨快的构建工具构建后提供给 Webpack 消费,这里也就需要配合 Webpack5 的新特性 Module Federation 来连接彼此。

这块业界已经有了解决方案,也就是 umijs 的 MFSU (Module Federation Speed Up)。

MFSU 是如何做的?

image.png

  1. 转换 imports

当 Webpack 开始构建,基于 babel-loaderesbuild-loader 遍历 AST 修改对项目依赖的导入,映射到由 Module Federation 容器导出的模块。

import react from 'react';

// 转换后
import react from 'mf/react';

与此同时生成供 ESBuild 构建的入口文件,如动态生成的 react.js 文件,内容如下:

这里会兼容处理 CJS/ESM 的多种导出情况

import _ from 'react';
export default _;
export * from 'react';

所有依赖导入均被转为一个导出文件输出至临时文件夹中,待 ESBuild 构建后封装成 Module Federation Container 导出。

  1. 构建 ModuleGraph

在转换 imports 过程中已经完成了 ModuleGraph 的构建,主要用作缓存以及记录具体的依赖导入,以精确判断是否需要触发新一轮的依赖构建。

(早期 MFSU 每次依赖变化需要手动 rm -rf 缓存目录以触发重新构建)

最终会生成一份 ModuleGraph 的 JSON 文件,下次会通过该缓存 hydrate 以跳过 ModuleGraph 构建。

// .mfsu/MFSU_CACHE.json
{
  "cacheDependency": {},
  "moduleGraph": {
    "roots": [
      "/Users/***"
      "/Users/***"
    ],
    "fileModules": {
      "/Users/***": {
        "importedModules": [
          "/Users/***"
        ],
        "isRoot": true
      },
      ...
    },
    "depModules": {
      "react/jsx-runtime": {
        "version": "18.2.0"
      },
      ...
    },
    "depSnapshotModules": {
      "react/jsx-runtime": {
        "file": "react/jsx-runtime",
        "version": "18.2.0"
      },
      ...
    }
  }
}
  1. 生成 remoteEntry.js

属于 node_modules 的模块会由 ESBuild 构建,并在输出模块中声明 __webpack_require__ 并附加 Module Federation Container 的初始化逻辑,通过 devServer 供主项目消费。

  1. 并行构建

依赖的构建与主项目构建会并行,在 Webpack 项目启动后同时触发基于 ESBuild 的依赖构建。

开启前后完整构建耗时对比

这里的依赖预构建的思路和 Vite 相似,只不过 Vite 是为了解决 CJS 和 UMD 兼容性以及 ES 子模块大量请求导致的性能问题,而 MFSU 是为了减少 Webpack 所需要处理的模块,将 node_modules 交给 ESBuild 构建,避开了 Webpack 「慢」的问题从而减少了构建耗时。

四、小结

十分钟快速提升我的 Webpack 项目速度:

  • 开启持久缓存和按需编译(1分钟)
{
  ...
  cache: {
    type: "filesystem",
    buildDependencies: {
      config: [__filename],
    },
  },
  experiments: {
    lazyCompilation: true
  }
}
  • 使用 esbuild-loader/swc-loader 替换 babel-loader(2分钟)

  • 使用 ESBuildMinifyPlugin 替换 TerserPlugin (2分钟)

  • 接入 MFSU (5分钟)
© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容