Vite 实战:手把手教你写一个 Vite 插件

5,169 阅读8分钟

哈喽,很高兴你能点开这篇博客,本博客是针对 Vite 的体验系列文章之实战篇,认真看完后相信你也能如法炮制写一个属于自己的 vite 插件。

Vite 是一种新型的前端构建工具,能够显著提升前端开发体验。

我将会从 0 到 1 完成一个 vite:markdown 插件,该插件可以读取项目目录中的 markdown 文件并解析成 html,最终渲染到页面中。

如果你还没有使用过 Vite,那么你可以看看我的前两篇文章,我也是刚体验没两天呢。(如下)

本系列文件还对 Vite 源码进行了解读,往期文章可以看这里:

实现思路

其实 vite 插件的实现思路就是 webpackloader + plugin,我们这次要实现的 markdown 插件其实更像是 loader 的部分,但是也会利用到 vite 插件的一些钩子函数(比如热重载)。

我需要先准备一个对 markdown 文件进行转换,转换成 html 的插件,这里我使用的是 markdown-it,这是一个很流行的 markdown 解析器。

其次,我需要识别代码中的 markdown 标签,并读取标签中指定的 markdown 文件,这一步可以使用正则加上 nodefs 模块做到。

好,实现思路都理清了,我们现在可以来实现这个插件了。

初始化插件目录

我们使用 npm init 命令来初始化插件,插件名称命名为 @vitejs/plugin-markdown

为了方便调试,该插件目录我直接创建在我的 Vite Demo 项目 中。

本次插件实战的仓库地址为 @vitejs/plugin-markdown,感兴趣的同学也可以直接下载代码来看。

package.json 中,我们先不用着急设置入口文件,我们可以先把我们的功能实现。

创建测试文件

这里,我们在测试项目中创建一个测试文件 TestMd.vueREADME.md,文件内容和最终效果如下图所示。

image

在创建好了测试文件后,我们现在就要来研究怎么实现了。

创建插件入口文件 —— index.ts

下面,我们来创建插件入口文件 —— index.ts

vite 的插件支持 ts,所以这里我们直接使用 typescript 来编写这个插件。

该文件的内容主要是包含了 nameenforcetransform 三个属性。

  • name: 插件名称;
  • enforce: 该插件在 plugin-vue 插件之前执行,这样就可以直接解析到原模板文件;
  • transform: 代码转译,这个函数的功能类似于 webpackloader
export default function markdownPlugin(): Plugin {
  return {
    // 插件名称
    name: 'vite:markdown',

    // 该插件在 plugin-vue 插件之前执行,这样就可以直接解析到原模板文件
    enforce: 'pre',

    // 代码转译,这个函数的功能类似于 `webpack` 的 `loader`
    transform(code, id, opt) {}
  }
}

module.exports = markdownPlugin
markdownPlugin['default'] = markdownPlugin

过滤非目标文件

接下来,我们要对文件进行过滤,将非 vue 文件、未使用 g-markdown 标签的 vue 文件进行过滤,不做转换。

transform 函数的开头,加入下面这行正则代码进行判断即可。

const vueRE = /\.vue$/;
const markdownRE = /\<g-markdown.*\/\>/g;
if (!vueRE.test(id) || !markdownRE.test(code)) return code;

markdown 标签替换成 html 文本

接下来,我们要分三步走:

  1. 匹配 vue 文件中的所有 g-markdown 标签
  2. 加载对应的 markdown 文件内容,将 markdown 文本转换为浏览器可识别的 html 文本
  3. markdown 标签替换成 html 文本,引入 style 文件,输出文件内容

我们先来匹配 vue 文件中所有的 g-markdown 标签,依旧是使用上面的那个正则:

const mdList = code.match(markdownRE);

然后对匹配到的标签列表进行一个遍历,将每个标签内的 markdown 文本读取出来:

const filePathRE = /(?<=file=("|')).*(?=('|"))/;

