上一篇文章,我们手写了一个 Vite Server
,实现了一些基本的功能,例如:JS 编译、CSS 处理等,但是这些能力都是写死的,我们的 Vite
没有任何的可扩展性,如果需要新增功能,就必须得改 Vite
核心的代码。那么这次我们就来解决一下这个问题,将它改造成插件化架构,通过新增插件来新增能力,例如 Less
文件的编译。
本文的代码放在 GItHub 仓库,链接:https://github.com/candy-Tong/my-vite,目录为
packages/2. my-vite-middleware-plugins
以下内容部分来自:《前端进阶:跟着开源项目学习插件化架构[2]》
插件化架构(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 的拥有非常多的插件钩子[3]
指插件之间,能够相互进行通信。
在 Vue 插件中,其实并没有规定关于插件通信的内容,因为大多数插件,应该是互相独立的。
当然在特殊情况下,也有可能真的需要进行插件通信,这时候,可以通过 Vue 全局属性、Event Bus 等方式进行通信,但这些方式和通信规范,完全由开发者自行决定。
同样的,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
中立即执行(加载时机)插件的加载,这里其实是做了简化的,实际上还会有插件过滤、插件排序等一系列操作,这里为了简单,直接返回插件列表的数组了。
我们先回顾一下我们在上一篇文章所实现的内容。
我们创建了一个 Dev Server,并利用中间件,实现了以下的功能
示例项目中部分的时序图如下:
之前实现的 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
对象被创建后,加入中间件(钩子的执行时机)当然实际上,我们可以把整个 Server
上下文对象,都提供出去,这样插件就能访问到整个 Server
上下文对象的属性了,其中 server.app
就是 Dev Server
的实例了。
于是架构,就会变成如下:
我们将这个钩子命名为 configureServer
,因为 Vite 中也是叫这个名字,提供的能力也是一样的。
我们先来定义一下钩子函数的类型:
export type ServerHook = (server: ViteDevServer) => void | Promise<void>;
export interface Plugin {
configureServer?: ServerHook;
}
configureServer
提供 server
上下文对象,不接受任何的返回值,支持异步调用。
接下来我们实现钩子的执行时机:
export async function createServer() {
const plugins = loadInternalPlugins();
const app = connect();
// server 对象,作为上下文对象,用于保存一些状态和对象,将会在 Dev Server 的各个流程中被使用
const server: ViteDevServer = {
plugins,
app
};
+ // 在创建 server 对象后,执行钩子
+ for (const plugin of plugins) {
+ plugin?.configureServer?.(server);
+ }
http.createServer(app).listen(3000);
console.log('open http://localhost:3000/');
}
那么我们将之前写好的中间件,改造成插件:
import {Plugin} from '../server/plugin';
export function transformPlugin(): Plugin{
return {
configureServer(server){
server.app.use(transformMiddleware());
}
};
}
import {Plugin} from '../server/plugin';
export function cssPlugin(): Plugin{
return {
configureServer(server){
server.app.use(cssMiddleware());
}
};
}
import {Plugin} from '../server/plugin';
export function staticPlugin(): Plugin{
return {
configureServer(server){
server.app.use(staticMiddleware());
}
};
}
然后这样使用即可:
export function loadInternalPlugins(): Plugin[]{
return [
+ transformPlugin(),
+ cssPlugin(),
+ staticPlugin(),
];
}
我们来看看效果:
可以看出页面已经能够渲染出来。
到这一步,我们已经实现了较为完整的插件架构,我们可以通过新增内置插件,来扩展 Dev Server 的能力,而不需要修改最核心的代码 server/index.ts
。
当然,其实内置插件的注册表,其实也算是内核的一部分,如果我们要想做到在不修改内核代码的情况下,扩展 Dev Server,就需要使用到外部注册表。
Vite 的外部插件,是通过配置文件注册的。
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
// 外部插件
],
});
因此,我们需要实现配置读取的能力。
为了简单,我们这里只实现 ES6 module
的 js
配置文件的读取,因为要支持其他格式的读取,还要经过比较复杂的处理,感兴趣的可以查看我之前写的文章,《五千字剖析 vite 是如何对配置文件进行解析的》[4]
我们创建 resolveConfig
函数用于读取配置:
// src/node/config.ts
import { pathToFileURL } from 'url';
// 配置的类型
export type ResolvedConfig = Readonly<{
plugins?: Plugin[]
}>
export async function resolveConfig(): Promise<ResolvedConfig>{
const configFilePath = pathToFileURL(resolve(process.cwd(), './vite.config.js'));
const config = await import(configFilePath.href);
return config.default.default;
}
由于只支持 ES6 module
的 js
配置文件,我们这里直接用 import
函数引入即可。
resolveConfig
函数的使用方式如下:
export async function createServer() {
+ const config = await resolveConfig();
- const plugins = loadInternalPlugins();
+ const plugins = [...(config.plugins || []), ...loadInternalPlugins()];
const app = connect();
// server 作为上下文对象,用于保存一些状态和对象,将会在 Server 的各个流程中被使用
const server: ViteDevServer = {
plugins,
app,
+ config,
};
for (const plugin of plugins) {
plugin?.configureServer?.(server);
}
http.createServer(app).listen(3000);
console.log('open http://localhost:3000/');
}
将配置文件读取后,将内部插件和外部插件合并(我们这里并没有处理顺序),然后将配置也保存到 server 对象中。这样插件也能通过 configureServer
钩子中,拿到整个 Vite 的配置了。
最后再实现 defineConfig
函数:
export interface UserConfig {
root?: string;
plugins?: Plugin[];
}
export type UserConfigExport = UserConfig;
export function defineConfig(config: UserConfigExport) {
return config;
}
其实 defineConfig
并没有做任何处理,只是用来提供类型检查。
这一小节,我们来在外部实现一个支持 Less 语法的插件,在 Vite 配置文件中注册使用。
Less 是一种 CSS 扩展语言,由于浏览器无法识别 Less 语法,开发时使用 Less 语法,在浏览器运行前需要将 Less 语法转换成 CSS 语法。这个处理过程一般称为预处理,因此 Less 也被称为一种 CSS 的预处理器。
Less 插件的实现如下:
export function lessPlugin(): Plugin {
return {
configureServer(server) {
server.app.use(lessMiddleware());
},
};
}
核心还是 Less 中间件的实现:
import postcss from 'postcss';
import atImport from 'postcss-import';
import less from 'less';
import {dirname} from 'path';
function lessMiddleware(): NextHandleFunction {
return async function viteLessMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = cleanUrl(req.url!);
if (isLessRequest(url)) {
// 解析文件路径
const filePath = url.startsWith('/') ? '.' + url : url;
// 读取文件,获取代码的字符串
const rawCode = await readFile(filePath, 'utf-8');
// 预处理器处理 less
const lessResult = await less.render(rawCode, {
// 用于 @import 查找路径
paths: [dirname(filePath)]
});
// 后处理器处理 css
const postcssResult = await postcss([atImport()]).process(lessResult.css, {
from: filePath, // 用于 @import 查找路径
to: filePath, // 用于 @import 查找路径
});
res.setHeader('Content-Type', 'application/javascript');
return res.end(`
var style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = \`${postcssResult.css} \`
document.head.appendChild(style)
`);
}
next();
};
}
Less 中间件的实现和 CSS 中间件的实现几乎相同,只是在 PostCSS 处理前,将 Less 进行编译,这就是预处理器的执行时机。
我们构造两个 Less 文件来测试一下
// less-test.less
@import "style-imported.css";
body{
font-size: 24px;
font-weight: 700;
font-style: italic;
}
// less-import.less
@fontSize: 50px;
然后在 main.ts
进行引入:
import './style/less-test.less';
然后在 vite.config.js
中使用该插件
import { defineConfig } from 'my-vite-middleware-plugins';
import { lessPlugin } from './plugins/less';
export default defineConfig({
plugins: [lessPlugin()],
});
运行效果如下:
本篇文章,先介绍了插件化的架构的相关概念,根据概念,我们的 Vite 插件化改造,需要解决插件管理和插件连接两个核心问题。
接下来细化了流程,先实现内部插件的注册和加载;
然后是进行插件钩子的设计,在这过程中,回顾了上篇文章的例子,并一步步进行推演出要实现相关能力所需要钩子 —— 需要一个钩子提供 Dev Server
实例用于注册中间件,然后将中间件分别抽离到插件中实现。
接下来实现外部插件的注册,核心是读取配置文件,并从配置文件中获取注册的插件。
最后,实现了一个外部插件 —— Less 插件,让项目能够支持 less 文件的引入
这整个改造过程完成之后,我们的手写 Vite 已经是拥有了一套插件化的架构,但是这套插件架构够不够好呢?
答案是否定的。
敏感的小伙伴可能会发现,在这个过程中,其实很多代码都是没有被复用的:
总的来说,本次我们实现的插件化架构,颗粒度较大,那么能复用的内容就小。我们只实现了中间件级别(颗粒度)的插件化,没有对更底层的逻辑进行抽象。例如文件的加载和读取:
root
(项目根目录),这个会影响到所有中间件的读取文件逻辑,这时候每个中间件都得去读取 root
配置项,才能正确的读取到文件。那能不能够将这些内容,在 Vite 内部处理好呢?
答案是可以的,这也是本系列下一篇文章要讲述的内容 —— 如何设计一个更好的插件化架构系统,敬请期待
[1]
《手把手教你手写一个 Vite Server(一)》: https://juejin.cn/post/7116504953828409351
[2]
前端进阶:跟着开源项目学习插件化架构: https://segmentfault.com/a/1190000022991956
[3]
插件钩子: https://cn.vitejs.dev/guide/api-plugin.html#universal-hooks
[4]
《五千字剖析 vite 是如何对配置文件进行解析的》: https://juejin.cn/post/7104912601775095815
[5]
手把手教你手写一个 Vite Server(一): https://juejin.cn/post/7116504953828409351
[6]
Vite Server 是如何处理页面资源的?: https://juejin.cn/post/7112031759163719694
[7]
五千字剖析 vite 是如何对配置文件进行解析的: https://juejin.cn/post/7104912601775095815
[8]
前端进阶:跟着开源项目学习插件化架构: https://segmentfault.com/a/1190000022991956
[9]
Candy 的修仙秘籍: https://img-1252756644.cos.ap-nanjing.myqcloud.com/img/OfficialAccounts.png