likes
comments
collection
share

Vite 源码解读(插件篇)

作者站长头像
站长
· 阅读数 24

TL;DR

本篇会依次讲解下面几个模块:

  1. 插件的基本用法和注意事项
  2. 插件各个生命周期钩子讲解,这其中我们也会介绍 Rollup 的插件机制
  3. 实际写几个插件,在实践中学习如何编写插件
  4. 简化版插件源码,理解各个生命周期实际的作用

如果你想学习 Vite 的插件,相信看这一篇就够了。读完后,你不仅可以较为轻松的写 Vite 插件,而且会对它的实现原理有比较好的理解。

这是我写的一个 mini-vite,里面也内置了简化版的插件系统,同时,我也实现了一些内置插件,可以在读完本文后再去看代码。如果觉得有帮助,可以帮忙点一个 star,我将非常感谢。

基本用法

这个部分详细的内容可以参阅 官网。下面我总结一些比较重要的内容。

插件的使用比较简单,就是在配置文件里 Plugins 数组里面插入我们的插件,而我们引入的插件一般是一个返回对象的工厂函数,我们不妨写一个最简单的插件来说明这件事情。

目前,我们随意挑选了插件的一个生命周期,让它打印 "Hello World":

// vite.config.ts
import { defineConfig, Plugin } from "vite";

export default defineConfig({
    plugins: [helloPlugin()]
})

function helloPlugin(): Plugin {
    return {
        name: "print-hello",
        configResolved() {
            console.log("Hello World")
        },
    }
}

在真实项目中,插件往往是好几个,我们可以通过 enforce 参数来制定插件的执行顺序,分别是 prepost 和不指定,如下图所示。

Vite 源码解读(插件篇)

上面是指定的整个插件级别的顺序,但由于 Vite 的插件机制出自 Rollup,所以还隐藏了一种方法,*并且目前官网上还没有给出说明:我们可以对插件的某一个钩子指定顺序,并且这个顺序的优先级会高于插件指定的顺序:

export default function resolveFirst() {
    return {
      name: 'resolve-first',
      resolveId: { // 钩子可以不是函数,而是一个包含 hander 函数的对象
        order: 'pre', // 在这个级别指定
        handler(source) {
          if (source === 'external') {
            return { id: source, external: true };
          }
          return null;
        }
      }
    };
  }

一般来说,resolveId 是一个函数,但如果我们想单独指定我们插件下的一个钩子的顺序,那就要把它改写为带 handler 函数的对象,其中的顺序用对象里的 order 字段指定。

我们还是用一个例子来演示一下:

import { Plugin } from 'vite'

export default defineConfig({
    plugins: [nextPlugin(),firstPlugin()]
})

export function firstPlugin(): Plugin {
    return {
        name: 'first-plugin',
        enforce: "post",
        options:  {
            order: 'pre',
            handler(options) {
                console.log('first-plugin')
                return null
            }
        } as any,
    }
}

export function nextPlugin(): Plugin {
    return {
        name: 'next-plugin',
        enforce: "pre",
        options:  {
            order: 'post',
            handler() {
                console.log('second-plugin')
                return null
            }
        } as any,
    }
}

尽管我们的 next-plugin 指定的是 pre,firstPlugin 指定的是 post,但是 options 的执行顺序还是和内置指定的顺序有关:

first-plugin
second-plugin

这个特性对于那些有很多钩子的插件非常实用,比如我们可能想针对性的修改插件里某一个钩子的顺序。值得注意的是,这个特性是 3.1.0 版本被添加的,如果你也想实验这个功能,请注意 Vite 版本要高于这个的版本。在本文的源码解读部分,我们会有这部分内容的讲解,届时相信会对这部分内容会有更深的理解。

有时候插件需要只在某些条件下会被调用的,Vite 提供给我们了一个和 enforce 平级的 apply 参数,可以指示在打包时用还是在本地开发时用:

Vite 源码解读(插件篇)

如果指定了在打包阶段用,那完全可以使用所有的 Rollup 插件,在我们的第二节中有这部分源码的解读。如果打包、本地开发这两种情况满足不了你,你有更复杂的场景,也可以选择通过插件的配置控制在插件里返回 falsy 值,这样的话,插件也不执行了:

import { defineConfig, Plugin } from "vite";

export default defineConfig({
    plugins: [helloPlugin()] // helloPlugin 不会执行
})

function helloPlugin(): Plugin | false {
    return false
}

Rollup 插件简介

可能你早就听说过,Vite 是基于 Rollup 和 ESbuild,同时,Vite 可以使用绝大多数 Rollup 的插件,这使得它的生态在出生之际就变得非常好。

事实上,Vite 使用 Rollup 的过程只是在打包的时候,在启动本地服务的时候并没有使用 Rollup,而是自己内建了一套具有和 Rollup 插件系统一样的来调度整个流程,在其中,编译的工作就交给了 ESbuild,我们将在下篇文章中看到 ESBuild 如何被使用。除此之外,Vite 还有预编译的功能,也是交给 ESBuild 处理。

在 dev server 阶段是 Vite 自己内建的,但无法支持所有的 Rollup 插件。接下来我们先简单介绍 Rollup 的的插件机制,再来看哪些钩子不支持,相信这样更有助于大家理解。

为了快速抓住核心,我们不妨先对那一堆生命周期进行分类,Rollup 的插件可以通过两个角度来分类:

1. 通过阶段分,Build 阶段 和 Output 阶段

首先,Build 阶段是通过 rollup.rollup(inputOptions) 来触发的,Build 阶段的钩子主要是用作定位引用文件的位置、加载、转译文件。钩子举例有 resolveIdloadtransform。分别代表资源从哪里引用,怎么加载,怎么转换。

Vite 源码解读(插件篇)

其次,Output 阶段会根据配置去生成文件,这里的生成文件可以选择写入磁盘还是不写入磁盘,分别是调用bundle.generate(outputOptions) 或者 bundle.write(outputOptions)。钩子举例有 renderDynamicImport,可用于转译动态 import 语法。详细内容,本文暂且略过不表。

调用 JavaScript API 的打包流程伪代码如下:

import { rollup } from 'rollup';

// 一些关于 Build 阶段的配置
const inputOptions = {...};

// 一些关于 Output 阶段的配置
const outputOptionsList = [{...}, {...}];

// *** Build 阶段 ***
bundle = await rollup(inputOptions);
// *** Output 阶段 ***
const { output } = await bundle.generate(outputOptions);

请大家好好的看一下上图的流程,下面我们还会提到这里的 API,到时候希望大家大概明白处于哪个阶段。

2. 通过种类分,async、first、sequential、parallel

  • async:异步钩子,它可能返回一个 Promise,但如果不返回 Promise 的话就作为同步处理。Build 阶段的所有钩子都是支持异步的。
  • first: 短路钩子,如果多个插件实现了这个钩子,那几个钩子会按 顺序 执行,直到第一个不返回 nullundefined 的出现,就把这个值当做钩子的值。典型的钩子有 resolveIdload

下面来举例说明一下上面两个钩子。假设我们想加载一个叫做 juejin-virtual-module 的文件,并且它在项目中不存在,我们要写插件来帮我们加载,加载文件要用到的钩子是 load。而在 load 之前要先经过 resolveId,它的返回结果来确定去哪里加载文件。

如果我们写了 3 个插件,第 1 个插件就是 resolve 我们模块的插件,为了告诉第 2、3 个插件juejin-virtual-module 要去哪里读

function resolvePlugin() {
  return {
    name: 'resolve-plugin',
    resolveId(source: string) {
      if (source === 'juejin-virtual-module') {
        return 'virutal-module'; // 改名字是为了让大家理解 resolveId 和 load 的关系
      }
      return null;
    }
  }
}

