《vite技术揭秘、还原与实战》第8节--插件化方案设计

前言

我们总是说"程序设计与实现",之所以把设计放在前,是因为它能避免我们像无头苍蝇一样随遇而安

如果我们接着前边几章继续往下写,那大概率是这样的开发思路:在 http 服务器中监听由浏览器解析后抛出的文件请求,然后去读取文件源码,最后做一定的处理转换后返还给浏览器客户端

如果 vite 是一个面向普通用户提供服务的产品的话,闭塞似乎没有问题。可它面向的是开发人员,定位是工具。因此必须要考虑到开发者的客制化需求,而插件机制是提供这一能力的最优解

好文推荐

源码获取

传送门

更新进度

公众号:更新至第18

博客:更新至第8

公众号

插件机制设计要素

注册机制

在 vite 中通过 vite.config.xx 文件的 plugins 配置项向 vite 注入插件,根据"配置文件"一节中在 svite 的实现我们知道 vite 内部会对该文件进行加载从而获取到用户配置的插件列表

ts 复制代码
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
    plugins:[
        ...
    ]
})

约定

插件应当为开发者提供推荐的约定俗成,如下

它也应该对插件格式有一定的要求,在 vite 中,它和 rollup 保持一致,是一个包含特定 key 的对象

有了这些约定之后,开发者能够一眼识别出第三方插件的作用范围并做出选择,能够根据插件格式快速搭建插件的基础模版,最重要的是在 vite 内部能针对特定的 key 做特定的逻辑处理,如下,根据 apply 属性来对作用于 dev 和 build 环境的插件做出过滤和区分

ts 复制代码
// packages/vite/src/node/config.ts
const filterPlugin = (p: Plugin) => {
  if (!p) {
    return false;
  } else if (!p.apply) {
    return true;
  } else if (typeof p.apply === "function") {
    return p.apply({ ...config, mode }, configEnv);
  } else {
    return p.apply === command;
  }
};

生命周期

vite 必须根据主体应用程序提供的功能服务来划分阶段以明确插件程序可介入的时机,以 vite 在开发阶段为例,它创建一个 http 服务器用以处理从浏览器发出的文件请求,这中间至少要经过如下阶段:

  • 服务器开始创建

  • 获取配置信息

  • 接收到浏览器文件请求

  • 根据请求获取文件模块源码

  • 对源码做加工处理

  • 服务器停止运行

