哈喽,很高兴你能点开这篇博客,本博客是针对 Vite
的体验系列文章之实战篇,认真看完后相信你也能如法炮制写一个属于自己的 vite 插件。
Vite
是一种新型的前端构建工具,能够显著提升前端开发体验。
我将会从 0 到 1 完成一个 vite:markdown
插件,该插件可以读取项目目录中的 markdown
文件并解析成 html
,最终渲染到页面中。
如果你还没有使用过 Vite
,那么你可以看看我的前两篇文章,我也是刚体验没两天呢。(如下)
本系列文件还对 Vite
源码进行了解读,往期文章可以看这里:
实现思路
其实 vite
插件的实现思路就是 webpack
的 loader
+ plugin
,我们这次要实现的 markdown
插件其实更像是 loader
的部分,但是也会利用到 vite
插件的一些钩子函数(比如热重载)。
我需要先准备一个对 markdown
文件进行转换,转换成 html
的插件,这里我使用的是 markdown-it
,这是一个很流行的 markdown
解析器。
其次,我需要识别代码中的 markdown
标签,并读取标签中指定的 markdown
文件,这一步可以使用正则加上 node
的 fs
模块做到。
好,实现思路都理清了,我们现在可以来实现这个插件了。
初始化插件目录
我们使用 npm init
命令来初始化插件,插件名称命名为 @vitejs/plugin-markdown
。
为了方便调试,该插件目录我直接创建在我的 Vite Demo 项目 中。
本次插件实战的仓库地址为 @vitejs/plugin-markdown,感兴趣的同学也可以直接下载代码来看。
在 package.json
中,我们先不用着急设置入口文件,我们可以先把我们的功能实现。
创建测试文件
这里,我们在测试项目中创建一个测试文件 TestMd.vue
和 README.md
,文件内容和最终效果如下图所示。
在创建好了测试文件后,我们现在就要来研究怎么实现了。
创建插件入口文件 —— index.ts
下面,我们来创建插件入口文件 —— index.ts
。
vite
的插件支持ts
,所以这里我们直接使用typescript
来编写这个插件。
该文件的内容主要是包含了 name
、enforce
、transform
三个属性。
- name: 插件名称;
- enforce: 该插件在 plugin-vue 插件之前执行,这样就可以直接解析到原模板文件;
- transform: 代码转译,这个函数的功能类似于
webpack
的loader
。
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
文本
接下来,我们要分三步走:
- 匹配
vue
文件中的所有g-markdown
标签 - 加载对应的
markdown
文件内容,将markdown
文本转换为浏览器可识别的html
文本 - 将
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
文本(如下图)
接下来,我们需要实现一个函数,来对这一段文本进行转换,这里我们使用之前提到的插件 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
),就可以看到下面这样的效果图啦!(如下图)
配置热重载
此时,我们的插件还缺一个热重载功能,没有配置该功能的话,修改 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 文件,就可以看到页面实时更新啦!(如下图)
顺便吐槽一下,关于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"
},
然后,别忘了安装 rimraf
、run-s
、esbuild
相关依赖,安装完依赖后,我们运行 npm run build
,就可以看到我们的代码被编译到了 dist
目录中。
当所有都准备就绪后,我们使用 npm publish
命令发布我们的包就可以啦。(如下图)
然后,我们可以将 vue.config.ts
中的依赖换成我们构建后的版本,实现如下:
// 由于我本地网络问题,我这个包传不上去,这里我直接引入本地包,和引用线上 npm 包是同理的
import markdown from './plugin-markdown';
然后我们运行项目,成功解析 markdown
文件即可!(如下图)
小结
到这里,我们本期教程就结束了。
想要更好的掌握 vite
插件的开发,还是要对下面几个生命周期钩子的作用和职责有清晰的认识。
字段 | 说明 | 所属 |
---|---|---|
name | 插件名称 | vite 和 rollup 共享 |
handleHotUpdate | 执行自定义 HMR(模块热替换)更新处理 | vite 独享 |
config | 在解析 Vite 配置前调用。可以自定义配置,会与 vite 基础配置进行合并 | vite 独享 |
configResolved | 在解析 Vite 配置后调用。可以读取 vite 的配置,进行一些操作 | vite 独享 |
configureServer | 是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件。 | vite 独享 |
transformIndexHtml | 转换 index.html 的专用钩子。 | vite 独享 |
options | 在收集 rollup 配置前,vite (本地)服务启动时调用,可以和 rollup 配置进行合并 | vite 和 rollup 共享 |
buildStart | 在 rollup 构建中,vite (本地)服务启动时调用,在这个函数中可以访问 rollup 的配置 | vite 和 rollup 共享 |
resolveId | 在解析模块时调用,可以返回一个特殊的 resolveId 来指定某个 import 语句加载特定的模块 | vite 和 rollup 共享 |
load | 在解析模块时调用,可以返回代码块来指定某个 import 语句加载特定的模块 | vite 和 rollup 共享 |
transform | 在解析模块时调用,将源代码进行转换,输出转换后的结果,类似于 webpack 的 loader | vite 和 rollup 共享 |
buildEnd | 在 vite 本地服务关闭前,rollup 输出文件到目录前调用 | vite 和 rollup 共享 |
closeBundle | 在 vite 本地服务关闭前,rollup 输出文件到目录前调用 | vite 和 rollup 共享 |
如果大家发现有什么比较好的文章或者文档对这些钩子函数有更详细的介绍,也欢迎分享出来。
到这篇文章位置,总共 6 期的 Vite
系列文章也圆满画上了句号,谢谢大家的支持。
最后一件事
如果您已经看到这里了,希望您还是点个赞再走吧~
您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!
如果觉得本文对您有帮助,请帮忙在 github 上点亮 star
鼓励一下吧!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。