Vite 为什么这么快前言 webpack统治了前端打包界也有一段时间了,除了比较老的项目,新项目都会采用webpack
前言
webpack统治了前端打包界也有一段时间了,除了比较老的项目,新项目都会采用webpack来打包。然而时代总是会更替的,就像十几年前用的诺基亚、摩托罗拉这样带键盘的手机,现在已难寻踪迹。如今人手一部正面一整块屏幕的智能手机。随着浏览器对原生esm模块的支持越来越好,近几年社区也出现了像 rollup, vite 等直接使用esm模块打包的工具,并逐渐流行起来,大有要挑战webpack统治地位的意思。在这篇文章中我们用一个简易项目来说明vite的工作原理,了解一下vite为什么这么快。
1. 初始化项目
首先初始化一个项目
npm init -y
并装一些依赖
npm i react react-dom chokidar // 生产环境依赖
npm i esno express ws chalk -D // 测试环境依赖
// esno用来执行采用esm模块规范编写的js文件
// ws 是node端的websocket库
// chokidar 用来监听文件变化
// chalk 用来设置控制台文字的样式
接着在package.json里加入一个npm脚本命令
./package.json
{
...
"scripts":{
"dev":"esno src/opt-command.js && esno src/dev-command.js" // 串行执行
}
...
}
这行启动命令会串行执行两个js文件,我们暂且先知道要需要这两个文件,在下面的小结会逐个创建。列一个列表,可以对要做的事情先留个印象。
- 起一个本地node服务
- 访问这个服务时返回一个html模版
- 这个html的头部塞入一个js文件,这个js文件使用websocket接收服务端传来的文件变化(也就是热更新)
- 在服务端处理客户端的其他请求
- 在服务端监听文件变化,并使用websocket告知客户端 这么一看有些抽象,没关系我们一步一步来
2. 搭建服务端
在项目根目录创建一个src文件夹,并在src里创建一个dev-command.js, 并把一些需要的依赖引进来。
// /src/dev-command.js
import express from 'express';
import { createServer } from 'http';
import { join, extname, posix } from 'path'
import { readFileSync } from 'fs';
import chokidar from 'chokidar' // 监听文件变化的库
import WebSocket from 'ws'; // 服务端的websocket库
import { transformCode, transformCss, transformJSX } from './transform'; // 这个文件需要我们自己写
起一个本地服务
// /src/dev-command.js
async function dev(){
const app = express()
...
const server = createServer(app) // createServer来自node自带的http模块
const port = 3333
server.listen(port, () => {
console.log('App is running at 127.0.0.1:', port)
})
}
dev().catch(console.error)
当我们在地址栏输入127.0.0.1:3333
时要返回一个html文件,这个html文件就不费劲吧啦自己写了。用vite创建一个react项目,直接用它生成的html文件。
在一个新的文件夹下执行
yarn create vite
在这一步选择react
在我们的项目根目录下创建target文件夹,把vite项目生成的html文件和src目录下的所有文件复制过去。
// 这是刚刚生成的vite项目
|-vite-project
|-index.html
|-package.json
|-src
| |-App.css
| |-App.jsx
| |-favicon.svg
| |-index.css
| |-logo.svg
| |-main.jsx
|-vite.config.js
|-yarn.lock
演示项目的目录结构
// 这是复制完成后演示项目当前的目录结构
|-myvite
|-package.json
|-src
| |-dev.command.js
|-target
| |-App.css
| |-App.jsx
| |-index.css
| |-index.html
| |-logo.svg
| |-main.jsx
|-yarn.lock
接着返回html文件
// ./src/dev-command.js
...
async function dev(){
const app = express()
app.get('/', (req, res) => {
res.set('Content-type', 'text/html') // 设置响应头
const htmlPath = join(__dirname, '../target', 'index.html') // html文件的路径
let html = readFileSync(htmlPath, 'utf-8') // 读取html文件
html = html.replace('<head>', '<head> \n <script type="module" src="/@myvite/client"></script>').trim()
send(html)
})
...
}
dev().catch(console.error)
当浏览器解析到这个script标签时,会向src里的地址发一个请求,我们给它返回一个js文件。
// ./src/dev-command.js
...
async function dev(){
...
app.get('/@myvite/client', (req, res) => {
res.set('Content-Type', 'application/javascript')
res.send(
transformCode({
code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
}).code
)
})
这个返回的js文件,等会儿再讲。在html的body尾部还有一个用来渲染页面的js, 我们先来处理这个。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> // 删掉这一行,这个svg也可以删除
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script> // 把src改成 /target/main.jsx, 因为我们把文件都复制到了target下
</body>
</html>
浏览器会去获取main.jsx, 但浏览器不认识啥是jsx, 我们需要把jsx转成js, 顺道把其他文件也一并处理了。
// /src/dev-command.js
...
async function dev(){
...
// 命中target下的所有文件
app.get('/target/*', (req, res) => {
// 获取文件的完整路径
// req.path >>> /target/main.jsx
const filePath = join(__dirname, '..', req.path.slice(1))
switch(extname(req.path)){ // extname从fs解构而来
case '.svg': // 处理svg文件
res.set('Content-Type', 'image/svg+xml')
res.send(
readfileSync(filepath, 'utf-8')
)
break
case '.css': // 处理css文件
res.set('Content-Type', 'application/javascript')
res.send(
transformCss({
path: req.path,
code: readfileSync(filepath), 'utf-8')
})
)
break
default: // 处理jsx文件
res.set('Content-Type', 'application/javascript')
res.send(
transformJSX({
path: req.path,
code: readfileSync(filepath), 'utf-8')
})
)
break
}
...
})
dev-command文件先写到这里,其他逻辑后面再补上。
3. 转换各种类型文件
在html里解析到这个script标签时,就走到了第二节最后的switch里的default分支
<script type="module" src="/target/main.jsx"></script>
我们在src目录下新建一个transform.js文件,先试着把jsx转换成浏览器认识的js. 这里要说明的是esbuild这个库,初始化项目的时候也没有装,它是从哪来的?它又是干什么的?其实不难想到,一定是安装的某个依赖又依赖了这个包。答案是esno把esbuild作为依赖引入了,esbuild使用golang编写, 在编译js方面相比于js书写的工具更有优势,vite内部在转译jsx时也使用了这个库。
// /src/transform.js
import { transformSync } from 'esbuild';
import { extname, dirname, join } from 'path'
// 封装一个工具函数,返回esbuild转换后的内容
export function transformCode({code, loader}){
return transformSync(code, { // jsx -> js
loader: loader || 'js',
sourcemap: true,
format: 'esm'
})
}
function transformJSX(opts){
const {appRoot, code, path} = opts
const ext = extname(path).slice(1) // jsx
const ret = transformCode({
loader: ext,
code
})
...
}
...
export {
transformJSX,
transformCss,
transformCode
}
借助esbuild成功的把jsx转成了js, 其实就是把带尖括号的标签转成了React.createElement
.
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App.jsx";
ReactDOM.render(/* @__PURE__ */ React.createElement(React.StrictMode, null, /* @__PURE__ */ React.createElement(App, null)), document.getElementById("root"));
现在问题来了,顶部这么些个import能成功引入么。要知道每个import浏览器都会发一个请求去获取相应资源,对浏览器来说"react", "react-dom", "./index.css" 是什么鬼,不好意思我不认识。所以转成js之后下一步就是处理这些import.来一个脑阔痛的正则 😁,匹配目标是import语句。再来一个脑阔没那么疼的replace第二个参数为函数时的高级用法。 😭
// /src/transform.js
...
function transformJSX(opts){
...
const {code} = ret
// 反向预查,非捕获分组,反向引用... 额 补一波正则知识吧
code = code.replace(
/\bimport(?!\s+type)(?:[\w*{}\r\n\t, ]+from\s*)?\s*("([^']+)"|'([^"]+)')/gm,
(a, b, c, d, e, f)=>{
// 每成功匹配一次,就调用一次此函数。因为只有三个捕获分组,所以有六个有效行参。
// 如果一个捕获分组都没有,那就有3个有效形参(去除b, c, d)
// 函数的返回将用来替换正则匹配到的字符串
// a 为匹配到的字符串
// b 为第一个捕获分组捕获到的内容
// c 为第二个捕获分组捕获到的内容
// d 为第三个捕获分组捕获到的内容
// e 为匹配到的字符串在原始字符串中的索引位置
// f 为原始字符串
...
}
)
}
...
单独写一个代码块吧,毕竟上面的内容需要消化一下 😢。ok 接下来要判断一下哪些是项目内的文件比如 './index.css', 哪些是第三方依赖比如 'react'. 怎么判断呢? 简单粗暴点,以' . '开始的我们就当是项目内文件,否则就是第三方依赖。
// /src/transform.js
...
function transformJSX(opts){
...
const { code } = ret
code = code.replace(
/\bimport(?!\s+type)(?:[\w*{}\r\n\t, ]+from\s*)?\s*("([^']+)"|'([^"]+)')/gm,
(a, b, c, d, e, f)=>{
// 举个例子
// a import App from './App.jsx'
// b './App.jsx'
// c ./App.jsx
let realFrom // 用这个替换原 from 后面的内容
if(c.charAt(0) === '.'){ // 项目内文件
// opts.path 为 /target/main.jsx
// 调用dirname后返回/target/
// join后为 /target/App.jsx
realFrom = join(dirname(opts.path), c)
// 如果是svg文件标记一下,标记的用途下面再讲
if(['svg'].includes(extname(realFrom).slice(1))){
realFrom = `${realFrom}?import`
}
}else{ // 第三方依赖
// taget下现在还没有.cache文件夹,这个也在下面讲
realFrom = `/target/.cache/${c}/cjs/${c}.development.js`
}
// 返回的内容,举个例子
// import App from '/target/App.jsx'
return a.relace(b, `'${realFrom}'`)
}
)
// 最终返回所有import处理好的js代码字符串
return code
}
处理jsx文件的函数写好了,接下来写处理css文件的函数 transformCss. 这里应该不难理解,返回的js代码做的事情就是创建一个style标签,内容设置为css代码,最后塞到head里。
// /src/transform.js
...
function transformCss(opts){
return `
const css = "${opts.code.replace(/\n/g, '')}"
const styleTag = document.createElement('style')
styleTag.setAttribute('type', 'text/css')
styleTag.innerHTML = css
document.head.appendChild(styleTag)
`.trim()
}
好, 现在回过头说说为什么svg文件要标记一下。
// 如果是svg文件标记一下,标记的用途现在就讲
if(['svg'].includes(extname(realFrom).slice(1))){
realFrom = `${realFrom}?import`
}
在浏览器里我们只能import进来js文件,引入svg文件会报下面的错误。
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "image/svg+xml". Strict MIME type checking is enforced for module scripts per HTML spec.
所以我们在后面拼接上 ?import 当浏览器解析到下面这一行时,会发一个请求。
import logo from '/target/logo.svg?import'
我们接到这个请求后返回一个js文件就行了。
// /src/dev-command.js
...
async function dev(){
...
app.get('/target/*', (req, res) => {
...
// 在这里捕获刚刚标记的svg文件
if('import' in req.query){
res.set('Content-Type', 'application/javascript')
res.send(`export default '${req.path}'`)
// 返回一行js代码字符串
return
}
switch(extname(req.path)){ // extname从fs解构而来
...
}
...
})
这里要注意的是,我们返给浏览器的js代码字符串执行后export的是一串字符串 '/target/logo.svg' . 即下面这个logo其实就是这个字符串。
// /target/App.jsx
import React from 'react';
import './App.css';
import logo from './logo.svg'; // 这里import进来的logo, 就是 '/target/logo.svg'
function App() {
const [count, setCount] = React.useState(0);
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Hello Vite + React!</p>
...
logo作为img的src属性传入后,浏览器去请求这个资源,就会进入下面的这个case里。这样页面就能正常显示这个图片了。
// /src/dev-command.js
...
async function dev(){
...
app.get('/target/*', (req, res) => {
...
switch(extname(req.path)){ // extname从fs解构而来
case '.svg': // 处理svg文件
res.set('Content-Type', 'image/svg+xml')
res.send(
readfileSync(filepath, 'utf-8')
)
break
...
}
...
})
4. 处理第三方依赖
处理完项目内各类型的文件后还有一个坑需要填,那就是target
下的.cache文件夹是做什么的。先在 /target 下 手动创建 .cache 文件夹
}else{ // 第三方依赖
// taget下现在还没有.cache文件夹,这个现在讲
realFrom = `/target/.cache/${c}/cjs/${c}.development.js`
}
还记得 package.json 里面的 scripts 脚本吗,我们是这么配置的。dev.command.js 写的差不多了, opt.command.js 还没有写。顺带一提,这里的opt是optimize的缩写。
"scripts": {
"dev": "esno src/opt.command.js && esno src/dev.command.js"
},
在 src 下创建 opt.command.js, 里面有只有一个 async iife.
// /src/opt.command.js
import { esbuild } from 'esbuild' // 再次登场
import { join } form 'path'
const appRoot = join(__dirname, '..') // 获取项目根目录
const cache = join(appRoot, 'target', '.cache')
(function async (){
const dep = ['react', 'react-dom'] // 需要处理的依赖列表
const ep = dep.reduce((a, b) => {
a.push(join(appRoot, 'node_modules', b, `cjs/${b}.development.js`))
return a
}, [])
await esbuild({ // 从属性名应该能大致猜到作用,具体可以参考esbuild官网
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")
}
})
})()
执行了这个js文件后,.cache文件夹里就会生成按照esm规范打包好的依赖文件。
5. 热更新
热更新是个比较实用的功能,这看似魔法的功能,细想的话其实也不难琢磨。首先肯定要监听文件的变化,那么怎么监听文件的变化呢? 这就要用到 chokidar 这个库了。 在 dev.command.js 文件中已经引入了 chokidar , 在启动服务时就开始监听。
// /src/dev.command.js
...
import chokidar from 'chokidar'
const targetRootPath = join(__dirname, '../target') // target文件夹的绝对路径
(async function dev(){
...
const watcher = chokidar.watch(targetRootPath, {
ignored: ['**/node_modules**', '**/.catch/**'], // 忽略目录
ignoreInitial: true,
ignorePremissionErrors: true,
disableGlobbing: true
})
// 文件变化时
watcher.on('change', (file)=>{
// TODO
})
})()
ok 那么文件变化时要做哪些事情呢? 在这里要说明一下的是,现在是在服务端监听文件变化。但代码最终是在客户端也就是浏览器上运行的,那服务端监听到了变化如何主动告知客户端呢?没错,正是你的脑海中浮现出的 websocket. 服务端的 websocket 我们使用 ws 库,下面的代码创建了webSocket实例。
// /src/dev.command.js
...
import WebSocket from 'ws'
import chalk from 'chalk'
...
function createWebSocketServer(server){
const wss = new WebSoket.Server({noServer : true}) // 允许WebSocket服务器完全脱离HTTP/S服务器
// server为服务的实例,监听upgrade事件,处理升级请求
server.on('upgrade', (req, socket, head) => {
// 确认匹配客户端的websocket, 客户端的websocket等会儿创建
if(req.headers['sec-websocket-protocol'] === 'vite-hmr'){
wss.handleUpgrade(req, socket, head, (ws) => { // noServer模式下手动调用 handleUpgrade
wss.emit('connection', ws, req)
})
}
// 监听建立连接事件,注册处理函数
wss.on('connection', (websocket) => {
// 向客户端发送数据
websocket.send(JSON.stringify({type: 'connected'}))
})
// 监听错误事件,注册处理函数
wss.on('error', (e) => {
if(e.code !== 'EADDRINUSE'){
console.log(chalk.red(`WebSocket server error: \n ${e.stack || e.message}`))
}
})
// 最后返回包含了两个方法的对象,send方法向客户端发送数据,close方法关闭websocket
return {
send(payload){
const stringified = JSON.stringify(payload)
// 遍历所有的客户端
wss.clients.forEach( client => {
client.send(stringified)
})
},
close(){
wss.close()
}
}
})
}
...
把 server 传进 createWebSocketServer, 并在监听到文件发生变化时调用 handleHMRUpdate
// /src/dev.command.js
...
(async function dev(){
...
const server = createServer(app) // app是express实例,createServer解构自http模块
const wsMethods = createWebSocketServer(server)
// 文件变化时
watcher.on('change', (file)=>{
handleHMRUpdate({file, wsMethods})
})
const port = '3333'
server.listen(port, ()=>{
console.log('App is running at 127.0.0.1:' + port)
})
})()
在 handleHMRUpdate 里,把更新的信息发给了客户端。
// /src/dev.command.js
...
const targetRootPath = join(__dirname, '../target')
function handleHMRUpdate({file, wsMethods}){
// 获取文件名
const shortName = file.startsWith(targetRootPath + '/') ?
posix.relative(targetRootPath, file) : // posix也是解构自path模块,是一种兼容性方案
file
;const timestamp = Date.now()
let updates
if(shortName.endsWith('.css') || shortName.endsWith('.jsx')){
updates = {
type: 'js-update',
timestamp,
path: `/${shortName}`,
acceptedPath: `/${shortName}`
}
}
// 发给客户端
weMethods.send({
type: 'update',
updates
})
}
...
终于到了最后一步了,现在还差一个处理服务端返回的热更新信息的逻辑。还记得我们在处理html文件的返回时,head塞的script标签么
// ./src/dev-command.js
...
async function dev(){
const app = express()
app.get('/', (req, res) => {
res.set('Content-type', 'text/html') // 设置响应头
const htmlPath = join(__dirname, '../target', 'index.html') // html文件的路径
let html = readFileSync(htmlPath, 'utf-8') // 读取html文件
html = html.replace('<head>', '<head> \n <script type="module" src="/@myvite/client"></script>').trim()
send(html)
})
...
}
dev().catch(console.error)
当浏览器解析到这个script标签,发请求获取src里的资源时,我们会返回一个名为client的js文件。
// ./src/dev-command.js
...
async function dev(){
app.get('/@myvite/client', (req, res) => {
res.set('Content-Type', 'application/javascript')
res.send(
transformCode({
code: readFileSync(join(__dirname, 'client.js'), 'utf-8')
}).code
)
})
...
}
我们在src目录下创建一个 client.js 文件。
// /src/client.js
// 创建一个客户端的websocket, 协议指定为 vite-hmr , 才能和服务端成功建立连接。
// 这是为什么呢?可以在页面 ctrl/command + f 搜索下 vite-hmr
const socket = new WebSocket(`ws://${location.host}`, 'vite-hmr')
// 拿到数据
socket.addEventListener('message', ({data})=>{
handleMessage(JSON.parse(data)).catch(console.error)
})
// 处理数据
async function(payload){
switch(payload.type){
case 'connected':
console.log('connected')
break
case 'update': // 处理更新内容
payload.updates.forEach(async (update) => {
if(update.type === 'js-update'){ // 不明白这个type的含义的话,也可以在页面搜一下'js-update' :)
console.log('js update...')
await import(`/target/${update.path}?t=${update.timestamp}`) // 动态加载
location.reload()
}
})
break
}
}
到这里所有代码就写完了。
结语
正如你看到的,最后是用 location.reload 刷新了整个页面,并没有实现动态替换有变化的部分......这个我也需要进一步学习,不过就像开篇时说的, 对于 Vite 为什么这么快,相信你已经有了一定的了解。
转载自:https://juejin.cn/post/7024474558321131528