从零构建一个插件系统 1. 插件的核心概念

从今天起准备开一个全新系列,就是关于从 0 到 1 构建一个功能齐全的插件系统。准备写文章也蛮久了,不过一直处于拖延阶段,恰逢前段时间我的项目 yliu-blog-engine 就使用到了这个插件功能,所以就打算以此来讲解一下。

考虑真实的场景,我的项目是一个 Next.js 项目,它最终构建成 SSG 发布到 GitHub Pages 上。可以简单理解,博客本身的所有内容都来自 GitHub Issues,但我们并不能简单地直接拉取然后写到一个 JSON 文件就结束,它经历了以下的处理流程:

  1. 拉取所有的 Issues
  2. 对 Issue 内容中的图片进行防盗链处理(例如,转存到自己的图床)
  3. 基于处理后的文章内容,自动提取摘要
  4. 基于处理后的文章内容,提取并设置文章的缩略图
  5. 处理专栏(合并标题类似的文章)
  6. 考虑缓存复用,例如对于步骤 2 中的图片转存结果,避免重复上传
  7. 输出成最终的 JSON 文件

可以看到,这个流程虽然步骤不多,但是存在明确的依赖关系(例如,步骤 3 和 4 都依赖于步骤 2 的完成)。如果我们简单地用面向过程的方式把代码写在一起,当然也是可以的,只不过后续增加新功能或调整顺序时,维护起来会有点繁琐。这种情况下,我们就很自然地想到了基于插件化的思想来对它进行改造。

不过作为系列的第一篇文章,我们先不去讨论如何立刻动手构建插件,而是先来聊一聊设计一个插件系统需要考虑哪些基本概念。

生命周期

经常使用 Rollup、Webpack 等构建工具的同学会发现,它们其实都为整个构建过程定义了不同的阶段。我觉得,将这些"阶段"理解成生命周期会更贴切,它精确地定义了插件应该在何时介入、何时运行。

通常情况下,生命周期的划分需要根据所构建的场景来定义。例如在上述的博客引擎流程中,我大致可以将其划分为三个核心阶段:

  1. load (资源加载):这个阶段的唯一目标就是获取原始数据。在我们的例子里,就是去拉取所有的 GitHub Issues。
  2. transform (内容转换):这是最核心、最繁忙的阶段。我们之前提到的图片处理、摘要提取、专栏合并等,都属于这个范畴。它接收原始数据,然后对其进行各种形式的修改和增强。
  3. generate (产物生成):在所有转换工作都完成之后,这个阶段负责将处理好的最终数据输出成我们需要的文件格式,比如写入 JSON 文件到磁盘。

上下文 (Context)

另外一个核心概念就是 Context,可以称之为"上下文"。在 Webpack 或者 Rollup 中,它各自有不同的名称和形态(例如 compilation 或钩子函数中的 this),但无论如何称呼,其本质都是相通的:它是一个在整个插件执行流程中,负责携带数据和提供 API 的"共享对象"。

在我们的流程里,load 阶段负责向 Context 上挂载基础数据,之后的 transform 阶段也是从 Context 读取并处理这些数据,甚至最后的 generate 阶段,也只是从 Context 获取最终数据,再调用 fs 模块写入磁盘。

Context 的概念很简单,但它却是考验一个插件系统架构是否健全的试金石。一个非常值得考量的点是:不同的生命周期,应当暴露的 Context 属性(或 API)也应各不相同。如果图省事,一股脑地把所有能力都暴露给所有阶段,虽然实现最简单,但也会带来一个严重的问题:一个本该在 load 阶段工作的插件,可能会"越权"去执行 generate 阶段的操作,从而污染流程或导致不可预期的错误。

因此,我们应当遵循最小权限原则,做到最小化暴露。

回到我们定义的生命周期钩子中,我们可以这样设计:

  1. 资源拉取 (load):我们可能只会向插件暴露一个 this.emitAsset() 类似的方法,它负责接收插件加载的资源,并将其安全地放入 Context 中。插件在这个阶段不应该能读取到其他插件加载的资源。
  2. 转换 (transform):插件可以通过 this.getAsset() 获取之前阶段加载的资源数据,处理后再返回新的内容。插件之间的数据流转由框架控制,而不是直接修改共享的 Context 对象。
  3. 输出 (generate):这里插件才能拿到所有转换后的最终数据,并获得 this.writeFile() 这样的 API 来写入磁盘。