第 2 个插件等待 100 ms 返回结果,第 3 个插件快一点,等待 20 ms 返回结果,我们来看看最后执行效果如何:


function slowLoad() {
  return {
    name: 'slow-load',
    async load(id: string) {
      console.log('from slow')

      if (id === 'virtual-module') {
        await sleep(100)
        return 'export default "from slow load!"';
      }
      return null;
    }
  };
}

function fastLoad() {
  return {
    name: 'fast-load',
    async load(id: string) {
      console.log('from fast')
      if (id === 'virtual-module') {
        await sleep(20)
        return 'export default "from fast load!"';
      }
      return null; // 
    }
  };
}

这是使用插件的地方:

export default ({
  input: 'juejin-virtual-module', // resolved by our plugin
  plugins: [resolvePlugin(), slowLoad(), fastLoad()],
  output: [{
    file: 'bundle.js',
    format: 'es'
  }]
});

function sleep(ms: number) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(true)
    }, ms)
  })
}

打包结果如下:

Vite 源码解读(插件篇)

通过这个例子,笔者想向大家说明短路钩子的真正含义,不是哪一个先结束就用哪个,而是要按顺序一个个的执行,有异步也要等前一个异步完了才执行下一个。选择第一个返回非 null 或者 undefined 值的终止。在理解了这个的基础上,下面两个就好理解了。

  • sequential:顺序钩子,和短路钩子相同的是,在同一个钩子的生命周期里,它会按照插件的指定的顺序一个一个的运行,就算上一个插件是异步的,它也会等上一个结束了再执行下一个。与短路钩子不同的是,它不会中断,会按照这个逻辑一直执行完。典型的代表是 transfrom 钩子。

  • parallel: 并行钩子,顾名思义,如果多个插件实现了这个钩子,他们会按照定义的顺序开始运行,但是不必等当前钩子执行完才执行下一个。典型的代表是 watchChange 钩子。

简单介绍完 Rollup 的插件分类,我们来介绍 Vite 和 Rollup 的之间的关系。

由于在 Vite 内部, Build 阶段就是直接调用的 Rollup,所以起效的钩子和 Rollup 没有什么不同。而配置可以通过 Build.rollupOptions 传递下去, 另外,Vite 还有一个库模式。正因为这一点,在 Vite 打包的时候,会先合并配置参数:

const outputs = resolveBuildOutputs(
  options.rollupOptions?.output, // Rollup 的配置,options 就是 build 配置
  libOptions, // 库模式的配置
  config.logger
)

合并完配置后,就可以调用 Rollup 去产出文件了,此时可以根据 build.write 属性确定是输出文件还是不输出文件,这一点使用的 API 在上面笔者也和大家提过了:

// output 即是上面合并好的配置数组的一项
const generate = (output: OutputOptions = {}) => {
  return bundle[options.write ? 'write' : 'generate'](
    buildOutputOptions(output) // 预处理一下 output,再返回
  )
}

通过上面这两步,笔者不妨请大家思考一个问题,目前 Vite 的库模式是不支持配置多入口的,那怎么让 Vite 支持多入口呢?

