vite
-
vite 是神马?
-
Vite (法语意为 "快速的",发音
/vit/
) 是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:- 一个开发服务器,它基于 原生 ES 模块 提供了 丰富的内建功能,如速度快到惊人的 模块热更新(HMR)。
一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。
它有如下特点:
光速启动
按需编译
热模块替换HMR
接下来我们使用vue3+vite 搭建第一个 Vite 项目;
开启 vscode
-
vite why?
-
ES module
- Vite利用了浏览器native ES module imports特性,使用ES方式组织代码,浏览器自动请求需要的文件,并在服务端按需编译返回,完全跳过了打包过程
Vite vs webpack 参考链接
- Webpack
- webpack 是一个现代 JavaScript 应用程序的静态模块打包器。开发时启动本地开发服务器,实时预览。它通过解析应用程序中的每一个 import 和 require ,将整个应用程序构建成一个基于 JavaScript 的捆绑包,并在运行时转换文件,这都是在服务器端完成的,依赖的数量和改变后构建/重新构建的时间之间有一个大致的线性关系。需要对整个项目文件进行打包,开发服务器启动缓慢。
- Webpack 的热更新会以当前修改的文件为入口重新 build 打包,所有涉及到的依赖也都会被重新加载一次,所以反应速度会慢一些。
- Vite
- Vite 不捆绑应用服务器端。相反,它利用浏览器中的原生 ES Moudle 模块,在具体去请求某个文件的时候,才会在服务端编译这个文件。
- vite 只启动一台静态页面的服务器,对文件代码不打包,服务器会根据客户端的请求加载不同的模块处理,实现真正的按需加载。
- 对于热更新问题, vite 采用立即编译当前修改文件的办法。同时 vite 还会使用缓存机制( http 缓存=> vite 内置缓存),加载更新后的文件内容。
vite优势
- 不需要等待打包,所以冷启动的速度将会非常快。
- 代码是按需编译的。只有你当前页面实际导入的模块才会被编译。你不需要等待整个应用程序被打包完才能够启动服务。这在巨型应用上体验差别更加巨大。(router demo)
- 热替换HMR的性能将与模块的总数量无关。
vite 工作原理
vite 启动服务
1\. vite 在启动时,内部会启一个 http server,用于拦截页面的脚本文件。
处理.js/.vue文件,npm模块
// 精简了热更新相关代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/server.ts
import http, { Server } from 'http'
import serve from 'serve-handler'
import { vueMiddleware } from './vueCompiler'
import { resolveModule } from './moduleResolver'
import { rewrite } from './moduleRewriter'
import { sendJS } from './utils'
export async function createServer({
port = 3000,
cwd = process.cwd()
}: ServerConfig = {}): Promise {
const server = http.createServer(async (req, res) => {
const pathname = url.parse(req.url!).pathname!
if (pathname.startsWith('/__modules/')) {
// 返回 import 的模块文件
return resolveModule(pathname.replace('/__modules/', ''), cwd, res)
} else if (pathname.endsWith('.vue')) {
// 解析 vue 文件
return vueMiddleware(cwd, req, res)
} else if (pathname.endsWith('.js')) {
// 读取 js 文本内容,然后使用 rewrite 处理
const filename = path.join(cwd, pathname.slice(1))
const content = await fs.readFile(filename, 'utf-8')
return sendJS(res, rewrite(content))
}
serve(req, res, {
public: cwd,
// 默认返回 index.html
rewrites: [{ source: '**', destination: '/index.html' }]
})
})
return new Promise((resolve, reject) => {
server.on('listening', () => {
console.log(`Running at http://localhost:${port}`)
resolve(server)
})
server.listen(port)
})
}
// 访问index.html
解析js文件
index.html 文件会请求 /src/main.js
if (pathname.endsWith('.js')) {
// 读取 js 文本内容,然后使用 rewrite 处理
const filename = path.join(cwd, pathname.slice(1))
const content = await fs.readFile(filename, 'utf-8')
return sendJS(res, rewrite(content))
}
// 精简了部分代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/moduleRewriter.ts
import { parse } from '@babel/parser'
export function rewrite(source: string, asSFCScript = false) {
// 通过 babel 解析,找到 import from、export default 相关代码
const ast = parse(source, {
sourceType: 'module',
plugins: [
'bigInt',
'optionalChaining',
'nullishCoalescingOperator'
]
}).program.body
let s = source
ast.forEach((node) => {
if (node.type === 'ImportDeclaration') {
if (/^[^\.\/]/.test(node.source.value)) {
// 在 import 模块名称前加上 /__modules/
// import { foo } from 'vue' --> import { foo } from '/__modules/vue'
s = s.slice(0, node.source.start)
+ `"/__modules/${node.source.value}"`
+ s.slice(node.source.end)
}
} else if (asSFCScript && node.type === 'ExportDefaultDeclaration') {
// export default { xxx } -->
// let __script; export default (__script = { xxx })
s = s.slice(0, node.source.start)
+ `let __script; export default (__script = ${
s.slice(node.source.start, node.declaration.start)
})`
+ s.slice(node.source.end)
s.overwrite(
node.start!,
node.declaration.start!,
`let __script; export default (__script = `
)
s.appendRight(node.end!, `)`)
}
})
return s.toString()
}
处理npm模块
请求的文件如果是 /__modules/ 开头的话,表明是一个 npm 模块
// 精简了部分代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/moduleResolver.ts
import path from 'path'
import resolve from 'resolve-from'
import { sendJSStream } from './utils'
import { ServerResponse } from 'http'
export function resolveModule(id: string, cwd: string, res: ServerResponse) {
let modulePath: string
modulePath = resolve(cwd, 'node_modules', `${id}/package.json`)
if (id === 'vue') {
// 如果是 vue 模块,返回 vue.runtime.esm-browser.js
modulePath = path.join(
path.dirname(modulePath),
'dist/vue.runtime.esm-browser.js'
)
} else {
// 通过 package.json 文件,找到需要返回的 js 文件
const pkg = require(modulePath)
modulePath = path.join(path.dirname(modulePath), pkg.module || pkg.main)
}
sendJSStream(res, modulePath)
}
处理 vue 文件
// 精简了部分代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/vueCompiler.ts
import url from 'url'
import path from 'path'
import { parse, SFCDescriptor } from '@vue/compiler-sfc'
import { rewrite } from './moduleRewriter'
export async function vueMiddleware(
cwd: string, req, res
) {
const { pathname, query } = url.parse(req.url, true)
const filename = path.join(cwd, pathname.slice(1))
const content = await fs.readFile(filename, 'utf-8')
const { descriptor } = parse(content, { filename }) // vue 模板解析
if (!query.type) {
let code = ``
if (descriptor.script) {
code += rewrite(
descriptor.script.content,
true /* rewrite default export to `script` */
)
} else {
code += `const __script = {}; export default __script`
}
if (descriptor.styles) {
descriptor.styles.forEach((s, i) => {
code += `\nimport ${JSON.stringify(
pathname + `?type=style&index=${i}`
)}`
})
}
if (descriptor.template) {
code += `\nimport { render as __render } from ${JSON.stringify(
pathname + `?type=template`
)}`
code += `\n__script.render = __render`
}
sendJS(res, code)
return
}
if (query.type === 'template') {
// 返回模板
}
if (query.type === 'style') {
// 返回样式
}
}
经过解析,.vue
文件返回的时候会被拆分成三个部分:script、style、template。
// 解析前
// 解析后
import HelloWorld from "/src/components/HelloWorld.vue";
let __script;
export default (__script = {
name: "App",
components: {
HelloWorld
}
})
import {render as __render} from "/src/App.vue?type=template"
__script.render = __render
template 中的内容,会被 vue 解析成 render 方法。《Vue 模板编译原理》
import {
parse,
SFCDescriptor,
compileTemplate
} from '@vue/compiler-sfc'
export async function vueMiddleware(
cwd: string, req, res
) {
// ...
if (query.type === 'template') {
// 返回模板
const { code } = compileTemplate({
filename,
source: template.content,
})
sendJS(res, code)
return
}
if (query.type === 'style') {
// 返回样式
}
}
[图片上传失败...(image-aaf414-1637751525318)]
而 template 的样式
import {
parse,
SFCDescriptor,
compileStyle,
compileTemplate
} from '@vue/compiler-sfc'
export async function vueMiddleware(
cwd: string, req, res
) {
// ...
if (query.type === 'style') {
// 返回样式
const index = Number(query.index)
const style = descriptor.styles[index]
const { code } = compileStyle({
filename,
source: style.content
})
sendJS(
res,
`
const id = "vue-style-${index}"
let style = document.getElementById(id)
if (!style) {
style = document.createElement('style')
style.id = id
document.head.appendChild(style)
}
style.textContent = ${JSON.stringify(code)}
`.trim()
)
}
}
复制代码
style 的处理也不复杂,拿到 style 标签的内容,然后 js 通过创建一个 style 标签,将样式添加到 head 标签中。
小结
通过上文解析了 vite 是如何拦截请求,然后返回需要的文件的过程;我们大概了解了vite是如何提高本地开发速度;接下来说一下 Vite 热更新的实现
HMR 处理机制
实现热更新,那么就需要浏览器和服务器建立某种通信机制,这样浏览器才能收到通知进行热更新。Vite 的是通过 WebSocket
来实现的热更新通信。
客户端
客户端的代码在 src/client/client.ts
,主要是创建 WebSocket
客户端,监听来自服务端的 HMR 消息推送。
Vite 的 WS 客户端目前监听这几种消息:
connected
: WebSocket 连接成功vue-reload
: Vue 组件重新加载(当你修改了 script 里的内容时)vue-rerender
: Vue 组件重新渲染(当你修改了 template 里的内容时)style-update
: 样式更新style-remove
: 样式移除js-update
: js 文件更新full-reload
: fallback 机制,网页重刷新
服务端
核心是监听项目文件的变更,然后根据不同文件类型(目前只有 vue
和 js
)来做不同的处理:
watcher.on('change', async (file) => {
const timestamp = Date.now() // 更新时间戳
if (file.endsWith('.vue')) {
handleVueReload(file, timestamp)
} else if (file.endsWith('.js')) {
handleJSReload(file, timestamp)
}
})
// 简单的源码分析如下:
// 以 vue 文件处理
async function handleVueReload(
file: string,
timestamp: number = Date.now(),
content?: string
) {
const publicPath = resolver.fileToRequest(file) // 获取文件的路径
const cacheEntry = vueCache.get(file) // 获取缓存里的内容
debugHmr(`busting Vue cache for ${file}`)
vueCache.del(file) // 发生变动了因此之前的缓存可以删除
const descriptor = await parseSFC(root, file, content) // 编译 Vue 文件
const prevDescriptor = cacheEntry && cacheEntry.descriptor // 获取前一次的缓存
if (!prevDescriptor) {
// 这个文件之前从未被访问过(本次是第一次访问),也就没必要热更新
return
}
// 设置两个标志位,用于判断是需要 reload 还是 rerender
let needReload = false
let needRerender = false
// 如果 script 部分不同则需要 reload
if (!isEqual(descriptor.script, prevDescriptor.script)) {
needReload = true
}
// 如果 template 部分不同则需要 rerender
if (!isEqual(descriptor.template, prevDescriptor.template)) {
needRerender = true
}
const styleId = hash_sum(publicPath)
// 获取之前的 style 以及下一次(或者说热更新)的 style
const prevStyles = prevDescriptor.styles || []
const nextStyles = descriptor.styles || []
// 如果不需要 reload,则查看是否需要更新 style
if (!needReload) {
nextStyles.forEach((_, i) => {
if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
send({
type: 'style-update',
path: publicPath,
index: i,
id: `${styleId}-${i}`,
timestamp
})
}
})
}
// 如果 style 标签及内容删掉了,则需要发送 `style-remove` 的通知
prevStyles.slice(nextStyles.length).forEach((_, i) => {
send({
type: 'style-remove',
path: publicPath,
id: `${styleId}-${i + nextStyles.length}`,
timestamp
})
})
// 如果需要 reload 发送 `vue-reload` 通知
if (needReload) {
send({
type: 'vue-reload',
path: publicPath,
timestamp
})
} else if (needRerender) {
// 否则发送 `vue-rerender` 通知
send({
type: 'vue-rerender',
path: publicPath,
timestamp
})
}
}
客户端逻辑注入
代码里并没有引入 HRM
的 client
代码,Vite 是如何把 client
代码注入的呢??
回到上面的一张图,Vite 重写 index.html
文件的内容并返回时:
入口注入client.js文件
app.vue引入css文件
vue文件 新增class变更后,热更新代码变更差异
热更新的具体怎么替换模块???
到此,热更新的整体流程已经解析完毕
后续
依据原理手写实现自己的Vite -- lvite