别让整个组件库都打进包里——从 entry 出口设计开始做 Tree Shaking

Tree Shaking 能生效的前提,不是你有没有写 sideEffects: false,而是你设计的 entry 出口,能不能让打包工具“看见”哪些代码是死的、哪些是活的。入口文件一旦写成全量导出的桶,后面所有优化都白搭。

为什么你的 Tree Shaking 总是失败

大多数组件库的入口文件长这样:

// src/index.js
export { default as Button } from './components/Button';
export { default as Input } from './components/Input';
export { default as Table } from './components/Table';
// ... 50 个组件

然后项目的使用代码:

import { Button } from 'my-ui-library';

直觉上,我们只引入了 Button,打包体积应该只包含 Button 的代码。但实际结果往往是整个组件库都进来了。

问题出在打包工具的静态分析逻辑上。import { Button } from 'my-ui-library' 这条语句,打包工具先要解析 my-ui-library 指向的入口文件。入口文件里那 50 行 export { default as X } 语句,每一条都会触发对应模块的加载和求值。打包工具看到的是:这个入口模块依赖了 50 个子模块,而子模块里可能有副作用代码,它不敢随便删。

这里的“副作用”判断是关键。就算你在 package.json 里写了 "sideEffects": false,也只是告诉打包工具“所有模块都没有副作用,可以安全删除未使用的导出”。但如果你的入口文件是这种集中导出模式,打包工具在分析时仍然需要把 50 个模块都加载一遍、分析一遍,才能确定 Button 到底依赖了什么、Table 有没有副作用。这个过程本身就拖慢了构建速度,而且在某些打包工具的默认配置下(比如 Webpack 的 optimization.sideEffects 配合特定的模块解析策略),可能直接放弃优化,全部打进 bundle。

拆 entry:让每个组件都有自己的入口

真正的解法是把入口拆到组件粒度。目录结构改成这样:

my-ui-library/
├── es/
│   ├── button/
│   │   ├── index.js
│   │   └── style.css
│   ├── input/
│   │   ├── index.js
│   │   └── style.css
│   └── index.js          # 保留,但不作为默认入口
├── lib/
│   └── ...               # CommonJS 版本同理
└── package.json

es/button/index.js 的内容就是 Button 组件本身的导出,不引入任何其他组件:

// es/button/index.js
import Button from './Button';
export default Button;
export { Button };

package.json 的关键字段改成这样:

{
  "main": "lib/index.js",
  "module": "es/index.js",
  "sideEffects": [
    "**/*.css",
    "es/*/style.css"
  ]
}

注意 sideEffects 我用的是数组而不是 false。因为 CSS 文件通常是有副作用的——你引入 CSS 就是为了让它插入到 DOM 里,这一步本身就是副作用。如果直接标 false,打包工具会把没有被 JS 变量引用的 CSS 删掉,导致样式丢失。正确做法是只标记 .css 文件有副作用,其余 .js 模块全部视为无副作用。

这时候使用者可以这样引入:

import Button from 'my-ui-library/es/button';
import 'my-ui-library/es/button/style.css';

打包工具解析这条路径时,只加载 es/button/index.js 这一个模块,不会触碰 Table、Input 等其他组件的代码。Tree Shaking 在模块依赖图层面就完成了,根本不需要依赖工具的 dead code elimination 能力。

用 exports map 让路径更友好

裸路径 es/button 对开发者不够友好,而且暴露了内部目录结构。Node.js 12.7 以后支持的 exports 字段可以解决这个问题:

{
  "exports": {
    ".": {
      "import": "./es/index.js",
      "require": "./lib/index.js"
    },
    "./button": {
      "import": "./es/button/index.js",
      "require": "./lib/button/index.js"
    },
    "./button/style": "./es/button/style.css",
    "./input": {
      "import": "./es/input/index.js",
      "require": "./lib/input/index.js"
    },
    "./input/style": "./es/input/style.css"
  }
}

使用代码变成:

import Button from 'my-ui-library/button';
import 'my-ui-library/button/style';