在 vite 中它们被细分为如下生命周期(rollup 钩子列表看这里

ts 复制代码
export interface Plugin extends RollupPlugin {
  // 合并或修改用户配置项
  config?: ObjectHook<
    (
      this: void,
      config: UserConfig,
      env: ConfigEnv
    ) => UserConfig | null | void | Promise<UserConfig | null | void>
  >;
  // 获取合并后的最终配置
  configResolved?: ObjectHook<
    (this: void, config: ResolvedConfig) => void | Promise<void>
  >;
  // 配置开发服务器
  configureServer?: ObjectHook<ServerHook>;
  // 配置预览服务器
  configurePreviewServer?: ObjectHook<PreviewServerHook>;
  // 处理index.html
  transformIndexHtml?: IndexHtmlTransform;
  // 处理热更新
  handleHotUpdate?: ObjectHook<
    (
      this: void,
      ctx: HmrContext
    ) => Array<ModuleNode> | void | Promise<Array<ModuleNode> | void>
  >;
  // 模块路径解析
  resolveId?: ObjectHook<
    (
      this: PluginContext,
      source: string,
      importer: string | undefined,
      options: {
        assertions: Record<string, string>;
        custom?: CustomPluginOptions;
        ssr?: boolean;
        /**
         * @internal
         */
        scan?: boolean;
        isEntry: boolean;
      }
    ) => Promise<ResolveIdResult> | ResolveIdResult
  >;
  // 模块加载
  load?: ObjectHook<
    (
      this: PluginContext,
      id: string,
      options?: { ssr?: boolean }
    ) => Promise<LoadResult> | LoadResult
  >;
  // 模块转换
  transform?: ObjectHook<
    (
      this: TransformPluginContext,
      code: string,
      id: string,
      options?: { ssr?: boolean }
    ) => Promise<TransformResult> | TransformResult
  >;
}

生命周期调度

当指定阶段发生时,vite 需要去调用指定的周期函数,比如在服务启动阶段对 buildStart 的调用

ts 复制代码
const initServer = async () => {
    ...
    initingServer = (async function () {
      await container.buildStart({})
      ...
    })()
    return initingServer
}

插件调度

由于一个生命周期可以对应多个插件程序,因此需要从所有的 plugins 中找到存在对应周期函数的所有插件做遍历执行

ts 复制代码
// packages/vite/src/node/server/pluginContainer.ts
async load(id, options) {
      ...
      for (const plugin of getSortedPlugins('load')) { // 从所有plugin中获取挑选出load hook,并组成数组依次执行
        ...
        const handler =
          'handler' in plugin.load ? plugin.load.handler : plugin.load
        const result = await handler.call(ctx as any, id, { ssr })
        ...
      }
      return null
},

async or sync plugin

尽管 vite 约定插件需要是一个包含指定键的对象,但是事实是开发者出于封装、提取或便捷性的考虑经常使用函数形式来返回 plugin config,这就有概率会出现如下类似的情况

ts 复制代码
async function log() {
  return {};
}
export default {
  plugins: [log()],
};

vite 作为健壮的应用程序,需要对该情况进行兼容,如下,在内部将其转为同步

ts 复制代码
// packages/vite/src/node/utils.ts
export async function asyncFlatten<T>(arr: T[]): Promise<T[]> {
  do {
    arr = (await Promise.all(arr)).flat(Infinity) as any;
  } while (arr.some((v: any) => v?.then));
  return arr;
}

钩子类型

  • Async or Sync

这类钩子和 vuex 的 mutations 和 actions 是类似的,Async 里只能是异步,而 sync 中必须是同步行为,这似乎要求 vite 必须在内部对钩子类型进行识别,就像下边这样

ts 复制代码
if (p instanceof Promise) {
  p.then();
} else {
  p();
}

这无疑会让程序逻辑增加复杂度,幸运的是 JavaScript 的 await 关键字可以同时兼顾这两种情况,因此 vite 对钩子的执行都会强制性的加上 await 关键字

ts 复制代码
await p();
  • First

依次运行多个插件中对应的 hook,直到某个插件中的 hook 返回非 null 或 undefined 值时停止,比如 vite 中的 resolveId 钩子

如下,当 handler.call 取到返回值后,将会直接从循环中 break

ts 复制代码
// packages/vite/src/node/server/pluginContainer.ts
async resolveId(rawId, importer = join(root, 'index.html'), options) {
      ...
      for (const plugin of getSortedPlugins('resolveId')) {
        ...
        const handler =
          'handler' in plugin.resolveId
            ? plugin.resolveId.handler
            : plugin.resolveId
        const result = await handler.call(...)
        if (!result) continue
        // 跳出循环
        break
      }

      ...
}
  • Sequential

这类 hook 往往需要等待前一个插件 hook 的返回值并将其作为下一个插件的入参,比如 vite 中的 transform 钩子

如下,当当前 hook 函数存在返回值时,会对 code 进行替换,并在下一次 for 循环中传递给下一个 hook

ts 复制代码
// packages/vite/src/node/server/pluginContainer.ts
async transform(code, id, options) {
      ...
      for (const plugin of getSortedPlugins('transform')) {
        ...
        let result: TransformResult | string | undefined
        const handler =
          'handler' in plugin.transform
            ? plugin.transform.handler
            : plugin.transform
        try {
          // code作为下一次的输入值
          // 使用await强制转为同步
          result = await handler.call(ctx as any, code, id, { ssr })
        } catch (e) {
          ctx.error(e)
        }
        if (!result) continue
        ...
        if (isObject(result)) {
          if (result.code !== undefined) {
            // 替换code
            code = result.code
            ...
          }
          ...
        } else {
          code = result
        }
      }
      return {
        code,
        map: ctx._getCombinedSourcemap(),
      }
}
  • Parallel

这类钩子可以被同时运行,比如 vite 中 buildStart

如下,vite 收集所有插件的 buildStart 钩子,通过 Promise.all 同时运行所有的 hook

ts 复制代码
// packages/vite/src/node/server/pluginContainer.ts
async function hookParallel<H extends AsyncPluginHooks & ParallelPluginHooks>(
    hookName: H,
    context: (plugin: Plugin) => ThisType<FunctionPluginHooks[H]>,
    args: (plugin: Plugin) => Parameters<FunctionPluginHooks[H]>,
  ): Promise<void> {
    const parallelPromises: Promise<unknown>[] = []
    for (const plugin of getSortedPlugins(hookName)) { // 获取buildStart钩子列表
      const hook = plugin[hookName]
      if (!hook) continue
      ...
      parallelPromises.push(handler.apply(context(plugin), args(plugin)))
    }
    // 并发执行
    await Promise.all(parallelPromises)
}

总结

本节我们从 vite 的实现中分析了应用程序插件化机制的一些核心要点,从下一小节开始,我们将着手在 svite 中引入 plugin 机制

相关推荐
王小金Ryan3 天前
开发一个Vite插件,给所有DOM节点插入自定义属性
vue.js·vite
前端霸王防脱发洗发水6 天前
Vite常用插件配置
javascript·vue.js·vite
friend_ship8 天前
Vue3.0都有哪些新特性及优化点
vue.js·vite·vue3.0·es6新特性·proxy响应式对象
jason_yang8 天前
vue3复习-源码-迷你版vite
vue.js·vite
jason_yang8 天前
vue3复习-源码-编译原理-自定义vite插件
vue.js·vite
小霖家的混江龙9 天前
Vite 打包 H5 如何注入版本号
前端·vite
web_code9 天前
vite依赖预构建(源码分析)
前端·面试·vite
yinshimoshen17 天前
基于vite实现基本的浏览器兼容解决方案
前端·vite
applebomb22 天前
vite server正则表达式
正则·vite·proxy·regexp·转发·server
o翔哥o24 天前
我把大型团队项目从 vite 前端迁移到了 rsbuild,收益如何?
前端·vite·前端工程化