Rollup
Rollup
Rollup 是一个 JavaScript 模块打包工具,特别适合打包库和工具函数库。它支持现代的 ESM 模块标准,允许开发者将多个模块文件打包成一个文件,并输出多种格式(如 ESM、CommonJS、UMD),以便在不同环境中复用。Rollup 的主要特点包括高效的树摇(Tree Shaking)、简单配置和插件化体系,使其成为构建 JavaScript 库的理想选择。
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.js 或 rollup.config.mjs,并位于项目的根目录中。它导出一个默认对象,其中包含所需的选项。
Node 环境下要运行 ESM 模块化的内容,要么文件名后缀为 .mjs,要么 package.json 文件中配置 "type":"module"。
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 来进行支持。
- rollip.config.js
- babel.config.js
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'
})
]
}
// pnpm add -D @babel/core @babel/preset-env
export default {
presets: ['@babel/preset-env']
}
@babel/preset-env 只转换了语法,也就箭头函数、const 等,但对于进一步需要转换内置对象、实例方法等 API,就显得无能为力了。这些代码需要通过 polyfill (兼容性垫片) 来进行支持,所以需要 @babel/runtime 来进行处理。
@babel/runtime 是一种 polyfill 实现方式,但是在实现过程中,可能会产生很多重复的代码,所以需要 @babel/plugin-transform-runtime 防止污染全局及冗余。此外,在处理 polyfill 时,还需要 core-js 辅助,可使用 @babel/runtime-corejs3。
- rollip.config.js
- babel.config.js
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']
})
]
}
// pnpm add -D @babel/plugin-transform-runtime
// pnpm add @babel/runtime @babel/runtime-corejs3
export default {
presets: [
[
'@babel/preset-env',
{
targets: '> 0.25%, not dead',
useBuiltIns: 'usage',
corejs: 3
}
]
],
plugins: [
[
'@babel/plugin-transform-runtime',
{
corejs: 3
}
]
]
}
TypeScript
可使用 @rollup/plugin-typescript 来处理 TypeScript,其依赖了 typescript 和 tslib。
- rollip.config.ts
- tsconfig.json
// pnpm add typescript tslib @rollup/plugin-typescript -D
import typescript from '@rollup/plugin-typescript'
export default {
// ...
plugins: [
// ...
typescript()
]
}
{
"compilerOptions": {
"module": "esnext",
"target": "es5",
"lib": ["esnext", "dom", "dom.iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
},
"include": [
"src/**/*",
"rollup.config.ts"
],
}
其他插件
# 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.rollup 与 rollup.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()
inputOptions 和 outputOptions 可查看选项大全。
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 对象,并没有模块打包,这个对象的作用在于存储各个模块的内容及依赖关系,并且提供了 generate、write 等方法,方便输出阶段输出产物。
输出阶段
通过 bundle 对象提供的 generate、write 等方法,并根据 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:如果有多个插件实现此钩子,则钩子按顺序运行,直到钩子返回一个不是null或undefined的值。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
}
}
}
}
构建钩子
执行顺序
- 通过
options钩子读取配置,并进行配置的转换,得到处理后的配置对象。 - 调用
buildStart钩子,考虑了所有options钩子配置的转换,包含未设置选项的正确默认值,正式开始构建流程。 - 调用
resolveId钩子解析模块文件路径。Rollup 中模块文件的id就是文件路径。从inputOption的input配置指定的入口文件开始,每当匹配到引入外部模块的语句,便依次执行注册插件中的每一个resolveId钩子,直到某一个插件中的resolveId执行完后返回非null或非undefined的值,将停止执行后续插件的resolveId逻辑并进入下一个钩子。 - 调用
load钩子加载模块内容,resolveId中的路径一般为相对路径,load中的路径为处理之后的绝对路径。 - 判断当前解析的模块是否存在缓存,若不存在则执行所有的
transform钩子来对模块内容进行进行自定义的转换;若存在缓存则判断shouldTransformCachedModule属性,true则执行所有的transform钩子,false则进入moduleParsed钩子逻辑。 - 拿到最终的模块内容,进行 AST 分析,调用
moduleParsed钩子。如果内部没有import内容,进入buildEnd环节,如果还有import内容则继续。如果是普通的import,则继续回到步骤 3 调用resolveId;如果是动态import,则执行resolveDynamicImport钩子解析路径,如果解析成功,则回到步骤 4load加载模块,否则回到步骤 3 通过resolveId解析路径。 - 直到所有的
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')
}
}
}
示例
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 从大多数钩子中访问一些实用函数和信息位。
输出钩子
输出钩子可以提供有关生成的产物的信息并在构建完成后修改构建,其工作方式和类型与构建钩子类似。
执行顺序
- 执行所有插件的
outputOptions钩子函数,对output配置进行转换。 - 执行
renderStart,该钩子读取所有outputOptions钩子的转换之后的输出选项。 - 扫描动态
import语句执行renderDynamicImport钩子,让开发者能自定义动态import的内容与行为。 - 并发执行所有插件的
banner、footer、intro、outro钩子,这四个钩子功能简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如版本号、作者、内容、项目介绍等。 - 判断是否存在
import.meta语句,如果没有就直接进入下一步,否则,对于import.meta.url调用resolveFileUrl来自定义url解析逻辑。对于import.meta调用resolveImportMeta来进行自定义元信息解析。 - 生成
chunk调用renderChunk钩子,便于在该钩子中进行自定义操作。如果生成的chunk文件有hash值,执行augmentChunkHash钩子,来决定是否更改chunk的哈希值。 - 调用
generateBundle钩子,这个钩子的入参里面会包含所有的打包产物信息,包括chunk(打包后的代码)、asset(最终的静态资源文件)。在这个钩子中可以自定义操作,比如:可以在这里删除一些chunk或者asset,最终被删除的内容将不会作为产物输出。 - 执行
bundle对象的write方法,会触发writeBundle钩子,传入所有的打包产物信息,包括chunk和asset,与generateBundle钩子非常相似。唯一的区别是writeBundle钩子执行的时候,产物已经输出,而generateBundle执行的时候产物还并没有输出。 - 当
bundle的close方法被调用时,会触发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: '' }
};
},
};
}