这种路径在 Webpack 5、Rollup、Vite 里都能正确解析,而且依然保持每个组件独立的入口。打包工具处理 my-ui-library/button 时,直接映射到 es/button/index.js,不会触发全量加载。

这里有一个容易忽略的细节:exports 字段一旦定义,会完全覆盖包的导出规则,未在 exports 中声明的路径将无法访问。所以如果你还保留了 es/index.js 作为全量导出入口,要么在 exports 里显式声明,要么接受用户无法通过 my-ui-library 直接引入全量版本这一事实。我的建议是保留全量入口但让默认路径指向它,然后给子路径做独立映射,这样两种用法都支持。

构建工具侧的配合

目录结构和 package.json 搞好了,构建配置也得跟上。用 Rollup 构建组件库时,不要打成一个 bundle,而是保持文件结构输出:

// rollup.config.js
export default {
  input: {
    'index': 'src/index.js',
    'button/index': 'src/components/Button/index.js',
    'input/index': 'src/components/Input/index.js',
    // ... 每个组件一个 entry
  },
  output: [
    {
      dir: 'es',
      format: 'esm',
      preserveModules: false,  // 自己控制 chunk
      entryFileNames: '[name].js',
    }
  ],
  // ...
};

或者更简单的方式,用 preserveModules: true 保持源码目录结构直接输出,然后在 package.jsonexports 里映射到对应的输出文件。两种方式各有利弊:手动声明 entry 可以精确控制输出结构,但组件多了维护成本高;preserveModules 省事,但可能输出冗余的 internal 模块,需要配合 sideEffects 让使用者侧的打包工具去清理。

对于 CSS,我习惯每个组件目录下放一个 style.css,然后通过构建脚本从 Less/Sass 源码编译过来。CSS 不作为 JS 的副作用引入(即不在 JS 里 import './style.css'),而是让使用者显式引入。这样既避免了 CSS-in-JS 的运行时开销,又保持了样式引入的灵活性。

验证 Tree Shaking 是否生效

不是看 bundle 里有没有 Table 的代码——那是结果。你要看的是构建过程中的模块依赖图。以 Webpack 为例,用 stats 输出模块信息:

npx webpack --json > stats.json

然后在 webpack.github.io/analyse 上传 stats.json,查看 my-ui-library 下实际被打包了哪些模块。如果只用了 Button 却出现了 Input 的模块,说明入口设计还有问题。

更直接的方式是写一个最小复现用例:一个只引入 Button 的项目,跑打包后检查产物文件内容。搜索 Input 组件的特征字符串,如果有,就是 Tree Shaking 失效了。

常见问题

多个组件共用的内部工具函数会不会被打包多份?

不会。如果 Button 和 Input 都依赖同一个 utils/dom.js,而这个模块被标记为无副作用,打包工具会在构建阶段把它提取为公共 chunk,或者 inline 后消除重复。关键是这个工具模块不能有副作用——也就是说它不能在被 import 时执行任何影响外部状态的操作。如果你的工具函数只是在 export 定义,那完全没问题。

exports map 配了子路径,npm link 本地调试时为什么报错找不到模块?

这是 Node.js 的模块解析机制决定的。exports 字段要求路径完全匹配,而 npm link 创建的软链接在某些版本的 Node.js 中与 exports 解析有兼容性问题。临时解决方式是在调试项目的 webpack 配置里加 resolve.alias 指向组件库的源码目录,绕开 exports 解析。或者用 yalc 代替 npm link 做本地包测试,yalc 会把文件实际复制过去,不依赖软链接。

组件库内部有全局注册逻辑(比如注册全局指令),还能做按入口拆分吗?

能,但需要把全局注册逻辑单独拆到一个入口文件,比如 my-ui-library/register。这个入口文件使用者需要显式引入一次,通常在应用入口处调用。各个组件的独立入口不包含全局注册逻辑,只导出组件本身。这样拆分后,按需引入的组件不会触发全局副作用,而需要全局能力的项目多引入一个 register 文件即可。