一、前言
Rollup 是一个 JavaScript 模块打包器,它专注于将现代 JavaScript 模块按需打包成更小、更高效的输出。相比于其他打包工具,Rollup 更适用于构建针对现代浏览器的库和应用程序。Rollup拥有
各种各样的优势,包括:Tree Shaking,ES6 模块支持,输出单一文件,代码分割等。Rollup通过其强大的插件系统,利用插件系统开发者可以实现任意打包自定义功能。接下来会介绍一下如何从零到一开发一个rollup插件。
二、前期准备
- 如果你已经拥有rollup相关项目,则可以直接进入第三部分插件开发。
- 你跟着我下面的指引,建立demo工程。
1、环境依赖
环境信息
node: "14.18.1"
rollup: "^3.29.4"
"@babel/core": "^7.23.7"
"@babel/plugin-transform-runtime": "^7.23.7"
"rollup-plugin-babel": "^4.4.0"
lerna: "^6.4.1" // 用于项目管理,非必要
2、项目搭建
目录树
javascript
rollup-test
├─ README.md
├─ build
│ └─ rollup.config.js
├─ dist
│ └─ bundle.js
├─ main.js
├─ package-lock.json
└─ package.json
rollup配置文件
- 初始搭建先配置一个babel降级插件,方便验证项目是否搭建成功。
- 其中产物的输出目录在 dist,后续可以在dist目录中找到对应产物。
javascript
// rollup.config.js
import babel from 'rollup-plugin-babel'
export default {
input: 'main.js', // 入口文件
output: {
file: 'dist/bundle.js', // 输出文件
format: 'esm' // 输出格式
},
plugins: [
babel({
exclude: 'node_modules/**', // 忽略 node_modules 下的文件
runtimeHelpers: 'true',
presets: [
['@babel/preset-env']
],
plugins: [
'@babel/plugin-transform-runtime' // 使用 @babel/plugin-transform-runtime
]
})
]
};
构建测试
完成搭建后,可以执行构建命令尝试构建,建议把构建命令放入package.json,方便后续使用。
javascript
// 把构建命令放入package.json
"scripts": {
"build": "npx rollup -c ./build/rollup.config.js"
}
执行构建命令:
javascript
npm run build
构建后可以进行产物对比:
javascript
// 构建前:
let a = {}
let b = a?.rollup
// 构建后代码已经降级,证明构建成功。
var a = {};
a === null || a === void 0 ? void 0 : a.rollup;
三、rollup插件开发
在开发rollup之前,我们必须先了解rollup插件的机制,才能帮助我们更好的发挥rollup插件的作用。以下为官方对rollup插件的描述:
Rollup 插件是一个对象,具有属性、构建钩子和输出生成钩子 中的一个或多个,并遵循我们的 约定。插件应作为一个导出一个函数的包进行发布,该函数可以使用插件特定的选项进行调用并返回此类对象。
根据描述得知,rollup插件主要组成部分其实是属性和不同的钩子(构建钩子,输出生成钩子),其中属性只有两个:name和version,不同的钩子作用在不同的构建生命周期。我们通过官方例子简单了解以上三种组成部分:
javascript
// rollup-plugin-my-example.js
export default function myExample () {
return {
name: 'my-example', // 此名称将出现在警告和错误中
resolveId ( source ) {
if (source === 'virtual-module') {
// 这表示 rollup 不应询问其他插件或
// 从文件系统检查以找到此 ID
return source;
}
return null; // 其他ID应按通常方式处理
},
load ( id ) {
if (id === 'virtual-module') {
// "virtual-module"的源代码
return 'export default "This is virtual!"';
}
return null; // 其他ID应按通常方式处理
}
};
}
从上面例子可以看到,这个插件的name属性为:my-example。并且在两个不同钩子(resolveId和load)中实现了不同的业务逻辑。
约定
官方对插件的开发有以下建议的约定,开发时可以关注:
- 插件应该有一个明确的名称,并以rollup-plugin-作为前缀。
- 在package.json中包含rollup-plugin关键字。
- 插件应该被测试,我们推荐 mocha 或 ava,它们支持 Promise。
- 可能的话,使用异步方法,例如 fs.readFile 而不是 fs.readFileSync
- 用英文文档描述你的插件。
- 确保如果适当,你的插件输出正确的源映射。
- 如果插件使用"虚拟模块"(例如用于辅助函数),请使用\0前缀模块 ID。这可以防止其他插件尝试处理它。
属性
rollup插件的属性值一共有2个:name和version。
构建钩子
构建钩子的使用在rollup插件中十分重要。钩子是在构建的各个阶段调用的函数。钩子可以影响构建的运行方式,提供关于构建的信息,或在构建完成后修改构建。有不同种类的钩子:
- async:该钩子也可以返回一个解析为相同类型的值的 Promise;否则,该钩子被标记为 sync。
- first:如果有多个插件实现此钩子,则钩子按顺序运行,直到钩子返回一个不是 null 或 undefined 的值。
- sequential:如果有多个插件实现此钩子,则所有这些钩子将按指定的插件顺序运行。如果钩子是 async,则此类后续钩子将等待当前钩子解决后再运行。
- parallel:如果有多个插件实现此钩子,则所有这些钩子将按指定的插件顺序运行。如果钩子是 async,则此类后续钩子将并行运行,而不是等待当前钩子。
(1)使用对象形式的钩子
除了函数之外,钩子也可以是对象 。在这种情况下,实际的钩子函数(或 banner/footer/intro/outro 的值)必须指定为 handler。这允许你提供更多的可选属性,以改变钩子的执行:
- order: "pre" | "post" | null
- 影响执行顺序
如果有多个插件实现此钩子,则可以先运行此插件("pre"),最后运行此插件("post"),或在用户指定的位置运行(没有值或 null)。如果优先级重复,则最终根据用户指定的先后顺序执行。
javascript
// 官方示例
export default function resolveFirst() {
return {
name: 'resolve-first',
resolveId: {
order: 'pre',
handler(source) {
if (source === 'external') {
return { id: source, external: true };
}
return null;
}
}
};
}
- sequential: boolean
- 不要与其他插件的相同钩子并行运行此钩子,独立运行。
- 仅可用于 parallel 钩子。
- 可以将此选项与 order 结合使用进行排序。
使用此选项将使 Rollup 等待所有先前插件的结果,然后执行插件钩子,然后再次并行运行剩余的插件。例如,当你有插件 A、B、C、D、E,它们都实现了相同的并行钩子,并且中间插件 C 具有 sequential: true 时,Rollup 将首先并行运行 A + B,然后单独运行 C,然后再次并行运行 D + E。
javascript
// 官方示例
import { resolve } from 'node:path';
import { readdir } from 'node:fs/promises';
export default function getFilesOnDisk() {
return {
name: 'getFilesOnDisk',
writeBundle: {
sequential: true,
order: 'post',
async handler({ dir }) {
const topLevelFiles = await readdir(resolve(dir));
console.log(topLevelFiles);
}
}
};
}
在整个生命周期中,第一个钩子为options,最后一个钩子为buildEnd。以下为官方对整个生命周期的图例说明:
常用钩子
rollup共支持12个构建钩子,15个输出生成钩子。各个钩子详细信息可以参考官方文档:cn.rollupjs.org/plugin-deve...。以下会介绍日常会用到的几个钩子。
(1)options
在创建配置选项时触发,允许修改或扩展 Rollup 配置。
类型 | (options: InputOptions) => InputOptions | null |
---|---|
类别 | async,sequential |
上一个钩子 | 此为第一个钩子 |
下一个钩子 | buildStart |
替换或操作传递给 rollup.rollup 的选项对象。返回 null 不会替换任何内容。如果只需要读取选项,则建议使用 buildStart 钩子,因为该钩子可以访问所有 options 钩子的转换考虑后的选项。
此钩子不具有大多数 插件上下文 实用程序函数的访问权限,因为它可能在 Rollup 完全配置之前运行。唯一支持的属性是 this.meta 以及 this.error、this.warn、this.info 和 this.debug 用于记录和错误。
javascript
options(options) {
// 修改或扩展配置选项
return options;
}
(2)resolveId
:::info 在解析模块标识符时触发,允许修改模块的路径或返回其他模块标识符。 :::
类型 | ResolveIdHook(参见下方构造函数) |
---|---|
类别 | async, first |
上一个钩子 | 如果我们正在解析入口点,则为 buildStart,如果我们正在解析导入,则为 moduleParsed,否则作为 resolveDynamicImport 的后备。此外,此钩子可以通过调用 this.emitFile 来在构建阶段的插件钩子中触发以产出入口点,或随时调用 this.resolve 手动解析 id。 |
下一个钩子 | 如果尚未加载解析的 id,则为 load,否则为 buildEnd。 |
javascript
type ResolveIdHook = (
source: string,
importer: string | undefined,
options: {
attributes: Record<string, string>;
custom?: { [plugin: string]: any };
isEntry: boolean;
}
) => ResolveIdResult;
type ResolveIdResult = string | null | false | PartialResolvedId;
interface PartialResolvedId {
id: string;
external?: boolean | 'absolute' | 'relative';
attributes?: Record<string, string> | null;
meta?: { [plugin: string]: any } | null;
moduleSideEffects?: boolean | 'no-treeshake' | null;
resolvedBy?: string | null;
syntheticNamedExports?: boolean | string | null;
}
以以下代码为例:
javascript
// main.js
import {foo} from '../bar.js'
- source:'../bar.js'
- importer:'main.js'
- 是导入模块的解析完全后的 id。在解析入口点时,importer 通常为 undefined。
javascript
resolveId(source, importer) {
// 修改模块的路径或返回其他模块标识符
return source;
}
(3)load
定义一个自定义加载器。
类型 | (id: string) => LoadResult |
---|---|
类别 | async, first |
上一个钩子 | 已解析加载的 id 的 resolveId 或 resolveDynamicImport。此外,此钩子可以通过调用 this.load 来从插件钩子中的任何位置触发预加载与 id 对应的模块 |
下一个钩子 | 如果未使用缓存,或者没有具有相同 code 的缓存副本,则为 transform,否则为 shouldTransformCachedModule |
javascript
type LoadResult = string | null | SourceDescription;
interface SourceDescription {
code: string;
map?: string | SourceMap;
ast?: ESTree.Program;
attributes?: { [key: string]: string } | null;
meta?: { [plugin: string]: any } | null;
moduleSideEffects?: boolean | 'no-treeshake' | null;
syntheticNamedExports?: boolean | string | null;
}
- moduleSideEffects
- false:并且没有其他模块从该模块导入任何内容,则即使该模块具有副作用,该模块也不会包含在产物中。
- true:则 Rollup 将使用其默认算法包含模块中具有副作用的所有语句(例如修改全局或导出变量)。
- no-treeshake:关闭此模块的除屑优化,并且即使该模块为空,也将在生成的块之一中包含它。
- null:moduleSideEffects 将由第一个解析此模块的 resolveId 钩子,treeshake.moduleSideEffects 选项或最终默认为 true 确定。
- attributes:包括导入此模块时使用的导入属性,它们不会影响产物模块的呈现,而是用于文档目的。
(4)transform
可以被用来转换单个模块。
类型 | (code: string, id: string) => TransformResult |
---|---|
类别 | async, sequential |
上一个钩子 | load,用于加载当前处理的文件。如果使用缓存并且该模块有一个缓存副本,则为 shouldTransformCachedModule,如果插件为该钩子返回了 true |
下一个钩子 | moduleParsed,一旦文件已被处理和解析 |
javascript
type TransformResult = string | null | Partial<SourceDescription>;
interface SourceDescription {
code: string;
map?: string | SourceMap;
ast?: ESTree.Program;
attributes?: { [key: string]: string } | null;
meta?: { [plugin: string]: any } | null;
moduleSideEffects?: boolean | 'no-treeshake' | null;
syntheticNamedExports?: boolean | string | null;
}
(5)buildStart
在每个 rollup.rollup 构建上调用。当你需要访问传递给 rollup.rollup() 的选项时,建议使用此钩子,因为它考虑了所有 options 钩子的转换,并且还包含未设置选项的正确默认值。
类型 | (options: InputOptions) => void |
---|---|
类别 | async, parallel |
上一个钩子 | options |
下一个钩子 | 并行解析每个入口点的 resolveId |
(6)buildEnd
在 Rollup 完成产物但尚未调用 generate 或 write 之前调用;也可以返回一个 Promise。如果在构建过程中发生错误,则将其传递给此钩子。
类型 | (error?: Error) => void |
---|---|
类别 | async, parallel |
上一个钩子 | moduleParsed、resolveId 或 resolveDynamicImport |
下一个钩子 | 输出生成阶段的 outputOptions,因为这是构建阶段的最后一个钩子 |
第三节的内容大部分是rollup官网文档,如果有兴趣详细了解的同学可以直接查看:cn.rollupjs.org/plugin-deve...
四、实践出真知
理论知识我们学了个懵懵懂懂,接下来就需要在实际的开发中实践这些知识。
1、需求背景
开始之前我们先预设一个场景:在多端的构建中我们会经常遇到,需要根据不同的环境进行打包,不同环境可能逻辑不一样,但是实际所有的代码都在一个文件中。因此需要一种特殊的DSL(Domain-Specific Language),在源码中把不同环境的代码块标识出来,然后构建时再根据环境渠道,删减其余环境对应的代码,保留当前环境的代码,最后删除所有DSL标识。大概效果如下:
javascript
// 源码
//node-env-start
// 一些node环境逻辑
let a = 'node'
//node-env-end
// web-env-start
// 一些web环境逻辑
let a = 'web'
//web-env-end
//mp-env-start
// 一些mp环境逻辑
let a = 'mp'
//mp-env-end
// 构建产物:渠道为node时
let a = 'node'
其中 << xxxx-env-start >> 这类标签就是用来区分不同环境的DSL,当我们使用node环境进行打包的时候,其余环境的代码就都会被舍弃,只留下node标签下的代码内容。
2、rollup插件开发
首先由于这里是需要对源码进行一次转换,因此选择使用transform 这个钩子来处理:
(1)在build目录下新建 rollup.plugin.js
(2)在rollup配置中引入并使用此插件
后续插件的开发过程需要一定的调试,因此先把当前插件对象抛出,并且在rollup中使用,能比较方便的进行后续调试。
javascript
// rollup.plugin.js
console.log('进入测试逻辑')
function dslTransform() {
return {
name: 'dslTransform',
transform: {
handler() {
// todo
}
}
}
}
export default dslTransform
javascript
// rollup.config.js 中配置变更
// rollup.config.js
import babel from 'rollup-plugin-babel'
import dslTransform from './rollup.plugin.js';
export default {
input: 'main.js', // 入口文件
output: {
file: 'dist/bundle.js', // 输出文件
format: 'esm' // 输出格式
},
plugins: [
dslTransform(),
babel({
exclude: 'node_modules/**', // 忽略 node_modules 下的文件
runtimeHelpers: 'true',
presets: [
['@babel/preset-env']
],
plugins: [
'@babel/plugin-transform-runtime' // 使用 @babel/plugin-transform-runtime
]
})
]
};
(3)获取构建命令中的渠道参数
接下来我们需要知道打包时的环境渠道参数,假设我们渠道参数输入格式为:
javascript
npx rollup -c ./build/rollup.config.js --env=node
// 可以把上述命令放到package.json的自定义命令中,与之前的构建命令统一
"scripts": {
"build:node": "npx rollup -c ./build/rollup.config.js --env=node",
"build": "npx rollup -c ./build/rollup.config.js"
}
通过node提供的 process.argv 我们就可以获取到具体的构建参数。并且我们可以利用minimist模块更好的帮助我们解析命令行入参:
javascript
import minimist from 'minimist'
console.log('进入测试逻辑')
let cmdArgs = minimist(process.argv.slice(2))
let env = cmdArgs.env
function dslTransform() {
return {
name: 'dslTransform',
transform: {
handler(code, id) {
// todo
console.log(env)
}
}
}
}
export default dslTransform
(4)执行相关转换逻辑
通过transform钩子,我们把需要执行的代码转换一下,删除其余环境的代码,保留当前环境的代码。
以下为插件代码:
javascript
import minimist from 'minimist'
console.log('进入测试逻辑')
let cmdArgs = minimist(process.argv.slice(2))
let env = cmdArgs.env
let tagConfig = {
node: {
start: '//node-env-start',
end: '//node-env-end'
},
web: {
start: '//web-env-start',
end: '//web-env-end'
},
mp: {
start: '//mp-env-start',
end: '//mp-env-end'
}
}
function dslTransform() {
return {
name: 'dslTransform',
order: 'pre',
transform: {
handler(code) {
if(!env) {
code: code
}
let newCode = code
// 删除不是当前环境的tag信息
let temTag = Object.assign({}, tagConfig) // 复制一份避免操作源对象
!!temTag[env] && delete temTag[env]
for(let i in temTag) {
let regexPattern = new RegExp(`${temTag[i].start}([\\s\\S]*?)${temTag[i].end}`, 'gm')
newCode = newCode.replace(regexPattern, '')
}
// 删除当前环境标识 demo不实现容错,默认全支持
let envRegStart = new RegExp(`${tagConfig[env].start}`, 'gm')
let envRegEnd = new RegExp(`${tagConfig[env].end}`, 'gm')
newCode = newCode.replace(envRegStart, '').replace(envRegEnd, '')
return {
code: newCode
}
}
}
}
}
export default dslTransform
以下为需要转换的业务代码(main.js):
javascript
//node-env-start
// 一些node环境逻辑
let a = 'node'
//node-env-end
//web-env-start
// 一些web环境逻辑
let a = 'web'
//web-env-end
//mp-env-start
// 一些mp环境逻辑
let a = 'mp'
//mp-env-end
console.log(a)
console.log('产物输出')
完美,现在可以执行一下构建命令:npx rollup -c ./build/rollup.config.js --env=node。
:::info obbs!不对劲,我transform逻辑中明明已经删除了对应的代码了,为什么编译的时候还是会出现重复变量定义的问题,感觉我的插件逻辑没生效呀。 :::
(5)选择正确的钩子
上面问题其实很简单,就是我们钩子使用错。
Rollup在打包过程中,会先做一个预处理,将所有的ES模块进行预编译。在预编译阶段,它会检查所有代码的语法正确性,在这个阶段,因为代码的预处理还没有发生,所以它会认为你重复定义了变量。
因此我们使用更改使用load钩子:
javascript
import minimist from 'minimist'
import fs from 'fs'
console.log('进入测试逻辑')
let cmdArgs = minimist(process.argv.slice(2))
let env = cmdArgs.env
let tagConfig = {
node: {
start: '//node-env-start',
end: '//node-env-end'
},
web: {
start: '//web-env-start',
end: '//web-env-end'
},
mp: {
start: '//mp-env-start',
end: '//mp-env-end'
}
}
function dslTransform() {
return {
name: 'dslTransform',
order: 'pre',
load: {
handler(id) {
let newCode = fs.readFileSync(id, 'utf-8')
if(!env) {
return newCode
}
// 删除不是当前环境的tag信息
let temTag = Object.assign({}, tagConfig) // 复制一份避免操作源对象
!!temTag[env] && delete temTag[env]
for(let i in temTag) {
let regexPattern = new RegExp(`${temTag[i].start}([\\s\\S]*?)${temTag[i].end}`, 'gm')
newCode = newCode.replace(regexPattern, '')
}
// 删除当前环境标识 demo不实现容错,默认全支持
let envRegStart = new RegExp(`${tagConfig[env].start}`, 'gm')
let envRegEnd = new RegExp(`${tagConfig[env].end}`, 'gm')
newCode = newCode.replace(envRegStart, '').replace(envRegEnd, '')
return {
code: newCode
}
}
}
}
}
export default dslTransform
执行一下构建命令:npx rollup -c ./build/rollup.config.js --env=node。
检查一下bundle.js:
javascript
// 一些node环境逻辑
var a = 'node';
console.log(a);
console.log('产物输出');
五、结语
当然上面只是很简单的一个插件的实践,希望能帮助到各位熟悉rollup的插件开发。如果有任何问题,欢迎在评论区一起讨论。