阅读视图

发现新文章,点击刷新页面。

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

探索Vite深入 Rollup 分块插件:从零实现一个智能分包工具

告别正则匹配的硬编码,用规则引擎优雅管理代码分割

引言

在 Rollup 打包配置中,manualChunks 是最强大也最容易被误用的选项之一。社区常见的做法是写一堆 if (id.includes('node_modules')) 或正则表达式,把第三方库一股脑打入 vendor 块。这种方案在项目初期看似简单,但随着迭代,很容易出现:

  • chunk 体积失控:一个 vendor 文件动辄几 MB。
  • 缓存失效频繁:任何依赖更新都会导致整个 vendor 重新下载。
  • 代码复用不佳:被多个入口共享的公共模块无法独立拆分。

为了解决这些问题,我们开发了 rollup.plugin.robin-build 插件(纯 JS/TS 版本,下文简称“本插件”)。它提供了一套声明式的分块规则配置,支持路径匹配、引用次数阈值、优先级排序等高级特性,让代码分割变得可预测、可维护。

插件概览

本插件导出两个主要部分:

  1. output 对象:标准的 Rollup 输出配置,定义了文件命名与分类规则。
  2. createSplitChunks 函数:接收用户配置,返回一个符合 manualChunks 签名 (moduleId, { getModuleInfo }) => string | void 的函数。

插件本身不依赖任何外部库,仅使用 Node.js 内置模块 path。其核心思路是:用户以对象形式定义多个“规则组”,每个规则组包含匹配条件(路径字符串或正则)、目标 chunk 名称、优先级以及最小引用次数。插件在构建时遍历每个模块,按优先级匹配规则,决定模块归属的 chunk。

第一部分:输出配置(output)

export const output = {
    entryFileNames: 'js/robin-[hash].js',
    hashCharacters: 'hex', // 减少字符集,见下图1
    experimentalMinChunkSize: 20 * 1024,
    chunkFileNames: (chunkInfo) => {
        if(chunkInfo.name && chunkInfo.name.startsWith('vendor-')){
            return 'js/[name]-[hash].js'
        }
        return 'js/chunk-[hash].js'
    },
    assetFileNames: (info) => { ... }
}

在这里插入图片描述

1.1 entryFileNames 与 hash 配置

  • entryFileNames:入口 chunk 的文件名模板。这里使用 app-[hash].js,并放入 js/ 目录。
  • hashCharacters: 'hex':指定 hash 编码方式为十六进制(Rollup 5.0+ 支持)。
  • experimentalMinChunkSize:设置最小 chunk 大小(20KB),Rollup 会尝试合并小于此阈值的 chunk,减少 HTTP 请求数量。

1.2 chunkFileNames 动态命名

chunkFileNames 可以是函数,接收 chunkInfo 对象。插件判断如果 chunk 名称以 vendor- 开头(通常是通过规则生成的 vendor 块),则保留原名称,例如 vendor-react-[hash].js;否则统一命名为 chunk-[hash].js

这样做的好处是:vendor 块名称可读性高,便于调试和 CDN 缓存策略区分。

1.3 assetFileNames 按扩展名分类

assetFileNames 根据文件扩展名将静态资源归类到不同子目录:

扩展名类型 输出目录
.css asset/css/
.wasm asset/wasm/
.json, .map asset/data/
.txt, .xml, .pdf asset/docs/
图片格式 asset/img/
音视频格式 asset/media/
字体格式 asset/fonts/
其他 asset/other/

这种细粒度分类对于大型项目尤其重要:运维可以针对不同资源类型设置不同的 CDN 缓存头(例如图片缓存一年,JSON 缓存五分钟)。

第二部分:核心分块引擎 createSplitChunks

createSplitChunks 是整个插件的灵魂。它接收一个配置对象,返回 manualChunks 函数。我们先看它的完整实现:

export const createSplitChunks = (config = {}) => {
    if(!isObject(config)) return null

    const list = []
    Object.keys(config).forEach((key) => {
        const test = config[`${key}`].test

        if(!(isRegExp(test) || isString(test))) {
            throw new Error('test 必须为正则表达式或字符串')
        }

        if (isString(test) && !path.isAbsolute(test)) {
            throw new Error(`test 路径必须为绝对路径,实际获取到的是: ${test}`)
        }

        if (isRegExp(test) && test.global) {
            throw new Error('正则表达式测试不得使用 /g 标志')
        }

        list.push({
            ...config[key],
            chunk_name: `${key.startsWith('vendor') ? key : `vendor-${key}`}`,
            type: isRegExp(test) ? 'regexp' : 'string'
        })
    })
    list.sort((a, b) => (b.priority || 0) - (a.priority || 0))

    return (disk_path, { getModuleInfo }) => {
        const moduleInfo = getModuleInfo(disk_path)

        const target = list.find(item=> {
            if(item['minChunks'] && moduleInfo){
                const static_count = moduleInfo['importers'] ? moduleInfo['importers'].length : 0
                const dynamic_count = moduleInfo['dynamicImporters'] ? moduleInfo['dynamicImporters'].length : 0
                const total = static_count + dynamic_count
                if (total < item['minChunks']) return false
            }
            if(item.type === 'regexp') return item.test.test(disk_path)
            return disk_path.startsWith(item.test)
        })

        if(target && isNull(target.name)) return null

        if(target) return target.name || target.chunk_name

        return null
    }
}

