侧边栏

vite构建的拆包策略

发布于 | 分类于 前端/前端工程

前端拆包策略是一个非常个性化的话题,也是性能优化中首屏加载非常重要的一环。在不同的业务、不同的项目都有自己的策略。

在几年前,我负责的项目采用的是webpack + vue-cli,构建策略是

  • node_modules会单独打包到vendor.js文件
  • 其他业务代码打包到app.js
  • import()动态加载的模块会打包成单独的chunk

最近一两年逐渐切换到vite,vite的构建主要是依赖于rollup,默认构建ESM产物,同时通过@vitejs/plugin-legacy构建兼容低版本浏览器的systemjs产物。

在这个过程中,如何拆包、如何配置缓存等问题,都是需要考虑的。本文将深入这些问题,并尝试给出一个vite项目构建的解决方案。

相关构建工具文档

问题

ESM module 还是bundle

第一个问题就是:是否有必要构建两份构建产物。

先看看浏览器原生ESM的好处

  • 支持了ESM,说明也支持了ES6,可以减少很不不必要的polyfill
  • ESM可以让项目有更好的tree shaking,文件体积更小
  • 可以实现类似bundless的效果

但ESM也有很明显的缺点。

首先:浏览器默认的资源加载是线性的,比如下面的代码

js
// index.html
import { test2 } from './test2.js'

// test2.js
import { test1 } from './test1.js'

首先浏览器会加载index.html,然后加载test2.js,加载完成之后发现这个文件需要test1.js,然后又会去加载test1.js

整个过程根据import关系的嵌套,是完全线性的,如果文件依赖层级很深,那么整个过程将比直接加载一个bundle文件更久。

对于这个嵌套加载,常见的优化手段是preload,提前下载文件;或者进行bundle打包,减少依赖层级。关于这个问题,rollup本身进行了处理,具体细节在后面的嵌套依赖线性加载章节会具体描述,这里不再展开。

其次,如果项目是要兼容老版本浏览器,那就还是要构建一份兼容产物,相当于要构建两次,整个构建时间会拉长。

vite通过@vitejs/plugin-legacy这个插件,会自动判断环境加载对应版本的构建产物,考虑到项目的稳定性,我暂时没有定制这个策略,最终构建的还是两份代码。

最小化chunk拆分策略

第二个需要考虑的就是拆包的问题:将整个项目文件打包到一个或者少数几个文件,还是将文件拆分得足够细。

对于首屏加载优化来说,非常重要的一点就是拆包按需加载,只在访问页面时加载首屏需要的资源,这样才能保证打开速度最快。

传统的all in one 策略显然不太合适,即使配置了缓存,也没法避免首屏需要加载很大一个文件的问题

all in one的第二个问题是缓存效果不明显,项目代码发生了变化,整个bundle文件都需要被重新加载,哪怕里面只有一行代码发生了变化。

另外一种策略就是尽可能保证每个模块文件的独立性,这样每个页面都可以只加载自己需要的文件,真正做到按需加载。至于文件数量可能比较多的问题,可以开启http2,不受浏览器统一域名并发请求限制。

文件拆的足够细,缓存命中率就很高:每个chunk只有在其内容修改后,才会更新其hash,这样可以尽可能利用缓存。

考虑到首屏性能优化,我采用的是最小化chunk的拆分策略,将单个文件都拆分成了具体的chunk,同时开启了http2,根据文件hash名配置了强缓存。

但实际上,这个策略还生产环境中存在两个比较严重的问题,

  • 依赖层级变深,依赖文件由于线性加载导致整体时间变长
  • 单文件改动后,导致依赖该文件的其他文件hash也发生变化,缓存失效。

接下来的篇幅都是研究如何解决这个问题的。

单文件hash变化导致多文件缓存失效

如果每个chunk是独立的,那这种设想是很好的。但实际上,每个chunk或多或少都存在一些依赖,这导致按文件拆分chunk的这种策略有一个很严重的问题。

比如某个chunk文件A,如果依赖文件B发生了变化,A引入该依赖的代码就需要进行更新,这意味着A的代码也需要更新,同样那些依赖A的的模块也需要更新引入A的引入路径。

简单描述一下问题,首先有一个mod1.js文件

js
export function test1() {
    console.log('test1')
}

然后有个mod2.js文件,依赖了这个mod1.js文件

js
import {test1} from "./mod1.js";

export function test2() {
    test1()
    console.log('test2')
}

