从今天起准备开一个全新系列,就是关于从 0 到 1 构建一个功能齐全的插件系统。准备写文章也蛮久了,不过一直处于拖延阶段,恰逢前段时间我的项目 yliu-blog-engine 就使用到了这个插件功能,所以就打算以此来讲解一下。
考虑真实的场景,我的项目是一个 Next.js 项目,它最终构建成 SSG 发布到 GitHub Pages 上。可以简单理解,博客本身的所有内容都来自 GitHub Issues,但我们并不能简单地直接拉取然后写到一个 JSON 文件就结束,它经历了以下的处理流程:
- 拉取所有的 Issues
- 对 Issue 内容中的图片进行防盗链处理(例如,转存到自己的图床)
- 基于处理后的文章内容,自动提取摘要
- 基于处理后的文章内容,提取并设置文章的缩略图
- 处理专栏(合并标题类似的文章)
- 考虑缓存复用,例如对于步骤 2 中的图片转存结果,避免重复上传
- 输出成最终的 JSON 文件
可以看到,这个流程虽然步骤不多,但是存在明确的依赖关系(例如,步骤 3 和 4 都依赖于步骤 2 的完成)。如果我们简单地用面向过程的方式把代码写在一起,当然也是可以的,只不过后续增加新功能或调整顺序时,维护起来会有点繁琐。这种情况下,我们就很自然地想到了基于插件化的思想来对它进行改造。
不过作为系列的第一篇文章,我们先不去讨论如何立刻动手构建插件,而是先来聊一聊设计一个插件系统需要考虑哪些基本概念。
生命周期
经常使用 Rollup、Webpack 等构建工具的同学会发现,它们其实都为整个构建过程定义了不同的阶段。我觉得,将这些"阶段"理解成生命周期会更贴切,它精确地定义了插件应该在何时介入、何时运行。
通常情况下,生命周期的划分需要根据所构建的场景来定义。例如在上述的博客引擎流程中,我大致可以将其划分为三个核心阶段:
load
(资源加载):这个阶段的唯一目标就是获取原始数据。在我们的例子里,就是去拉取所有的 GitHub Issues。transform
(内容转换):这是最核心、最繁忙的阶段。我们之前提到的图片处理、摘要提取、专栏合并等,都属于这个范畴。它接收原始数据,然后对其进行各种形式的修改和增强。generate
(产物生成):在所有转换工作都完成之后,这个阶段负责将处理好的最终数据输出成我们需要的文件格式,比如写入 JSON 文件到磁盘。
上下文 (Context)
另外一个核心概念就是 Context
,可以称之为"上下文"。在 Webpack 或者 Rollup 中,它各自有不同的名称和形态(例如 compilation
或钩子函数中的 this
),但无论如何称呼,其本质都是相通的:它是一个在整个插件执行流程中,负责携带数据和提供 API 的"共享对象"。
在我们的流程里,load
阶段负责向 Context
上挂载基础数据,之后的 transform
阶段也是从 Context
读取并处理这些数据,甚至最后的 generate
阶段,也只是从 Context
获取最终数据,再调用 fs
模块写入磁盘。
Context
的概念很简单,但它却是考验一个插件系统架构是否健全的试金石。一个非常值得考量的点是:不同的生命周期,应当暴露的 Context
属性(或 API)也应各不相同。如果图省事,一股脑地把所有能力都暴露给所有阶段,虽然实现最简单,但也会带来一个严重的问题:一个本该在 load
阶段工作的插件,可能会"越权"去执行 generate
阶段的操作,从而污染流程或导致不可预期的错误。
因此,我们应当遵循最小权限原则,做到最小化暴露。
回到我们定义的生命周期钩子中,我们可以这样设计:
- 资源拉取 (
load
):我们可能只会向插件暴露一个this.emitAsset()
类似的方法,它负责接收插件加载的资源,并将其安全地放入Context
中。插件在这个阶段不应该能读取到其他插件加载的资源。 - 转换 (
transform
):插件可以通过this.getAsset()
获取之前阶段加载的资源数据,处理后再返回新的内容。插件之间的数据流转由框架控制,而不是直接修改共享的Context
对象。 - 输出 (
generate
):这里插件才能拿到所有转换后的最终数据,并获得this.writeFile()
这样的 API 来写入磁盘。
串行 vs. 并发
串行是插件执行最基础的方式,我们可以简单地按照插件的注册顺序,通过 for...of
循环来依次执行。但这种方式无法最大化地利用系统资源。如果插件之间没有依赖关系,我们完全可以通过 并发(Promise.all
)来同时执行它们,大大缩短处理时间。
然而,真实的场景往往更复杂,插件之间经常会存在依赖关系。处理这种带有依赖的并发任务,会棘手一些。后续的文章我们会详细介绍如何通过拓扑排序(Topological Sort)来完美解决这个问题。这里可以简单介绍一下它的思想:就是将所有插件和它们的依赖关系,构建成一个有向无环图(DAG)。执行时,总是从那些没有任何依赖(入度为 0)的插件开始并发执行,当它们完成后,再"解锁"那些依赖于它们的下一批插件,如此层层推进,最终高效且安全地完成整个流程。
缓存
这个也是值得深入讨论的一个地方。缓存可以最大程度地复用处理结果,避免重复的、昂贵的计算或 I/O 操作(比如我们的图片上传)。但这里牵扯到一个关键的架构决策:
- 插件系统本身提供缓存能力?
- 还是让插件开发者自己去实现缓存?
这里我给出自己的看法:最好是由插件系统来统一提供。此外,缓存的 key
可以设计成根据插件名称、生命周期阶段、以及输入内容的哈希值来生成,以确保缓存的精确性和隔离性。
由插件系统提供缓存有以下显而易见的好处:
- 统一实现,降低成本:开发者无需重复造轮子,只需简单配置即可享受缓存带来的性能提升。
- 更优的实现策略:框架可以实现更高级的缓存策略,例如,对于频繁的写入操作,可以通过
process.nextTick
将它们聚合到一个队列中,在事件循环的末尾批量执行,减少 I/O 开销。 - 集中化管理:在 CI/CD 环境中,可以方便地统一指定和管理缓存目录。
良好的开发者体验
这是个容易被忽略,但却至关重要的事情。在一个以 TypeScript 为主流的开发环境中,如果没有良好的类型定义和 IDE 提示,开发体验会非常糟糕。例如:
- 一个转换插件在
Context
上添加了一个新的属性(比如imgs
数组),但在后续的插件中,由于类型定义不完善,我们可能无法智能地提示出这个属性,甚至会得到一个类型错误。一个好的插件系统,应该允许插件通过泛型或声明合并(Declaration Merging)来扩展Context
的类型。 - 插件的定义形式也值得商榷。例如,Webpack 插件通常是通过
class
来定义,并实现一个apply
方法;而 Rollup/Vite 则更推崇返回一个普通对象的函数式风格。这两种风格各有优劣,也值得我们好好探讨。
最后
第一篇大概就简单介绍这些概念。在下一篇文章中,我们将基于上述的思考,尝试动手写一个最基本的、支持串行执行的插件系统。敬请期待!