2.1 配置解析与校验

插件期望 config 是一个对象,其每个 key 代表一个规则组的名称,value 必须包含 test 字段(字符串绝对路径或正则表达式)。此外还可以包含:

  • name:自定义 chunk 名称(如果未提供,会自动生成 vendor-${key})。
  • priority:优先级(数字越大越先匹配)。
  • minChunks:最小引用次数,只有模块被引用的总次数 ≥ 该值时才匹配。

首先进行严格的类型校验:

  • 使用 toString.call 来判断数据类型(因为 typeof null === 'object',需要区分)。
  • 对于字符串类型的 test,要求必须是绝对路径(通过 path.isAbsolute 验证)。这确保了匹配的确定性,避免相对路径在不同工作目录下产生歧义。
  • 对于正则表达式,禁止使用 g 全局标志,因为 test 方法在全局标志下会有状态残留,导致不可预期的行为。

2.2 构建规则列表与优先级排序

解析后的每个规则对象会被扩展两个内部字段:

  • chunk_name:自动生成的备用名称(如 vendor-react)。
  • type:标记匹配方式('regexp''string')。

然后将规则数组按 priority 降序排序。没有指定优先级的规则默认为 0。排序保证了高优先级规则先被匹配,避免低优先级规则“抢走”本应归属高优先级规则的模块。

2.3 manualChunks 回调逻辑

manualChunks 接收两个参数:disk_path(模块在磁盘上的绝对路径)和上下文对象 { getModuleInfo }getModuleInfo 可以获取模块的依赖关系信息。

对于每个模块,插件会:

  1. 获取模块的引用信息moduleInfo.importers(静态导入该模块的模块列表)和 dynamicImporters(动态导入该模块的模块列表)。两者的长度之和就是该模块被其他模块引用的总次数 total
  2. 遍历规则列表:按照优先级顺序查找第一个匹配的规则。
    • 如果规则定义了 minChunks,则检查 total >= minChunks,不满足则跳过该规则。
    • 根据规则类型,用 test 匹配 disk_path
  3. 决定 chunk 名称
    • 如果匹配到的规则中 name 字段为 null,则返回 null(表示不强制放入任何特定 chunk,由 Rollup 默认处理)。
    • 否则返回 name 或自动生成的 chunk_name
  4. 未匹配任何规则则返回 null,让 Rollup 按照默认算法处理(通常是基于模块共享度自动拆分)。

2.4 设计亮点

优先级机制解决规则冲突

当多个规则都能匹配同一个模块时,优先级决定了最终归属。例如:

{
  "vue-vendor": {
    test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
    priority: 10
  },
  "node-vendor": {
    test: /[\\/]node_modules[\\/]/,
    priority: 0
  }
}

vuevue-i18nvue-router 会进入 vue-vendor 块,而其他 npm 包则进入 node-vendor。如果没有优先级,node-vendor 可能会先匹配,导致 Vue 也被打入通用 vendor。

minChunks 避免过度拆分

一个模块如果被很多地方引用(例如工具函数 debounce),独立成 chunk 是有益的;但如果只被一个入口使用,则应该合并到该入口的 chunk 中,减少 HTTP 请求。minChunks 参数允许开发者设置阈值,只有达到引用次数的模块才独立打包。

路径匹配的两种模式
  • 字符串绝对路径:适用于明确知道模块所在目录的场景,例如 '/app/shared/utils'
  • 正则表达式:更灵活,可以匹配 node_modules 中的特定包名,例如 /node_modules\/lodash-es/
自定义 chunk 名称与 null 返回值

允许规则返回 null 可以让某些模块“逃逸”出规则体系,由 Rollup 默认算法处理。这在使用第三方插件或有特殊分块需求时非常有用。

第三部分:类型判断辅助函数

插件开头定义了几个类型判断函数:没有引入loadsh-es,个人感觉没有必要,所以简化写一下

const toString = Object.prototype.toString
const isObject = (data) => toString.call(data) === '[object Object]'
const isNull = (data) => toString.call(data) === '[object Null]'
const isRegExp = (data) => toString.call(data) === '[object RegExp]'
const isString = (data) => toString.call(data) === '[object String]'