当使用rollup对这两个文件构建单独的chunk时,mod1.js文件会被编译为mod1-B3QA91Bi.js,而mod2.js文件被被编译为mod2-E6MF3as5.js,其内容如下所示

js
// mod2-E6MF3as5.js
import { test1 } from './mod1-B3QA91Bi.js';

function test2() {
    test1();
    console.log('test2');
}

export { test2 };

当我们修改一下mod1.js源代码,

git
export function test1() {
-    console.log('test1')
+    console.log('test11')
}

重新构建,可以发现两个文件名字都发生了变化

  • mod1.js变成了mod1-BOuyLuqS.js,这个容易理解,内容变了,文件名hash也会变嘛
  • mod2.js变成了mod2-8EVo5NZM.js,这是为啥呢?我们又没有修改mod2.js的源码

答案就出现在mod2.js的第一行代码,由于mod1发生了变化

js
import { test1 } from './mod1-BOuyLuqS.js';

所以这里也会影响mod2.js构建后的代码,所以mod2的hash也被认为是发生了变化,所以会生成新的hash。

构建是没问题的,程序也能正常运行,但是文件名的变化会带来一个什么问题?

在现代前端工程中,一般会对文件名配置比较长的HTTP强缓存,这也是我们为什么要将文件名加上hash的缘故:同名的文件走缓存,没有缓存的文件说明是更新的文件,走首次加载。

因此,如果将原本没有变化的文件hash进行修改,就会导致缓存会失效。

最重要的是,这个过程会发生连锁反应,在上面的例子中,如果有其他模块mod3mod4...依赖了mod2,即使他们没有发生变化,只要上游的mod1发生了修改,那么他们都会跟着变。

最终的结果在每次构建后,即使大部分文件原本并没有修改,也会由于依赖文件的变动导致更新文件名,从而触发大面积的缓存失效,预期的通过文件名hash缓存的策略就失败了。

rollup的hash算法问题

rollup的hash算法问题,在rollup社区和vite社区已经讨论了很久

v3版本中,更新了hash算法,参考rollup pull 4543,但上面的问题依然存在。

文件依赖可以看做是一个拓扑排序的过程,排在上游的文件发生了变化,会影响下游所有文件的内容变化。

出现上面这种状况的原因,不在于hash出现在了文件名中,而在于其他模块直接使用了这个hash文件名

也就是说,rollup的生成新的hash的行为,本身是没有问题的,考虑到rollup esm的构建产物可以直接运行在浏览器的ESM中,ESM的模块就是需要根据文件名来加载模块。

可能可行的方案

那么,有没有什么办法可以解决这个问题呢?

缩小依赖层级

我们可以缩小文件的依赖层级,这样上游文件发生变化时影响的范围会更小一点。

比如有个公共包commonMod/index.js,采用了下面这种导出方式

js
export {a} from './sub/1'
export {b} from './sub/2'
export {c} from './sub/3'
export {d} from './sub/4'

然后有多个文件从这个index入口文件引入了某个方法

js
// 1.js
import {a} from './commonMod'
js
// 2.js
import {b} from './commonMod'

比如./sub/1文件发生了修改,commonMod/index.js都会发生变化,继而影响那些引用了这个index的文件的1.js2.js,而实际上,由于2.js并没有依赖sub/1文件的任何代码,这个改动理论上不应该影响2.js文件的hash

那么,该怎么解决这个问题呢?

看起来这个问题应该不是构建的问题,而是我们拆模块的问题。

在组织模块的时候,应该将模块的颗粒度拆的比较细,然后在引入的时候,直接引入具体的源文件,避免从一个很大的文件中导出多个模块,然后该中间文件又被很多地方引用的情况出现。

换言之,每个文件都应该直接导入他使用的那些具体的文件,而不需要通过这种中转的方式进行。

因此,我感觉正确的做法应该是,

  • 删除commonMod/index.js文件,
  • 1.js文件的导入代码修改为import {a} from './commonMod/sub/1'
  • 2.js同理修改

这样,即使修改了sub/1文件的内容,也不会影响具体的2.js的文件,即2.js还是可以使用之前的缓存。

那么如何实现呢?

这看起来类似于组件库的组件按需加载功能,也许可以编写一个babel插件,递归替换文件的依赖至最终的源文件。

但这还是没有解决根源问题:上游文件发生了变化,下游文件还是会改变其hash值,只是这个优化可以减少改动文件的数量而已。

