Agent Developer Roadmap
构建工具

Rollup:从静态 ESM 图生成可发布的库产物

核验日期:2026-07-08。示例版本来自 examples/build-tools/package.jsonrollup 4.62.2、@rollup/plugin-typescript 12.3.0、@rollup/plugin-html 2.0.0、rollup-plugin-postcss 4.0.2、@rollup/plugin-terser 1.0.0。

需要掌握的问题

主题读者必须回答的问题原理与边界高频错误代码或观察证据来源
静态 ESM 图Rollup 为什么适合库构建,和应用型打包器的主问题有什么不同?Rollup 从 input 出发解析静态 import/export,建立可分析的 ESM 图,再生成 escjsumd 等输出;它不提供完整开发服务器、框架 HMR 或类型检查用 CommonJS 动态 require 期待完整 Tree Shaking;把 Rollup 当 Vite dev serverexamples/build-tools/src/main.ts 的静态导入和动态导入Rollup ES Module Syntax
插件构建钩子resolveIdloadtransformmoduleParsed 各看见什么?resolveId 决定模块 id,load 可提供源码,transform 改单模块代码和 map,moduleParsed 在 AST 被解析后观察模块信息transform 里返回错误 Source Map,或在 moduleParsed 里试图改源码最小插件钩子表和示例配置中的 manifest() 插件Plugin Development
输出钩子renderChunkgenerateBundlewriteBundle 的职责差异是什么?renderChunk 改单个 chunk 代码,generateBundle 可看见完整 bundle 并增删资产,writeBundle 在文件写入后执行renderChunk 使用最终 hash 文件名;在 writeBundle 才尝试改产物内容manifest()generateBundle 中生成 manifest.jsonOutput Generation Hooks
Tree Shaking 与 moduleSideEffects未使用导出何时会被删除,副作用如何保留?Rollup 依赖静态 ESM、作用域分析和 treeshake.moduleSideEffects 判断模块是否可跳过;副作用模块必须被保留moduleSideEffects: false 删除 CSS、polyfill、注册代码;只看 bundle 体积不看运行语义共享测试要求 SHOULD_NOT_REACH_BUNDLE 被移除、preserved 被保留treeshake.moduleSideEffects
external 与 peer 依赖库构建怎样决定哪些依赖留给消费者?external 把 import 保留为外部引用;库的 peerDependencies 通常应 external,应用内小依赖可打包把 React/Vue 等 peer 打进库,造成重复运行时和 hooks/context 不一致external 函数、peer 检查脚本、消费端安装测试external
多入口与 package exports多入口库怎样让产物、声明文件和 exports 对齐?input 可传对象或数组生成多个 entry chunks;package.json#exports 决定消费者能 import 哪些入口产物有 dist/button.js,但 exports 没暴露;CJS/ESM 条件指错多入口配置和 exports 对照表input
dynamic import 与 manualChunks异步边界怎样进入 chunk graph,什么时候需要手动分块?动态导入形成 async chunk;manualChunks 可把模块归组,但会影响请求数、缓存和执行顺序HTML 直接加载 dynamic chunk;激进 manualChunks 把副作用提前或延后共享示例 detail.ts 是 dynamic entry,HTML 只加载 initial JSoutput.manualChunks
Source Map 链插件 map 如何跨 TypeScript、PostCSS、terser 保持可追溯?每个 transform/输出阶段要消费上游 map 并返回新 map;最终 output.sourcemap 决定外部 map插件返回 code 但不返回 map,导致 DevTools 定位到生成代码artifact test 检查 JS map 包含 main.ts/detail.ts,CSS map 包含 styles.cssoutput.sourcemap
watch cache 与增量Rollup watch 复用什么,插件怎样登记额外依赖?Rollup 可在 watch 中复用 bundle cache;插件读取外部文件时要登记 watch files,避免变化不触发读取模板、manifest 输入或配置文件但没加入 watch,导致热重建旧结果插件 this.addWatchFile、cache key 清单JavaScript API
循环依赖循环 ESM 为什么有时能工作,有时会输出 undefined 或顺序问题?ESM live binding 支持循环,但初始化顺序仍受执行图影响;Rollup 会保持语义并给出警告在循环模块顶层读取尚未初始化的绑定,或通过 chunk 拆分改变执行时机最小循环依赖示例和 warning 处理策略Troubleshooting
迁移与重大变化Rollup 2、3、4 哪些变化影响配置、插件和输出?Rollup 3 改默认值和 CJS 动态导入行为,Rollup 4 引入 native optional dependency、Node 要求与输出钩子/hash 行为变化没升级插件、依赖旧默认值、在 renderChunk 里读取最终 hash 文件名Rollup 2–4 迁移表Migrating to Rollup 4

