⚡️ 实现一个支持配置文件热更新的 vite 插件

1,132 阅读4分钟

「金石计划 . 瓜分 6 万现金大奖」

前言

在使用 vite 启动一个开发服务器时,当我们修改一下 vite.config.ts 文件后,开发服务器就会自动重启,也就是一个热更新的效果

vite开发服务器配置文件热更新效果.gif

你是否好奇这个效果是如何实现的呢?今天我们就使用 vite 强大的插件功能实现一个相同效果的插件!

场景描述

假设我们现在开发一个开源框架,名字叫 plasticine,对于这个框架,我需要从用户那里读取一些用户自定义的配置,为此我希望用户能够通过 plasticine.config.ts 提供配置信息,用户可以在这里面默认导出一个我们提供的 defineConfig 函数执行后返回的配置对象,然后我会根据这里面的配置信息去启动一个 vite 的开发服务器,并且在 plasticine.config.ts 文件保存时重启 vite 的开发服务器,实现一个配置文件热更新的效果

cli 搭建

首先我们的 plasticine 框架提供一个 cli 程序,这个 cli 只有一个 dev 命令,用于开启一个 vite 开发服务器

cli 的搭建我们使用 cac

src/cli.ts

import cac from 'cac'

import { createViteDevServer } from './dev-server'

const cli = cac('a demo framework')

cli
  .command('dev [root]', 'Start a vite dev server.')
  .action(async (root?: string) => {
    const resolvedRoot = root ?? process.cwd()

    // 启动 vite dev server
    await createViteDevServer(resolvedRoot)
  })

cli.help()
cli.version('1.0.0')
cli.parse()

接下来我们去实现 createViteDevServer 函数

src/dev-server.ts

async function createViteDevServer(root: string) {
  const server = await createServer({
    root,
  })

  await server.listen()
  server.printUrls()
}

接下来再在 package.json 中配置一下我们的 plasticine 框架的执行命令,这里需要直接执行 TypeScript 文件,因此我们需要先安装一个 tsx 库,然后在 package.json 添加一个 script

"start": "tsx src/cli.ts"

现在运行 pnpm start --help 能够查看我们的 cli 帮助信息

查看plasticine命令帮助信息

并且通过执行 pnpm start dev 开启一个 vite dev server

image.png

由于只是一个假设性框架,所以目前我们的 plasticine 框架就算开发完成了,然后到了本篇文章的重点,接入配置文件,并且通过 vite 插件实现配置文件热更新效果

配置文件解析器

我们要实现一个配置文件解析器,用于解析用户在工作目录下的 plasticine.config.ts 文件中默认导出的配置

这里我推荐使用 antfu 的 unconfig 库,它能够很方便的让我们构建一个配置文件解析器

src/config-resolver.ts

import { loadConfig } from 'unconfig'

interface PlasticineConfig {
  name: string
  age: number
}

/**
 * @description 配置文件解析
 * @param root 根目录
 */
function resolveConfig(root: string) {
  return loadConfig<PlasticineConfig>({
    cwd: root,
    sources: [
      {
        files: 'plasticine.config',

        // 支持 plasticine.config.ts 和 plasticine.config.js 作为配置文件
        extensions: ['ts', 'js'],
      },
    ],
  })
}

function defineConfig(config: PlasticineConfig): PlasticineConfig {
  return config
}

export { resolveConfig, defineConfig }

整合到 createViteDevServer

现在我们将配置解析器整合到我们的 createViteDevServer 函数中,方便之后传给我们的 vite 插件

async function createViteDevServer(root: string) {
  // config 就是读取到的配置
  // sources 是一个 string[],保存着配置文件的绝对路径
  const { config, sources } = await resolveConfig(root)

  // 输出解析的配置文件
  const placeHolder = `${'='.repeat(50)} plasticine 框架运行 ${'='.repeat(50)}`
  sources.forEach((source) => {
    console.log(placeHolder)
    console.log(relative(root, source), config)
    console.log(placeHolder)
  })

  const server = await createServer({
    root,
  })

  await server.listen()
  server.printUrls()
}

然后我们再在项目根目录下创建一个 plasticine.config.ts 文件,并默认导出一个配置对象

import { defineConfig } from './src/config-resolver'

export default defineConfig({
  name: 'plasticine',
  age: 21,
})

现在执行 pnpm start dev 就能够看到读取到的配置了

image.png

接下来就是去实现 vite 插件

vite 插件

我们要实现的这个插件和热更新相关,所以主要是用到 handleHotUpdate 这个钩子,它会接收一个 ctx 对象,我们可以在这里面获取到发生保存操作的文件以及开发服务器实例

虚假的热更新

或许你会认为,既然能够获取到开发服务器实例,那我直接调用实例上的 restart 方法不就好了吗?就像下面这样:

src/vite-plugin-config-hmr.ts

interface PluginConfig {
  /** @description 根目录 */
  root: string

  /** @description 配置文件路径 */
  sources: string[]
}