串行 vs. 并发

串行是插件执行最基础的方式,我们可以简单地按照插件的注册顺序,通过 for...of 循环来依次执行。但这种方式无法最大化地利用系统资源。如果插件之间没有依赖关系,我们完全可以通过 并发(Promise.all)来同时执行它们,大大缩短处理时间。

然而,真实的场景往往更复杂,插件之间经常会存在依赖关系。处理这种带有依赖的并发任务,会棘手一些。后续的文章我们会详细介绍如何通过拓扑排序(Topological Sort)来完美解决这个问题。这里可以简单介绍一下它的思想:就是将所有插件和它们的依赖关系,构建成一个有向无环图(DAG)。执行时,总是从那些没有任何依赖(入度为 0)的插件开始并发执行,当它们完成后,再"解锁"那些依赖于它们的下一批插件,如此层层推进,最终高效且安全地完成整个流程。

缓存

这个也是值得深入讨论的一个地方。缓存可以最大程度地复用处理结果,避免重复的、昂贵的计算或 I/O 操作(比如我们的图片上传)。但这里牵扯到一个关键的架构决策:

  1. 插件系统本身提供缓存能力?
  2. 还是让插件开发者自己去实现缓存?

这里我给出自己的看法:最好是由插件系统来统一提供。此外,缓存的 key 可以设计成根据插件名称、生命周期阶段、以及输入内容的哈希值来生成,以确保缓存的精确性和隔离性。

由插件系统提供缓存有以下显而易见的好处:

  1. 统一实现,降低成本:开发者无需重复造轮子,只需简单配置即可享受缓存带来的性能提升。
  2. 更优的实现策略:框架可以实现更高级的缓存策略,例如,对于频繁的写入操作,可以通过 process.nextTick 将它们聚合到一个队列中,在事件循环的末尾批量执行,减少 I/O 开销。
  3. 集中化管理:在 CI/CD 环境中,可以方便地统一指定和管理缓存目录。

良好的开发者体验

这是个容易被忽略,但却至关重要的事情。在一个以 TypeScript 为主流的开发环境中,如果没有良好的类型定义和 IDE 提示,开发体验会非常糟糕。例如:

  1. 一个转换插件在 Context 上添加了一个新的属性(比如 imgs 数组),但在后续的插件中,由于类型定义不完善,我们可能无法智能地提示出这个属性,甚至会得到一个类型错误。一个好的插件系统,应该允许插件通过泛型或声明合并(Declaration Merging)来扩展 Context 的类型。
  2. 插件的定义形式也值得商榷。例如,Webpack 插件通常是通过 class 来定义,并实现一个 apply 方法;而 Rollup/Vite 则更推崇返回一个普通对象的函数式风格。这两种风格各有优劣,也值得我们好好探讨。

最后

第一篇大概就简单介绍这些概念。在下一篇文章中,我们将基于上述的思考,尝试动手写一个最基本的、支持串行执行的插件系统。敬请期待!

相关推荐
waillyer1 分钟前
taro跳转路由取值
前端·javascript·taro
yume_sibai34 分钟前
Vue 生命周期
前端·javascript·vue.js
讨厌吃蛋黄酥1 小时前
🌟 React Router Dom 终极指南:二级路由与 Outlet 的魔法之旅
前端·javascript
安卓开发者2 小时前
OkHttp 与 RxJava/RxAndroid 完美结合:构建响应式网络请求架构
okhttp·架构·rxjava
轻语呢喃2 小时前
useMemo & useCallback :React 函数组件中的性能优化利器
前端·javascript·react.js
_未完待续2 小时前
Web 基础知识:CSS - 基础知识
前端·javascript·css
掘金012 小时前
初学者 WebRTC 视频连接教程:脚本逻辑深度解析
javascript·面试
GISer_Jing3 小时前
Node.js的Transform 流
前端·javascript·node.js
在钱塘江3 小时前
《你不知道的JavaScript-中卷》第一部分-类型和语法-笔记-4-强制类型转换
前端·javascript
皮皮虾仁3 小时前
让 VitePress 文档”图文并茂“的魔法插件:vitepress-plugin-legend
前端·javascript·vitepress