内容边界说明:

分类本文落点
编程语言与类型系统只解释 ESM、dynamic import、TypeScript 删除和 CJS/ESM 输出格式如何影响构建;不教学 JS/TS 语法,也不把 Rollup 当类型检查器
Browser APIs 与持久化能力覆盖浏览器 <script type="module">、dynamic import、Source Map 和相对资源路径;IndexedDB、Storage、权限模型不属于 Rollup 职责
UI 框架与元框架只说明库构建中 peer 依赖和 package exports 的边界;组件调度、渲染和水合不在本文重复
构建工具与运行时完整覆盖 input、resolve/load、transform、graph、Tree Shaking、chunk、output、cache、插件和产物验收
后端、数据与 Agent 技术只覆盖配置/插件以 Node 权限运行、peer 发布和 CI 产物校验;请求生命周期、数据库一致性和 Agent 评测不适用

Rollup 的主问题:把静态 ESM 图变成可消费产物

Rollup 的强项不是“开发时最快刷新”,而是“尽量利用 ESM 的静态结构,生成语义清楚、可被消费者继续优化的产物”。这也是它长期适合库构建的原因:库作者通常需要控制 entry、external、输出格式和 package exports,而不是先追求开发服务器能力。

Rollup 和相邻工具的边界:

工具主要输入核心问题不应期待它完成
Babel/SWC单个 JS/TS 文件语法转换、AST 改写、Source Map依赖图、HTML、CSS bundle、peer 发布边界
RollupESM entry 和插件静态图、Tree Shaking、库输出、多格式发布Vite 式 dev server、HMR、框架路由
ViteHTML 入口和 dev 请求开发服务器、按需 transform、生产构建 orchestration纯库发布策略、所有 SSR 框架语义
webpack/Rspack应用 entry 和 runtime应用 chunk/runtime、Loader/Plugin 生态、长期缓存默认生成最小库产物

严重问题完整流程:

症状根因观察命令修正
库体积异常大,消费者也打进一份 React/Vuepeer 依赖没有 external搜索产物 react.productioncreateContext 等运行时代码peerDependencies 生成 external,消费端安装 peer
用户 import { Button } from "pkg/button" 报错package exports 没暴露该入口,或 ESM/CJS 条件错node -e "import('pkg/button')"node -e "require('pkg/button')"让 Rollup input、产物路径、声明文件和 exports 一一对应
CSS 或注册代码消失moduleSideEffects: false 或包级副作用声明过激搜索产物 CSS、全局注册标记、preserved给副作用模块保留 side effect,或用函数精确判断

resolveId/load/transform/moduleParsed:插件先定义模块事实

Rollup 插件不是“构建后脚本”。它们参与模块事实的创建。构建阶段最常见的四个钩子是:

钩子输入输出适合做不适合做
resolveIdsourceimporter、解析选项模块 id、external 标记或 nullalias、虚拟模块、peer external、条件导出处理读取并转换完整源码
load已解析 id源码字符串、AST、map 或 null生成虚拟模块、读取非标准资源根据源码内容决定 chunk 结构
transform当前模块源码和 id{ code, map } 或字符串TS/JSX/CSS 转换、注入开发标记访问最终文件名、改其它 chunk
moduleParsed解析后的模块信息无直接源码输出收集 imports/exports、告警循环依赖或公共 API修改模块代码

一个最小虚拟模块插件:

export function virtualBuildInfo() {
  const virtualId = 'virtual:build-info';
  const resolvedId = '\0virtual:build-info';

  return {
    name: 'virtual-build-info',
    resolveId(source) {
      if (source === virtualId) return resolvedId;
      return null;
    },
    load(id) {
      if (id === resolvedId) {
        return 'export const builtAt = "2026-07-08";';
      }
      return null;
    },
    transform(code, id) {
      if (!id.endsWith('.ts')) return null;
      return { code, map: null };
    },
    moduleParsed(info) {
      if (info.isEntry) this.warn(`entry: ${info.id}`);
    },
  };
}

错误完整流程:

症状根因观察命令修正
alias 后同一模块进了两份resolveId 返回的 id 不稳定,或保留了不同 query/路径大小写在插件中打印 source → id,并用 manifest/stats 搜索重复模块规范化路径和 query;对包名使用同一入口条件
DevTools 行号错transform 返回了改写代码但没有正确 map解析最终 .map.sourcesmappings用 MagicString 或转换器生成 map,并向下游传递上游 map
虚拟模块出现在磁盘路径里虚拟 id 没使用 \0 前缀,和真实文件解析混淆打印 id,搜索输出 source map按 Rollup 约定使用内部虚拟 id,并在 map 中提供可读 source

输出钩子:renderChunk/generateBundle/writeBundle 决定最终产物

当模块图已经完成,Rollup 进入输出阶段。三个钩子职责差异很大:

钩子运行时机能看见什么常见用途严重误用
renderChunk每个 chunk 渲染时单个 chunk 的 code、chunkInfo、output options给 chunk 加 banner、改 dynamic import 表达式、追加局部 map读取最终 hash 文件名;在这里生成全局 manifest
generateBundle所有 chunk/asset 生成后、写入前完整 bundle 对象生成 manifest.json、改 HTML、删除或增加资产依赖磁盘已写入;异步写外部文件不纳入 Rollup 错误
writeBundlebundle.write() 写入磁盘后已写出的 bundle 清单上传产物、打印报告、触发后处理再修改已写文件但不更新 map/hash/manifest

共享示例的 manifest() 插件使用 generateBundle,因为它需要完整 bundle:

function manifest() {
  return {
    name: 'manifest',
    generateBundle(_options, bundle) {
      const entries = {};

      for (const chunk of Object.values(bundle)) {
        if (chunk.type !== 'chunk' || !chunk.facadeModuleId) continue;
        entries[toSourceName(chunk.facadeModuleId)] = {
          file: chunk.fileName,
          src: toSourceName(chunk.facadeModuleId),
          isEntry: chunk.isEntry,
          isDynamicEntry: chunk.isDynamicEntry,
        };
      }

      this.emitFile({
        type: 'asset',
        fileName: 'manifest.json',
        source: `${JSON.stringify(entries, null, 2)}\n`,
      });
    },
  };
}

预期结果是 manifest.json 至少能描述 src/main.ts 的 entry 和 src/detail.ts 的 dynamic entry。它不是给浏览器直接加载的脚本,而是给测试、服务端模板或发布系统使用的机器证据。

Tree Shaking 与 moduleSideEffects:不是“删掉没被 import 的文本”

Rollup 的 Tree Shaking 依赖 ESM 的两个特点:导入导出在语法层可见,live binding 让导出关系可以被追踪。它会尝试删除未使用导出,但不能随便跳过有副作用的模块。

共享源码里有两个观察点:

// catalog.ts
export const removableMarker = 'SHOULD_NOT_REACH_BUNDLE';

// side-effect.ts
globalThis.__BUILD_TOOLS_SIDE_EFFECT__ = 'preserved';

removableMarker 没被使用,生产 bundle 不应包含 SHOULD_NOT_REACH_BUNDLEside-effect.ts 虽然没有被读取导出,但它改变了 globalThis,必须保留。示例配置选择保守策略:

export default {
  treeshake: {
    moduleSideEffects: true,
  },
};

取舍表:

策略性能/体积可靠性使用难度维护成本适用规模
moduleSideEffects: true体积收益较保守不易误删副作用应用、小型示例、迁移初期
moduleSideEffects: false体积可能更小极易误删 CSS、注册、polyfill只适合可证明纯模块集合
函数式判断可兼顾体积和语义取决于规则覆盖中高中高大型库、monorepo、需要精确 external/side effect 策略
package sideEffects 数组方便交给消费者复用配错会影响所有打包器npm 库发布

