
这篇文章继续我们的‘跨平台’之旅, 之前我们聊过:
今天继续来聊一下怎么编写‘跨平台’的构建插件,前端构建工具一直都是一个比较卷的赛道,毕竟它是前端工程化的重要一环,此时此刻它正在经历着新一轮的变革 —— 使用系统编程语言(如 Rust、Go) 重构。
从 Webpack、Parcel,到 Vite, 再到 Turbopack、Rspack、Bun… 百花齐放。
那问题又来了,新的构建工具出来,意味着又有新的“技术债”产生。
在这个技术快速发展的时代,新旧并存的局面没办法避免。作为库的开发者,我们希望我们的库能够服务更多的人,那“跨平台”是我们不得不考虑的问题。
怎么开发一个跨平台的构建插件呢?
首先我们要站在更高的角度审视这些构建工具,这些构建工具主要做什么工作?从它们暴露的插件 API 中抽象共性。这些构建工具目的都是一致的,无非就是:
- 文件预处理/转换。比如 sass、typescript、image、icon 等,前端需要处理的各类资源的处理
- 依赖关系处理。解析和处理模块之间的依赖关系
- 代码输出。包含代码合并、代码优化、产物输出等。
主要的差异点无非就是实现不同,进而在扩展性、构建性能上面也会有不同的表现。
接下来我们就挑两个目前比较主流的构建工具来喵喵看,我挑选的是 Webpack 和 Rollup( Vite 也是基于 Rollup 的,两者差异不大)。
Webpack
尽管这几年受到了 Vite 等方案的挑战,但不得不承认,Webpack 依旧是王,至少在生态和存量市场上。
Webpack 是基于事件驱动(Event Driven) 的插件式编译器。Webpack 就是一个非常典型的微内核架构, 可以说 Webpack 的内核就是 Tapable,非常小、非常优雅。非常值得我们去反复咀嚼研究
几乎所有的功能,不管是内置的、还是第三方的都是通过插件的形式实现。包括我们看到的所有的 webpack 配置, 都会被解析转换成相应的插件,而配置不过是方便用户使用的用户界面罢了
Webpack 通过 Tapable Hooks 暴露了丰富的生命周期钩子,支持开发者对编译器、模块查找、文件转换、优化、产物生成的每一个细节进行定制。
💡 了解 Webpack 的一些基本概念。
Compiler:即 Webpack 编译器本身,它从整体上管理 Webpack 的生命周期,负责处理配置、加载插件、构造核心的对象(Compilation、Resolver、Modulefactory 等),可以认为就是一个全局的管理者。
Compilation: 由 Compiler 创建,可以认为是 Webpack 的核心大脑,Webpack 的大部分工作由它完成,它包含依赖图(Dependency Graph)的构造、负责模块构造、转换、优化、资源输出等各种核心的流程。
Resolver:模块查找器,简单说就是将相对路径转换为绝对路径
Module: JavaScript 模块在 Webpack 内的表示
Module Factory:Module 构造工厂
Parser:解析 Module 为 AST, 获取模块依赖。
Loader: 负责将文件输入(比如图片、CSS、JavaScript 文件等等)转换为 Module。Loader 通常只负责无副作用的转换工作,Loader 有点类似于 Shell 命令行
Plugin: 插件,为 Webpack 扩展实际的功能
Webpack 暴露了非常丰富的 Hooks,这些 Hooks 主要由两个主要的对象管理,即 Compiler 和 Compilation。怎么理解这两个对象呢?从两个角度看:
- 从构建工具的角度看, Compiler 代表的是 Webpack 构建的整体流程
- 从模块的角度看, Compilation 则负责具体模块的编译流程
下面,我整理 Compiler 和 Compilation 暴露的常用的 hooks 以及调用的顺序。可以一窥 Webpack 的运行原理。
Compiler 生命周期:

Compilation 生命周期:

这些 Hooks 的详情介绍和使用,可以参考 Webpack 的文档。
我建议你去直接去看 Webpack 的源码,技巧是:搜索对应的 Hooks 是怎么被触发和消费的, 可以帮助你进一步理解它们的意义。
Rollup
跟 Webpack 相比,Rollup 的 hooks 更加精练。没有像 Webpack 一样区分 Compiler 和 Compillation,Loader 和 Plugin。
Webpack 暴露了很多 Hooks,有些文档上都没有提及,甚至有些连 Webpack 自己也没用上。
尽管这样子可以给开发者很大的定制空间,但对于初学者来说,就很容易被这些细节淹没。
Rollup 构建 Hooks:

resolveId: 用于自定义模块查找逻辑
load: 用于自定义模块加载逻辑
transform:可以用于转换模块
moduleParsed:模块已解析
Rollup 代码生成 Hooks:

学习 Rollup 插件的最好方式,还是去临摹别人怎么写, 先从官方的插件开始吧。
编写跨平台的插件
除了 Webpack、Rollup,还有很多构建工具不停地被造出来,有没有办法开发一套跨平台的插件呢?
目前最佳答案是 unplugin, 它的主要贡献者还是 antfu 大佬。
unplugin 以 Rollup 插件 API 为基准,Rollup 这套 API 非常精练,这个抽象基本可以覆盖到主流的构建工具。

大家可以直接去看源码,代码并不多。以下是 Webpack 和 unplugin API 的映射关系:

实战
接下来,是实战部分。
我在 《前端如何破解 CRUD 的循环》介绍了我们的组件库,示例如下:
import { defineFatForm } from '@wakeadmin/components' import { ElMessageBox } from 'element-plus'
export default defineFatForm<{ name: string nickName: string }>(({ item, form, consumer, group }) => {
return () => ({ initialValue: { name: 'ivan', nickName: '狗蛋', },
submit: async (values) => { await ElMessageBox.confirm('确认保存') console.log('保存成功', values) },
children: [ item({ prop: 'name', label: '账号名' }), item({ prop: 'nickName', label: '昵称', }), ], }) })
|
现在有一个问题,defineFatForm 这种写法不支持热更新,每次修改都会重刷页面,体验很差。
因此今天我们就来写一个插件,让我们的组件库 define* 写法也支持像 Vue defineComponent 一样的热更新。
defineComponent 的热更新实现可以参考 @vitejs/plugin-vue-js
Vue 热更新初识
来简单看看 Vue 是怎么实现热更新
在 SFC (Single File Component)文件编译之后,Vue 插件会注入以下代码:
_sfc_main.__hmrId = '模块ID'
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)
export const _rerender_only = true
import.meta.hot.(mod => { if (!mod) return const { default: updated, _rerender_only } = mod if (_rerender_only) { __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render) } else { __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated) } })
|
Vue 内部是怎么实现 HMR 的呢?
源码在这里。来看看
实现
首先,快速通过 unplugin-starter 初始化一个项目模板:
$ npx degit antfu/unplugin-starter unplugin-wakeadmin-components
|
在项目命名上,遵循 unplugin 的规范, 使用 unplugin-* 的形式, Vite 下,插件用法如下:
import WakeadminComponents from 'unplugin-wakeadmin-components/vite'
export default defineConfig({ plugins: [ WakeadminComponents({ }), ], })
|
先来写一个 Hello world,小试牛刀。
第一步, 我们先给 define* 方法调用加上 #__PURE__ 注释,避免被识别为‘副作用’,这个有利于 Tree-Shaking 和 死代码消除(Dead Code Elimination)。
读者可以在 Terser REPL 上对比一下以下代码的优化结果:
const A = defineA({}) const B = defineA({})
console.log(A)
|
const A = defineA({}) const B = defineA({})
console.log(A)
|
现在开始写插件的实现。首先定义插件的参数:
import type { ParserOptions } from '@babel/core' import type { FilterPattern } from '@rollup/pluginutils'
export interface Options { /** * 待处理的文件,默认 会处理 .jsx、.tsx 文件 */ include?: FilterPattern exclude?: FilterPattern
/** * 是否开启 defineComponent 的处理,默认 false */ enabledDefineComponent?: boolean
/** * babel parser 插件,默认 ['jsx'] * 如果是 tsx 文件,会加上 typescript */ parserPlugins?: ParserOptions['plugins']
/** * 调试模式 */ debug?: boolean }
|
接着是实现:
import { UnpluginFactory, createUnplugin } from 'unplugin' import { createFilter } from '@rollup/pluginutils' import type { Options } from './types' import babel, { PluginObj, ParserOptions } from '@babel/core'
const PLUGIN_NAME = 'unplugin-wakeadmin-components' const t = babel.types
const DEFINE_FACTORIES = new Set(['defineFatTable', 'defineFatForm'])
function isDefineCall(node: babel.types.CallExpression) { return t.isIdentifier(node.callee) && DEFINE_FACTORIES.has(node.callee.name) }
export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, meta) => { const filter = createFilter(options?.include || /\.[jt]sx$/, options?.exclude) const enableDefineComponent = options?.enabledDefineComponent
if (enableDefineComponent) { DEFINE_FACTORIES.add('defineComponent') }
const REGEXP = new RegExp(`(${Array.from(DEFINE_FACTORIES).join('|')})`, 'g')
return { name: PLUGIN_NAME, // 🔴 配置为 pre, 在其他 loader 之前处理 enforce: 'pre', // 🔴 筛选要被转换的文件 transformInclude(id) { const [filepath] = id.split('?')
return filter(id) || filter(filepath) }, // 🔴 转换逻辑 transform(code, id) { if (code.match(REGEXP) == null) { // 没有包含 define* 跳过 return null }
const plugins: PluginObj[] = [] const parserPlugins: ParserOptions['plugins'] = ['jsx']
if (id.endsWith('.tsx')) { parserPlugins.push('typescript') }
if (options?.parserPlugins) { parserPlugins.push(...options.parserPlugins) }
plugins.push({ visitor: { // 🔴 为 define* 添加 #__PURE__ CallExpression(path) { if (isDefineCall(path.node)) { path.get('callee').addComment('leading', '#__PURE__') } }, }, })
const result = babel.transformSync(code, { sourceFileName: id, sourceMaps: true, babelrc: false, configFile: false, plugins, parserOpts: { plugins: parserPlugins, }, })
if (result?.code == null) { return null }
return { code: result.code, map: result?.map, } }, } }
export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory)
export default unplugin
|
这里使用 Babel 来 parse 和 transform 代码。逻辑很简单,就是找到所有的函数调用,如果名称匹配到我们的 define* 列表,就给它添加 #__PURE__ 注释。
如果你对 Babel 代码感到吃力,可以看我之前写的文章 深入浅出 Babel 上篇:架构和原理 + 实战。
支持 HMR
接下来,我们正式添加 HMR 的逻辑。
我们需要在 JavaScript 文件中找出所有 define* 的“导出”, 以下形式导出我们都需要支持:
// 命名导出 export const NamedExport = defineFatForm(/*...*/) const AnotherNamedExport = defineFatForm(/*...*/)
// 另一种命名导出 export { AnotherNamedExport }
// 默认导出形式 export default defineFatForm(/*...*/)
|
转换后的代码类似于:
// 命名导出 export const NamedExport = defineFatForm(/*...*/) const AnotherNamedExport = defineFatForm(/*...*/)
// 另一种命名导出 export { AnotherNamedExport }
// 🔴 默认导出形式, 使用一个临时变量存储,方便后面 patch const __default__ = defineFatForm(/*...*/) export default __default__
// 🔴 注册组件实例 if (typeof __VUE_HMR_RUNTIME__ !== 'undefined') { NamedExport.__hmrId = 'a1203740' AnotherNamedExport.__hmrId = 'bad12ad2' __default__.__hmrId = 'x12312w32'
__VUE_HMR_RUNTIME__.createRecord('a1203740', A) __VUE_HMR_RUNTIME__.createRecord('bad12ad2', AnotherNamedExport) __VUE_HMR_RUNTIME__.createRecord('x12312w32', __default__) }
// 🔴 vite HMR if (import.meta.hot) { import.meta.hot.accept( ({ NamedExport: __NamedExport, AnotherNamedExport: __AnotherNamedExport, default: __default, }) => { __VUE_HMR_RUNTIME__.reload('a1203740', __NamedExport) __VUE_HMR_RUNTIME__.reload('bad12ad2', __AnotherNamedExport) __VUE_HMR_RUNTIME__.reload('x12312w32', __default) } ) }
|
照着上面的约定,我们来实现看看:
// .. 省略 const DEFAULT_LOCAL_NAME = '__default__'
// 生成 hash 值 function getHash(text: string) { return createHash('sha256').update(text).digest('hex').substring(0, 12) }
// 解析出来的热更新组件 interface HotComponent { local: string exported: string id: string }
export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, meta) => { // .... let isWebpack = meta.framework === 'webpack' let isVite = meta.framework === 'vite'
const enableHMR = process.env.NODE_ENV === 'development' && (isWebpack || isVite) // ...
return { // ... transform(code, id) { // ... // 🔴 记录查找到的组件 const hotComponents: HotComponent[] = []
// ...
if (enableHMR) { // 🔴 支持热更新 plugins.push({ visitor: { // 🔴 处理命名导出 ExportNamedDeclaration(path) { if (path.node.declaration && t.isVariableDeclaration(path.node.declaration)) { // 🔴 export const xxx = defineXXX() 形式 const declarations = path.node.declaration.declarations for (const decl of declarations) { if ( t.isIdentifier(decl.id) && decl.init && t.isCallExpression(decl.init) && isDefineCall(decl.init) ) { hotComponents.push({ local: decl.id.name, exported: decl.id.name, id: getHash(id + decl.id.name), }) } } } else if (path.node.specifiers) { // 🔴 export { xxx } 形式 const specifiers = path.node.specifiers for (const spec of specifiers) { if (t.isExportSpecifier(spec)) { // 查找变量定义 const binding = path.scope.getBinding(spec.local.name) if ( binding && t.isVariableDeclarator(binding.path.node) && t.isCallExpression(binding.path.node.init) && isDefineCall(binding.path.node.init) ) { const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value hotComponents.push({ local: spec.local.name, exported, id: getHash(id + exported), }) } } } } }, // 🔴 默认导出 ExportDefaultDeclaration(path) { // export default defineXXX() 形式 const declaration = path.node.declaration if (t.isCallExpression(declaration) && isDefineCall(declaration)) { hotComponents.push({ local: DEFAULT_LOCAL_NAME, exported: 'default', id: getHash(id + 'default'), })
// 创建临时变量 const variable = t.variableDeclaration('const', [ t.variableDeclarator(t.identifier(DEFAULT_LOCAL_NAME), declaration), ]) const exportDefault = t.exportDefaultDeclaration(t.identifier(DEFAULT_LOCAL_NAME)) // 替换 export default 为 export default __default__ path.replaceWithMultiple([variable, exportDefault]) } }, }, }) }
// ....
if (hotComponents.length !== 0) { // 🔴 注入热更新代码 const hmrCode = patchHotComponents(hotComponents, meta.framework, id, options?.debug) result.code = result.code + hmrCode } // ... }, } }
// ...
|
上面代码的主要逻辑是运用 Babel 来遍历导出语句,找出所有通过 define* 创建的组件。
接下来就是注入 HMR 的代码:
function patchHotComponents( hotComponents: HotComponent[], framework: 'vite' | 'webpack', id: string, debug?: boolean ): string { let hmrCode = '' let callbackCode = '' const debugCode = debug ? `\n console.log('HMR reloading for ${id}')` : ''
for (const c of hotComponents) { hmrCode += `\n ${c.local}.__hmrId = ${c.local}.__wkhmr = '${c.id}'` + `\n __VUE_HMR_RUNTIME__.createRecord('${c.id}', ${c.local})` callbackCode += `\n __VUE_HMR_RUNTIME__.reload("${c.id}", __${c.exported})` }
hmrCode = `\nif (typeof __VUE_HMR_RUNTIME__ !== 'undefined') {\n${hmrCode}\n}\n`
if (framework === 'vite') { // 🔴 vite hmrCode += `\nif (import.meta.hot) { import.meta.hot.accept(({${hotComponents .map((c) => `${c.exported}: __${c.exported}`) .join(',')}}) => {${debugCode}${callbackCode}\n}) }` } else { // 🔴 webpack hmrCode += `\nif (module.hot) { // 接受自身, module.hot.accept() if (module.hot.status() !== 'idle') { const {${hotComponents .map((c) => `${c.exported}: __${c.exported}`) .join(', ')}} = __webpack_module__.exports ${debugCode} ${callbackCode} } }` }
return hmrCode }
|
Vite 和 Webpack 的 HMR API 有点差异。
相对而言 Vite 会更直观一点,使用 import.meta.hot.accept(callback) 一行代码就可以搞定,只要当前模块变动,回调就会被调用,并传入新的模块信息。
而 Webpack,要接受当前模块的更新,首先要调用一下 module.hot.accept(),表示未来的模块更新我可以自行处理。
后面每次代码模块更新,都会重新执行模块代码,我们通过 status === 'idle' 来区分首次执行,还是后续的热更新重新执行,如果是热更新执行,就 reload 组件。
学废了吗?源代码在这里
总结
这篇文章我们走马观花讲了讲 Webpack 和 Rollup 的插件 API,接着引入了 Unplugin。
Unplugin 以 Rollup 的简练插件 API 为母版,这套 API 基本就是主流构建工具插件 API 的最小并集了,可以轻松兼容主流的平台。
接着,实战部分,我们基于 Babel 写了一个简单的 Vue HMR 插件。
如果读者想要进一步如何编写插件,可以临摹一些官方插件,再深一点,可以顺藤摸瓜看看源码。
扩展阅读