function vitePluginConfigHMR(config: PluginConfig): Plugin {
  const { root, sources } = config

  return {
    name: 'vite-plugin-config-hmr',

    handleHotUpdate(ctx) {
      const shouldHotUpdate = (file: string) => sources.includes(file)

      if (shouldHotUpdate(ctx.file)) {
        console.log(
          `${relative(root, sources.at(0)!)} changed, restarting server...`,
        )

        ctx.server.restart()
      }
    },
  }
}

看上去貌似挺合理,那我们来实践一下看看是否能真的热更新,我们需要先把插件应用到我们的 vite 开发服务器的内联配置中

src/dev-server.ts

async function createViteDevServer(root: string) {
  // config 就是读取到的配置
  // sources 是一个 string[],保存着配置文件的绝对路径
  const { config, sources } = await resolveConfig(root)

  // 输出解析的配置文件
  sources.forEach((source) => {
    console.log('='.repeat(100))
    console.log(relative(root, source), config)
    console.log('='.repeat(100))
  })

  const server = await createServer({
    root,
+   plugins: [vitePluginConfigHMR({ root, sources })],
  })

  await server.listen()
  server.printUrls()
}

然后我们先执行 pnpm start dev,再修改一下配置文件 plasticien.config.ts 看看是否会热更新

测试配置文件热更新效果.gif

可以看到,虽然修改配置文件后,开发服务器是重启了,但是貌似有点问题,plasticine 框架运行的内容并没有输出到控制台,也就是说配置并没有被重新加载, 那这热更新了个寂寞,因此我们需要改进一下

真正的热更新

目前看来,仅仅调用 server.restart 并不会重新执行,也就是说我们现在的目的并不是仅仅重启 vite 的开发服务器,而是应该先关闭 vite 的开发服务器,然后再重新执行我们的 createViteDevServer 函数启动新的服务器实例,这样才会触发加载配置文件的逻辑,从而实现热更新的效果

搞清楚了问题所在,接下来就可以去动手了。目前我们的插件并不支持访问 createViteDevServer 函数的能力,或许你会说直接 import 进来执行不就行了?这样是可以,但是这样会导致模块耦合度太高,不利于维护

我们可以扩展我们的插件配置,提供一个类似生命周期的钩子,当需要热更新的时候,插件只需要执行外部传入的钩子回调函数即可,这样既能够解耦合,也能够实现我们的目的

首先给插件的配置对象类型声明扩展一个 onHotUpdate 属性

interface PluginConfig {
  /** @description 根目录 */
  root: string

  /** @description 配置文件路径 */
  sources: string[]

  /** @description 热更新发生时执行的回调 */
  onHotUpdate: () => Promise<void>
}

再将原来调用 ctx.server.restart() 的地方改成调用 onHotUpdate

function vitePluginConfigHMR(config: PluginConfig): Plugin {
  const { root, sources } = config

  // 重命名为 emitHotUpdate 更加语义化,表明这个函数起到一个通知 "hot-update" 事件触发
  // 当然,并不是真的有这样一个事件总线,只是说我们在语义层面上有这么个概念,提高代码可读性
  const { onHotUpdate: emitHotUpdate } = config

  return {
    name: 'vite-plugin-config-hmr',

    handleHotUpdate(ctx) {
      const shouldHotUpdate = (file: string) => sources.includes(file)

      if (shouldHotUpdate(ctx.file)) {
        console.log(
          `${relative(root, sources.at(0)!)} changed, restarting server...`,
        )

        emitHotUpdate()
      }
    },
  }
}

现在再去修改调用插件的地方,传入该配置回调,也就是修改 createViteDevServer 函数

async function createViteDevServer(
  root: string,
+ onHotUpdate: () => Promise<void>,
) {
  // config 就是读取到的配置
  // sources 是一个 string[],保存着配置文件的绝对路径
  const { config, sources } = await resolveConfig(root)

  // 输出解析的配置文件
  const placeHolder = `${'='.repeat(50)} plasticine 框架运行 ${'='.repeat(50)}`
  sources.forEach((source) => {
    console.log(placeHolder)
    console.log(relative(root, source), config)
    console.log(placeHolder)
  })

  const server = await createServer({
    root,
-   plugins: [vitePluginConfigHMR({ root, sources })],
+   plugins: [vitePluginConfigHMR({ root, sources, onHotUpdate })],
  })

  await server.listen()
  server.printUrls()

  // 将服务器实例返回出去方便外界获取
+ return server
}

最后修改一下调用 createViteDevServer 的地方,实现 onHotUpdate,在 onHotUpdate 里执行销毁开发服务器实例并重新执行初始化开发服务器的逻辑,也就是在我们的 dev 命令的 action 中修改

cli
  .command('dev [root]', 'Start a vite dev server.')
  .action(async (root?: string) => {
    const resolvedRoot = root ?? process.cwd()

    // 启动 vite dev server
    const _createViteDevServer = async () => {
      const server = await createViteDevServer(resolvedRoot, async () => {
        // 先销毁服务器实例
        await server.close()

        // 再重新执行服务器初始化的流程
        await _createViteDevServer()
      })
    }

    await _createViteDevServer()
  }

现在再来看看效果:

实现配置文件热更新效果.gif

完美实现!这样一个简单的支持配置文件热更新的 vite 插件就开发完啦~