6

从0开始写一个简单的vite hmr 插件 - MushRain

 1 year ago
source link: https://www.cnblogs.com/mushrain/p/16794508.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

从0开始写一个简单的vite hmr 插件

0. 写在前面#


🍍 在构建前端项目的时候,除开基本的资源格式(图片,json)以外,还常常会需要导入一些其他格式的资源,这些资源如果没有第三方vite插件的支持,就很难导入,了解vite导入资源的方式可以帮助我们应对各种复杂的资源,只需要一定的解析手段,资源可以全盘接收。 🍍 本博客中,将会从0开始写一个能够解析(.todo)的vite插件, 提供最基础的HMR功能

唠叨半天,赶紧开始吧

634a74dd16f2c2beb1411d08.png

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了

项目的结构如下

634a74dd16f2c2beb1411cfd.png

2. 初识vite plugin#

2.1 vite 插件是什么#

🍍 vite插件是为了丰富vite功能的备选项,从vite官网上看,vite只提供了一些比较基本的功能,更加丰富的功能其实是交给各位来实现的,如vue的vite插件,react的vite插件等等。

2.2 vite 插件的生命周期#

在说vite插件生命周期之前,我们还是先完善一下vite.config.ts

import { defineConfig } from "vite";

export default defineConfig({
    plugins: [
        // Plugins
    ],
    assetsInclude: [
        "src/**/*.todo"
    ]
})
  • 定义并导出一个配置
  • plugins 是用来存放vite插件实例的
  • assetsInclude 是用来指明需要解析的资源路径的,我们这里以.todo 为资源后缀

634a74dd16f2c2beb1411d01.png

插件生命分为3个阶段,启动时,模块传入时,服务器关闭时

关于这几个模块的具体说明见vite官方文档。

这里只说我们用到的transform

从名字可以看出是有关变化的函数,它的作用正是在我们执行导入的时候,提供检测的函数。

634a753f16f2c2beb141c60f.png

每当执行写一个import,vite就会把这个信息传递到每一个插件的transform中

插件根据所需转化自己需要的.

transform接收两个参数,src为导入的文本内容,另外一个id则是此模块的绝对路径(可以通过绝对路径进行文件类型判断)

transform(src, id) {
		
		return {
				code: "",
				// ...
		}
}

2.3 vite 插件是怎么提供其他资源导入的功能的?#

前面提到的transform函数是一个解析函数,当通过import导入的时候,就会触发,然后经过一定处理之后返回。

所以你应该想到了,其他的资源应该是以某种符合js语法的方法导入了,而这个处理过程transform实现了这个过程,让一个原本不符合js语法的资源,变的合法了。

634a753f16f2c2beb141c613.png

那么说到底是怎么实现的呢?

答:通过注入的方法。

634a753f16f2c2beb141c627.png

在浏览器加载之前,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;

634a74dd16f2c2beb1411d15.gif
  • 上面的预览图可以发现我们实现了功能,但是每次一写完,整个页面就会全部刷新,这可不太好,所以还需要HMR

4. HMR 实现#

🍍 vite的HMR是通过vite的websocket来实现的

634a753f16f2c2beb141c60c.png

注意,vite中server和client是可以相互通信!这里只需要server向client发送消息

4.1 server 发送#

vite服务器实例的获取有很多种方法:

  1. 直接通过vite钩子 configureServer(server) {}获取

    一般用来给vite服务器添加中间件

  2. 通过处理更新的钩子获取 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 [];
        }
    }
}

634a74dd16f2c2beb1411d24.gif
  • 如此简单的HMR就实现了,画面不会重新加载了。

5. 写在最后#

HMR最好还是精确到元素,所以最好给parser提供一个能够精确定位到元素的id,以便模块更新的时候,能够精确定位到对于的元素以更新,而不是把所有的资源重新加载一遍。

6. 拓展阅读#

强烈建议去阅读vite官方文档,写的真的很详细。

另外,vite的模块解析,有一部分是通过rollup来实现的,所以可以去学学rollup的解析,加深理解。

7. 代码#

Mushrr/vite-hmr-plugin-test (github.com)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK