在该系列的第一篇文章,我们实现了 Vite Server 的一些处理文件的功能(TS、TSX、CSS),但这个 Server 的功能是写死的,如果需要新增功能,就需要修改 Server 的代码,没有任何的可扩展性。
而在系列的第二篇文章中,我们解决了这个问题,我们介绍了插件架构的概念,然后根据概念,对 Server 进行了架构插件化改造,通过插件往 Server 中添加新的中间件,来给 Vite Server 新增功能。
改造后的架构如下:
但是这套架构其实是不够好的,因为可扩展的颗粒度为中间件,中间件内的很多代码都没有复用(例如文件路径解析和文件加载)。颗粒度较大,那么能复用的内容就小。我们只实现了中间件级别(颗粒度)的插件化,没有对更底层的逻辑进行抽象。
因此,本篇文章,将继续对架构进行改造,实现更细粒度的代码复用
本文的代码放在 GItHub 仓库,链接:https://github.com/candy-Tong/my-vite,目录为
packages/3. my-vite-transform-hook
我们来看看之前的几个处理文件的中间件: Transform
、CSS
、Less
,它们的共同点:
它们其实都经过这么三个阶段:
其实,所有的文件处理,都可以分成这三个阶段
在这三个中间件中,解析和加载这两个阶段的处理,其实是完全相同的。那既然完全相同,那就证明可以抽离出来,而不同的内容,则可以新增一个 transform 钩子,在 transform 阶段一次调用,那么这样就可以通过插件实现 transform 钩子,来扩展新的文件转换能力.
整体思路如上图所示:
其实这个 transform 钩子设计,Rollup 插件也有。Vite 生产环境用的是 Rollup 打包,因此这个思路也是从 Rollup 中借鉴过来的。更多相关内容可以查看我之前写的文章:《Vite 是如何兼容 Rollup 插件生态的》[3]
如果多个插件都有 transform 钩子,会怎样处理?
我们在《Vite 是如何兼容 Rollup 插件生态的》[4]详细描述过插件钩子的 4 种类型,其中 transform 钩子是 async
和 sequential
的:
为什么要这么设计?
必须要串行执行,因为并行执行钩子,transform 钩子的执行顺序就得不到保证,会导致每次的编译结果可能不一致
而 transform 后的结果会传递给下一个插件,这是一个管道的设计,这样设计的目的是,让一个模块能被多个插件处理,这种情况很常见,例如 Vue 插件分离出来的 ts 代码,还可以被 esbuild 插件处理成 js,还可以被代码压缩插件压缩。
我们新增 transform 钩子的定义
export type TransformResult = string | null | void;
export type TransformHook = (code: string, id: string) => Promise<TransformResult> | TransformResult;
export interface Plugin {
configureServer?: ServerHook; // 上篇文章用到的钩子
transform?: TransformHook; // 这次新增的钩子
}
钩子是一个函数,它的参数为 code 和 id:
返回的是转换后的代码 / 空
transform 钩子的处理流程,实现如下:
code = // 读取的模块代码
url = // 模块请求 vite server 时的 url
// 遍历所有的插件
for (const plugin of server.plugins) {
if (!plugin.transform) continue;
let result: TransformResult;
try {
result = await plugin.transform(code, url);
} catch (e) {
console.error(e);
}
// 如果返回为空,则表示当前钩子不转换当前模块
if (!result) continue;
// 如果有返回值,用结果覆盖 code,作为入参传给下一个 transform 钩子
code = result;
}
// 最终的 code 就是转换后的代码
transform 钩子是在模块转换中间件中调用的,因此我们还需实现一个 transform 中间件(名字也叫 transform,但它跟前两篇文章写的 transform 中间件是不一样的)
我们用一个中间件进行模块的处理,它有三个步骤:
中间件的实现如下:
export function transformMiddleware(server: ViteDevServer): NextHandleFunction {
return async function viteTransformMiddleware(req, res, next) {
if (req.method !== 'GET') {
return next();
}
const url: string = req.url!;
// JS 模块和 CSS 模块都是模块,都能用该中间件处理
if (isJSRequest(url) || isCSSRequest(url)) {
// 解析模块路径
const file = url.startsWith('/') ? '.' + url : url;
// 加载文件,获取文件的内容
let code: string = await readFile(file, 'utf-8');
// 遍历所有的插件
for (const plugin of server.plugins) {
if (!plugin.transform) continue;
let result: TransformResult;
try {
result = await plugin.transform(code, url);
} catch (e) {
console.error(e);
}
// 如果返回为空,则表示当前钩子不转换当前模块
if (!result) continue;
// 如果有返回值,用结果覆盖 code,作为入参传给下一个 transform 钩子
code = result;
}
res.setHeader('Content-Type', 'application/javascript');
// 最终的 code 就是转换后的代码
return res.end(code);
}
next();
};
}
这里的 isJSRequest
和 isCSSRequest
的逻辑也跟之前有所不同:
const knownJsSrcRE = /\.((j|t)sx?)$/;
export const isJSRequest = (url: string): boolean => {
url = cleanUrl(url);
return knownJsSrcRE.test(url);
};
const cssLangs = '\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)';
const cssLangRE = new RegExp(cssLangs);
export const isCSSRequest = (request: string): boolean => cssLangRE.test(request);
将类 JS 和 类 CSS 的语言,也加入到判断中,transform 中间件,不对再具体的模块进行处理和判断,改为在插件的 transform 钩子中自行判断。
原有的 transform 插件,改为 esbuild 插件(处理类 JS 的模块):
export function esbuildPlugin(): Plugin {
return {
async transform(code, url) {
if (isJSRequest(url)){
const extname = path.extname(url).slice(1);
const { code: resCode } = await transform(code, {
target: 'esnext',
format: 'esm',
sourcemap: true,
loader: extname as 'js' | 'ts' | 'jsx' | 'tsx',
});
return resCode;
}
},
};
}
直接在 transform 插件内,对 JS 的代码用 esbuild 进行编译。
less 和 css 合并成一个插件即可(实际上 Vite 也是这么做的):
export function cssPlugin(): Plugin {
return {
async transform(code,url){
if (isCSSRequest(url)) {
const file = url.startsWith('/') ? '.' + url : url;
if(isLessRequest(url)){
// 预处理器处理 less
const lessResult = await less.render(code, {
// 用于 @import 查找路径
paths: [dirname(file)],
});
code = lessResult.css;
}
const { css } = await postcss([atImport()]).process(code, {
from: file,
to: file,
});
return css;
}
}
};
}
如果是 less 模块,先用 less 进行预处理,然后用 postcss 处理,最终返回 css 字符串。
这里还需要一个将 css 转换为 js 的插件:
export function cssPostPlugin(): Plugin {
return {
async transform(code,url){
if (isCSSRequest(url)) {
return `
var style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = \`${code} \`
document.head.appendChild(style)
`;
}
}
};
}
为什么要多拆分一个 cssPostPlugin 的插件,不能写到 CSS 插件中吗?
因为实际项目中,可能还有其他 CSS 相关的插件。要等所有 CSS 组件处理完之后,才能将 CSS 转成 JS,否则 CSS 相关的工具就无法进行处理了。因此这个插件应该放在所有 CSS 相关的插件后面。
这几个插件的顺序如下:
export function loadInternalPlugins(): Plugin[] {
return [esbuildPlugin(), cssPlugin(), cssPostPlugin(),staticPlugin()];
}
只要保证 cssPostPlugin
在 cssPlugin()
之后即可
实际上 Vite 插件,有个 enforce
属性用于控制插件的顺序,只是我们这里没有实现,详情可以查看插件顺序[5]
本文先回顾了上篇文章的插件化架构的缺点——有复用性,但可扩展的粒度太大,复用性不高。
然后分析了模块处理的整个流程,分为解析模块。加载模块、转换模块。然后分析出之前的几个转换模块的中间件,其实只是在转换模块流程中不同,其他的流程都是相同的。
因此我们把转换流程,单独提取出来,插件通过提供 transform 钩子,来扩展 Vite 的转换模块能力。
用一个中间件负责模块的转换,在中间件中分别调用各个插件的 transform 钩子。这样就实现了基于处理流程粒度的扩展机制。