webpack的contenthash

webpack提供了一个contenthash的构建策略,这个hash可以看做是排除了import路径之外、文件内容哈希。

举个例子,假设有以下两个文件:

js
// main.js
import { foo } from './utils.js';
console.log(foo);

// utils.js
export const foo = 'Hello, world!';

如果我们修改了utils.js的内容:

js
export const foo = 'Hello, webpack!';

使用Rollup构建时,由于utils.js的hash变了,main.js的hash也会跟着变,因为它引用了utils.js

但是使用Webpack构建时,只有utils.jscontenthash会变,而main.jscontenthash保持不变,因为它的内容并没有改变。

看起来webpack的这种hash策略就是我想要的:上游文件的改动不会影响下游文件的hash。

那么这种策略是如何实现的呢。

看一下构建后的文件,对于每个模块文件,webpack有一个单独的id,在main文件里面,每个id对应了文件具体的路径。

也就是说,在某个文件引入其他模块时,使用的是这个id做了一层映射,文件里面本身并没有使用其他模块的实际地址。

这样,即使依赖文件发生了变化,只要对应文件的id不变,当前文件的contenthash就可以不变。

import map

本质上,webpack的这种构建,也是一种依赖映射import maps

依赖映射这个东西跟超链接是一样的,一个网页A里面外链了多个链接,比如包含B和C,其中B的页面内容发生了变化,按道理来说,只要B的链接没有发生改变,是A是完全不需要感知的,只要通过B链接,能加载到B的最新内容就可以了。

比如改了.sub/1文件的内容,引入路径并不会变,而是在加载模块的时候,通过映射将sub/1映射到新的文件上面去。

因此,我们可以朝着这个思路来控制rollup实现类似的构建。

通过import map实现的构建策略

文件内容变化,导致文件hash名称变化,最终导致依赖的文件都需要变化,出现链式反应。

这个问题的根源在于,每个依赖文件使用的是具体的文件名称。

js
// B.js
import A from './A.js'

依赖是

js
B.js -> A.js

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

我们将这个依赖结构改变一下,加一个imports map依赖映射中间层

js
B.js -> imports map -> A.js

当A的链接发生变化后,只需要更新imports map这个依赖映射,B和依赖B的那些文件就都不需要修改了。

这个依赖映射应该长的像这样

js
{
	'./A.js' => './A-xxx-hash.js',
	'./B.js' => './B-xxx-hash.js'
  // ...
}

不论./A.js最终指向的是哪个文件,B.js的这行代码import A from './A.js'都可以根据映射找到具体的A模块。

如何实现这个依赖映射呢?

构建import map

首先,每个文件在构建时,依赖的其他文件如./A.js,就应该是一个固定的,不会变化的字符串,

  • 比如webpack中的数字(当然这个数字是webpack生成的,还是有可能会变)
  • 或者直接就是原始的路径全称

rollup中提供了[name]占位符来获取原始文件名(同名文件会自增保证唯一)

js
// rollup.config.js
output: {
  entryFileNames: '[name].js',
  chunkFileNames: '[name].js',
},

这样才能保证每个文件,在只有依赖文件发生变化时,自身的内容不发生变化

文件名称一样了,怎么通过hash文件名来配置强缓存呢?

构建完成后,我们可以继续处理已经生成的文件,对于每个文件,我们可以拿到其文件名,也可以拿到该文件具体的文件内容。

根据每个文件的内容,更新其名字为hash文件名,通过保存这个改动的映射,即操作前的文件名A.js和操作后的文件名A-xxx-hash.js

js
const importMaps = {}
for (const file of files) {
  const filePath = `${dir}/${file}`
  const { name } = path.parse(filePath)
  const content = await fs.readFile(filePath, 'utf8')
  // 生成hash
  const hash = getHash(content)
  const cdnUrl = `${name}-${hash}.js`
  const host = './'
  importMaps[`./${file}`] = `${host}${cdnUrl}`
  fs.copy(filePath, path.resolve(deployDir, cdnUrl))
}

注意这一步不会改动文件内的任何代码,其他文件编写的import A from './A.js'仍旧保持原样

所有文件都处理之后,我们就可以得到一个依赖映射importMaps,但是现在我们还没有使用这个映射。

我们需要做的是让import A from './A.js'这种代码,通过importMaps可以访问到正确的模块文件。

ESM import map

