Skip to main content

Rollup

Rollup

Rollup 是一个 JavaScript 模块打包工具,特别适合打包库和工具函数库。它支持现代的 ESM 模块标准,允许开发者将多个模块文件打包成一个文件,并输出多种格式(如 ESM、CommonJS、UMD),以便在不同环境中复用。Rollup 的主要特点包括高效的树摇(Tree Shaking)、简单配置和插件化体系,使其成为构建 JavaScript 库的理想选择。

Bundler Timeline

Tree Shaking

Rollup 以 Tree Shaking 而闻名,其可以静态分析导入的代码,排除实际上没有使用的内容,以此优化构建体积。不少打包工具受此启发,都在构建时加入了 Tree Shaking 优化。

核心思想

Tree Shaking 的核心思想是在编译阶段通过静态分析确定代码的使用情况,而不是在运行时,所以 Tree Shaking 一般是建立在 ESM 模块化语法基础之上,因为 ESM 的导入导出是静态的。

CommonJS 模块的导入和导出是动态的,无法在编译阶段静态确定代码的使用情况。一般情况下,Tree Shaking 工具无法在 CommonJS 模块中进行精确的优化,因为无法静态分析模块间的导入和导出关系。

一些构建工具(如 Webpack)会尝试通过静态分析和启发式方法对 CommonJS 模块进行近似的 Tree Shaking。它们会尽可能地识别出那些可以在编译阶段确定未被使用的代码,并进行剔除,但这种处理方式可能不如对 ESM 模块的优化效果好,且有一定的限制。

原理
  • 静态分析:对 JavaScript 代码进行静态分析,识别出模块的导入和导出关系。
  • 标记未使用代码:标记出在导入和导出关系上没有被使用的代码,这些代码可能是模块的导出函数、变量、类等。
  • 剔除未使用代码:根据标记结果,构建工具会将未被使用的代码从最终的打包结果中剔除,只保留被使用的部分。

由于是静态分析,所以在写代码时,要尽量使用最小导入,帮助构建工具进行更准确的分析。

// 难以分析
import util from "./util.js";
const r = util.getRandomNum(1, 10)
console.log(r)

// 容易分析 (推荐)
import { getRandomNum } from "./util.js";
const r = getRandomNum(1, 10)
console.log(r)

配置文件

Rollup 的配置文件是一个 ESM 模块。通常起名为 rollup.config.jsrollup.config.mjs,并位于项目的根目录中。它导出一个默认对象,其中包含所需的选项。

note

Node 环境下要运行 ESM 模块化的内容,要么文件名后缀为 .mjs,要么 package.json 文件中配置 "type":"module"

rollup.config.mjs
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'umd',
name: 'bundle'
},
}
多产物

output 指定为一个数组,可输出不同格式模块的产物,如 ESN、CJS、AMD、UMD、IIFE、SystemJS等,保证良好的兼容性。

export default {
input: 'src/index.js',
output: [
{
file: 'dist/bundle-iife.js',
format: 'iife'
},
{
file: 'dist/bundle-esm.js',
format: 'esm'
},
{
file: 'dist/bundle-cjs.js',
format: 'cjs'
}
]
}
多入口

Rollup 也支持多入口配置:

export default {
input: ['./src/index.js', './src/main.js'],
output: [
{
dir: './dist/cjs',
format: 'cjs'
},
{
dir: './dist/esm',
format: 'esm'
}
]
}

上述配置会把两个入口一起进行构建,生成的产物大致如下:

dist/
├── cjs/
│ ├── index.js
│ └── main.js
└── esm/
├── index.js
└── main.js

有时候是想一个入口一种构建方式,可采用如下方式:

const buildIndexOptions = {
input: 'src/index.js',
output: {
dir: './dist/cjs',
format: 'cjs',
}
}

const buildMainOptions = {
input: 'src/main.js',
output: {
dir: './dist/esm',
format: 'esm'
}
}
export default [buildIndexOptions, buildMainOptions]

