手写 Vite Server 系列(3)—— 更细粒度的复用

1,141 阅读8分钟

前言

在该系列的第一篇文章,我们实现了 Vite Server 的一些处理文件的功能(TS、TSX、CSS),但这个 Server 的功能是写死的,如果需要新增功能,就需要修改 Server 的代码,没有任何的可扩展性

而在系列的第二篇文章中,我们解决了这个问题,我们介绍了插件架构的概念,然后根据概念,对 Server 进行了架构插件化改造,通过插件往 Server 中添加新的中间件,来给 Vite Server 新增功能。

改造后的架构如下:

但是这套架构其实是不够好的,因为可扩展的颗粒度为中间件,中间件内的很多代码都没有复用(例如文件路径解析和文件加载)。颗粒度较大,那么能复用的内容就小。我们只实现了中间件级别(颗粒度)的插件化,没有对更底层的逻辑进行抽象

因此,本篇文章,将继续对架构进行改造,实现更细粒度的代码复用

本文的代码放在 GItHub 仓库,链接:github.com/candy-Tong/…,目录为 packages/3. my-vite-transform-hook

基础中间件扩展的问题及解决思路

我们来看看之前的几个处理文件的中间件TransformCSSLess,它们的共同点:

它们其实都经过这么三个阶段:

  1. 解析模块(resolve),获取模块的真实路径
  2. 加载模块(load),获取模块文件的代码字符串文本
  3. 转换模块(transform),对代码进行转换处理,不同的中间件,处理的内容和结果都不相同。

其实,所有的文件处理,都可以分成这三个阶段

在这三个中间件中,解析和加载这两个阶段的处理,其实是完全相同的。那既然完全相同,那就证明可以抽离出来,而不同的内容,则可以新增一个 transform 钩子,在 transform 阶段一次调用,那么这样就可以通过插件实现 transform 钩子,来扩展新的文件转换能力.

整体思路如上图所示:

  1. 中间件的扩展粒度太大,我们只用一个中间件,专门负责模块的处理
  2. 插件通过提供 transform 钩子,实现不同的文件处理方式,实现更细粒度的扩展

其实这个 transform 钩子设计,Rollup 插件也有。Vite 生产环境用的是 Rollup 打包,因此这个思路也是从 Rollup 中借鉴过来的。更多相关内容可以查看我之前写的文章:《Vite 是如何兼容 Rollup 插件生态的》

如果多个插件都有 transform 钩子,会怎样处理?

我们在《Vite 是如何兼容 Rollup 插件生态的》详细描述过插件钩子的 4 种类型,其中 transform 钩子是 asyncsequential 的:

  1. transform 钩子支持异步
  2. 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();
  };
}

这里的 isJSRequestisCSSRequest 的逻辑也跟之前有所不同:

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 钩子。这样就实现了基于处理流程粒度的扩展机制

拓展阅读

最后

如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。

最近注册了一个公众号,刚刚起步,名字叫:Candy 的修仙秘籍,欢迎大家关注~