前言
上一篇文章,我们手写了一个 Vite Server
,实现了一些基本的功能,例如:JS 编译、CSS 处理等,但是这些能力都是写死的,我们的 Vite
没有任何的可扩展性,如果需要新增功能,就必须得改 Vite
核心的代码。那么这次我们就来解决一下这个问题,将它改造成插件化架构,通过新增插件来新增能力,例如 Less
文件的编译。
本文的代码放在 GItHub 仓库,链接:github.com/candy-Tong/…,目录为
packages/2. my-vite-middleware-plugins
插件化架构
以下内容部分来自:《前端进阶:跟着开源项目学习插件化架构》
插件化架构(Plug-in Architecture),有时候又被成为微内核架构(Microkernel Architecture),是一种面向功能进行拆分的可扩展性架构。微内核架构模式允许你将其他应用程序功能作为插件添加到核心应用程序,从而提供可扩展性以及功能分离和隔离。
内核的功能相对稳定,不会因为功能的扩展而不断修改,功能的扩展通过插件来实现。
架构的设计关键
插件化架构,有三个设计的关键:
- 插件管理
- 插件连接
- 插件通讯
我们用 Vue 插件作为例子,来解析这三个概念:
插件管理
核心系统需要知道当前有哪些插件可用,如何加载这些插件,什么时候加载插件。常见的实现方法是插件注册表机制。
对于 Vue 来说,通过调用 use()
方法使用插件。
import { createApp } from 'vue' import { createPinia } from 'pinia' import { createRouter } from 'vue-router' const pinia = createPinia() const router = createRouter() const app = createApp({}) // vue-router 插件 app.use(router) // pinia 插件 app.use(pinia) app.mount('#app')
- 使用
use
方法,注册需要使用的插件(告诉核心系统需要加载哪些插件) use
方法实际上会立即加载插件(加载时机)- 加载插件实际上会调用插件的
install
函数(如何加载)
这个过程就是使用代码,提供了插件的注册表(注册了 vue-router
和 pinia
)。
注册表的还可以是其他的形式,例如配置文件(Vite、Webpack),这种属于静态的注册表。而用代码形式的注册表,则是在运行时动态注册插件的。
插件连接
插件连接是指插件如何连接到核心系统。通常来说,核心系统必须指定插件和核心系统的连接规范,然后插件按照规范实现,核心系统按照规范加载即可。
下面是一个 Vue 的国际化插件
// plugins/i18n.js export default { install: (app, options) => { // 支持 $translate('x,y,z'),视为读取 options.x.y.z app.config.globalProperties.$translate = key => { return key.split('.').reduce((o, i) => { if (o) return o[i] }, options) } app.provide('i18n', options) } }
连接规范,这里就是指 install
函数,它提供了 app
参数,用于让插件能够获取到 Vue 实例,这就起到了连接的作用。该插件根据该连接规范,给 Vue 实例设置全局属性 $translate
,并且 provide
了名为 i18n
的内容。
Vue 的连接规范,比较简单,只有 install
一个函数,我们一般称这种函数为插件钩子(hook,你可以往钩子上挂任何东西,程序执行到 hook 的时候,你预先挂上/勾上 (hook) 的是什么,就执行什么。本质是一种回调函数)
实际上一个内核的连接规范,可能非常的复杂,就例如 Vite 的拥有非常多的插件钩子
插件通信
指插件之间,能够相互进行通信。
在 Vue 插件中,其实并没有规定关于插件通信的内容,因为大多数插件,应该是互相独立的。
当然在特殊情况下,也有可能真的需要进行插件通信,这时候,可以通过 Vue 全局属性、Event Bus 等方式进行通信,但这些方式和通信规范,完全由开发者自行决定。
同样的,Vite 也没有规定插件通信的内容。
最后,我们同一个图总结一下插件的三个关键设计:
Vite 的插件化改造
我们有了一些插件化架构的知识之后,要对我们手写的 Vite 进行插件化改造,主要要解决问题只有两个:
- 实现插件管理:如何注册和加载插件(内部插件和外部插件)
- 实现插件连接:如何设计插件的钩子,并实现插件
为了尽快的能看到效果,我会按照以下的顺序实现:
- 实现内部插件的注册和加载
- 设计插件的钩子,并实现几个内部插件
- 实现外部插件的注册
- 实现一个外部插件
当我们做完第二步的时候,其实就有一套较为完整的插件架构,可以看出插件化改造的效果了。
内部插件的注册和加载
首先我们定义一下 Plugin
的类型:
// src/node/server/plugin.ts export interface Plugin {}
具体插件有什么,我们先不管,这个留到后面再进行设计。
内部插件的注册,我们只需要用代码实现一个注册表就行了
// src/node/plugins/index.ts export function loadInternalPlugins(): Plugin[] { return [ // 内部插件一 // 内部插件二 // 内部插件三 // …… ]; }
插件加载的实现如下:
// src/node/server/index.ts export async function createServer() { const plugins = loadInternalPlugins(); const app = connect(); // server 作为上下文对象,用于保存一些状态和对象,将会在 Server 的各个流程中被使用 const server: ViteDevServer = { plugins, app, }; }
插件的加载非常简单,其实就是把插件保存起来
这里的 server 上下文对象,用来保存 Dev Server 的实例和运行中会用到的一些对象内容,例如插件列表,该对象会贯穿整个 Vite 的运行周期,在各个流程中被使用。
那我们对应插件管理的概念来看:
- 直接在
loadInternalPlugins
注册内部的插件(告诉核心系统需要加载哪些插件) loadInternalPlugins
会在createServer
中立即执行(加载时机)- 插件被保存到 server 对象中(如何加载)
插件的加载,这里其实是做了简化的,实际上还会有插件过滤、插件排序等一系列操作,这里为了简单,直接返回插件列表的数组了。
设计插件钩子
我们先回顾一下我们在上一篇文章所实现的内容。
我们创建了一个 Dev Server,并利用中间件,实现了以下的功能
- 转换 TS、TSX 文件的请求
- 转换 CSS 文件的请求
- 处理其他静态资源的请求
示例项目中部分的时序图如下:
之前实现的 Dev Server 的核心代码如下:
export async function createServer() { const app = connect(); app.use(transformMiddleware()); app.use(cssMiddleware()); app.use(staticMiddleware()); http.createServer(app).listen(3000); console.log('open http://localhost:3000/'); }
Server 的架构如下:
可以看出,这三个中间件之间没有耦合,互不影响,它们只处理自己能处理的请求。
它们都共同依赖 app
,因为这是使用中间件的方式,
我们来分析一下,做成插件,需要什么:
- 它们都依赖 在
app
对象,需要提供一个钩子,用于提供app
对象(插件的连接规范) - 插件需要在
app
对象被创建后,加入中间件(钩子的执行时机)