/**
dist/
├── cjs/
│ └── index.js
└── esm/
└── main.js
*/
代码分割

在使用 import() 动态导入后,Rollup 可使用动态导入创建一个仅在需要时加载的单独块。

比如在 main.js 中使用 import 动态导入了 utils.js,最终构建的产物大致如下:

const buildMainOptions = {
input: 'src/main.js',
output: {
dir: 'dist/esm/',
entryFileNames: '[name].js',
chunkFileNames: 'chunk-[name]-[hash].js',
format: 'esm',
}
}

export default [buildMainOptions]

/*
├── dist
│ ├── esm
│ │ ├── main.js
│ │ └── chunk-utils-C-eXBUeK.js
│ └── umd
│ └── index.js
└──
*/

如果定义了又多个入口点且都动态导入或者导入了 utils.js 文件,那么会将其分割出来打包并自动的引入分割出来的文件:

const buildMainOptions = {
input: ['src/main.js', 'src/main2.js'],
output: {
dir: 'dist/esm/',
entryFileNames: '[name].js',
chunkFileNames: 'chunk-[name]-[hash].js',
format: 'esm',
}
}

/**
main.js: import util from './chunk-util-371e3ef9.js'
main2.js: import util from './chunk-util-371e3ef9.js'
*/

除了自动代码分割之外,也可通过 output.manualChunks 选项进行手动代码分割:

const buildIndexOptions = {
input: 'src/index.js',
output: {
// ...
manualChunks: {
'lodash-es': ['lodash-es'],
}
// 函数形式
// manualChunks(id){
// if(id.includes('lodash-es')){
// return 'lodash-es'
// }
// }
},
plugins: [nodeResolve()]
}
常用插件

Rollup 也支持插件,以便处理更复杂的打包需求,the Rollup Awesome List

node-resolve

Rollup 默认只能解析相对路径,也就是 /./ 或者 ../ 开头的路径,对于 bare import,也就是像 import { chunk } from 'lodash-es' 直接导入的第三方包的格式,并不支持,而 @rollup/plugin-node-resolve 就是来解决此问题。

import nodeResolve from '@rollup/plugin-node-resolve'
export default {
// ...
plugins: [nodeResolve()]
}
commonjs

很多时候并不清楚一个包的模块类型,如果没有使用期望的导入方式就会跑出错误,而@rollup/plugin-commonjs 可以很好的处理此问题。

import commonjs from '@rollup/plugin-commonjs'
export default {
// ...
plugins: [commonjs()]
}
babel

使用 Babel 以使用尚未被浏览器和 Node.js 支持的最新 JavaScript 特性,Rollup 中可使用 @rollup/plugin-babel 来进行支持。

import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'

const buildIndexOptions = {
// ...
plugins: [
nodeResolve(),
commonjs(),
babel({
babelHelpers: 'bundled'
})
]
}

@babel/preset-env 只转换了语法,也就箭头函数、const 等,但对于进一步需要转换内置对象、实例方法等 API,就显得无能为力了。这些代码需要通过 polyfill (兼容性垫片) 来进行支持,所以需要 @babel/runtime 来进行处理。

@babel/runtime 是一种 polyfill 实现方式,但是在实现过程中,可能会产生很多重复的代码,所以需要 @babel/plugin-transform-runtime 防止污染全局及冗余。此外,在处理 polyfill 时,还需要 core-js 辅助,可使用 @babel/runtime-corejs3

import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'

export default {
// ...
plugins: [
nodeResolve(),
commonjs(),
babel({
babelHelpers: 'runtime',
include: 'src/**',
exclude: 'node_modules/**',
extensions: ['.js', '.ts']
})
]
}
TypeScript

可使用 @rollup/plugin-typescript 来处理 TypeScript,其依赖了 typescripttslib

