懒了大半年,2021年底,有机会用上vite+vue3用在实战项目上,vite搭建项目上手很快,配置也好理解,快速搭建新的小型项目,极速开发业务需求,搞定!2k工资到账,打卡下班🐶。
不不,菜鸟就多学点吧,想着有没有可以简单了解vite原理的路径,人们一直说vite是基于浏览器原生 ES imports是咋实现的?本着这个目的,git clone了vite仓库,直接翻到最初的log历史记录,可以看到有个v0.1.1的最初版本,我们就基于refactor: use async fs + expose createServer API这条历史记录进行git checkout(因为后续就是加入ts,样式热替换等,我们就从最简单的入手就好)
项目目录
可以看到,现在项目目录很简单,如下,后续再一一介绍每个文件。
.
├── bin
│ └── vds.js
├── lib
│ ├── hmrClient.js
│ ├── hmrWatcher.js
│ ├── moduleMiddleware.js
│ ├── moduleRewriter.js
│ ├── parseSFC.js
│ ├── server.js
│ ├── utils.js
│ └── vueMiddleware.js
├── package.json
└── yarn.lock
现在我们手动在项目根目录添加index.html、main.js、Comp.vue三个文件,方便调试
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</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>
main.js
import { createApp } from 'vue'
import Comp from './Comp.vue'
createApp(Comp).mount('#app')
Comp.vue
<template>
<button @click="count++">{{ count }}</button>
</template>
<script>
export default {
setup() {
return {
count: 1
}
}
}
</script>
然后我们在package.json添加下启动命令
"scripts": {
"dev": "bin/vds.js"
}
这样我们就可以yarn dev启动项目,尝试更改下Comp.vue、main.js文件的代码,浏览器都会实时更新,木问题,说明前期准备搞好了。
如果我们使用的是vscode,我们可以debug来代替yarn dev启动,如下点击Debug Script
然后我们就可以在我们想要的代码行进行断点调试了,如下,我们可以代码最左侧点击选择我们想要断点的代码行。
进入正题
我们先看下server.js文件,启动项目的开始。先不用看那么细
const server = http.createServer(async (req, res) => {
const pathname = url.parse(req.url).pathname
if (pathname === '/__hmrClient') {
return sendJS(res, await hmrClientCode)
} else if (pathname.startsWith('/__modules/')) {
return moduleMiddleware(pathname.replace('/__modules/', ''), res)
} else if (pathname.endsWith('.vue')) {
return vue(req, res)
} else if (pathname.endsWith('.js')) {
const filename = path.join(process.cwd(), pathname.slice(1))
try {
const content = await fs.readFile(filename, 'utf-8')
return sendJS(res, rewrite(content))
} catch (e) {
if (e.code === 'ENOENT') {
// fallthrough to serve-handler
} else {
console.error(e)
}
}
}
serve(req, res, {
rewrites: [{ source: '**', destination: '/index.html' }]
})
})
大概看if/else语句分成了好几种情况。可以看到目前,分成5种请求情况
请求路径
- /__hmrClient
- /moduels/
- .vue
- .js
- 其他重置到/index.html
一般我们访问网站先请求index.html,然后开始加载js资源
请求js
// 相对于当前项目根目录下的文件路径
// 如请求路径/main.js --> /Users/your_name/your_project/main.js
const filename = path.join(process.cwd(), pathname.slice(1))
try {
const content = await fs.readFile(filename, 'utf-8')
return sendJS(res, rewrite(content))
} catch (e) {
if (e.code === 'ENOENT') {
// fallthrough to serve-handler
} else {
console.error(e)
}
}
代码块中sendJS(res, rewrite(content))很简单,就是向客户端返回js响应
function send(res, source, mime) {
res.setHeader('Content-Type', mime)
res.end(source)
}
function sendJS(res, source) {
send(res, source, 'application/javascript')
}
可以看到这里还有有rewrite(content)对js代码进行了重写,它引用于const { rewrite } = require('./moduleRewriter'),我们前往moduleRewriter文件看看
const { parse } = require('@babel/parser')
const ms = require('magic-string')
exports.rewrite = (source, asSFCScript = false) => {
const ast = parse(source, {
// ...
}).program.body
let s
ast.forEach((node) => {
if (node.type === 'ImportDeclaration') {
if (/^[^\.\/]/.test(node.source.value)) {
// module import
// import { foo } from 'vue' --> import { foo } from '/__modules/vue'
;(s || (s = new ms(source))).overwrite(
node.source.start,
node.source.end,
`"/__modules/${node.source.value}"`
)
}
} else if (node.type === 'ExportDefaultDeclaration') {
;(s || (s = new ms(source))).overwrite(
node.start,
node.declaration.start,
`let __script; export default (__script = `
)
s.appendRight(node.end, `)`)
}
})
return s ? s.toString() : source
}
这里使用babel解析成ast抽象语法树,并且暂时处理两种情况。
一种是ImportDeclaration,可以看到注释是将import { foo } from 'vue' --> import { foo } from '/__modules/vue',这是为了后续引入vue等第三方包的时候提供标识,让请求服务发现含有__modules时前往node_modules获取资源。而例如我们自己写的代码如import utils fom './utils'等就正常查找项目路径。
另一种是ExportDefaultDeclaration,例子如下
export default {
setup() {
return {
count: 0
}
}
}
// 转换成
let __script; export default (__script = {
setup() {
return {
count: 0
}
}
})
请求__modules
请求_modules,也即请求第三包的资源,我们看下是如何做的
// ...
else if (pathname.startsWith('/__modules/')) {
return moduleMiddleware(pathname.replace('/__modules/', ''), res)
}
// ...
我们前往moduleMiddleware文件查看
// moduleMiddleware.js
const path = require('path')
const resolve = require('resolve-cwd')
const { sendJSStream } = require('./utils')
exports.moduleMiddleware = (id, res) => {
let modulePath
try {
// 例如moduleMiddleware('vue', res) --> resolve('vue'),会自动查找node_modules中vue包路径
// 将'vue'变成'/Users/your_name/your_project/node_modules/vue/index.js'
modulePath = resolve(id)
if (id === 'vue') {
modulePath = path.join(
path.dirname(modulePath),
'dist/vue.runtime.esm-browser.js'
)
}
} catch (e) {
res.setStatus(404)
res.end()
}
sendJSStream(res, modulePath)
}
可以看到moduleMiddleware.js文件代码还是很简单的,目的是找到node_modules里第三方资源并返回给客户端。
例如查找vue的包,首先使用resolve-cwd工具,modulePath = resolve(id),查找到
modulePath="/Users/your_name/your_project/node_modules/vue/index.js"
再对vue引用路径特殊更改
modulePath = path.join(
path.dirname(modulePath),
'dist/vue.runtime.esm-browser.js'
)
// 更改为:modulePath="/Users/your_name/your_project/node_modules/vue/dist/vue.runtime.esm-browser.js"
最后使用sendJSStream返回给客户端(这里没直接使用上面的sendJS,而是使用fs模块的流来读取。因为第三方包的源码文件都比较大,一次性读取会占用大量内存,效率很低,用流来读取更合适)
// utils.js
function sendJSStream(res, file) {
res.setHeader('Content-Type', 'application/javascript')
const stream = fs.createReadStream(file)
stream.on('open', () => {
stream.pipe(res)
})
stream.on('error', (err) => {
res.end(err)
})
}
exports.sendJSStream = sendJSStream
请求vue文件
接下来是请求vue文件
const vue = require('./vueMiddleware')
// ...
else if (pathname.endsWith('.vue')) {
return vue(req, res)
}
// ...
同样我们前往vueMiddleware文件看看,代码有几十行,我们拆开来讲吧
const { parseSFC } = require('./parseSFC')
module.exports = async (req, res) => {
const parsed = url.parse(req.url, true)
const query = parsed.query
const filename = path.join(process.cwd(), parsed.pathname.slice(1))
const [descriptor] = await parseSFC(filename, true)
//...
}
首先会对请求的vue文件进行parseSFC方法进行解析处理,我们再前往parseSFC文件看下,如下。对vue文件进行解析,顺便做一下缓存,方便后续监听文件变化的时候对比新旧数据。
const { parse } = require('@vue/compiler-sfc')
const cache = new Map()
exports.parseSFC = async (filename, saveCache = false) => {
const content = await fs.readFile(filename, 'utf-8')
const { descriptor, errors } = parse(content, { filename })
// 获取上一次缓存数据
const prev = cache.get(filename)
if (saveCache) {
cache.set(filename, descriptor)
}
return [descriptor, prev]
}
@vue/compiler-sfc是Vue的底层工具,方便库作者能够对 Vue 单文件实现重新挂载、渲染。
我们知道,Vue单文件组件(SFC)有template,script,style标签组成。当解析拿到的descriptor字段,我们会分成template,script,style三个情况进行处理。
// ...
const { rewrite } = require('./moduleRewriter')
if (!query.type) {
// inject hmr client
let code = `import "/__hmrClient"\n`
if (descriptor.script) {
code += rewrite(descriptor.script.content, true)
} else {
code += `const __script = {}; export default __script`
}
if (descriptor.template) {
code += `\nimport { render as __render } from ${JSON.stringify(
parsed.pathname + `?type=template${query.t ? `&t=${query.t}` : ``}`
)}`
code += `\n__script.render = __render`
}
if (descriptor.style) {
// TODO
}
code += `\n__script.__hmrId = ${JSON.stringify(parsed.pathname)}`
return sendJS(res, code)
}
首先对script标签的内容,进行上面讲过的用moduleRewriter进行部分重写。
export default {
setup() {
return { count: 0 }
}
}
// 转换成
let __script; export default (__script = {
setup() {
return { count: 0 }
}
})
对于template部分先不急着进行编译,先给上面声明的变量__scritpt添加唯一表识__hmrtId和render方法,这目的是热替换时,方便能够拿到对应vue组件的render进行重新渲染。
请求Comp.vue文件内容如下(style部分先忽略)
我们留意到,请求vue文件的时候还会import "/__hmrClient",这是用来浏览器端接受hmr通知的,我们下面再讲。
然后,还会再次import一遍Comp.vue,并且为请求路径添加参数type=template,这才会真正对template进行编译解析。另外t=query.t(query.t为时间轴)是当Comp.vue更改的时候才会添加上,这是我们常用的防止浏览器缓存的方法。看下vueMiddleware对请求Comp.vue?type=template处理
const { compileTemplate } = require('@vue/compiler-sfc')
if (query.type === 'template') {
const { code, errors } = compileTemplate({
source: descriptor.template.content,
filename,
compilerOptions: {
// TODO infer proper Vue path
runtimeModuleName: '/__modules/vue'
}
})
return sendJS(res, code)
}
可以看到,这时才开始调用@vue/compiler-sfc工具的compileTemplate方法,进行编译。
例如
<template>
<button @click="count++">{{ count }}</button>
</template>
将变成
至此,启动项目到初次渲染的请求过程就算粗略地介绍完了,之后浏览器端拿到渲染函数,开始虚拟DOM树的构建,转换真实DOM......
监听文件变化
当启动项目,并在浏览器渲染完成之后,修改文件之后是怎么触发重新渲染更新的呢?我们再回到server.js文件看看
const ws = require('ws')
const wss = new ws.Server({ server })
const sockets = new Set()
wss.on('connection', (socket) => {
sockets.add(socket)
socket.send(JSON.stringify({ type: 'connected' }))
socket.on('close', () => {
sockets.delete(socket)
})
})
可以看到,我们创建了webSocket,在本地服务监听文件变化,推送通知给浏览器端
const { createFileWatcher } = require('./hmrWatcher')
createFileWatcher((payload) =>
sockets.forEach((s) => s.send(JSON.stringify(payload)))
)
我们前往看下createFileWatcher方法的监听规则
const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar')
exports.createFileWatcher = (notify) => {
const fileWatcher = chokidar.watch(process.cwd(), {
ignored: [/node_modules/]
})
fileWatcher.on('change', async (file) => {
const resourcePath = '/' + path.relative(process.cwd(), file)
if (file.endsWith('.vue')) {
// ...
} else {
console.log(`[hmr:full-reload] ${resourcePath}`)
notify({
type: 'full-reload'
})
}
})
}
我们监听process.cwd()进程的当前工作目录,一般都是当前项目根目录。监听所有文件变化(不包括node_modules目录),除了vue文件单独处理之外,其他文件的更改都会通知浏览器刷新页面(触发full-reload),很明显目前的代码还是比较简单粗暴的。
我们再单独看下对vue的监听处理
if (file.endsWith('.vue')) {
// check which part of the file changed
const [descriptor, prevDescriptor] = await parseSFC(file)
if (!prevDescriptor) {
// the file has never been accessed yet
return
}
// 监听script的代码更改
if (
(descriptor.script && descriptor.script.content) !==
(prevDescriptor.script && prevDescriptor.script.content)
) {
notify({ type: 'reload', path: resourcePath})
return
}
// 监听template的代码更改
if (
(descriptor.template && descriptor.template.content) !==
(prevDescriptor.template && prevDescriptor.template.content)
) {
notify({ type: 'rerender', path: resourcePath})
return
}
// TODO styles
}
parseSFC解析方法我们在上面说过了,现在我们对比更新前后的descriptor和prevDescriptor,发现是script部分的更改,那么触发reload;是template部分的更改,则触发rerender;style样式部分先不考虑。
至此,我们了解到目前,触发hmr热替换有三个规则:full-reload、reload、rerender
浏览器端是怎么通过webscoket接受这些消息规则的?上面我们暂时没讲的请求hmrClient,现在前往这个文件看下
const socket = new WebSocket(`ws://${location.host}`)
socket.addEventListener('message', ({ data }) => {
const { type, path, index } = JSON.parse(data)
switch (type) {
case 'connected':
break
// 组件重新挂载
case 'reload':
import(`${path}?t=${Date.now()}`).then(m => {
__VUE_HMR_RUNTIME__.reload(path, m.default)
})
break
// 组件重新渲染
case 'rerender':
import(`${path}?type=template&t=${Date.now()}`).then(m => {
__VUE_HMR_RUNTIME__.rerender(path, m.render)
})
break
case 'update-style':
import(`${path}?type=style&index=${index}&t=${Date.now()}`).then(m => {
// TODO style hmr
})
break
// 页面刷新
case 'full-reload':
location.reload()
}
})
__VUE_HMR_RUNTIME__是Vue3在开发环境暴露出来提供给我们方便对组件重新渲染挂载的功能。
reload
例如我们更改了Comp.vue的script代码,那么浏览器端会重新请求Comp.vue
import(`/Comp.vue?t=${Date.now()}`)
接着调用__VUE_HMR_RUNTIME__.reload方法重新挂载Comp.vue组件
rerender
例如我们更改了Comp.vue的template代码,那么浏览器端只需拿到Comp.vue的新渲染函数
import(`/Comp.vue?type=template&t=${Date.now()}`)
接着调用__VUE_HMR_RUNTIME__.rerender方法重新渲染Comp.vue组件
full-reload
更改其他文件,直接location.reload()刷新页面(当然现在vite不可能直接这样做)
最后
vite最初版本的代码我们看完啦,那么我们对vite原理也算是了解了大概,如果需要更多地了解,我们可以切换到vite更高的版本分支,继续撸代码。我们对上面的内容进行简单回顾吧
| 文件 | 说明 |
|---|---|
| moduleRewriter.js | 改写js中import/export代码,例如import { createApp } from 'vue',让vite知道前往node_modules第三方包请求资源 |
| moduleMiddleware.js | 寻找到第三包资源路径 |
| parseSFC.js | 解析vue组件,并且缓存解析结果。方便组件更改前后比较 |
| vueMiddleware.js | 编译vue组件,为组件添加渲染函数 |
| hmrWatcher.js | 监听项目中文件变化,通过webSocket通知浏览器端 |
| hmrClient.js | 接受更新通知,根据规则决定重新挂载组件、重新渲染组件或刷新页面 |
鹅是进击的小前端,如果文章有不对的地方,欢迎👏大家指出,最后希望对你有收获。