目前,已经有部分浏览器支持了ESM的import map。所以我们可以直接构建ESM代码

diff
// rollup.config.js
output: {
  entryFileNames: '[name].js',
  chunkFileNames: '[name].js',
+ format: 'es',
}

然后在HTML中插入依赖映射和入口文件

js
const html = `<script type="importmap">
  ${JSON.stringify((importsMap))}
</script>
<script src="${entry}" type="module"></script>
`

这样整个应用就能正常运行了。

当我们修改代码之后,重新上面的步骤,这个步骤只会更新那些改动了代码的文件,以及importMaps对象。

其他的文件没有任何改动,也就仍然可以使用之前的缓存,这就达到了预期的效果。

使用systemjs兼容兜底

对于那些不支持importmap或者不支持ESM的浏览器,还可以通过systemjs来进行兼容处理。

修改rollup的output,输出system格式的产物

diff
// rollup.config.js
output: {
  entryFileNames: '[name].js',
  chunkFileNames: '[name].js',
+ format: 'system',
}

根据systemjs 的import map文档,我们将得到的依赖映射对象,拼接到HTML中,同时还需要引入system加载器本身的代码。

js
const html = `
<script type="systemjs-importmap">
  ${JSON.stringify(({imports:importsMap}))}
</script>
<script src="https://cdn.jsdelivr.net/npm/systemjs/dist/system.js"></script>
<script src="./app-xxx-hash.js"></script>
`

现在整个应用就可以正常运行了,之后的改动也只会影响对应的文件和importmap。

处理node_modules中的依赖

rollup默认不会处理node_modules中的文件,可以安装@rollup/plugin-node-resolve 插件进行处理,将依赖文件打包到构建产物中。

在默认情况下,只是将用到的第三方依赖代码挪动到我们的文件中,如果改动了第三方依赖,也会导致文件hash变化。

比如这行代码

js
// index.js
import { indexOf } from 'lodash-es'

如果更新了lodash-es的版本,我们还是希望index.js的文件不会发生变化,而是通过依赖映射将lodash-es映射到新的模块上面。

可以通过rollup的manualChunks解决这个问题,比如社区比较常见的做法是将全部的第三方依赖打包到一个vendor文件里面。

js
output: {
  manualChunks(id) {
    if (id.includes('node_modules')) {
      return 'vendor'
    }
  },
},

这么做的缺点是往往首屏就需要加载一个很大的第三方依赖bundle,尽管里面的库可能会在很后面的某个页面才会使用到。

因此,对于第三方依赖,我也倾向于按单个模块进行打包:比如lodash-es包的对应一个lodash-es-xxx-hash的文件,vue包对应一个vue-xxx-hash的文件。

要实现这个目的,可以从id中提取出模块的名字,比如下面这种拆分

js
manualChunks(id) {
  // pnpm等包管理工具,在id路径存在多个node_modules,从最后一个取真实的包名字
  const idx = id.lastIndexOf('node_modules')
  const re = /node_modules\/(.*?)\//
  const [_, moduleName] = re.exec(id.slice(idx))
  return moduleName
}

嵌套依赖线性加载?

最小化chunk拆分策略,会导致文件的嵌套层级可能会比较深,我们来看看前面提到的了ESM依赖文件嵌套import 线性加载的问题。

嵌套依赖线性加载的本质是浏览器需要知道当前文件的内容之后,才知道可以去哪里加载这些文件。

就好比一个网页上的图片,浏览器得先解析了HTML的内容,才知道这个img标签的src是啥,然后才会去加载。

如果是手动编写的bundless的代码,除非在入口文件提前加载后续嵌套文件里面的依赖,否则无法绕开这个问题。

但在大多数情况下,我们会使用打包器来进行构建,在构建过程中,对于每个文件的具体依赖,实际上打包器本身就知道某个文件具体会依赖哪些嵌套的文件。

所以对于嵌套依赖的线性加载问题,使用rollup来构建的话,可以忽略这个问题。

首先我们来构建一个最小demo,假设有下面三个文件

js
// deep3.js
export function deep3() {
  console.log('deep3')
}

// deep2.js
import { deep3 } from './deep3.js'

export function deep2() {
  deep3()
  console.log('deep2')
}

// deep1.js
import { deep2 } from './deep2.js'

export function deep1() {
  deep2()
  console.log('deep1')
}

对于这个三个文件,我们将他构建三个小的chunk

