从0开始写一个简单的vite hmr 插件
0. 写在前面
唠叨半天,赶紧开始吧
1. 初始化项目
由于是真从0开始,我们这里不选择vite官方提供的create-vite,而是通过依赖安装的方式一步步搭建起来一个vite-plugin
按照你习惯的方式初始化项目
mkdir vite-plugin-todo // pnpm pnpm init // yarn yarn init // npm npm init cd vite-plugin-todo
安装vite
// pnpm pnpm add vite // yarn yarn add vite // npm npm add vite
初始化项目目录
// 用来作为vite的入口,以及页面展示 touch index.html // src文件夹以及main入口 mkdir src touch src/main.ts // plugins文件夹,存放我们的vite插件 mkdir plugins // 创建vite配置文件, 以及vite环境配置文件 touch vite.config.ts touch src/vite-env.d.ts
在index.html 中添加main.ts 入口
// index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>vite-hmr-plugin-test</title> </head> <body> <!--这里添加main.ts 入口--> <script src="/src/main.ts" type="module"></script> </body> </html>
修改package.json 的命令
{ "name": "vite-plugin-todo", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "dev": "vite dev" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@types/node": "^18.8.5", "vite": "^3.1.8" } }
为了使得typescript能够解析nodejs模块
pnpm add @types/nodejs yarn add @types/nodejs npm install @types/nodejs
尝试一下pnpm dev
没报错的话就OK了
项目的结构如下
2. 初识vite plugin
2.1 vite 插件是什么
2.2 vite 插件的生命周期
在说vite插件生命周期之前,我们还是先完善一下vite.config.ts
import { defineConfig } from "vite"; export default defineConfig({ plugins: [ // Plugins ], assetsInclude: [ "src/**/*.todo" ] })
- 定义并导出一个配置
plugins
是用来存放vite插件实例的assetsInclude
是用来指明需要解析的资源路径的,我们这里以.todo
为资源后缀
插件生命分为3个阶段,启动时,模块传入时,服务器关闭时
关于这几个模块的具体说明见vite官方文档。
这里只说我们用到的transform
从名字可以看出是有关变化的函数,它的作用正是在我们执行导入的时候,提供检测的函数。
每当执行写一个import,vite就会把这个信息传递到每一个插件的transform中
插件根据所需转化自己需要的.
transform接收两个参数,src为导入的文本内容,另外一个id则是此模块的绝对路径(可以通过绝对路径进行文件类型判断)
transform(src, id) { return { code: "", // ... } }
2.3 vite 插件是怎么提供其他资源导入的功能的?
前面提到的transform函数是一个解析函数,当通过import导入的时候,就会触发,然后经过一定处理之后返回。
所以你应该想到了,其他的资源应该是以某种符合js语法的方法导入了,而这个处理过程transform实现了这个过程,让一个原本不符合js语法的资源,变的合法了。
那么说到底是怎么实现的呢?
答:通过注入的方法。
在浏览器加载之前,vite先帮你把各种import模块全部转换好,转换为如上的形式,那你说这都定义成变量了,浏览器肯定认啊,对吧!
那你可能会问我还是不明白,到底怎么转换的,其实就是通过transform的返回值来解析转换的。
transform(src, id) { // 解析这个文件,是不是你要的type // 执行转换 // 把转换的结果可以通过 `` 插值到code里面 return { code: "", // 转换后的代码 // ... } }
2.4 vite插件长什么样?
export default function todoParser() { // 插件创建之初的代码,可以在这里配置插件所需的资源 return { name: "todo-parser", // 插件名 // 生命周期函数 transform(src, id) { // 解析这个文件,是不是你要的type // 执行转换 // 把转换的结果可以通过 `` 插值到code里面 return { code: "", // 转换后的代码 // ... } } } }
2.5 如何让typescript支持导入这个模块?
回到之前,我们不是说vite插件中transform能够丰富资源的导入,
但是这不代表typescript就认可,不认可依然不能提供完备的补全和检查,
所以为了让typescript彻底服气,就需要在vite-env.d.ts中写一段模块解析的配置
// vite-env.d.ts declare module '*.todo' { export const data: string; export function parser(content: string); }
这里定义了一个模块,并导出了两个成员
一个叫data, 是string类型的资源
一个叫parser,是个解析函数(稍后会介绍)
这样写了之后,typescript就会默认我们能够导入.todo
后缀的文件,并且这里面有两个成员,一个是data,一个是parser。
3. todo插件编写
O 吃饭 X 喝水 O 跑步五公里
这样一种文本,O表示未完成,X表示完成,后面表示当前todo的信息
3.1 todo插件
在plugins中创建一个todoParser.ts
export default function todoParser(): Plugin { let todoFileRegex = /.(todo)$/; // 解析.todo 的正则 return { name: "todo-parser", transformIndexHtml(html) { return html.replace(/<title>(.*?)</title>/, '<title>TODO Parser</title>'); }, async transform(src, id) { // module inject console.log(id); // 看看当前文件是否通过了正则,如果通过了,就执行 if (todoFileRegex.test(id)) { return { // 这里的parser是解析器,稍后会说 code: ` export let data = "${parser(src)}" export ${parser} ` }; } } } }
相信阅读了前面有关vite插件的介绍应该不难理解
3.2 parser
为了能够解析.todo文件,并且输出我们希望的内容,
还需要提供解析一个解析器来解析。
// todoParser.ts function parser(src: string) { // 解析 const lines = src.split('n'); let todoList = ""; let finishRegex = /^X/; let readyRegex = /^O/; let content = /s(.*)$/ let randomId: string; for (let line of lines) { randomId = Math.random().toString(32).slice(2); let html: string; if (finishRegex.test(line)) { console.log(line); html = `<li><input type='checkbox' checked id='${randomId}'/><label for='${randomId}'>${line.trim().match(content)![1]}</li>` console.log("通过",html); } else if (readyRegex.test(line)) { html = `<li><input type='checkbox' id='${randomId}'/><label for='${randomId}'>${line.trim().match(content)![1]}</li>` console.log("拒绝",html); } todoList += html!; } return todoList; }
我们这里通过正则获取了每一行数据中表示状态的 OX, 以及其内容,并且封装为一组checkbox
这些文本信息可以直接插入html以显示其内容
3.3 插件的装载
import { defineConfig } from "vite"; import todoParser from './plugins/todoParser'; export default defineConfig({ plugins: [ todoParser() ], assetsInclude: [ "src/**/*.todo" ] })
回到vite.config.ts中,在plugins数组内部直接执行todoParser(),实现插件的装载
在main.ts 中接收这个导入的资源,并且赋值到document中
import { data } from './assets/journey.todo' import './style.css'; // 样式,这里消除了li的一般样式 list-style: none console.log(data); document.body.innerHTML = data;
- 上面的预览图可以发现我们实现了功能,但是每次一写完,整个页面就会全部刷新,这可不太好,所以还需要HMR
4. HMR 实现
注意,vite中server和client是可以相互通信!这里只需要server向client发送消息
4.1 server 发送
vite服务器实例的获取有很多种方法:
-
直接通过vite钩子 configureServer(server) {}获取
一般用来给vite服务器添加中间件
-
通过处理更新的钩子获取 handleHotUpdate({file, server, modules}){}
这里我们要实现的是热更新,所以采用handleHotUpdate就可以了,在模块更新的时候,就会触发这个函数,通过server向client发送更新的消息,以及更新的数据,然后让浏览器在未刷新的情况下直接更新
async handleHotUpdate({ server, file, modules }) { let fileData = await fs.readFile(modules[0].id as string); server.ws.send({ type: 'custom', event: 'special-update', // 事件名 data: { msg: "Update from server", updateVal: fileData.toString() } }) console.log(`${file} should be updated`); return []; }
通过node的fs模块读取到了文本的数据
随后通过server.ws.send()向client发送的数据,其中更新之后的数据存放在data.updateVal中
4.2 client 获取
在vite中,模块热更新以事件的形式抛出,具体来说是
import.meta.hot.on('xxx事件', () => {} /*事件回调*/)
我们这里编写如下代码
if (import.meta.hot) { import.meta.hot.on('special-update', (data) => { data = parser(data.updateVal); document.body.innerHTML = data; }) }
如果更新了,那么就执行parser,解析数据,最后把数据赋值到document.body.innerHtml上。
那这个代码应该写在哪儿呢?
答应该写在,模块导入的未知,也就是transform
函数的返回值
中
这样才能保证每一个.todo
模块都能够热更新!
// 完整的parser export default function todoParser(): Plugin { let todoFileRegex = /.(todo)$/; // local variable function log(msg) { console.log(msg); } return { name: "todo-parser", transformIndexHtml(html) { return html.replace(/<title>(.*?)</title>/, '<title>TODO Parser</title>'); }, transform(src, id) { // module inject console.log(id); if (todoFileRegex.test(id)) { return { code: ` export let data = "${parser(src)}" export ${parser} if (import.meta.hot) { import.meta.hot.on('special-update', (data) => { data = parser(data.updateVal); document.body.innerHTML = data; }) } `, }; } }, async handleHotUpdate({ server, file, modules }) { let fileData = await fs.readFile(modules[0].id as string); server.ws.send({ type: 'custom', event: 'special-update', data: { msg: "Update from server", updateVal: fileData.toString() } }) console.log(`${file} should be updated`); return []; } } }
- 如此简单的HMR就实现了,画面不会重新加载了。
5. 写在最后
HMR最好还是精确到元素,所以最好给parser提供一个能够精确定位到元素的id,以便模块更新的时候,能够精确定位到对于的元素以更新,而不是把所有的资源重新加载一遍。
6. 拓展阅读
强烈建议去阅读vite官方文档,写的真的很详细。
另外,vite的模块解析,有一部分是通过rollup来实现的,所以可以去学学rollup的解析,加深理解。