严重问题完整流程:

症状根因观察命令修正
页面显示 副作用标记:undefinedside-effect.ts 被错误摇掉rg "preserved" examples/build-tools/dist/rollup保守使用 moduleSideEffects: true,或把副作用文件列入白名单
无用标记仍在产物里CommonJS、动态访问或 minifier/treeshake 未生效rg "SHOULD_NOT_REACH_BUNDLE" examples/build-tools/dist/rollup保留 ESM,减少动态导出访问,确认 production/minify
CSS 消失把 CSS 导入当作无副作用模块检查 index.html 是否有 stylesheet linkCSS 文件或 CSS 导入所在模块必须标记为有副作用

external、peer 依赖和库发布边界

应用构建通常倾向“能打进 bundle 就打进 bundle”。库构建相反:凡是消费者应该自己提供的运行时,尤其 peerDependencies,通常要 external。否则消费者会同时得到两份框架运行时,出现 context、hooks、样式单例或全局注册不一致。

最小 external 策略:

import pkg from './package.json' with { type: 'json' };

const peerNames = new Set(Object.keys(pkg.peerDependencies ?? {}));

export default {
  input: {
    index: 'src/index.ts',
    button: 'src/button.ts',
  },
  external(id) {
    const name = id.startsWith('@')
      ? id.split('/').slice(0, 2).join('/')
      : id.split('/')[0];
    return peerNames.has(name);
  },
  output: [
    { dir: 'dist/es', format: 'es', sourcemap: true },
    { dir: 'dist/cjs', format: 'cjs', sourcemap: true, exports: 'named' },
  ],
};

external 的边界:

依赖类型常见策略判断依据
peerDependencies默认 external消费者必须共用单例或自行选择版本
小型纯工具函数可打包不暴露全局状态,升级风险低
CSS runtime、polyfill谨慎 external需要明确加载顺序和浏览器支持
Node 内置模块浏览器库通常不能打包应分清 browser/node 条件导出

严重问题完整流程:

症状根因观察命令修正
React hooks 报多实例错误peer 被打进库 bundlerg "react" dist/es dist/cjs 并检查 bundle 内容从 peer 生成 external,发布前跑消费者 fixture
浏览器端报 process is not definedNode 条件或 external 指向了 Node-only 入口查看 package exports condition 和产物 import配置 browser/import/default 条件,或拆分 browser/node 入口
UMD/IIFE 消费时报全局变量不存在external 了依赖但没给 output.globals打开 UMD 产物看参数和全局访问对 UMD/IIFE external 配 globals,或不发布该格式

多入口、ESM/CJS 与 package exports 必须一起设计

Rollup 的 input 可以是字符串、数组或对象。库构建推荐对象形式,因为它让入口名、文件名和 package exports 更容易对齐。

export default {
  input: {
    index: 'src/index.ts',
    'components/button': 'src/components/button.ts',
  },
  output: [
    {
      dir: 'dist/es',
      format: 'es',
      entryFileNames: '[name].js',
      sourcemap: true,
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      entryFileNames: '[name].cjs',
      sourcemap: true,
    },
  ],
};

对应的 package exports

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/es/index.js",
      "require": "./dist/cjs/index.cjs",
      "types": "./dist/types/index.d.ts"
    },
    "./components/button": {
      "import": "./dist/es/components/button.js",
      "require": "./dist/cjs/components/button.cjs",
      "types": "./dist/types/components/button.d.ts"
    }
  }
}

package exports 的常见坑:

症状根因观察命令修正
Node ESM 能 import,CJS require 失败require 条件指向 .js 且包为 "type": "module"node -e "console.log(require('./pkg'))"CJS 产物用 .cjs,exports require 指向 .cjs
深层路径在本地能用,发布后不能用文件存在但 exports 未暴露node -e "import('./pkg/components/button')"显式列出子路径,或使用经过审查的 pattern exports
类型声明和 JS 入口不一致DTS 输出结构与 Rollup entryFileNames 不同tsc --traceResolution 或编辑器跳转让声明生成目录跟 entry 命名同步