// pnpm add typescript tslib @rollup/plugin-typescript -D
import typescript from '@rollup/plugin-typescript'

export default {
// ...
plugins: [
// ...
typescript()
]
}
其他插件
# html文件模板
pnpm add rollup-plugin-generate-html-template -D

# 替换字符串
pnpm add @rollup/plugin-replace -D

# 开发服务器与live server
pnpm add rollup-plugin-serve rollup-plugin-livereload -D

# clear插件
pnpm add rollup-plugin-clear -D

# scss
pnpm add rollup-plugin-scss sass -D

# postcss
pnpm add postcss rollup-plugin-postcss -D

# 图片处理
pnpm add @rollup/plugin-image -D

# nodejs typescript类型
pnpm add @types/node -D

# 别名插件
pnpm add @rollup/plugin-alias -D

# terser
pnpm add @rollup/plugin-terser -D

# visualizer
pnpm add rollup-plugin-visualizer -D

API

Rollup 提供了一个可从 Node.js 使用的 JavaScript API。通常它很少使用,但通过这些 API 可以了解 Rollup 构建的大致流程。

Rollup API 主要包含核心两个函数:rollup.rolluprollup.watch

rollup.rollup

rollup.rollup 函数接收一个输入选项对象作为参数,并返回一个 Promise,该 Promise 解析为一个 bundle 对象。在此步骤中,Rollup 将构建模块图并执行除屑优化,但不会生成任何输出。

bundle 对象上,可以多次调用 bundle.generate 并使用不同的输出选项对象来生成不同的产物到内存中。如果想将产物写入磁盘,可调用 bundle.write(使用 bundle.write 可代替 bundle.generate 直接写入磁盘)。

使用完 bundle 对象后,可调用 bundle.close(),通过 closeBundle 钩子让插件清理它们的外部进程或服务。

import { rollup, watch } from 'rollup'

const inputOptions = {
input: 'src/main.js',
external: [],
plugins: []
}

const outputOptions = {
dir: './dist',
format: 'esm',
sourcemap: true,
entryFileNames: '[name].[hash].js'
}

async function build() {
const bundle = await rollup(inputOptions)
// await bundle.generate(outputOptions)
await bundle.write(outputOptions)
}

build()

inputOptionsoutputOptions 可查看选项大全

rollup.watch

rollup.watch 函数,当检测到磁盘上的某个模块已更改时,它将重新打包。当在命令行中使用 --watch 标志运行 Rollup 时,它会在内部使用。

当通过 JavaScript API 使用观察模式时,需要在响应 BUNDLE_END 事件时调用 event.result.close(),以允许插件在 closeBundle 钩子中清理资源。

import { rollup, watch } from 'rollup'

const watchOptions = {
...inputOptions,
output: [outputOptions],
watch: {
// buildDelay,
// chokidar,
// clearScreen,
// skipWrite,
include: 'src/**',
exclude: 'node_modules/**'
}
}

const watcher = watch(watchOptions)

watcher.on('event', (event) => {
console.log(event)

if (event.result) {
event.result.close()
process.exit(0)
}
})

// 更改了一个文件
watcher.on('change', (id, { event }) => {
});

// 新触发了一次运行
watcher.on('restart', () => {
});

// 监视器被关闭了
watcher.on('close', () => {
});

// 停止监听
watcher.close();
构建工作流

Rollup 打包构建流程主要分为两大步骤。

构建阶段

构建阶段主要负责创建模块依赖,初始化各个模块的 AST,以及模块之间的依赖关系。

在构建阶段产生的 bunlde 对象,并没有模块打包,这个对象的作用在于存储各个模块的内容及依赖关系,并且提供了 generatewrite 等方法,方便输出阶段输出产物。

输出阶段

通过 bundle 对象提供的 generatewrite 等方法,并根据 outputOptions 进行产物输出。

插件开发

Rollup 插件是一个对象,具有属性构建钩子输出生成钩子中的一个或多个,并遵循一些约定