为什么不直接用 typeofinstanceof

  • typeof null === 'object',无法区分 null 和普通对象。
  • 在 Rollup 插件环境中,moduleInfo 等对象可能来自不同的上下文,instanceof 可能失效。而 Object.prototype.toString 返回的是内部 [[Class]] 属性,跨框架可靠。

第四部分:使用示例

4.1 基础配置

// rollup.config.js
import { createSplitChunks, output } from 'rollup-plugin-robin-build';

export default {
  input: 'src/main.js',
  output: {
    dir: 'dist',
    ...output,
    manualChunks: createSplitChunks({
      // 规则1:vue 全家桶单独打包
      vue: {
        test: /[\\/]node_modules[\\/](vue|vue-router|vue-i18n)[\\/]/,
        priority: 10,
        minChunks: 1
      },
      // 规则2:antd 组件库单独打包
      antd: {
        test: /[\\/]node_modules[\\/]antd[\\/]/,
        priority: 9
      },
      // 规则3:其他 node_modules 打入 vendor
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        priority: 0
      },
      // 规则4:src/utils 下的公共工具,引用次数 >=3 时独立
      utils: {
        test: path.resolve(__dirname, 'src/utils'),
        minChunks: 3,
        name: 'shared-utils'
      }
    })
  }
}

4.2 配合动态导入

Rollup 能识别动态导入(import()),getModuleInfo 中的 dynamicImporters 会记录哪些模块动态引入了当前模块。因此,minChunks 同样适用于动态导入的场景。

4.3 高级:跳过某些模块

如果某个模块我们不想受任何规则影响,可以在规则中设置 name: null

{
  exclude: {
    test: /[\\/]src[\\/]legacy[\\/]/,
    name: null,   // 让 Rollup 默认处理
    priority: 100
  }
}

第五部分:性能考量

5.1 时间复杂度

每个模块都会遍历规则列表(最坏 O(m·n)),其中 m 为模块数,n 为规则数。对于大型项目(几千个模块,几十条规则),遍历开销仍然可控。但如果规则数量膨胀到上百条,可以考虑将正则表达式编译一次并缓存结果,或使用 Trie 树优化字符串前缀匹配。

5.2 使用 getModuleInfo 的开销

getModuleInfo 是 Rollup 内部维护的模块图查询函数,调用开销极小。我们只在需要检查 minChunks 时才调用,且仅访问 importersdynamicImporters 属性,性能影响可忽略。

5.3 避免重复计算

插件没有做缓存,因为 manualChunks 函数在构建过程中会被多次调用(每个模块一次)。如果规则中包含复杂的自定义函数,可以考虑在外层 memoize。不过本插件完全基于配置,没有用户自定义函数,所以不需要缓存。

第六部分:与其他方案的对比

方案 优点 缺点
原生 manualChunks + 硬编码 简单直接 规则硬编码,难以维护;无法基于引用次数动态拆分
SplitChunksPlugin (Webpack) 功能强大,支持 cacheGroups 配置复杂,且 Webpack 与 Rollup 生态不同
本插件 声明式配置,支持优先级、minChunks,轻量无依赖 需要手动编写规则;无法像 Webpack 那样自动提取公共模块

本插件更适合那些希望将分块规则集中管理、且对 Rollup 生态有强依赖的项目。配合 experimentalMinChunkSize 和 Rollup 自带的 Tree Shaking,可以获得接近 Webpack 的代码分割体验。

第七部分:扩展建议

虽然当前插件已经满足大多数场景,但还可以进一步增强:

  • 支持函数形式的 test:允许用户传入 (id) => boolean,实现更灵活的匹配逻辑。
  • 支持异步规则:例如根据模块内容的大小或依赖关系动态决策。
  • 提供内置预设:例如 preset: 'vue' 自动配置 Vue 相关的分块规则。
  • 集成 bundle 分析器:生成分块报告,帮助用户调整规则。

结语

代码分割是前端性能优化的核心环节之一,但往往被忽视或粗暴处理。通过本插件,我们可以用声明式的规则引擎精细控制每个模块的去向,实现:

  • 更合理的缓存策略:稳定依赖单独 chunk,业务代码频繁更新不影响第三方库缓存。
  • 更快的首屏加载:避免一次性加载巨大的 vendor 文件。
  • 更清晰的构建产物:每个 chunk 有明确的命名和用途。

希望这篇文章能帮助你理解 manualChunks 的高级用法,并启发你构建属于自己的分块工具。如果你对插件有任何疑问或改进建议,欢迎在评论区交流。

对比

vue 全家桶单独打包 我在项目中走CDN啦! 默认分包 在这里插入图片描述 只用插件分包 在这里插入图片描述 很明显有很大的差别!

❌