build: { 
    lib: { entry: resolve(__dirname, 'lib/main.js'), // 目前格式是字符串,不能是对象
    name: 'MyLib',
    fileName: 'my-lib' 
}

理解了上面,我们就知道了,Vite 的 lib 配置只是为了方便大家,提供了一个 Rollup 配置的预设,如果我们真的有场景,直接在 rollupOptions 里配置就好了,下面笔者给大家一份样例作为参考:

build: {
  rollupOptions: {
    input: {
      index1: resolve(__dirname, 'src/index1.tsx'),
      index2: resolve(__dirname, 'src/index2.ts')
    },
    output: [
      {
        format: 'esm',
        name: 'packageName',
        dir: resolve(__dirname, 'dist')
      }
    ],
    external: ['react', 'react-dom'],
  }
}

但是在 Vite 的 dev server 阶段就有点不一样了,有两个钩子是特殊的:

  • moduleParse 不会在开发阶段调用。原因是 Vite 为了性能而避免全部的 AST Parse。在 Rollup 中,此钩子在 transform 钩子之后被调用,之后的动作是并发的调用的钩子 resolveId ( 静态 import ) 或 resolveDynamicImport( 动态 import ) ,去 resolve 它内部所有的静态和动态模块。它被调用的时机是一个模块被完整的 parse 了。此钩子也需要等到这个模块内部所有的 import 全部被 resolved 之后才完成。

  • 原本属于 Rollup Output 阶段的 closeBundle 会在 Vite 开发阶段调用。在 Rollup 中,这个钩子是 Output 阶段的最后一个钩子,它的作用是在每次运行时,清理一些还在运行的服务。这个构造在 Vite 调用的时机是服务器被关闭,但是作用是类似的,我们也可以做一些额外的清理工作。

关于 Rollup 插件的稳定性,推荐看文档 这里的解释。大家且留一个大致的印象,在源码部分我们会再讲解一下。

总的来说,Vite 的 dev server 可以看做只调用 rollup.rollup() 而不调用 bundle.generate()

写几个插件

在有了上面的理论以后,可能大家还会觉得不太熟悉,下面笔者就带大家来实操一下。

写插件前,我们先阅读一下 Vite 插件的约定。在『约定』这份链接也有说明,对于没有使用 Vite 专属钩子的,推荐 Rollup 的插件规范,由于这部分没有中文文档,所以笔者翻译这部分在下面供大家参考:

  • 插件应该有一个清晰明了的名字,并且这个名字是以 rollup-plugin- 开头的。
  • 在 package.json 里包含 rollup-plugin 关键字。
  • 插件应该要写测试用例
  • 尽可能的使用异步方法,比如使用 fs.readFile 而不是 fs.readFileSync
  • 用英语给你的插件写文档
  • 如果可以,确保你的插件输出正确的 source map(这是因为要依赖 source map 调试源代码,如果在我们的插件把之前有的 source map 去掉了,那就影响用户使用了)
  • 如果你的插件使用了虚拟模块,模块的前缀要用 \0 开头,这可以阻止其他插件处理它

PS: 如果大家对于虚拟模块这部分知识不了解的,可以参阅文档 这部分

1. 引入 HTML 模板

有时候,我们可能想使用 HTML 模板的功能,如下:

import templates from './templates.t' // 后缀名是随意取的

document.querySelector('#app')!.innerHTML = templates.getContent()

而 templates.html 的文件内容如下:

<script id="getContent">
    <div>Hello World</div>
</script>

下面我们就来实现这个插件:

import { PluginOption } from 'vite'
import { parse } from 'node-html-parser';

export default function htmlTemplate(): PluginOption {
    return {
        name: 'vite-plugin-html-template',
        async transform(code, id) {
            if (!id.endsWith('.t')) {
                return null
            }

            const dom = parse(code);
            const result: string[] = []
            dom.querySelectorAll("script").forEach(ele => {
                result.push(`${ele.id}: () => ${JSON.stringify(ele.innerHTML.trim())}`)
            })
            
            return `export default {${result}}`
        },
    }
}

大家通过用法可以看到,导出的 HTML 是一个对象,有时候我们可能想直接获取到这个 HTML 文件的字符串:

import templates from './templates.t?inline'

console.log(typeof templates) // string

可以看到,我们上面并没有实现 load 方法,在 Rollup 中,如果没有对应这个路径的 load 钩子,它最后会有一个兜底逻辑,那就是走查找这个路径对应的本地文件。此时 id 的值也刚刚好是文件的路径,而这正好符合我们的意图,所以没必在要去实现。

在 Vite 中,这个兜底逻辑实现是分两种,在 dev Server 阶段,处理逻辑是走了 这里,也就是说不在插件里处理,而是在调用 load 钩子都没有返回的结果时候,去文件系统读取;而在 Build 阶段,是有一个兜底插件,这个在本文最后一部分有介绍。

但是如果我们加了 ?inline 这个后缀,文件路径就变了,就变成 path/to/template.t?inline,使用它的兜底逻辑肯定就不行了,所以我们得手动处理一下这一块,也就是把 ?inline 去掉,在上面的基础上,我们可以这么做:

+ import fs from 'fs/promises';

export default function htmlTemplate(): PluginOption {
    return {
        name: 'vite-plugin-html-template',
+       async load(id) {
+           if (id.endsWith('.t?inline')) {
+              const content = await fs.readFile(id.replace('.t?inline', '.t'), 'utf-8')
+               return `export default ${JSON.stringify(content)}`
+           }
+        },
        async transform(code, id) {
+           if (id.endsWith('.t?inline')) {
+               return {
+                   code
+               }
+           }

            // ...
        },
    }
}

这样做了之后:

import templates from './templates.t?inline'

console.log(typeof templates) // string

2. 引入资源 CDN 化

提高打包工具的构建速度是我们前端同学一直在追求的事情。在 Webpack 中,曾经提出了 DLL 的方案,将一些资源预先编译好。这个思路是一个最直接的方案了,试想一下,如果我们打包的东西少了,速度肯定就变快了。

回到我们项目中,这时候我们就可以联想到把一些经常不变的资源抽离出来,比如 React 项目可以把 React、ReactDOM 单独独立出来。

更进一步,我们可以使用他们的 CDN 链接。这样不仅可以提高我们的构建速度,而且还能利用浏览器的缓存机制(比如 unpkg 会缓存一年),在版本不更新的时候直接使用缓存。

比较常用的 NPM 的 CDN 服务有 jsdelivrunpkg。一般来说,我们可以把包名、版本号、入口文件路径拼起来得到一个网址:

https://cdn.jsdelivr.net/npm/react@18.2.0/index.min.js

笔者先和大家说一下基本的思路:我们的 dev server 阶段是没必要改成引用 CDN 的。

其一,本地阶段也不会慢,包提前就已经下载好了,并且做了预处理;

其二,引用 CDN 就没有了类型提示。所以我们使用的时候还是正常使用。

而在 build 阶段,我们插件做的事情就是把我们需要配置 CDN 化的资源 external 掉,然后再拼接好资源对应的 CDN 链接,加入到我们的主入口 index.html 中,由于我们依赖的 CDN 的资源,所以我们得保证要在我们入口资源之前加载。

明白了思路代码也就比较好写了,实现逻辑如下:

import { Plugin, UserConfig } from "vite";
import externalGlobals from 'rollup-plugin-external-globals'

interface Module {
    name: string;
    var: string;
    url: string
}

interface Options {
    modules: Module[]
}

export default function createCDNImport(options: Options): Plugin {
    let isBuild = false

    return {
        name: 'vite-plugin-cdn-import',
        config(_, { command }) {
            isBuild = command === 'build'

            if (isBuild) {
                const externalMap = options.modules.reduce((prev, cur) => {
                    prev[cur.name] = cur.var
                    return prev
                }, {} as Record<string, string>) // 形如 {react: 'React'}

                const userConfig: UserConfig = {
                    build: {
                        rollupOptions: {
                            external: options.modules.map(module => module.name),
                            
                            // 这个插件是为了解决 ESM external 后引用全局变量的问题,
                            // 内部会把 import 语句给改写掉,使其在 ESM 下也引用全局变量
                            // 比如 import x from 'react'; x.createElement(...)
                            // 会变成 React.createElement(...)
                            plugins: [externalGlobals(externalMap)]
                        }
                    }
                }

                return userConfig
            }
        },
        transformIndexHtml(html) {
            if (isBuild) {
                const jsScripts = options.modules.map(module => {
                    return `<script src="${module.url}"></script>`
                })
                // 插入到 title 后面,一般就是最开始的 script 标签了
                return html.replace('</title>', `</title>${jsScripts.join('')}`)
            }
        }
    }
}

使用方法如下:

import { defineConfig } from "vite";
import pkg from './package.json';
import createCDNImport from "./plugins/vite-plugin-cdn-import";
const ReactDOMVersion = pkg.dependencies['react-dom']
const ReactVersion = pkg.dependencies['react']

export default defineConfig({
    plugins: [createCDNImport({
        modules: [
            {
                name: 'react',
                var: 'React',
                url: `https://unpkg.com/react@${ReactVersion}/umd/react.production.min.js`
            },
            {
                name: 'react-dom',
                var: 'ReactDOM',
                url: `https://unpkg.com/react-dom@${ReactDOMVersion}/umd/react-dom.production.min.js`
            }
        ]
    })]
})

最后让我们看看打包好的文件是什么样子:

Vite 源码解读(插件篇)

这一部分我们再总结几个关键点:

  1. config 钩子可以返回部分配置,最后 Vite 会把所有的配置都合并,在这里我们也使用到了这个特性
  2. transformIndexHtml 是 Vite 特有的钩子,可以修改入口文件 index.html ,可以进行增加、减少标签诸如此类的操作

好,应用的内容基本就这些了。接下来,我会向大家介绍各个钩子在背后是怎么串联的、Vite 如何内建 Rollup 的机制的、核心的插件功能讲解。

Vite 插件源码

现在我们到了最后一部分了,通过前面的铺垫,相信大家已经对 Vite 插件不再陌生了,现在就让我们来攻下本文的最后一块。

在 dev server 阶段调度插件运行的对象叫做 PluginContainer,我们来看看它的具体实现。

对于所有的任意一个钩子,执行的逻辑基本都是到了某一个固定的时间点,然后循环的去执行它:

for (let p of plugins) {
    p()
}

这其中最关键的点便是某一个固定的钩子要执行哪些插件,要按照怎样的顺序执行插件。关于如果这方面的的配置,我们在第一部分就已经讲过了,所以我们直接看怎么实现的

首先按照 enforce 对钩子进行排序:

export function sortUserPlugins(
  plugins: (Plugin | Plugin[])[] | undefined
): [Plugin[], Plugin[], Plugin[]] {
  const prePlugins: Plugin[] = []
  const postPlugins: Plugin[] = []
  const normalPlugins: Plugin[] = []

  if (plugins) {
    plugins.flat().forEach((p) => {
      if (p.enforce === 'pre') prePlugins.push(p)
      else if (p.enforce === 'post') postPlugins.push(p)
      else normalPlugins.push(p)
    })
  }

  return [prePlugins, normalPlugins, postPlugins]
}

然后在使用的时候会去取排好序插件的某一个特定的钩子,比如 config 钩子:

getSortedPluginsByHook('config', sortUserPlugins(plugins))

在上面的语句中,getSortedPluginsByHook 的作用一是取出所有插件里的 config 函数/对象,作用二就是按照 order 字段进行排序,值得注意的是,order 写法只能在插件的某一个钩子是对象的时候才有。我们在第一部分已经和大家演示过这个示例了。接着,在某一个特定钩子的执行实际就是执行 getSortedPluginsByHook 函数的返回了。

export function getSortedPluginsByHook(
  hookName: keyof Plugin, // 名字,形如 config, resolve, load
  plugins: readonly Plugin[] // 已经按照 enforce 排好序的插件列表
): Plugin[] {
  const pre: Plugin[] = []
  const normal: Plugin[] = []
  const post: Plugin[] = []
  for (const plugin of plugins) {
    const hook = plugin[hookName]
    if (hook) {
      if (typeof hook === 'object') { // 形式是 {order: 'pre', handler: Function} 的
        if (hook.order === 'pre') {
          pre.push(plugin)
          continue
        }
        if (hook.order === 'post') {
          post.push(plugin)
          continue
        }
      }
      normal.push(plugin) // 形式是函数的
    }
  }
  return [...pre, ...normal, ...post]
}

PluginContainer 的实现

Vite 中的插件可以分为两部分。

一部分是 Vite 与 Rollup 共同都有

以上笔者都附上链接了,忘记的话可点击跳转去复习,这些的调度都在 PluginContainer 这个对象里实现。我们先给出 PluginContainer 的类型

export interface PluginContainer {
  options: InputOptions
  getModuleInfo(id: string): ModuleInfo | null
  buildStart(options: InputOptions): Promise<void>
  resolveId(
    id: string,
    importer?: string,
    options?: {
      custom?: CustomPluginOptions
      skip?: Set<Plugin>
      ssr?: boolean
      /**
       * @internal
       */
      scan?: boolean
      isEntry?: boolean
    }
  ): Promise<PartialResolvedId | null>
  transform(
    code: string,
    id: string,
    options?: {
      inMap?: SourceDescription['map']
      ssr?: boolean
    }
  ): Promise<SourceDescription | null>
  load(
    id: string,
    options?: {
      ssr?: boolean
    }
  ): Promise<LoadResult | null>
  close(): Promise<void>
}

PluginContainercreatePluginContainer 这个工厂函数创造而来:

export async function createPluginContainer(
    config: any,
): Promise<PluginContainer> {

    const container: PluginContainer = {
      // ...
    }

    return container 
}

根据上面这份类型,我们依次来实现:

  1. options

此钩子是异步、顺序钩子,也就是说会按照钩子顺序一个个执行,前一个执行完了才执行下一个。options 被执行的时候,我们的 dev server 还没有初始化完毕,

let options = {} 
for (const optionsHook of getSortedPluginHooks('options')) {
    options = (await optionsHook.call(minimalContext, options)) || options
}

const minimalContext: MinimalPluginContext = {
    meta: {
      rollupVersion: JSON.parse(fs.readFileSync(rollupPkgPath, 'utf-8'))
        .version,
      watchMode: true
    }
}
  1. buildStart

这是异步、并行钩子。但是介绍之前,我们不得不说一个 Rollup 隐藏的属性了,Rollup 可以指定某一个钩子为 sequential,可以让插件内部某一个钩子变成顺序的,比如按照顺序,我们在 buildStart 阶段收集了 A、B、C、D、E 四个钩子。而 C 被指定为 sequential,那执行顺序就是 A、B 先并行,执行完了再执行 C,执行完了再并行执行 D、E。

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)) {
      const hook = plugin[hookName]
      if (!hook) continue
      const handler: Function = 'handler' in hook ? hook.handler : hook
      if ((hook as { sequential?: boolean }).sequential) {
        await Promise.all(parallelPromises)
        parallelPromises.length = 0
        await handler.apply(context(plugin), args(plugin))
      } else {
        parallelPromises.push(handler.apply(context(plugin), args(plugin)))
      }
    }
    await Promise.all(parallelPromises)
  }

