Vite 实现原理
-
Vite是什么?
Vite 是 vue 的作者尤雨溪在开发 vue3.0 的时候开发的一个 基于原生 ES-Module 的前端构建工具。
核心特点:
- 快速的冷启动
- 即时的模块热更新(HMR)
- 真正的按需编译
-
vite 实现原理
原理:
- 利用 ES6 的 import 会发送请求去加载文件的特性,拦截这些请求,做一些预编译,省去 webpack 冗长的打包时间
- 利用 websoket 实现双通道通信,用于热更新有改动的文件。
-
一步步实现一个vite
3.1 准备基本项目模板
准备一个 react 模板
有以下特征:
main.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App.jsx'
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)
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>Document</title>
</head>
<body>
<div id="root"></div>
<!-- es module导入方式 -->
<script type="module" src="/target/main.jsx"></script>
</body>
</html>
3.2 使用express启动一个服务
import express from 'express'
const app = express()
3.3 拦截入口请求,返回给用户处理过的 html 文件
// 拦截这个入口请求,返回给用户处理过的 html 文件
app.get('/', (req, res) => {
// content-type
res.set('Content-Type', 'text/html')
// html 文件的绝对路径
const htmlPath = join(__dirname, '../target', 'index.html')
// 根据路径取读文件,获取到文件的字符串
let html = readFileSync(htmlPath, 'utf-8')
// 塞入客户端代码(包含了热更新等)
html = html
.replace(
'<head>',
`<head>\n <script type="module" src="/@vite/client"></script>`
)
.trim()
res.send(html)
})
3.4 给客户端塞入一个 script 标签,而是 es module 类型引入,浏览器会发起一个请求
<script type="module" src="/@vite/client"></script>
3.5 再通过 node 起的服务拦截这个请求@vite/client, 注入以下代码
console.log('[vite] is connecting....')
// 获取
const host = location.host
// 客户端 - 服务端建立一个通信
const socket = new WebSocket(`ws://${host}`, 'vite-hmr')
// 监听通信,拿数据,然后做处理
socket.addEventListener('message', async ({ data }) => {
handleMessage(JSON.parse(data)).catch(console.error)
})
async function handleMessage(payload) {
switch (payload.type) {
// 如果拿到的是已连接json消息,就定时器启动个心跳,避免没有消息的时候,websoket掉线了
case 'connected':
console.log('[vite] connected.')
setInterval(() => socket.send('ping'), 30000)
break
// 如果收到需要更新的消息通知
case 'update':
// 拿到需要更新的数组,遍历以下,分类处理
payload.updates.forEach(async update => {
// 如果是JS更新
if (update.type === 'js-update') {
console.log('[vite] js update....')
await import(`/target/${update.path}?t=${update.timestamp}`)
// mock
// location.reload();
}
})
break
}
}
// 向浏览器注入如何更新css的方法, 创建缓存
const sheetsMap = new Map()
// 创建个style css 标签,把css code 放进去,插入到head里
export function updateStyle(id, content) {
let style = sheetsMap.get(id)
if (!style) {
style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.innerHTML = content
document.head.appendChild(style)
} else {
style.innerHTML = content
}
sheetsMap.set(id, style)
}
// css更新需要先删除style标签,再注入
export function rmStyle(id) {
const style = sheetsMap.get(id)
if (style) {
document.head.removeChild(style)
}
sheetsMap.delete(id)
}
3.6 接下来同理, 拦截 target 目录下所有类型的格式,该转换的转换,不需要转换的不处理,此时网站已经可以正常访问了
// jsx代码,使用esbuild转换成esm
export function transformCode(opts) {
return transformSync(opts.code, {
loader: opts.loader || 'js',
sourcemap: true,
format: 'esm',
})
}
// 把 css 封装成 js 里,通过 style 绑定到页面去
export function transformCss(opts) {
return `
import { updateStyle } from '/@vite/client';
const id = "${opts.path}";
const css = "${opts.code.replace(/\n/g, '')}";
updateStyle(id, css);
export default css;
`.trim()
}
// JSX文件转换
export function transformJSX(opts) {
const ext = extname(opts.path).slice(1) // 'jsx'
const ret = transformCode({
// jsx -> js
loader: ext,
code: opts.code,
})
let { code } = ret
// 分析代码字符串的 import
// 为啥要分析 import 呢?
// import type { XXXX } from 'xxx.ts';
// import React from 'react';
// 下面的正则取出 from 后面的 "react", 然后通过有没有 "." 判断是引用的本地文件还是三方库
// 本地文件就拼路径
// 三方库就从我们预先编译的缓存里面取
cons reg = /\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm;
code = code.replace(
reg,
(a, b, c) => {
let from
if (c.charAt(0) === '.') {
// 本地文件
from = join(dirname(opts.path), c)
const filePath = join(opts.appRoot, from)
if (!existsSync(filePath)) {
if (existsSync(`${filePath}.js`)) {
from = `${from}.js`
}
}
if (['svg'].includes(extname(from).slice(1))) {
from = `${from}?import`
}
} else {
// 从 node_modules 里来的
from = `/target/.cache/${c}/cjs/${c}.development.js`
}
return a.replace(b, `"${from}"`)
}
)
return {
...ret,
code,
}
}
// 静态文件的处理,返回给浏览器能认识的
app.get('/target/*', (req, res) => {
// req.path -----> /target/main.jsx
// 完整的文件路径
const filePath = join(__dirname, '..', req.path.slice(1))
// 静态资源给一个 flag
if ('import' in req.query) {
res.set('Content-Type', 'application/javascript')
res.send(`export default "${req.path}"`)
return
}
// 对不同类型的文件做不同的处理,返回的是浏览器能够认识的结构,比如如果是 jsx 文件,就需要转成 js
// 如果是 css 文件,就需要放 style 标签,然后塞到 html header
switch (extname(req.path)) {
case '.svg':
res.set('Content-Type', 'image/svg+xml')
res.send(readFileSync(filePath, 'utf-8'))
break
case '.css':
res.set('Content-Type', 'application/javascript')
res.send(
transformCss({
path: req.path,
code: readFileSync(filePath, 'utf-8'),
})
)
break
default:
res.set('Content-Type', 'application/javascript')
res.send(
transformJSX({
appRoot: join(__dirname, '../target'),
path: req.path,
code: readFileSync(filePath, 'utf-8'),
}).code
)
break
}
})
3.7 使用 chokidar 库创建一个监听,监听 target 文件目录下的文件变化,变化时触发 websoket 发送消息
import chokidar from 'chokidar'
// 创建文件监听方法
function watch() {
const watcher = chokidar.watch(join(__dirname, '../target'), {
ignored: ['**/node_modules/**', '**/.cache/**'],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
})
return watcher
}
// 文件改变事件,回调后传参被修改的路径,触发热更新函数
watch().on('change', async ({ file }) => {
handleHMRUpdate({ file, ws })
})
// 文件变化了执行的回调,里面其实就是用 websocket 推送变更数据
function handleHMRUpdate(opts) {
const { file, ws } = opts
const shortFile = getShortName(file, targetRootPath)
const timestamp = Date.now()
let updates
// 判断是什么类型的文件,发什么类型的消息,组合json消息串
if (shortFile.endsWith('.css') || shortFile.endsWith('.jsx')) {
updates = [
{
type: 'js-update',
timestamp,
path: `/${shortFile}`,
acceptedPath: `/${shortFile}`,
},
]
}
// websoket发送update更新消息,浏览器端注入的JS ,此时应该可以接受到更新json串,
ws.send({
type: 'update',
updates,
})
}
3.8 回到浏览器被注入的 JS,消息事件监听处,重新 import 加载 js,带上时间搓,缓存更新,热更新完成
async function handleMessage(payload) {
switch (payload.type) {
case 'connected':
console.log('[vite] connected.')
setInterval(() => socket.send('ping'), 30000)
break
case 'update':
payload.updates.forEach(async update => {
if (update.type === 'js-update') {
console.log('[vite] js update....')
// 局部更新只需要把对应的JS重新import一下就可以重新走请求拦截编译流程
await import(`/target/${update.path}?t=${update.timestamp}`)
// 也可以刷新页面测试
// location.reload();
}
})
break
}
}