【Vite】核心原理解析
本篇文章主要是通过原生实现一个迷你的
vite脚手架,将React的demo跑起来,用来探究vite的核心原理
既然要实现一个vite,首先要准备一个小项目用于测试,成功启动就算成功
前戏
文件目录结构
|-- my-vite
|-- src
|-- target
|-- App.css
|-- App.js
|-- index.css
|-- index.html
|-- logo.svg
|-- main.jsx
|-- package.json
|-- yarn.lock
其中,src为vite脚手架源码,target为测试用的react项目
利用浏览器支持
ESM机制,发请求获取对应那个文件的方式
esm机制:import 的内容都会走请求去拉取资源,我们自己起一个服务,就可以对这些请求的返回进行拦截处理,返回我们处理过后的内容/整个应用就完全基于 node 服务,静态资源加载,没有编译构建的过程,肯定就会很快了。
主要思路如下
-
使用
express启动服务 -
拦截入口请求,塞入客户端代码文件,返回他的
html文件 -
客户端代码内可以加一些骚操作,比如 热更新
-
拦截客户端请求接口,获取代码,使用
esbuild依赖将代码转换为esm格式统一处理后返回 -
拦截静态文件请求接口,处理后返回该目录下对应文件
-
处理
svg图片 -
处理
css文件 -
处理
jsx文件-
使用正则分析
import -
分别处理本地文件和依赖包
- 依赖包预先编译并缓存
-
-
-
客户端代码
- 使用
websocket建立一个全双工通信 - 监听通信,拿服务端塞过来的 已修改 的代码文件数据
- 根据修改的文件类型,使用对应的策略进行处理,更新页面
代码
启动服务,塞入客户端
先用express启动一个服务,塞入客户端代码文件,返回他的html文件
src/dev.js
import express from 'express'
import { readFileSync } from 'fs'
import { createServer } from 'http'
import { join } from 'path'
// 目标文件路径
const targetRootPath = join(__dirname, '../target')
export async function dec() {
const app = express()
// 入口请求
app.get('/', (req, res) => {
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)
})
const server = createServer(app)
const port = 8888
server.listen(port, () => {
console.log('App is running at 127.0.0.1' + port);
})
}
src/dev.command.js
import {dev} from './dev'
dev().catch(console.error)
注意:浏览器只支持ESM模块化的语法,所以要在代码内只能用ESM而不能用CMJ,可以使用esno在node环境使用ESM
package.json
{
"name": "my-vite",
"version": "1.0.0",
"description": "demo",
"main": "index.js",
"scripts": {
"dev": "esno src/dev.command.js"
},
"author": "cbbfcd",
"license": "ISC",
"devDependencies": {
"esno": "^0.5.0",
"express": "^4.17.1",
"prettier": "^2.3.0",
"prettier-plugin-organize-imports": "^2.1.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"typescript": "^4.2.4",
"ws": "^7.4.5"
},
"dependencies": {
"chokidar": "^3.5.2"
}
}
可以使用命令行启动该服务看看
yarn dev
拦截客户端请求,返回客户端代码
- 服务端建立一个通信
- 监听通信,拿数据,然后做处理
src/dev.js
// 客户端
app.get('/@vite/client', (req,res) => {
res.set('Content-Type', 'application/json')
res.send(
// TODO 读取准备好的客户端文件
)
})
src/client.js
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) {
case 'connected':
console.log('[vite] connected.');
setInterval(() => {
socket.send('ping')
}, 30000);
break;
default:
payload.update.forEach(update => {
if(update.type === 'js-update') {
console.log('[vite] is update...');
}
});
break;
}
}
统一打包处理代码
由于浏览器只认识ESM,所以需要封装函数,统一打包处理,防止用户使用CMJ或者其他方式引用文件
src/transform.js
import { transformSync } from 'esbuild'
export function transformCode(opts) {
return transformSync(opts.code, {
loader: opts.loader || 'js',
sourcemap:true,
format: 'esm'
})
}
返回客户端代码前,先处理一遍
src/dev.js
// 客户端
app.get('/@vite/client', (req,res) => {
res.set('Content-Type', 'application/json')
res.send(
transformCode({
code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
}).code
)
})
拦截target路径的所有请求,处理返回的数据
src/dev.js
import { join, extname } from 'path'
// 静态文件的处理,返回给浏览器能认识的
app.get('/target/*', (req,res) => {
// extname获取文件的后缀
// 获取完整的文件路径
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
}
switch (extname(req.path)) {
// svg图片可以直接返回,浏览器会识别
case '.svg':
// TODO 处理svg图片
break;
case '.css':
// TODO 处理css文件
break;
default:
// TODO 处理jsx文件
break;
}
})
处理svg图片
src/dev.js
case '.svg':
// TODO 处理svg图片
res.set('Content-Type', 'image/svg+xml')
res.send(
readFileSync(filePath, 'utf-8')
)
break;
处理css文件
src/client.js
// 封装一些操作 css 的工具方法,因为 client 是放 html 里的,可以导出来给其它模块使用
const sheetsMap = new Map();
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);
}
export function rmStyle(id) {
const style = sheetsMap.get(id);
if (style) {
document.head.removeChild(style);
}
sheetsMap.delete(id);
}
src/transform.js
// 把 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()
}
src/dev.js
import { transformCode,transformCss } from './transform'
...
case '.css':
res.set('Content-Type', 'application/javascript')
res.send(
transformCss({
path: req.path,
code: readFileSync(filePath, 'utf-8')
})
)
break;
处理jsx文件
src/dev.js
import { transformCode,transformCss,transformJSX } from './transform'
...
default:
res.set('Content-Type', 'application/javascript');
res.send(
transformJSX({
appRoot: join(__dirname, '../target'),
path: req.path,
code: readFileSync(filePath, 'utf-8')
}).code
)
break;
将jsx转成js
src/transform.js
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", 然后通过有没有 "." 判断是引用的本地文件还是三方库
// 本地文件就拼路径
// 三方库就从我们预先编译的缓存里面取
code = code.replace(
/\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm,
(a, b, c) => {
// a import React from "react"
// b "react"
// c react
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`
}
// 将反斜杠替换成斜杠,否则浏览器不认识该路径
from = from.replace(/\/g,"/");
}
else { // 从 node_modules 里来的
from = `/target/.cache/${c}/cjs/${c}.development.js`;
}
return a.replace(b, `"${from}"`)
}
)
return {
...ret,
code
}
}
预处理node_modules依赖文件
预先编译node_modules里面的包,预先编译,并且缓存起来
可以预先把相关文件从 node_modules 取出来,build 成 esm 模块,放进一个缓存文件中,这些依赖的三方库一般是不会变更的,所以可以这样预先处理
src/optmize.js
// node_modules 里的包,预先编译,并且缓存起来
import { build } from 'esbuild'
import { join } from 'path'
const appRoot = join(__dirname, '..')
const cache = join(appRoot, 'target', '.cache')
export async function optmize(pkgs = ['react', 'react-dom']) {
// 获取原依赖包的目录
const ep = pkgs.reduce((c, n) => {
c.push(join(appRoot, 'node_modules', n, `cjs/${n}.development.js`))
return c
}, [])
await build({
entryPoints: ep, // 入口文件
bundle: true, // 配置所有的文件打包成一个文件
format: 'esm', // 打包格式
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cache,
treeShaking: 'ignore-annotations',
metafile: true,
define: {
"process.env.NODE_ENV": JSON.stringify("development")
}
})
}
src/opt.command.js
import { optmize } from './optmize';
(async () => {
await optmize()
})()
修改package.json,启动项目前预先打包缓存依赖
"scripts": {
"dev": "esno src/opt.command.js && esno src/dev.command.js"
}
至此,已经可以将该React项目跑起来了
处理热更新
- 使用
chokidar监听本地文件的变更 - 使用
ws,通过websocket通知客户端 - 客户端收到消息,进行自动更新
src/client.js
....
async function handleMessage(payload) {
switch (payload.type) {
case 'connected':
...
break;
default:
payload.updates.forEach(async(update) => {
if(update.type === 'js-update') {
console.log('[vite] is update...');
await import(`/target/${update.path}?t=${update.timestamp}`);
location.reload();
}
});
break;
}
}
....
src/dev.js
import express from 'express'
import { readFileSync } from 'fs'
import { createServer } from 'http'
import { join, extname, relative } from 'path'
import chokidar from 'chokidar'
import WebSocket from 'ws';
import { transformCode,transformCss,transformJSX } from './transform'
const targetRootPath = join(__dirname, '../target');
// 建立一个 websocket 服务,封装 send 方法
function createWebSocketServer(server) {
const wss = new WebSocket.Server({ noServer: true })
server.on('upgrade', (req, socket, head) => {
if (req.headers['sec-websocket-protocol'] === 'vite-hmr') {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
}
});
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected' }));
});
wss.on('error', (e) => {
if (e.code !== 'EADDRINUSE') {
console.error(
chalk.red(`WebSocket server error:\n${e.stack || e.message}`),
);
}
});
return {
send(payload) {
const stringified = JSON.stringify(payload);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(stringified);
}
});
},
close() {
wss.close();
},
}
}
// 监听文件变更
function watch() {
return chokidar.watch(targetRootPath, {
ignored: ['**/node_modules/**', '**/.cache/**'],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
})
}
function getShortName(file, root) {
console.log('file, root',file, root);
// 方法根据当前工作目录返回从 from 到 to 的相对路径
return file.startsWith(root + '\') ? relative(root, file) : file;
}
// 文件变化了执行的回调,里面其实就是用 websocket 推送变更数据
function handleHMRUpdate(opts) {
const {file, ws} = opts;
const shortFile = getShortName(file, targetRootPath);
console.log('shortFile',shortFile);
const timestamp = Date.now();
let updates
if (shortFile.endsWith('.css') || shortFile.endsWith('.jsx')) {
updates = [
{
type: 'js-update',
timestamp,
path: `/${shortFile}`,
acceptedPath: `/${shortFile}`
}
]
}
ws.send({
type: 'update',
updates
})
}
export async function dev() {
const app = express()
// 入口请求
app.get('/', (req, res) => {
...
})
// 客户端
app.get('/@vite/client', (req,res) => {
...
})
// 静态文件的处理,返回给浏览器能认识的
app.get('/target/*', (req,res) => {
...
})
const server = createServer(app)
const ws = createWebSocketServer(server);
// 监听文件的变化
watch().on('change', async (file) => {
handleHMRUpdate({ file, ws });
})
const port = 8888
server.listen(port, () => {
console.log('App is running at 127.0.0.1:' + port);
})
}
以上,就是所有的代码了
直接运行命令行启动,即可
yarn dev
最终目录结构如下
|-- my-vite
|-- src
|-- client.js
|-- dev.command.js
|-- dev.js
|-- opt.command.js
|-- optmize.js
|-- transform.js
|-- target
|-- .cache
|-- ...
|-- App.css
|-- App.js
|-- index.css
|-- index.html
|-- logo.svg
|-- main.jsx
|-- package.json
|-- yarn.lock