js
manualChunks(id) {
  if (id.includes('deep')) {
    return path.parse(id).name
  }
},

最后在index中引入deep1.js

js
// index.js
import { deep1 } from './deep/deep1.js'
deep1()

执行构建,可以看见index.js最终的输出

js
System.register(['./deep1.js', './deep2.js', './deep3.js'], (function (exports, module) {
  'use strict';
  var deep1;
  return {
    setters: [function (module) {
      deep1 = module.d;
    }, null, null],
    execute: (function () {
      deep1();
    })
  };
}));

虽然在源代码中只引入了deep1,但是rollup会智能地将文件所有的依赖都提前注册,这样在加载index.js之后,就会同时加载这三个静态依赖,而不是ESM那样的线性加载。

下图展示了三个文件并列加载的情况

看来systemjs是没有这个线性加载的问题,那么ESM呢?看一下output.formatesindex.js

js
import { d as deep1 } from './deep1.js';
import './deep2.js';
import './deep3.js';

deep1();

rollup也是智能地将deep2deep3也放在了index.js里面,这样就可以在加载index.js,同时加载这三个文件。这样也可以避免线性加载的问题。

这样看起来,上面通过import map来实现最小化chunk的拆包策略是完全可行的。

小结

在http2逐渐流行的今天,最小化chunk的拆分策略也许是首屏优化、按需加载更优的做法。

vite的构建底层使用的是rollup,rollup的文件hash算法导致上游文件内容变化之后,所有下游的文件hash名称也会发生变化,导致缓存文件大面积失效,这导致最小化chunk并没有看起来那么美好。

本文研究了使用import map依赖映射来解决这个问题,接下来会尝试将这个方案用到生产环境中。

其他的策略问题

上面解决了最重要的拆包问题,在实际项目中,还会面对一些其他的构建策略问题,这里稍作整理。

直接上传到CDN,还是配置CDN回源?

对于构建产物,使用CDN有两种做法

  • 在CI流程中,通过插件直接上传到CDN上面
    • 优点,不需要CDN额外配置,服务器上不额外保存静态资源文件
    • 缺点,增加CI构建时间
  • 在构建时保存到服务器磁盘上,通过CDN回源配置访问静态资源
    • 优点,构建直接生成,不需要额外等待上传
    • 缺点:
      • 历史构建文件不能被移除(或者需要保存很长一段时间),避免发版时旧版本资源失效;
      • CDN和服务器网关都需要额外配置

建议方案:直接通过插件上传到CDN上面。

  • chunks数量很多,可以通过预检测等手段优化,已存在的文件无需上传
  • chunks数量少,那么上传耗时速度基本可以忽略

按文件拆分chunk,还有一个问题是chunk数量很多,而有的OSS的SDK上传文件夹(比如阿里云的),需要按文件进行上传,拆分的chunk过多,上传速度也是影响CI构建的整体耗时,这里也是可以优化的。

常见的做法是先fetch检测一下同名文件是否存在,如果有了就不用上传了;或者本地缓存记录一下已经上传文件的值,检测到同名的文件就无需再上传。

资源文件使用相对路径,还是超链接

对于项目中的静态资源,如图片、字体等,也有两种策略

  • 资源保存在本地,代码中使用相对路径
    • 优点
      • 使用直观,可以通过git追踪资源变化;
      • 开发简单,将资源放在目录下即可;
      • 可以通过插件自动优化,比如图片压缩、转base64等
    • 缺点,
      • 项目变大时,git仓库变大,拉取速度变慢;版本迭代一段时间后,会导致项目中存在很多过时无用的静态资源
      • 需要为资源取名字,编码时需要注意相对路径的拼写;
      • 构件时需要额外处理这些资源,构建速度变慢
  • 在使用前直接将资源上传到CDN上面,代码中使用HTTPS超链接
    • 优点,
      • git只维护代码相关的资源,静态资源只通过代码维护链接的变化;
      • 不需要考虑资源名称和代码编写,直接复制链接即可
      • 构建速度快,因为根本不需要处理这些资源
    • 缺点,
      • 需要手动(或通过脚本)上传资源,在编码之外有额外的工作
      • 需要通过链接才能看到对应的资源文件,不如直接git diff直观

建议方案:直接上传到CDN上面,直接在代码中HTTPS超链接,对于项目的维护比较友好。图标之类的较小的静态资源文件,可以考虑SVG组件等实现。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。