插件通常应以一个函数导出的形式进行发布,该函数可以使用插件特定的选项进行调用并返回此类对象。简单来说,Rollup 插件通常是一个函数,该函数返回一个对象,返回的对象中包含一些属性和不同阶段的钩子函数。

export default function MyCustom(options) {
return {
name: 'rollup-plugin-my-custom',
version: '1.0.0'
// ...
}
}
钩子函数

钩子函数是在整个构建过程中的不同阶段自动执行的函数。钩子可以影响构建的运行方式,提供关于构建的信息,或在构建完成后修改构建。

特点
  • 钩子函数区分不同的调用时机。
  • 钩子函数是有执行顺序的。
  • 钩子函数有不同的执行方式。
  • 钩子函数也可以是对象的形式。
  • 对象形式的钩子函数可以改变钩子的执行,让不同插件的同名钩子函数按照指定的顺序执行。
调用时机

Rollup 提供了构建钩子输出钩子,可在这两个阶段调用。

// 构建阶段
const bundle = await rollup.rollup(inputOptions)

// 输出阶段
await bundle.generate(outputOptions)/write(outputOptions)
执行方式

钩子函数也可根据不同的执行方式进行分类:

  • async/sync:异步/同步钩子,async 标记的钩子可以返回一个解析为相同类型的值的 Promise,否则,该钩子被标记为 sync
  • first:如果有多个插件实现此钩子,则钩子按顺序运行,直到钩子返回一个不是 nullundefined 的值。
  • sequential:如果有多个插件实现此钩子,则所有这些钩子将按指定的插件顺序运行。如果钩子是 async,则此类后续钩子将等待当前钩子解决后再运行。
  • parallel:如果有多个插件实现此钩子,则所有这些钩子将按指定的插件顺序运行。如果钩子是 async,则此类后续钩子将并行运行,而不是等待当前钩子。
对象钩子

除了函数之外,钩子也可以是对象。在这种情况下,实际的钩子函数(或 banner/footer/intro/outro 的值)必须指定为 handler。这允许配置更多的可选属性,以改变钩子的执行:

order: "pre" | "post" | null

如果有多个插件实现此钩子,则先运行此插件 pre,最后运行此插件 post,或在用户指定的位置运行(没有值或 null)。

export default function resolveFirst() {
return {
name: 'resolve-first',
resolveId: {
order: 'pre',
handler(source) {
console.log(source)
return null
}
}
}
}
构建钩子
执行顺序
Bulding Hooks
  1. 通过 options 钩子读取配置,并进行配置的转换,得到处理后的配置对象。
  2. 调用 buildStart 钩子,考虑了所有 options 钩子配置的转换,包含未设置选项的正确默认值,正式开始构建流程。
  3. 调用 resolveId 钩子解析模块文件路径。Rollup 中模块文件的 id 就是文件路径。从 inputOptioninput 配置指定的入口文件开始,每当匹配到引入外部模块的语句,便依次执行注册插件中的每一个 resolveId 钩子,直到某一个插件中的 resolveId 执行完后返回非 null 或非 undefined 的值,将停止执行后续插件的 resolveId 逻辑并进入下一个钩子。
  4. 调用 load 钩子加载模块内容,resolveId 中的路径一般为相对路径,load 中的路径为处理之后的绝对路径。
  5. 判断当前解析的模块是否存在缓存,若不存在则执行所有的 transform 钩子来对模块内容进行进行自定义的转换;若存在缓存则判断 shouldTransformCachedModule 属性,true 则执行所有的 transform 钩子,false 则进入 moduleParsed 钩子逻辑。
  6. 拿到最终的模块内容,进行 AST 分析,调用 moduleParsed 钩子。如果内部没有 import 内容,进入 buildEnd 环节,如果还有 import 内容则继续。如果是普通的 import,则继续回到步骤 3 调用 resolveId;如果是动态 import,则执行 resolveDynamicImport 钩子解析路径,如果解析成功,则回到步骤 4 load 加载模块,否则回到步骤 3 通过 resolveId 解析路径。
  7. 直到所有的 import 都解析完毕,执行 buildEnd 钩子,构建阶段结束。
