DRAFT: pre-bundle dependencies for vite

背景

如果我不想每次构建 SPA 的时,把项目的依赖也一并重新构建一遍,我应该怎么做?
预打包项目依赖,当项目依赖长久没有发生变更时,不再重新 bundle node_modules。

解决方案

类似 Webpack DLL plugin,但是要更加智能。

只预打包项目中多次使用的依赖包。
同时支持 build/serve 两种模式。

实现细节——预打包部分:

用正则表达式找出项目中所有的 import 语句
用单独的 vite config 配合 preBundle 插件为第三方包生成预打包文件

实现细节——使用预打包文件:

build 模式,这里涉及 rollup 相关的插件逻辑,需要让 rollup 将预打包的模块 id 解析为外部依赖,并且将预打包文件作为 prebuilt-chunk 输出。

dev 模式,这里涉及 vite 跟 esbuild 相关的逻辑。
由于我们并不会为所有的第三方依赖生成预打包文件,所以存在某些 optimizeDeps 模块引用预打包模块的情况。
vite 会使用 esbuild optimize deps,所以需要配合 esbuild 插件处理。

如何在导入方使用 commonjs 导出的 named export ?

在预打包阶段, rollup 不会为 cjs entry 生成 named export,当我们在导入预打包文件时,需要对这些被转换成 esm 的 cjs 做一些额外处理。

需要自己实现一个 transform hook:

// input
import { useState } from 'react';
// ouput
import React from 'react';
const useState = React.useState;

反思

预打包多次使用的依赖会为每个依赖都创建一个 bundle 文件,这可能会让浏览器加载页面的时候需要发送更多的请求。

但是考虑到这些预打包文件可能很少更新,浏览器本地缓存可以起到很好的效果。如果浏览器跟服务器之间使用的是 http2 协议,这些请求似乎也不太算是问题?
如果要从根本上解决这个问题,还得靠 web bundle。

实现 merged exports

https://github.com/tc39/proposal-module-declarations?tab=readme-ov-file

@ant-design/icons 中提供了很多 icons, 如果为每个 icon 都创建一个 prebundle chunk, 那么 output 目录中就会出现上千的小文件。

要避免这种情况,需要实现合并 exports 的功能。

// vite.config.mts

plugins: [
  prebundleReference({
    merge: {
      '<ruleName>': ['<module-id-prefix>', '@ant-design/icons']
    }
  })
]

以上的配置会在 transform 环节产生如下的代码:

// pre-bundle-merged-<ruleName>.mjs
import * as __at__ant_design_icons from '@ant-design/icons';
import * as __at__ant_design_icons_Foo from '@ant-design/icons/Foo';
export const __ns_at__ant_design_icons = __at__ant_design_icons;
export const __ns_at__ant_design_icons_Foo = __at__ant_design_icons_Foo;

对应的 manifest 部分

[
  {
    "moduleId": "@ant-design/icons",
    "moduleFilePath": "pre-bundle-merged-<ruleName>.mjs",
    "exports": [
      "default",
      "__moduleExports"
    ],
    "exportAs": "__ns_at__ant_design_icons",
    "isCommonJS": false
  },
  {
    "moduleId": "@ant-design/icons/Foo",
    "moduleFilePath": "pre-bundle-merged-<ruleName>.mjs",
    "exports": [
      "default",
      "__moduleExports"
    ],
    "exportAs": "__ns_at__ant_design_icons_Foo",
    "isCommonJS": false
  },
]

当在 reference 插件中使用时,需要转换 import 代码:

// helper

export function _prebundle_merge_get_default(moduleLike) {
  return moduleLike.default ?? moduleLike;
}

export function _prebundle_merge_get_named(moduleLike, name) {
  if(name in moduleLike){
    return moduleLike[name];
  }
  if(moduleLike.default && name in moduleLike.default) {
    return moduleLike.default[name];
  }
}
// default import
import Foo from '@ant-design/icons/Foo';
// =>
import { __ns_at__ant_design_icons_Foo as __ns_at__ant_design_icons_Foo$1 } from '@ant-design/icons/Foo';
const Foo = _prebundle_merge_get_default(__ns_at__ant_design_icons_Foo$1);


// named import
import { Foo } from '@ant-design/icons';
// =>
import { __ns_at__ant_design_icons_Foo as __ns_at__ant_design_icons_Foo$2 } from '@ant-design/icons/Foo';
const Foo = _prebundle_merge_get_named(__ns_at__ant_design_icons_Foo$2, 'Foo');

// ns import
import * as Icons from '@ant-design/icons';
// =>
import { __ns_at__ant_design_icons_Foo as __ns_at__ant_design_icons_Foo$3 } from '@ant-design/icons/Foo';
const Icons = __ns_at__ant_design_icons_Foo$3;

prebundle merge transform 的逻辑应该在 commonjs 转换之后,因为 commonjs transform 假定 module 只会导出 { default: blabla }

// input
import {useCallback} from 'react';

// transform commonjs
import React from 'react';
const useCallback = React.useCallback;

// transform prebundle merge
import { __ns_react as __ns_react$1 } from 'react';
const React = _prebundle_merge_get_default(__ns_react$1);
const useCallback = React.useCallback;

好像也可以将 commonjs transform 跟 prebundle merge transform 合并?

[
  {
    "moduleId": "react",
    "moduleFilePath": "pre-bundle-merged-<ruleName>.mjs",
    "exports": [
      "default"
    ],
    "exportAs": "default",
    "isCommonJS": true
  }
]
import React, {useCallback} from 'react';

// =>
import { default as __ns_react$1 } from 'react';
const React = _prebundle_merge_get_default(__ns_react$1);
const useCallback = _prebundle_merge_get_named(__ns_react$1.'useCallback');

接着,在 resolve 阶段,只需要将模块 id 都替换成 <path-to>/<moduleFilePath>

预打包依赖项,真的可以提高构建效率吗?

在我的项目中,第三方依赖的体积远大于实际的代码量,每次启动 dev server 或者构建 production artifacts,都需要消耗很多时间在转换 node_modules 代码上。预打包依赖就可以很好的解决这个问题。

另外,配合 nx 的共享缓存,这个项目的所有贡献者、CI 流水线,都可以享受到预打包带来的提升。

为啥不能用 UMD 格式提供 pre-bundle ?

rollup 不支持为 umd bundle 生成 common shared chunk

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Index