dynamic import、manualChunks 和 HTML 加载边界

main.ts 中的动态导入:

button.addEventListener('click', async () => {
  const { renderDetail } = await import('./detail.ts');
  renderDetail(detail, tool);
});

它的含义不是“HTML 应该多加一个 <script>”。正确链路是:HTML 加载 initial JS,initial JS 运行到点击时再通过浏览器 dynamic import 请求 async chunk。

manualChunks 可以改变 chunk 归组:

export default {
  output: {
    manualChunks(id) {
      if (id.includes('node_modules')) return 'vendor';
      if (id.endsWith('/src/detail.ts')) return 'detail';
      return null;
    },
  },
};

取舍表:

方案性能可靠性使用难度维护成本适用规模
让 Rollup 自动分块请求数和共享拆分由工具决定最少人为错误小到中型库、示例
简单 vendor chunk长缓存可能更稳定vendor 过大影响首屏应用型构建或文档站
精细 manualChunks可按路由/功能控制缓存容易造成重复依赖和执行顺序问题大型应用,需配合真实网络数据

严重问题完整流程:

症状根因观察命令修正
index.html 直接加载 detail.*.jsHTML 插件没有过滤 async chunkrg "detail" examples/build-tools/dist/rollup/index.htmlHTML 只注入 file.type === "chunk" && file.isEntry 的 JS
点击后 async chunk 404相对路径、base 或部署目录错构建后静态预览并点击按钮使用相对资源路径,部署时保留目录结构
manualChunks 后副作用顺序改变把有副作用模块拆入提前/延后执行的 chunk在产物里搜索副作用模块并手动预览不手动移动副作用模块,或增加运行时回归测试

Source Map 链:插件要把上游位置传下去

共享配置里有多段转换:TypeScript 插件处理 .ts,PostCSS 处理 CSS,terser 压缩 JS,Rollup 最终输出外部 Source Map。

export default {
  plugins: [
    typescript({ compilerOptions: { sourceMap: true, target: 'ES2022' } }),
    postcss({ extract: 'assets/styles.css', minimize: true, sourceMap: true }),
    terser(),
  ],
  output: {
    sourcemap: true,
  },
};

Source Map 的链路要求是:每个阶段要么不改代码,要么返回能映射回上游的 map。只要某个插件返回 map: null 但实际改了行列,后续 map 仍可能存在,却已经失真。

观察点预期结果
主 JS .mapsources 能追溯到 main.ts
async JS .mapsources 能追溯到 detail.ts
CSS .mapsources 能追溯到 styles.css
HTML不泄露 file: 或工作区绝对路径

错误完整流程:

