# 插件机制
# Rollup插件概述
Rollup 插件是一个对象,具有 属性 (opens new window)、构建钩子 (opens new window) 和 输出生成钩子 (opens new window) 中的一个或多个,并遵循我们的 约定 (opens new window)。插件应作为一个导出一个函数的包进行发布,该函数可以使用插件特定的选项进行调用并返回此类对象。
简单来说,rollup插件一般会做成一个函数,函数返回一个对象,返回的对象中包含一些属性和不同阶段的钩子函数。
# 约定
插件应该有一个明确的名称,并以rollup-plugin-
作为前缀。
# 属性
name:插件的名称,用于在警告和错误消息中标识插件。
version:插件的版本
# 钩子函数的特点
- 钩子函数区分不同的调用时机
- 钩子函数是有执行顺序的
- 钩子函数有不同的执行方式
- 钩子函数也可以是对象的形式
- 对象形式的钩子函数可以改变钩子的执行,让不同插件的同名钩子函数获取不通的执行先后
# 钩子函数的调用时机
这里的调用时机,其实就是以我们上面的API,build和output两大工作流的不同阶段进行分类。根据这两个不同阶段,rollup提供的不同的函数让我们调用
- const bundle = await rollup.rollup(inputOptions) 执行期间的构建钩子函数 - build-hooks (opens new window)
- await bundle.generate(outputOptions)/write(outputOptions) 执行期间的输出钩子函数-output-generation-hooks (opens new window)
# 钩子函数的执行方式
除了上面简单的划分为两个阶段的调用时机之外,我们还可以以钩子函数的执行方式来分类。
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; } } }; }
成功
2
3
4
5
6
7
8
9
10
11
12
# 构建钩子执行顺序
- 通过
options
钩子读取配置,并进行配置的转换,得到处理后的配置对象 - 调用
buildStart
钩子,考虑了所有options
钩子配置的转换,包含未设置选项的正确默认值,正式开始构建流程 - 调用
resolveId
钩子解析模块文件路径。rollup中模块文件的id就是文件地址,所以,类似resolveId这种就是解析文件地址的意思。从inputOption
的input
配置指定的入口文件开始,每当匹配到引入外部模块的语句(如:import moudleA from './moduleA'
)便依次执行注册插件中的每一个resolveId
钩子,直到某一个插件中的resolveId
执行完后返回非null
或非undefined
的值,将停止执行后续插件的resolveId
逻辑并进入下一个钩子 - 调用
load
钩子加载模块内容,resolveId
中的路径一般为相对路径,load中的路径为处理之后的绝对路径 - 接着判断当前解析的模块是否存在缓存,若不存在则执行所有的
transform
钩子来对模块内容进行进行自定义的转换;若存在则判断shouldTransformCachedModule
属性,true则执行所有的transform
钩子,false则进入moduleParsed
钩子逻辑 - 拿到最后的模块内容,进行
AST
分析,调用moduleParsed
钩子。如果内部没有imports
内容,进入buildEnd
环节。如果还有imports
内容则继续,如果是普通的import
,则执行resolveId
钩子,继续回到步骤3-调用resolveId;如果是动态import
,则执行resolveDynamicImport
钩子解析路径,如果解析成功,则回到步骤4-load加载模块,否则回到步骤3通过resolveId
解析路径 - 直到所有的
import
都解析完毕,Rollup
执行buildEnd
钩子,Build阶段结束
// rollup-plugin-example.js 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"); } }; }
成功
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
# 调用虚拟模块插件示例
const virtualModuleId = 'virtual-module'; // rollup约定插件使用“虚拟模块”,使用\0前缀模块 ID。这可以防止其他插件尝试处理它。 const resolvedVirtualModuleId = '\0' + virtualModuleId; export default function virtualModule() { return { name: 'virtual-module', resolveId (source) { if (source === 'virtual-module') { return resolvedVirtualModuleId; // 告诉Rollup,这个ID是外部模块,不要在此处查找它 } return null; // 其他ID应按通常方式处理 }, load (id) { console.log("🌈 - id:", id) if (id === resolvedVirtualModuleId) { // return 'export default "This is virtual!"'; // 告诉Rollup,如何加载此模块 return 'export default function fib(n) { return n <= 1 ? n : fib(n - 1) + fib(n - 2); }' } return null; // 其他ID应按通常方式处理 }, }; }
成功
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
界面调用
import fib from "virtual-module"; console.log(fib(10))
成功
2
# JSON插件示例
rollup默认是不能直接读取json文件的内容的,我们自己写一个插件处理一下,不过写这个插件之前,有一些小知识点需要补充一下
@rollup/pluginutils (opens new window) rollup官方提供的工具插件,里面有一些制作插件常用的方法
安装
pnpm add @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/pluginutils -D
成功
这个其实也是插件中很常用的一些api,可以通过 this
从大多数钩子 (opens new window)中访问一些实用函数和信息位
rollup-plugin-json
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模块 // 其实就是 export default "xxx" code: dataToEsm(parse), map: { mappings: '' } }; } catch (err) { const message = 'Could not parse JSON file'; this.error({ message, id, cause: err }); return null; } } } }; }
成功
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
界面调用
import pkg from "../package.json"; import test from "../test.json"; // 错误json格式演示 console.log(pkg.name)
成功
2
3
# 插件上下文 (opens new window)
import { createFilter } from '@rollup/pluginutils'; export default function customPlugin(options) { const filter = createFilter(options.include, options.exclude); return { name: 'custom-plugin', transform(code, id) { if (!filter(id)) { return null; } const parsedCode = this.parse(code); // 解析代码,获取AST const source = `${code}\n\n${JSON.stringify(parsedCode, null, 2)}`; const fileName = id.split('/').pop(); if (options.emitFile) { this.emitFile({ type: 'asset', fileName: fileName + '.txt', source, }); } }, }; }
成功
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
# 图片读取
import { createFilter,dataToEsm } from "@rollup/pluginutils"; import { extname,resolve,basename,relative,normalize,sep } from "path"; import fs from "fs"; import svgToMiniDataURI from "mini-svg-data-uri"; const defaults = { fileSize: 1024 * 4, target: "./dist", include: null, exclude: null, } const mimeTypes = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".webp": "image/webp", ".avif": "image/avif" } const getDataUri = ({ format, isSvg, mime, source }) => isSvg ? svgToMiniDataURI(source) : `data:${mime};${format},${source}`; const ensureDirExists = async (dirPath) => { try { await fs.promises.access(dirPath); return true; } catch (err) { // 文件夹不存在就创建文件夹 try { await fs.promises.mkdir(dirPath, { recursive: true }); return true; } catch (err) { console.error(err); return false; } } } export default function myImage(opts = {}) { const options = Object.assign({}, defaults, opts); const filter = createFilter(options.include, options.exclude); return { name: "my-image", async transform(code, id) { if (!filter(id)) return null; // 获取后缀 const ext = extname(id); // 判断是否是图片 if(!mimeTypes.hasOwnProperty(ext)) { return null; } // 获取图片的mime类型 const mime = mimeTypes[ext]; // 判断是否svg const isSvg = mime === mimeTypes[".svg"]; // 图片format格式 const format = isSvg ? "utf-8" : "base64"; // 目标路径 const assetsPath = resolve(process.cwd(), options.target); console.log("---",process.cwd()) console.log("---",options.target) console.log("---", assetsPath); //获取文件名 const fileName = basename(id); // 最终文件路径 const filePath = resolve(assetsPath, fileName); console.log("===", filePath); let relativePath = normalize(relative(process.cwd(), filePath)); relativePath = relativePath.substring(relativePath.indexOf(sep) + 1); console.log(relativePath); try { // 如果图片文件过大,就应该直接拷贝文件,返回文件路径 // 读取图片文件大小与设置的大小进行比较 const stat = await fs.promises.stat(id); if (stat.size > options.fileSize) { // 文件的拷贝,以及对象的返回 // 文件拷贝,无非就是文件源路径,目标路径 //copyFile 拷贝文件地址的文件夹必须存在 // 如果文件夹不存在,那么就创建文件夹 const dirExists = await ensureDirExists(assetsPath); dirExists && await fs.promises.copyFile(id, filePath); return { code: dataToEsm(relativePath), //返回拷贝之后处理的路径 map: { mappings: "" } } } else { // 否则转换为base64格式 // 读取文件 const source = await fs.promises.readFile(id, format); return { code: dataToEsm(getDataUri({ format, isSvg, mime, source })), map: { mappings: "" } } } } catch (err) { const message = "图片转换失败:" + id; this.error({ message, id, cause: err }); return null; } } } }
成功
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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# 输出钩子执行顺序
- 执行所有插件的
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
,最终被删除的内容将不会作为产物输出 - 上节课讲解的javascript api---
rollup.rollup
方法会返回一个bundle
对象,bundle
对象的write方法,会触发writeBundle
钩子,传入所有的打包产物信息,包括chunk
和asset
,与generateBundle
钩子非常相似。唯一的区别是writeBundle
钩子执行的时候,产物已经输出了。而generateBundle
执行的时候产物还并没有输出。简单来说,顺序是:generateBundle--->输出并保存产物到磁盘--->writeBundle
- 当
bundle
的close
方法被调用时,会触发closeBundle
钩子,这个output阶段结束
export default function myExample2() { return { name: 'my-example2', outputOptions (options) { console.log("🎉 ~ options:", options) }, renderStart (options) { console.log("✨ ~ renderStart:", options) }, renderDynamicImport (options) { console.log("✨~ renderDynamicImport:", options) }, banner(chunk) { console.log("🔥 ~ banner(chunk):", chunk) const comment = chunk.name === "index" ? `/* * * ┏┓ ┏┓+ + * ┏┛┻━━━┛┻┓ + + * ┃ ┃ * ┃ ━ ┃ ++ + + + * ████━████ ┃+ * ┃ ┃ + * ┃ ┻ ┃ * ┃ ┃ + + * ┗━┓ ┏━┛ * ┃ ┃ * ┃ ┃ + + + + * ┃ ┃ * ┃ ┃ + 神兽保佑 * ┃ ┃ 代码无bug * ┃ ┃ + * ┃ ┗━━━┓ + + * ┃ ┣┓ * ┃ ┏┛ * ┗┓┓┏━┳┓┏┛ + + + + * ┃┫┫ ┃┫┫ * ┗┻┛ ┗┻┛+ + + + * */` : ""; return comment; }, renderChunk (source) { console.log("🚀 ~ source:", source) return null; }, augmentChunkHash (chunk) { console.log("🎉 ~ augmentChunkHash:", chunk) }, generateBundle(options, bundle) { console.log("🌈 ~ options:", options) console.log("🌈 ~ bundle:", bundle) Object.keys(bundle).forEach(key => { if (key.includes("sum")) { //删除对象中的这个键值对 delete bundle[key]; } }); }, closeBundle() { console.log("😁 ~ closeBundle"); } }; }
成功
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
# 打包大小和时间示例:
export default function bundleStats() { let startTime; return { name: 'bundle-stats', options() { startTime = Date.now(); }, generateBundle(_, bundle) { const fileSizes = {}; for (const [fileName, output] of Object.entries(bundle)) { if (output.type === 'chunk') { const content = output.code; const size = Buffer.byteLength(content, 'utf8'); const sizeInKB = (size / 1024).toFixed(2); fileSizes[fileName] = `${sizeInKB} KB`; } } console.log('Bundle Stats:'); console.log('-------------'); console.log('File Sizes:'); console.log(fileSizes); console.log('-------------'); }, closeBundle() { const totalTime = Date.now() - startTime; console.log(`Total Bundle Time: ${totalTime} ms`); console.log('-------------'); } }; }
成功
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
# 代码压缩
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: '' } }; }, }; }
成功
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
← 使用插件