buildStart 源码相对简单,如下所示:

await hookParallel(
    'buildStart',
    (plugin) => new Context(plugin),
    () => [container.options as NormalizedInputOptions]
)

到这里我们就可以先暂停一下了,我们可以看到,钩子在执行的时候都是被绑定了一个上下文,option 绑定的是 minimalContextbuildStartContext。这个 Context 就和 Rollup 挂上钩了。Rollup 有 PLuginContext 来调度整个流程,与之相对的在 Vite 中是一个阉割版的 Plugin Context,这个也就是我们通过 this.xxx 在写插件的钩子里调用的方法。可以看到下面 omit 的这些都是比较起 Rollup 缺少了的部分。

type PluginContext = Omit<
  RollupPluginContext,
  // not documented
  | 'cache'
  // deprecated
  | 'emitAsset'
  | 'emitChunk'
  | 'getAssetFileName'
  | 'getChunkFileName'
  | 'isExternal'
  | 'moduleIds'
  | 'resolveId'
  | 'load'
>

你可能会疑问,为什么没有 this.load,因为在 Rollup 中 this.load 是 load、transfrom、moduleParse 的合集,我们不能在 Vite 中使用 moduleParse 功能,自然就不开放这个钩子了。this.resolveId 没有被使用,在 Vite 中只实现了 this.resolve,我猜测 this.resolveId 可能没有很大应用场景的原因,目前笔者没有想到使用场景用到 this.resolveId,而像 emitAsset、emitChunk 在 dev server 阶段也用不到,那就更可以忽略了。

  1. resolveId

