11-Rollup打包机制及插件开发
# 0.前言
本节课程代码的仓库 (opens new window):
13-vite-rollup
承接上文,本篇博客为《深入浅出 Vite
》掘金手册第十一章的总结。
# 1.构建机制
# 问题1:在Rollup一次完整的构建过程中,Rollup会经历哪两个阶段?每个阶段的作用是什么?
Rollup
内部会经历 build
和 output
两个大阶段
代码逻辑简化如下:
// Build 阶段
const bundle = await rollup.rollup(inputOptions);
// Output 阶段
await Promise.all(outputOptions.map(bundle.write));
// 构建结束
await bundle.close();
2
3
4
5
6
7
8
Build
阶段:通过 debug
断点后,简化 bundle
对象(以简化部分)如下:
{
/* 缓存 ast 相关信息 */
cache: {
modules: [
{
assertions: {
},
ast: {
.......
sourceType: "module",
},
/* 构建后代码 */
code: "export var add = function (a, b) { return a + b; };\r\nexport var multiple = function (a, b) { return a * b; };\r\n",
/* 导入的模块 */
id: "/Users/jiashengwang/Project/Learn-vite/examples/13-vite-rollup/src/basic/util.ts",
moduleSideEffects: true,
/* 源代码 */
originalCode: "export const add = (a: number, b: number) => a + b;\n\nexport const multiple = (a: number, b: number) => a * b;\n",
},
],
/* 记录使用到的插件 */
plugins: {},
close: /* 关闭构建*/
closed: /* 标识构建是否结束 */
generate: /* 生成 chunk */
write: /* 将 chunk 写入到磁盘 */
watchFiles: [ /* 记录入口文件相关信息 */
"/Users/../examples/13-vite-rollup/src/basic/index.ts",
"/Users/../examples/13-vite-rollup/src/basic/util.ts",
],
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
从上可以看出,Build
阶段主要完成的事情有:
bundle
对象的cache.modules
中实际存储各个模块的内容(源码及构建后代码),模块依赖关系,以及解析后的ast
树。- 暴露出三个函数:
generate
、write
和close
方法,用于进入到后的Outup
阶段。
Output
阶段:通过打断点 const {output} = bundle.generate({})
查看 output
对象。
分析如下:
// 入口源码 src/basic/index.ts
import { add, multiple } from "./util";
console.log(add(1, 2));
// ========== 构建结果 ==========
{
output: [
{
exports: [], /* 当前无导出 */
facadeModuleId: "/Users/jiashengwang/Project/Learn-vite/examples/13-vite-rollup/src/basic/index.ts",
isEntry: true,
isDynamicEntry: false, /* 是否为动态导入入口模块 */
isImplicitEntry: false, /* 是否为隐式入口模块 */
isEntry: true, /* 是否为入口 */
type: 'chunk', /* 类型 */
/* 打包后的代码 */
code: "import { add } from './util.85d9f98d.js';\n\nconsole.log(add(1, 2));\r\n/* console.log(multiple(2, 3)); */\n//# sourceMappingURL=index.ce505c09.js.map\n",
dynamicImports: [],
fileName: "index.ce505c09.js", /* 构建后名称 */
imports: [
"util.85d9f98d.js", /* 导入的模块 */
],
// 其余属性省略
}
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
非入口文件分析:
// src/basic/util.ts
export const add = (a: number, b: number) => a + b;
export const multiple = (a: number, b: number) => a * b;
// ========== 构建结果 ==========
{
output: [
{
/* 导出的方法 */
exports: [
"add",
"multiple",
],
facadeModuleId: "/Users/../examples/13-vite-rollup/src/basic/util.ts",
isDynamicEntry: false,
isImplicitEntry: false,
isEntry: true,
type: 'chunk', /* 类型 */
/* 打包后的代码 */
code: "var add = function (a, b) { return a + b; };\r\nvar multiple = function (a, b) { return a * b; };\n\nexport { add, multiple };\n//# sourceMappingURL=util.85d9f98d.js.map\n",
dynamicImports: [],
fileName: "util.85d9f98d.js", /* 构建后名称 */
imports: [],
// 其余属性省略
}
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
对应 map
文件结构:
{
output: [
{
fileName: "index.ce505c09.js.map",
source: "{\"version\":3,\"file\":\"index.ce505c09.js\",\"sources\":[],\"sourcesContent\":[],\"names\":[],\"mappings\":\";;;\"}",
type: "asset", /* 类型为 assets */
},
{
fileName: "util.85d9f98d.js.map",
}
]
}
2
3
4
5
6
7
8
9
10
11
12
最终的输出结果:
# 问题2:rollup 中 Build Hook 和 Output Hook 的本质区别是什么?
插件的各种 Hook 可以根据这两个构建阶段分为两类: Build Hook
与 Output Hook
。
Build Hook
:是以module
作为处理边界。Output Hook
:则是以Chunk
作为处理边界。
# 问题3:根据 Hook 执行方式可以把插件分成哪五类?
Rollup
中的钩子类型应该也是参考 tabpable
这个库。
hook
钩子类型大致可以分为 5 类:
- 同步
Sync
or 非同步Async
。 - 并行
Parallel
or 串行Sequential
:类比Promise.all
和async + await
First
:类比Promise.race
仅处理第一个返回值。
上述分类太八股了点,背住即可,实际流程还是挺简单的。
# 问题4:请描述一下Rollup插件在build阶段的工作流程?
Build
的执行流程图如下:
主要记住以下几点即可:
对于多个插件的启动是并发的,因此构建开始阶段
buildStart
为parallel
模式。而单个插件内部的执行流程是串行的。
其中负责解析
module
的钩子,即resolve
+load
类属于First
型。当某个模块被插件处理过后,其他模块无法处理了,如果此时仍需处理,可通过this.resolve()
发起二次模块解析操作,这一点特性在rollup
插件阶段的alias
插件时体现的很明显。核心构建流程:
resolve
=>load
=>transform
(字符串到字符串)=>moduleParse
(字符串到ast
树,这个阶段很耗时可以是并发模式)
# 问题5:请描述一下Rollup插件在Output阶段的工作流程?
Output
的执行流程图如下:
Build 和 Output 其实也挺像的,只是前者处理 moudle
,或者处理 chunk
,如何 emit
到磁盘:
- 在执行多个插件时基本都是并发的。如
renderStart
中并发执行所有插件的banner\footer\intro\outro
钩子。这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。 writeBundle
也是如此,构建结束后,output
结果为一个数组,根据数组中的filename
去往磁盘中输出产物。- 特殊的钩子解析:
augmentChunkHash
:决定是否要以hash
方式命名。resolveFileUrl
:之前在构建__dirname
时有使用过,此阶段遇到import.meta.url
语句时,可通过此函数解析。(路径解析类的都属于First
型)resolveImportMeta
:对于import.meta.属性
语句时,可通过此函数解析。(路径解析类的都属于First
型)
- 构建的核心流程:
renderChunk
=>generateBundle
=>writeBundle
# 2. 官方插件源码解析
# 问题6:如何实现别名替换 alias
插件?
官方
alias
插件 (opens new window) 功能很全,我在 仓库 (opens new window)中仅实现了一个简易版本。
使用方式:
// 官方插件
import alias from "@rollup/plugin-alias";
// 常用 inputOptions 配置
const inputOptions = {
input: path.join(SRC_PATH, "plugin", "alias.js"),
plugins: [
otherPlugin(),
myAlias({
/* 将 util-a 这个虚假模块替换为 ./util.js 相对 */
entries: [{ find: "util-a", replacement: "./util.js" }],
}),
/* 官方用法 */
alias({
entries: [{ find: "util-b", replacement: "./util.js" }],
}),
],
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
其中,util-a
和 util-b
为两个虚拟模块,使用官方实践处理 util-b
,使用myAlias
处理 util-a
。
import { add } from "util-a";
import { multiple } from "util-b";
2
实践思路:
- 通过
resolveId
筛选出util-a
模块,筛选后通过字符串的replace
函数替换成目标模块./util.js
。 - 特别注意时,由于
resolveId
属于First
型,当myAlias
处理后,后续所有的插件将不再处理此模块,需考虑一个情况,就是转译后的./util.js
有可能还会被二次处理。因此需要通过this
上下文,进行模块二次触发。 - 第二次触发时,
myAlias
不需要再进行处理了,通过透传{ skipSelf: true }
可跳过当前插件。
简易版代码如下:
/* 简易版-官方插件,经支持 find(不支持正则) 和 replacement 两个参数 */
function myAlias(options) {
// 获取 entries 配置
const { entries } = options;
return {
name: "myAlias",
// 传入三个参数,当前模块路径、引用当前模块的模块路径、其余参数
resolveId(importee, importer, resolveOptions) {
log(importee, importer, "alias-plugin");
// 根据 find 过滤出模块
const matchedEntry = entries.find(
(entry) =>
/* matches(entry.find, importee), */
entry.find === importee,
);
/* 判断是否为入口模块 */
const isEntry = !importer;
// 如果不能匹配替换规则,或者当前模块是入口模块,则不会继续后面的别名替换流程
if (!matchedEntry || isEntry) {
return null;
}
// 执行替换操作
const updatedId = importee.replace(
matchedEntry.find,
matchedEntry.replacement,
);
/* ===== END ===== */
/* 理论上替换完成后,直接 return string 或对象 即可,但是仍需考虑一个问题,
转译后的模块(本例中为 "./util.js")需不需要被其他模块所处理。*/
/* 因此:需通过 this.resolve 会执行所有插件(除当前插件外)的 resolveId 钩子,重新发起一轮构建去处理 "./util.js" 依赖。 */
/* 新一轮依赖处理,当前插件无需处理,则可以通过传入第三个参数 {skipSelf: true} 跳过 */
console.log("\n触发第二轮依赖解析......\n");
return this.resolve(
updatedId,
importer,
Object.assign({ skipSelf: true }, resolveOptions),
).then((resolved) => {
// 替换后的路径即 updateId 会经过别的插件进行处理
/* 如果是个真实的地址,最终会被 rollup 替换为绝对路径,若为虚拟模块的话 */
let finalResult = resolved;
if (!finalResult) {
// 如果其它插件没有处理这个路径,则直接返回 updateId
finalResult = { id: updatedId };
}
return finalResult;
});
},
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
这边为了能更好的看出二次依赖解析的过程,封装 log
函数打印处理结果。
function log(importee, importer, pluginName) {
const isEntry = !importer;
if (isEntry) {
console.log(`${pluginName}解析: 入口文件`);
} else {
console.log(`${pluginName}解析: ${importee}`);
}
}
2
3
4
5
6
7
8
执行:npm run plugin:alias
,打印结果如下:
otherPlugin解析: 入口文件
myAlias解析: 入口文件
otherPlugin解析: util-a
otherPlugin解析: util-b
myAlias解析: util-a
触发第二轮依赖解析......
myAlias解析: util-b
otherPlugin解析: ./util.js
otherPlugin 可以捕获到经 alias 插件 replace 后的模块
otherPlugin解析: ./util.js
otherPlugin 可以捕获到经 alias 插件 replace 后的模块
myAlias解析: ./util.js
🚀 Build Finished!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
可以发现如下:
插件生效时为并发,因此两个插件
myAlias
和otherPlugin
同时处理入口依赖。且整体执行次序按照plugins
的书写顺序。otherPlugin解析: 入口文件 myAlias解析: 入口文件
1
2单个插件属于串行,因此两个插件会依次触发解析
util-a
和util-b
两个包。otherPlugin解析: util-a otherPlugin解析: util-b myAlias解析: util-a // 调用 this.resolve 此为 async 模式。 myAlias解析: util-b
1
2
3
4
5因为
util-a
符合alias
的find
条件,会被转译为./util.js
,触发二次依赖解析。会在myAlias
触发结束后执行this.resolve
函数。这就是hook
钩子为async
的体现。由于
{ skipSelf: true }
二次构建时,只有otherPlugin
和官方的alias
参与解析。otherPlugin解析: ./util.js otherPlugin 可以捕获到经 alias 插件 replace 后的模块
1
2当
alias
模块解析到util-b
时,也会触发二次构建,因此最后执行:otherPlugin解析: ./util.js otherPlugin 可以捕获到经 alias 插件 replace 后的模块
1
2
分析结束,感觉我这个例子设计的超级好,读懂这个例子就能完全明白 hook
5种类型的实际含义了。
# 问题7:如何支持图片加载 image
插件?
官方
image
插件 (opens new window) , 仓库 (opens new window)仅实现了一个简易版本。相当于
webpack
的file-loader
插件去处理图片。
使用方式:
// 常用 inputOptions 配置
const inputOptions = {
input: path.join(SRC_PATH, "plugin", "image.js"),
plugins: [
myImage({
dom: true,
}),
],
};
2
3
4
5
6
7
8
9
myImage
插件支持一个 dom
参数,开启后可如下使用:
import logo from './rollup.png';
document.body.appendChild(logo);
2
涉及依赖处理流程:resolve
=> load
实现思路:
使用
resolveId
拦截.png
等后缀图片模块(由于太过简单,在load
钩子中也可直接完成)在
load
钩子函数中,使用fs.readFileSync(xxx,"base64")
以base64
的方式读取图片资源。如果
dom:false
,则直接返回base64
,构造export default ${dataUri}
即可。如果
dom:true
,通过new Image
创建一个<img>
标签后返回,如下:function domeTemplate({dataUri}){ return ` var img = new Image(); img.src = "${dataUri}"; export default img; `; }
1
2
3
4
5
6
7
额外需要注意的是,对不同格式的图片处理逻辑有所不同:
- 对于
img
图片:直接fs.readFileSync("xxx.png","base64")
获取dataUri
- 对于
svg
图片:对于svg
格式的图片,并不是直接以base64
的方式读取,而是通过fs.readFileySync("xxx.svg","utf-8")
的方式获取svg
字符串,再通过mini-svg-data-uri
这个第三方库获取压缩后的dataUri
。
简化版的代码:
const defaultOption = {
dom: false,
exclude: null,
include: null,
};
function myImage(opts = {}) {
const options = { ...defaultOption, ...opts };
return {
name: "rollup:image",
load(id) {
/* 1. 获取文件后缀名 */
const fileExtname = path.extname(id);
const mime = mimeTypes[fileExtname];
if (!mime) {
/* 非图标格式文件 */
return null;
}
const isSvg = fileExtname === ".svg";
const format = isSvg ? "utf-8" : "base64";
const source = fs.readFileSync(id, format).replace(/[\r\n]+/gm, "");
const dataUri = isSvg
? svgToMiniDataURI(source)
: `data:${mime};${format},${source}`;
const code = options.dom
? domTemplate({ dataUri })
: constTemplate({ dataUri });
return code.trim();
},
};
}
function domTemplate({ dataUri }) {
return `
var img = new Image();
img.src = "${dataUri}";
export default img;
`;
}
function constTemplate({ dataUri }) {
return `
var img = "${dataUri}";
export default img;
`;
}
async function build() {
const bundle = await rollup.rollup(inputOptions);
await bundle.write({
dir: path.join(DIST_PATH, "plugin", "image"),
format: "cjs",
});
}
build()
.then(() => {
console.log("🚀 Build Finished!");
})
.catch((error) => {
console.log("rollup failed", error);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# 问题8:如何实现一个全局替换 replace
插件?
官方
replace
插件 (opens new window) , 仓库 (opens new window)仅实现了一个简易版本。
使用方式:
// 常用 inputOptions 配置
const inputOptions = {
input: path.join(SRC_PATH, "plugin", "replace.js"),
external: [],
plugins: [
replace({
"process.env.Jacky": "'Jack!!!'",
}),
myReplace({
"process.env.Hello": "'Jack!!!'",
/* 支持回调形式,且回调中可获取模块id */
"process.env.World": () => "'Jack!!!'",
delimiters: ["\\b", "\\b(?!\\.)"],
}),
],
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
测试文本如下:
console.log("process.env.Jacky", process.env.Jacky);
console.log("process.env.Hello", process.env.Hello);
console.log("process.env.World", process.env.World);
2
3
输出结果如下:
'use strict';
console.log("'Jack!!!'", 'Jacky!!!');
console.log("'Jack!!!'", 'Jack!!!');
console.log("'Jack!!!'", 'Jack!!!');
2
3
4
5
实现思路:
- 这个插件功能是典型的
transform
功能,即string => string
- 核心思路是,通过
transform(code,id)
可以获取字符串,通过magic-string
第三方包工具完成字符串的替换后返回。
代码逻辑:
读取转化规则字段。由于
myReplace
组件只接受一个对象,配置字段和替换规则混合在一起了,所以需要过滤规则,通过getReplacements
删除delimiters/include/exclude
等属性。规则支持回调格式,如
{"process.env.World": () => "'Jack!!!'"}
,通过mapToFunctions
将所有规则键值改为:Record<string,function>
格式。对替换键值进行
escape
处理,是为了防止后续使用magic string
包替换被正则匹配。生成替换规则,如:
/\b(process\.env\.Hello|process\.env\.World)\b(?!\.)/g
带匹配的键值都通过
(规则1|规则2|规则3)
分割,前后通过\b
和\b(?!\.)
作为单词分隔。以上准备做完,就直接通过
excuteReplacement
进行字符替换工作。
具体简易版实现代码如下:
function myReplace(opts = {}) {
/* 此处 \\b xxxx \\b 用于单词边界区分 */
const { delimiters = ["\\b", "\\b(?!\\.)"] } = opts as any;
const replacements = getReplacements(opts);
const functionValues = mapToFunctions(replacements);
const keys = Object.keys(functionValues).map(escape);
const pattern = new RegExp(
`${delimiters[0]}(${keys.join("|")})${delimiters[1]}`,
"g",
);
return {
name: "rollup:replace",
transform(code, id) {
if (!keys.length) return null;
debugger;
return executeReplacement(code, id);
},
};
/* 过滤额外属性 */
function getReplacements(options) {
const values = Object.assign({}, options);
delete values.delimiters;
return values;
}
/* 将对象转化为函数 */
function mapToFunctions(object) {
return Object.keys(object).reduce((pre, cur) => {
const functions = { ...pre };
functions[cur] = ensureFunction(object[cur]);
return functions;
}, {});
}
/* 将 value 转化为函数 */
function ensureFunction(functionOrValue) {
if (typeof functionOrValue === "function") return functionOrValue;
return () => functionOrValue;
}
function executeReplacement(code, id) {
const magicString = new MagicString(code);
let match;
while ((match = pattern.exec(code))) {
const start = match.index;
const end = start + match[0].length;
const replacement = String(functionValues[match[1]](id));
magicString.overwrite(start, end, replacement);
}
const result = { code: magicString.toString() };
return result;
}
}
/* []内的特殊字符都无需添加转义 */
function escape(str) {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
上述处理中一大段代码都是在做准备工作,最后触发 executeReplacement
函数执行替换,比较有意思的是正则规则,如:
escape
替换:str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&)"
为所有特殊字符添加反斜杠通过
[]
包裹特殊字符时,就无需对所有特殊字符添加\
反斜杠,除]
比较特殊。通过
$&
可以获取匹配值,测试结果如下:"[]{}".replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&") '\\[\\]\\{\\}'
1
2通过
\b
可以作为单词边界,如获取完整的cat
单词"cat catb".match(/\bcat\b/g) > ['cat']
1
2