Rollup配置实战:打造高效的前端打包流程

UI丽萍 优化 阅读 1,417
赞 17 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个项目是个内部工具库,要打包成一个轻量级的 JS SDK,供其他团队通过 npm 引入。一开始我们用 Webpack,配置写了一堆,结果打包出来的文件太大,还带了一堆 runtime 和 polyfill,明明只是个简单的工具函数集合。

Rollup配置实战:打造高效的前端打包流程

后来我寻思着,这种纯 JS 库其实不需要 HMR、Code Splitting 那些前端工程化功能,真正需要的是:干净的输出、支持 tree-shaking、模块格式灵活(cjs + esm)。Rollup 就是干这个的,于是决定切到 Rollup。

开始没想到,看着官网文档“几行配置搞定”,实际落地的时候一堆坑,尤其和 TypeScript 和 Babel 的联动上,折腾了快一周才稳定下来。

最开始的配置长这样

先搭了个基础架子,支持 TS 编译,输出 esm 和 cjs 两种格式:

// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'dist/index.esm.js',
      format: 'esm'
    },
    {
      file: 'dist/index.cjs.js',
      format: 'cjs'
    }
  ],
  plugins: [
    nodeResolve(),
    commonjs(),
    typescript({ tsconfig: './tsconfig.json' })
  ]
};

看着挺清爽对吧?跑起来发现——TypeScript 报错直接被吞了,编译失败也不中断构建,CI 上都过,本地一引入就炸。查了半天才发现,@rollup/plugin-typescript 默认不会把类型错误抛出来,得手动加个配置:

typescript({
  tsconfig: './tsconfig.json',
  declaration: true,
  declarationDir: 'dist',
  emitDeclarationOnly: false,
  noEmitOnError: true // 关键!不然报错不终止
})

这里注意我踩过好几次坑,noEmitOnError 不加的话,TypeScript 类型报错只会打个 warning,Rollup 继续走后面的流程,最后输出一个残缺的 js 文件,非常隐蔽。

最大的坑:Babel 和 tslib 的纠缠

项目里用了 async/await,按理说经过 Babel 转义后应该没问题。但我加上 @rollup/plugin-babel 后,打包出来的代码里出现了大量的 regeneratorRuntime 引用,运行时报错找不到 regeneratorRuntime。

第一反应是没配好 preset-env,后来发现是 babelHelpers 没设对:

import babel from '@rollup/plugin-babel';

// 在 plugins 里
babel({
  babelHelpers: 'runtime', // 必须设为 runtime,不然会内联 helpers
  extensions: ['.ts', '.tsx']
})

但这样又引出了新问题:tslib。TypeScript 会生成 __extends__awaiter 这种辅助函数,默认是 inline 的,导致每个文件都重复一份。虽然可以用 importHelpers: true 让它引用 tslib,但 Rollup 打包时如果处理不好,tslib 会被当成外部依赖拎出去。

我们的目标是“尽可能自包含”,不希望用户还得额外装 tslib。最终方案是:保留 importHelpers,但把 tslib 设为 internalDependencies:

import nodePolyfills from 'rollup-plugin-polyfill-node';

// 在 plugins 数组里加
nodePolyfills(), // 处理一些 Node 内置模块的兼容
{
  resolveId(id) {
    if (id === 'tslib') return 'tslib';
  },
  load(id) {
    if (id === 'tslib') {
      return export * from 'tslib'; export { default } from 'tslib';;
    }
    return null;
  }
}

有点 hack,但有效。打包后 tslib 的内容会被正确内联进来,不增加外部依赖。

Tree-shaking 怎么就是不生效?

有个同事反馈说,他只引入了一个函数,结果整个包都被加载了。我一看,糟了,tree-shaking 没起作用。

排查一圈发现是我们导出方式的问题。我们原来是这样写的:

// src/index.ts
import { foo } from './foo';
import { bar } from './bar';

export default { foo, bar };

这种 default 导出对象的方式,Rollup 无法静态分析具体用了哪个属性,直接整个引入。改成 named export 就好了:

// 改成
export { foo } from './foo';
export { bar } from './bar';

同时确保 package.json 里有:

{
  "module": "dist/index.esm.js",
  "main": "dist/index.cjs.js",
  "sideEffects": false
}

尤其是 sideEffects 设为 false,告诉打包工具这个包没有副作用,可以放心摇。

最终的解决方案

磨了一周,最终配置长这样:

// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import json from '@rollup/plugin-json';

export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'dist/index.esm.js',
      format: 'esm',
      sourcemap: true
    },
    {
      file: 'dist/index.cjs.js',
      format: 'cjs',
      sourcemap: true
    }
  ],
  external: [
    'lodash', // 假设有外部依赖不想打包进去
    /@babel/runtime/ // babel runtime 也作为外部依赖
  ],
  plugins: [
    json(),
    nodeResolve({
      browser: true,
      preferBuiltins: false
    }),
    commonjs(),
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist',
      emitDeclarationOnly: false,
      noEmitOnError: true
    }),
    babel({
      babelHelpers: 'runtime',
      extensions: ['.ts', '.tsx'],
      exclude: 'node_modules/**'
    })
  ]
};

配合 .babelrc:

{
  "presets": [
    ["@babel/preset-env", { "targets": { "ie": "11" } }],
    "@babel/preset-typescript"
  ],
  "plugins": [
    ["@babel/plugin-transform-runtime", { "regenerator": true }]
  ]
}

终于稳定了。打包体积从最初的 80KB(gzip 前)压到了 22KB,tree-shaking 也正常了。

还有点小毛病

不是所有问题都解决了。比如动态导入 dynamic import,目前直接报错,因为我们没开 code splitting。但这块暂时用不到,先放着。

另一个问题是 source map 路径偶尔对不上,怀疑是 TS 和 Babel 两层转换导致的映射丢失,但影响不大,调试时看原始文件就行。

总的来说,这方案不是最优的,但最简单,维护成本低,适合我们这种小团队。

回顾与反思

回头看看,最大的教训是:别看 Rollup 官网说“简单配置”,真到项目里,TS + Babel + 多格式输出,组合起来全是坑。

有几个关键点必须记住:

  • typescript 插件一定要开 noEmitOnError,不然 CI 会骗你
  • babelHelpers 设成 runtime,避免重复 helper 代码
  • 导出方式优先用 named export,别用 default 对象包装
  • external 列表要合理控制,该外链的就外链

另外,Rollup 真不适合复杂应用,但它做库是真的香。这次改完之后,其他团队接入顺利多了,没再反馈打包问题。

以上是我的项目经验

以上是我踩坑后的总结,希望对你有帮助。如果有更优的实现方式,比如怎么优雅地处理 tslib 或 dynamic import,欢迎评论区交流。这类配置问题,永远没有银弹,只有权衡。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论