export default function myExample() {
return {
name: 'my-example',
options(options) {
console.log('options:', options)
},
buildStart(options) {
console.log('buildStart:', options)
},
resolveId(source, importer) {
console.log('resolveId(source):', source)
console.log('resolveId(importer):', importer)
return null
},
load(id) {
console.log('id:', id)
return null
},
transform(code, id) {
console.log('transform')
console.log('---', code)
console.log('---', id)
},
moduleParsed(info) {
console.log('moduleParsed:', info)
},
buildEnd() {
console.log('buildEnd')
}
}
}
示例
rollup-plugin-json.js
import { createFilter,dataToEsm } from '@rollup/pluginutils';
import path from 'path';

export default function myJson(options = {}) {
// createFilter 返回一个函数,这个函数接收一个id路径参数,返回一个布尔值
// 这个布尔值表示是否要处理这个id路径

// rollup 推荐每一个 transform 类型的插件都需要提供 include 和 exclude 选项,生成过滤规则
const filter = createFilter(options.include, options.exclude);
return {
name: 'rollup-plugin-json',
transform: {
order: "pre",
handler(code, id) {
if (!filter(id) || path.extname(id) !== '.json') return null;
try {
const parse = JSON.stringify(JSON.parse(code));
return {
// dataToEsm 将数据转换成esm模块
code: dataToEsm(parse),
map: { mappings: '' }
};
} catch (err) {
const message = 'Could not parse JSON file';
this.error({ message, id, cause: err });
return null;
}
}
}
};
}
插件上下文

插件上下文可以通过 this 从大多数钩子中访问一些实用函数和信息位。

输出钩子

输出钩子可以提供有关生成的产物的信息并在构建完成后修改构建,其工作方式和类型与构建钩子类似。

执行顺序
Outputting Hooks
  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。
  2. 执行 renderStart,该钩子读取所有 outputOptions 钩子的转换之后的输出选项。
  3. 扫描动态 import 语句执行 renderDynamicImport 钩子,让开发者能自定义动态 import 的内容与行为。
  4. 并发执行所有插件的 bannerfooterintrooutro 钩子,这四个钩子功能简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如版本号、作者、内容、项目介绍等。
  5. 判断是否存在 import.meta 语句,如果没有就直接进入下一步,否则,对于 import.meta.url 调用 resolveFileUrl 来自定义 url 解析逻辑。对于 import.meta 调用 resolveImportMeta 来进行自定义元信息解析。
  6. 生成 chunk 调用 renderChunk 钩子,便于在该钩子中进行自定义操作。如果生成的 chunk 文件有 hash 值,执行 augmentChunkHash 钩子,来决定是否更改 chunk 的哈希值。
  7. 调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk(打包后的代码)、asset(最终的静态资源文件)。在这个钩子中可以自定义操作,比如:可以在这里删除一些 chunk 或者 asset,最终被删除的内容将不会作为产物输出。
  8. 执行 bundle 对象的 write 方法,会触发 writeBundle 钩子,传入所有的打包产物信息,包括 chunkasset,与 generateBundle 钩子非常相似。唯一的区别是 writeBundle 钩子执行的时候,产物已经输出,而 generateBundle 执行的时候产物还并没有输出。
  9. bundleclose 方法被调用时,会触发 closeBundle 钩子,输出阶段结束。
示例
import { minify } from 'uglify-js';

export default function uglifyPlugin() {
return {
name: 'uglify',

renderChunk(code) {
const result = minify(code);
if (result.error) {
throw new Error(`minify error: ${result.error}`);
}
return {
code: result.code,
map: { mappings: '' }
};
},
};
}