mdList?.forEach(md => {
  // 匹配 markdown 文件目录
  const fileRelativePaths = md.match(filePathRE);
  if (!fileRelativePaths?.length) return;

  // markdown 文件的相对路径
  const fileRelativePath = fileRelativePaths![0];
  // 找到当前 vue 的目录
  const fileDir = path.dirname(id);
  // 根据当前 vue 文件的目录和引入的 markdown 文件相对路径,拼接出 md 文件的绝对路径
  const filePath = path.resolve(fileDir, fileRelativePath);
  // 读取 markdown 文件的内容
  const mdText = file.readFileSync(filePath, 'utf-8');

  //...
});

mdText 就是我们读取的 markdown 文本(如下图)

image

接下来,我们需要实现一个函数,来对这一段文本进行转换,这里我们使用之前提到的插件 markdown-it,我们新建一个 transformMarkdown 函数来完成这项工作,实现如下:

const MarkdownIt = require('markdown-it');

const md = new MarkdownIt();
export const transformMarkdown = (mdText: string): string => {
  // 加上一个 class 名为 article-content 的 wrapper,方便我们等下添加样式
  return `
    <section class='article-content'>
      ${md.render(mdText)}
    </section>
  `;
}

然后,我们在上面的遍历流程中,加入这个转换函数,再将原来的标签替换成转换后的文本即可,实现如下:

mdList?.forEach(md => {
  //...
  // 读取 markdown 文件的内容
  const mdText = file.readFileSync(filePath, 'utf-8');

  // 将 g-markdown 标签替换成转换后的 html 文本
  transformCode = transformCode.replace(md, transformMarkdown(mdText));
});

在得到了转换后的文本后,此时页面已经可以正常显示了,我们最后在 transform 函数中添加一份掘金的样式文件,实现如下:

transform(code, id, opt) {
  //...
  // style 是一段样式文本,文本内容很长,这里就不贴出来了,感兴趣的可以在原仓库找到
  transformCode = `
    ${transformCode}
    <style scoped>
      ${style}
    </style>
  `

  // 将转换后的代码返回
  return transformCode;
}

@vitejs/plugin-markdown 实战插件地址

引用插件

我们需要在测试项目中引入插件,我们在 vite.config.ts 中进行配置即可,代码实现如下:

在实际开发中,这一步应该早做,因为提前引入插件,插件代码的变更可以实时看到最新效果。

在引入插件后,可能会报某些依赖丢失,此时需要在测试项目中先安装这些依赖进行调试(生产环境不需要),例如 markdown-it

import { defineConfig } from 'vite'
import path from 'path';
import vue from '@vitejs/plugin-vue'
import markdown from './plugin-markdown/src/index';

// https://vitejs.dev/config/
export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    }
  },
  plugins: [
    vue(),
    // 引用 @vitejs/plugin-markdown 插件
    markdown()
  ]
});

然后,使用 vite 命令,启动我们的项目(别忘了在 App.vue 中引入测试文件 TestMd.vue),就可以看到下面这样的效果图啦!(如下图)

image

配置热重载

此时,我们的插件还缺一个热重载功能,没有配置该功能的话,修改 md 文件是无法触发热重载的,每次都需要重启项目。

我们需要在插件的 handleHotUpdate 钩子函数中,对我们的 md 类型文件进行监听,再将依赖该 md 文件的 vue 文件进行热重载。

在此之前,我们需要先在 transform 的遍历循环中,存储引入了 md 文件的 vue 文件吧。

在插件顶部创建一个 map 用于存储依赖关系,实现如下

const mdRelationMap = new Map<string, string>();

然后在 transform 中存储依赖关系。

mdList?.forEach(md => {
  //...
  // 根据当前 vue 文件的目录和引入的 markdown 文件相对路径,拼接出 md 文件的绝对路径
  const mdFilePath = path.resolve(fileDir, fileRelativePath);
  // 记录引入当前 md 文件的 vue 文件 id
  mdRelationMap.set(mdFilePath, id);
});

然后,我们配置新的热重载钩子 —— handleHotUpdate 就可以了,代码实现如下:

handleHotUpdate(ctx) {
  const { file, server, modules } = ctx;
  
  // 过滤非 md 文件
  if (path.extname(file) !== '.md') return;

  // 找到引入该 md 文件的 vue 文件
  const relationId = mdRelationMap.get(file) as string;
  // 找到该 vue 文件的 moduleNode
  const relationModule = [...server.moduleGraph.getModulesByFile(relationId)!][0];
  // 发送 websocket 消息,进行单文件热重载
  server.ws.send({
    type: 'update',
    updates: [
      {
        type: 'js-update',
        path: relationModule.file!,
        acceptedPath: relationModule.file!,
        timestamp: new Date().getTime()
      }
    ]
  });

  // 指定需要重新编译的模块
  return [...modules, relationModule]
},

此时,我们修改我们的 md 文件,就可以看到页面实时更新啦!(如下图)

image

顺便吐槽一下,关于 handleHotUpdate 处理的文档内容很少,server.moduleGraph.getModulesByFile 这个 API 还是在 vite issue 里面的代码片段里找到的,如果大家发现有相关的文档资源,也请分享给我,谢谢。

到这里,我们的插件开发工作就完成啦。

发布插件

在上面的步骤中,我们都是使用本地调试模式,这样的包分享起来会比较麻烦。

接下来,我们把我们的包构建出来,然后传到 npm 上,供大家安装体验。

我们在 package.json 中,添加下面几行命令。

  "main": "dist/index.js", // 入口文件
  "scripts": {
    // 清空 dist 目录,将文件构建到 dist 目录中
    "build": "rimraf dist && run-s build-bundle",
    "build-bundle": "esbuild src/index.ts --bundle --platform=node --target=node12 --external:@vue/compiler-sfc --external:vue/compiler-sfc --external:vite --outfile=dist/index.js"
  },

然后,别忘了安装 rimrafrun-sesbuild 相关依赖,安装完依赖后,我们运行 npm run build,就可以看到我们的代码被编译到了 dist 目录中。

当所有都准备就绪后,我们使用 npm publish 命令发布我们的包就可以啦。(如下图)

image

然后,我们可以将 vue.config.ts 中的依赖换成我们构建后的版本,实现如下:

// 由于我本地网络问题,我这个包传不上去,这里我直接引入本地包,和引用线上 npm 包是同理的
import markdown from './plugin-markdown';

然后我们运行项目,成功解析 markdown 文件即可!(如下图)

image

小结

到这里,我们本期教程就结束了。

想要更好的掌握 vite 插件的开发,还是要对下面几个生命周期钩子的作用和职责有清晰的认识。

字段说明所属
name插件名称viterollup 共享
handleHotUpdate执行自定义 HMR(模块热替换)更新处理vite 独享
config在解析 Vite 配置前调用。可以自定义配置,会与 vite 基础配置进行合并vite 独享
configResolved在解析 Vite 配置后调用。可以读取 vite 的配置,进行一些操作vite 独享
configureServer是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件。vite 独享
transformIndexHtml转换 index.html 的专用钩子。vite 独享
options在收集 rollup 配置前,vite (本地)服务启动时调用,可以和 rollup 配置进行合并viterollup 共享
buildStartrollup 构建中,vite (本地)服务启动时调用,在这个函数中可以访问 rollup 的配置viterollup 共享
resolveId在解析模块时调用,可以返回一个特殊的 resolveId 来指定某个 import 语句加载特定的模块viterollup 共享
load在解析模块时调用,可以返回代码块来指定某个 import 语句加载特定的模块viterollup 共享
transform在解析模块时调用,将源代码进行转换,输出转换后的结果,类似于 webpackloaderviterollup 共享
buildEndvite 本地服务关闭前,rollup 输出文件到目录前调用viterollup 共享
closeBundlevite 本地服务关闭前,rollup 输出文件到目录前调用viterollup 共享

如果大家发现有什么比较好的文章或者文档对这些钩子函数有更详细的介绍,也欢迎分享出来。

到这篇文章位置,总共 6 期的 Vite 系列文章也圆满画上了句号,谢谢大家的支持。

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!