前言
在该系列的第一篇文章,我们实现了 Vite Server 的一些处理文件的功能(TS、TSX、CSS),但这个 Server 的功能是写死的,如果需要新增功能,就需要修改 Server 的代码,没有任何的可扩展性。
而在系列的第二篇文章中,我们解决了这个问题,我们介绍了插件架构的概念,然后根据概念,对 Server 进行了架构插件化改造,通过插件往 Server 中添加新的中间件,来给 Vite Server 新增功能。
改造后的架构如下:
但是这套架构其实是不够好的,因为可扩展的颗粒度为中间件,中间件内的很多代码都没有复用(例如文件路径解析和文件加载)。颗粒度较大,那么能复用的内容就小。我们只实现了中间件级别(颗粒度)的插件化,没有对更底层的逻辑进行抽象。
因此,本篇文章,将继续对架构进行改造,实现更细粒度的代码复用
本文的代码放在 GItHub 仓库,链接:github.com/candy-Tong/…,目录为
packages/3. my-vite-transform-hook
基础中间件扩展的问题及解决思路
我们来看看之前的几个处理文件的中间件: Transform
、CSS
、Less
,它们的共同点:
它们其实都经过这么三个阶段:
- 解析模块(resolve),获取模块的真实路径
- 加载模块(load),获取模块文件的代码字符串文本
- 转换模块(transform),对代码进行转换处理,不同的中间件,处理的内容和结果都不相同。
其实,所有的文件处理,都可以分成这三个阶段
在这三个中间件中,解析和加载这两个阶段的处理,其实是完全相同的。那既然完全相同,那就证明可以抽离出来,而不同的内容,则可以新增一个 transform 钩子,在 transform 阶段一次调用,那么这样就可以通过插件实现 transform 钩子,来扩展新的文件转换能力.
整体思路如上图所示:
- 中间件的扩展粒度太大,我们只用一个中间件,专门负责模块的处理
- 插件通过提供 transform 钩子,实现不同的文件处理方式,实现更细粒度的扩展
其实这个 transform 钩子设计,Rollup 插件也有。Vite 生产环境用的是 Rollup 打包,因此这个思路也是从 Rollup 中借鉴过来的。更多相关内容可以查看我之前写的文章:《Vite 是如何兼容 Rollup 插件生态的》
如果多个插件都有 transform 钩子,会怎样处理?
我们在《Vite 是如何兼容 Rollup 插件生态的》详细描述过插件钩子的 4 种类型,其中 transform 钩子是 async
和 sequential
的:
- transform 钩子支持异步
- transform 钩子必须串行执行
- 较前的插件的 transform 钩子先执行,因此插件顺序会影响到最终的编译结果
- 前一个的插件 transform 之后的 code 代码,会传递给下一个插件的 transform 钩子。
为什么要这么设计?
必须要串行执行,因为并行执行钩子,transform 钩子的执行顺序就得不到保证,会导致每次的编译结果可能不一致
而 transform 后的结果会传递给下一个插件,这是一个管道的设计,这样设计的目的是,让一个模块能被多个插件处理,这种情况很常见,例如 Vue 插件分离出来的 ts 代码,还可以被 esbuild 插件处理成 js,还可以被代码压缩插件压缩。
transform 钩子
我们新增 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:
- code:源代码或前一个钩子转换后的代码
- id:模块 id
返回的是转换后的代码 / 空
- 如果返回为空值,则表示当前钩子不转换当前模块
- 如果有返回值,则覆盖源码/上次转换接口,同时作为入参传给下一个 transform 钩子
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 中间件是不一样的)
transform 中间件的实现
我们用一个中间件进行模块的处理,它有三个步骤:
- 解析模块(resolve),获取模块的真实路径
- 加载模块(load),获取模块文件的代码字符串文本
- 转换模块(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
属性用于控制插件的顺序,只是我们这里没有实现,详情可以查看插件顺序
总结
本文先回顾了上篇文章的插件化架构的缺点——有复用性,但可扩展的粒度太大,复用性不高。
然后分析了模块处理的整个流程,分为解析模块。加载模块、转换模块。然后分析出之前的几个转换模块的中间件,其实只是在转换模块流程中不同,其他的流程都是相同的。
因此我们把转换流程,单独提取出来,插件通过提供 transform 钩子,来扩展 Vite 的转换模块能力。
用一个中间件负责模块的转换,在中间件中分别调用各个插件的 transform 钩子。这样就实现了基于处理流程粒度的扩展机制。
拓展阅读
- 《手把手教你手写 Vite Server(二)—— 插件架构设计》
- 手把手教你手写一个 Vite Server(一)
- Vite Server 是如何处理页面资源的?
- 五千字剖析 vite 是如何对配置文件进行解析的
- 前端进阶:跟着开源项目学习插件化架构
最后
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。
最近注册了一个公众号,刚刚起步,名字叫:Candy 的修仙秘籍,欢迎大家关注~