这个钩子是异步短路钩子,或者翻译为异步熔断钩子。我们来看看它是怎么实现的,这个代码比较多,我们给一个简化实现:

resolveId(rawId, importer) {
    const ctx = new Context()
    let id = null
    for (const plugin of getSortedPlugins('resolveId')) {
        const result = await handler.call(ctx as any, rawId, importer)
        if (result) {
            id = result
            break;
        } else {
            continue
        }
    }

    return id
}

  1. load

这个钩子和 resolveId 一样,我们就直接给出代码:

async load(id, options) {
  const ssr = options?.ssr
  const ctx = new Context()
  ctx.ssr = !!ssr
  for (const plugin of getSortedPlugins('load')) {
    if (!plugin.load) continue
    ctx._activePlugin = plugin
    const handler =
      'handler' in plugin.load ? plugin.load.handler : plugin.load
    const result = await handler.call(ctx as any, id, { ssr })
    if (result != null) {
      if (isObject(result)) {
        updateModuleInfo(id, result) // 更新模块依赖图,模块依赖图内容预计在下下篇更新。
      }
      return result
    }
  }
  return null
},

说点题外话,不知你还记不记得,我们说过 load 有个兜底逻辑是访问本地文件。这个是通过 Vite 的一个内置插件实现的:

export function loadFallbackPlugin(): Plugin {
  return {
    name: 'vite:load-fallback',
    async load(id) {
      try {
        // if we don't add `await` here, we couldn't catch the error in readFile
        return await fs.readFile(cleanUrl(id), 'utf-8')
      } catch (e) {
        return fs.readFile(id, 'utf-8')
      }
    }
  }
}

而它被内置注册再来 'post' 阶段。关于内置插件的详细讲解,我们将在下篇给出。

  1. transform

这个钩子和第 4 个逻辑基本一致,我们略过不表。

  1. buildEnd & closeBundle

两个接连触发:

async close() {
  if (closed) return
  const ctx = new Context()
  await hookParallel(
    'buildEnd',
    () => ctx,
    () => []
  )
  await hookParallel(
    'closeBundle',
    () => ctx,
    () => []
  )
  closed = true
}

上面没有列出的另外一部分是 Vite 独有的,往往直接在它该执行的位置顺序执行,如 config 钩子

有了上面的几个实现,相信大家已经对比较好的看到钩子的类型就能大概知道是怎么实现的了,剩下的我就不再啰嗦了,有兴趣的同学可以自己研究。

为了进一步加深大家理解,在下一篇,我将带大家动手实现 Vite 的插件机制,同时讲解几个有代表性的内置插件,把插件的流水线串联起来,帮助大家更好的掌握 Vite 。如果这篇阅读量不高,也就不写下一篇了,但是我会把下一篇简易版 Vite 插件的源码附在末尾。

更新:这是简易版 Vite 插件的仓库 github.com/mysteryven/…