症状根因观察命令修正
DevTools 定位到压缩后代码terser 或前置插件 map 链断裂打开 .js.map 检查 sourcesmappings确认每个转换插件启用 source map
CSS map 指向绝对路径CSS 插件使用本机路径作为 source`rg "$PWDfile:" examples/build-tools/dist/rollup`
map 文件存在但浏览器 404JS 尾部 sourceMappingURL 和部署文件名不一致tail -n 2 assets/*.js保证 .map 与 JS 同版本发布,或使用 hidden map

watch cache、循环依赖和构建时信任边界

Rollup watch 的快来自复用前一次构建图和插件状态,但它不会猜到插件读取了哪些外部文件。插件读模板、生成 HTML、读 .env 或读某个 JSON 时,需要登记依赖:

function htmlTemplate(templateFile) {
  return {
    name: 'html-template',
    buildStart() {
      this.addWatchFile(templateFile);
    },
    generateBundle() {
      // 读取 templateFile 并 emit HTML。
    },
  };
}

循环依赖不是 Rollup 独有问题。ESM live binding 支持循环引用,但初始化顺序仍然重要:

// a.js
import { b } from './b.js';
export const a = b + 1;

// b.js
import { a } from './a.js';
export const b = a + 1; // 读取尚未初始化的 a,可能触发 TDZ 或 undefined 风险

处理原则:

问题观察入口修正
watch 不响应模板变化改模板文件,看 Rollup 是否 rebuildthis.addWatchFile,缓存 key 纳入模板内容或 mtime
循环依赖 warning构建日志、onwarnonLog抽出第三个无状态模块,避免顶层互读
插件泄露密钥审查插件代码、CI 日志、产物搜索 process.env最小 CI 权限,不在插件中处理秘密,锁定依赖
cache 命中旧输出修改 config/env 后产物不变清 cache 对比;把 env/config/plugin version 计入 key

共享示例:Rollup 构建浏览器入口

运行命令:

pnpm --filter @roadmap/build-tools-slice build:rollup

输入:

examples/build-tools/src/main.ts
examples/build-tools/src/catalog.ts
examples/build-tools/src/detail.ts
examples/build-tools/src/side-effect.ts
examples/build-tools/src/styles.css

预期结果:

examples/build-tools/dist/rollup/index.html
examples/build-tools/dist/rollup/manifest.json
examples/build-tools/dist/rollup/assets/*.js
examples/build-tools/dist/rollup/assets/*.css
examples/build-tools/dist/rollup/assets/*.js.map
examples/build-tools/dist/rollup/assets/*.css.map

验证观察:

find examples/build-tools/dist/rollup -type f | sort
node --test --test-name-pattern rollup examples/build-tools/test/artifacts.test.mjs

预期输出要点:

观察点预期结果
index.html只通过相对 script type="module" 加载 entry JS,通过 link rel="stylesheet" 加载 CSS
manifest.jsonsrc/main.ts entry,也有 src/detail.tsisDynamicEntry: true entry
dynamic JS由浏览器 dynamic import 加载,不由 HTML 直接加载
bundled JS包含 Babelpreserved,不包含 SHOULD_NOT_REACH_BUNDLE
Source MapJS map 能追溯 main.ts/detail.ts,CSS map 能追溯 styles.css

如果只看到 Rollup 命令退出 0,还不能证明产物正确;至少要检查 HTML 引用、manifest、async chunk、CSS、Source Map 和可观察副作用。

Rollup 2 到 4:迁移和重大变化

从 Rollup 2、3 迁移到 4,改配置文件扩展名只是表面工作。真正影响工程的是默认值、插件 API、输出 hash 和 native 包。

变化Rollup 2 常见状态Rollup 3/4 状态迁移动作
Node 版本老项目可能仍在 Node 12/14/16Rollup 4 要求至少 Node 18CI、本地和发布脚本统一升级
native optional dependency主要是 JS 包心智模型Rollup 4 安装平台相关 optional native 包,不支持平台可用 WASM 替代不要在 CI 禁掉 optionalDependencies;镜像源要保留平台包
默认值依赖旧 preserveEntrySignatures、interop 等行为Rollup 3/4 调整多个推荐默认值先在 3.29.x 开 strictDeprecations,再升 4
CJS 动态导入旧输出可能把动态导入改写成 require 包装Rollup 3 起 CJS 输出默认保留 external dynamic import老 Node 目标才考虑 output.dynamicImportInCjs: false
输出钩子/hash插件可能在 renderChunk 读取最终文件名Rollup 4 hash 基于 renderChunk 后内容,hook 中可能是占位符需要最终文件名的逻辑放到 generateBundle
文件名 placeholder旧插件可能使用 [ext] 等 placeholderpreserveModules + entryFileNames 下部分 placeholder 不再可用按迁移指南调整命名函数

迁移严重问题完整流程:

症状根因观察命令修正
CI 安装成功但运行 Rollup 报平台包缺失npm/pnpm 配置跳过 optional dependency 或镜像缺包pnpm why rollup、检查 lockfile optional packages允许 optionalDependencies;不支持平台改 @rollup/wasm-node
升级后 manifest 文件名是占位符插件在 renderChunk 记录 hash 文件名打印 chunk.fileName 所在钩子把记录最终文件名移动到 generateBundle
CJS 消费端动态 import 行为变了output.dynamicImportInCjs 默认语义变化对 CJS 产物执行消费者 fixture明确 Node 版本目标,必要时设置该选项
大型配置升级后报 deprecation直接从 Rollup 2 跳 4strictDeprecations 在 Rollup 3.29.x 先跑分阶段升级插件和配置

资料入口

在 GitHub 上编辑此页

本页目录