Vite原理
1 、ESM&esbuild
ESM
在ES6没有出现之前,随着js代码日益膨胀,往往会对资源模块化来提效,这也就出现了多个模块化方案。如CommonJS常用于服务端,AMD、CMD规范常用在客户端。ES6出现后,紧接着出现了ESM。ESM是浏览器支持的一种模块化方案,允许在浏览器实现模块化。
- CommonJS:模块同步,如Browserify会对代码进行解析,整理出代码中的所有模块依赖关系,然后把nodejs的模块编译成浏览器可用的模块,相关的模块代码都打包在一起,形成一个完整的JS文件,这个文件中不会存在 require 这类的模块化语法,变成可以在浏览器中运行的普通JS,运行时加载
- AMD:模块异步,依赖前置,是requireJS在推广过程中对模块定义的规范化产出,加载完依赖后立即执行依赖模块,依赖加载成功后执行回调
- CMD:模块异步,延迟执行,是seaJS在推广过程中对模块定义的规范化产出,就近依赖,先加载所有依赖模块,运行时才执行require内容,按顺序执行
与CommonJS、AMD不同,ESM的对外接口只是一种静态定义,为编译时加载,遇到模块加载命令import,就会生成一个只读引用。等脚本真正执行时,再根据这个只读引用,到被加载的那个模块内取值。由于ESM编译时就能确定模块的依赖关系,因此能够只包含要运行的代码,可以显著减少文件体积,降低浏览器压力。
由于ESM是一个比较新的模块化方案,目前其浏览器能力支持如下:
可以看到,除了IE、Opera等,新一代浏览器中绝大部分都已支持。
接下来以Vite创建的模板为例,看一下ESM的解析过程:
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Hello Vue 3.0 + Vite" />
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
}
}
</script>
当浏览器解析 import HelloWorld from './components/HelloWorld.vue' 时,会向当前域名发送一个请求获取对应的资源(ESM支持解析相对路径)。
浏览器下载对应的文件,然后解析成模块记录。接下来会进行实例化,为模块分配内存,然后按照导入、导出语句建立模块和内存的映射关系。最后,运行上述代码,把内存空间填充为真实的值。
esbuild
Vite 对 js/ts 的处理没有使用如 glup, rollup 等传统打包工具,而是使用了 esbuild。esbuild 是一个全新的js打包工具,底层使用了go,大量使用了并行操作,可以充分利用CPU资源。esbuild支持如babel, 压缩等的功能。
对比各打包工具性能,可以看到esbuild比rollup等工具快十几倍。
2、请求拦截
Vite 的基本实现原理,就是启动一个 koa 服务器拦截由浏览器请求 ESM的请求。通过请求的路径找到目录下对应的文件做一定的处理最终以 ESM的格式返回给客户端。
2.1、依赖处理
Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。依赖大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。
- 依赖解析
以 Vite 官方 demo 为例,当我们请求 localhost:3000 时,Vite 默认返回 localhost:3000/index.html 的代码。而后发送请求 src/main.js。
main.js 代码如下:
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
可以观察到浏览器请求 vue.js 时, 请求路径是 @modules/vue.js。在 Vite 中约定若 path 的请求路径满足 /^/@modules// 格式时,被认为是一个 node_modules 模块。
平时开发中,webpack & rollup(rollup有对应插件) 等打包工具会帮我们找到模块的路径,但浏览器只能通过相对路径去寻找,而如果是直接使用模块名比如:import vue from 'vue' ,浏览器就会报错,这个时候就需要一个三方包进行处理。Vite 对ESM形式的 js 文件模块使用了 ES Module Lexer 处理。Lexer 会找到代码中以 import 语法导入的模块并以数组形式返回。Vite 通过该数组的值获取判断是否为一个 node_modules 模块。若是则进行对应改写成 @modules/:id 的写法。
重写完路径后,浏览器会发送 path 为 /@modules/:id 的对应请求,接下来会被 Vite 客户端做一层拦截来解析模块的真实位置。
首先正则匹配请求路径,如果是/@modules开头就进行后续处理,否则就跳过。若是,会设置响应类型为js,读取真实模块路径内容,返回给客户端。
客户端注入本质上是创建一个script标签(type='module'),然后将其插入到head中,这样客户端在解析html是就可以执行代码了
export const moduleRE = /^/@modules//
// plugin for resolving /@modules/:id requests.
app.use(async (ctx, next) => {
if (!moduleRE.test(ctx.path)) {
return next()
}
// path maybe contain encode chars
const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
ctx.type = 'js'
const serve = async (id: string, file: string, type: string) => {
// 在代码中做一个缓存,下次访问相同路径直接从 map 中获取 304 返回
moduleIdToFileMap.set(id, file)
moduleFileToIdMap.set(file, ctx.path)
debug(`(${type}) ${id} -> ${getDebugPath(root, file)}`)
await ctx.read(file)
return next()
}
}
// 兼容 alias 情况
const importerFilePath = importer ? resolver.requestToFile(importer) : root
const nodeModulePath = resolveNodeModuleFile(importerFilePath, id)
// 如果是个 node_modules 的模块,读取文件。
if (nodeModulePath) {
return serve(id, nodeModulePath, 'node_modules')
}
})
- 依赖预构建
依赖预构建主要有两个目的:
- CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
- 性能: Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。
Vite使用esbuild在初次启动开发服务器前把检测到的依赖进行预构建。Vite 基于ESM,在使用某些模块时,由于模块依赖了另一些模块,依赖的模块又基于另一些模块。会出现页面初始化时一次发送数百个模块请求的情况。
以 lodash-es 为例,代码中以 import { debounce } from 'lodash' 导入一个命名函数时候,并不是只下载包含这个函数的文件,而是有一个依赖图。
可以看到一共发送了651个请求。一共花费1.53s。
Vite 为了优化这个情况,利用esbuild在启动的时候预先把debounce用到的所有内部模块全部打包成一个bundle,这样就浏览器在请求debounce时,便只需要发送一次请求了
可以看到预构建后,只发送了14个请求。
2.2、静态资源加载
当请求的路径符合 imageRE, mediaRE, fontsRE 或 JSON 格式,会被认为是一个静态资源。静态资源将处理成ESM模块返回。
// src/node/utils/pathUtils.ts
const imageRE = /.(png|jpe?g|gif|svg|ico|webp)(?.*)?$/
const mediaRE = /.(mp4|webm|ogg|mp3|wav|flac|aac)(?.*)?$/
const fontsRE = /.(woff2?|eot|ttf|otf)(?.*)?$/i
export const isStaticAsset = (file: string) => {
return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file)
}
// src/node/server/serverPluginAssets.ts
app.use(async (ctx, next) => {
if (isStaticAsset(ctx.path) && isImportRequest(ctx)) {
ctx.type = 'js'
ctx.body = export default ${JSON.stringify(ctx.path)} // 输出是path
return
}
return next()
})
export const jsonPlugin: ServerPlugin = ({ app }) => {
app.use(async (ctx, next) => {
await next()
// handle .json imports
// note ctx.body could be null if upstream set status to 304
if (ctx.path.endsWith('.json') && isImportRequest(ctx) && ctx.body) {
ctx.type = 'js'
ctx.body = dataToEsm(JSON.parse((await readBody(ctx.body))!), {
namedExports: true,
preferConst: true
})
}
})
}
2.3、vue文件缓存
当 Vite 遇到一个 .vue 后缀的文件时。由于 .vue 模板文件的特殊性,它被拆分成 template, css, script 模块三个模块进行分别处理。最后会对 script, template, css 发送多个请求获取
如上图中请求 App.vue 获取script 代码 , App.vue?type=template 获取 template, App.vue?type=style。这些代码都被插入在 App.vue 返回的代码中。
2.4、 js/ts处理
Vite使用esbuild将ts转译到js,约是tsc速度的20~30倍,同时HMR更新反应到浏览器的时间会小于50ms。但是,由于esbuild转换ts到js对于类型操作仅仅是擦除,所以完全保证不了类型正确,因此需要额外校验类型,比如使用tsc --noEmit。
将ts转换成js后,浏览器便可以利用ESM直接拿到js资源。
3、 热更新原理
Vite 的热加载原理,其实就是在客户端与服务端建立了一个 websocket 连接,当代码被修改时,服务端发送消息通知客户端去请求修改模块的代码,完成热更新。
- 服务端:服务端做的就是监听代码文件的改变,在合适的时机向客户端发送 websocket 信息通知客户端去请求新的模块代码。
- 客户端:Vite 中客户端的 websocket 相关代码在处理 html 中时被写入代码中。可以看到在处理 html 时,vite/client 的相关代码已经被插入。
export const clientPublicPath = `/vite/client`
const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"</script>\n`
async function rewriteHtml(importer: string, html: string) {
return injectScriptToHtml(html, devInjectionCode)
}
当request.path 路径是 /vite/client 时,请求获取已经提前写好的关于 websocket 的代码。因此在客户端中我们创建了一个 websocket 服务并与服务端建立了连接。
Vite 会接受到来自客户端的消息。通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。包括 connect、vue-reload、vue-rerender 等事件,分别触发组件vue 的重新加载,render等。
// Listen for messages
socket.addEventListener('message', async ({ data }) => {
const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
if (payload.type === 'multi') {
payload.updates.forEach(handleMessage)
} else {
handleMessage(payload)
}
})
async function handleMessage(payload: HMRPayload) {
const { path, changeSrcPath, timestamp } = payload as UpdatePayload
console.log(path)
switch (payload.type) {
case 'connected':
console.log(`[vite] connected.`)
break
case 'vue-reload':
queueUpdate(
import(`${path}?t=${timestamp}`)
.catch((err) => warnFailedFetch(err, path))
.then((m) => () => {
__VUE_HMR_RUNTIME__.reload(path, m.default)
console.log(`[vite] ${path} reloaded.`)
})
)
break
case 'vue-rerender':
const templatePath = `${path}?type=template`
import(`${templatePath}&t=${timestamp}`).then((m) => {
__VUE_HMR_RUNTIME__.rerender(path, m.render)
console.log(`[vite] ${path} template updated.`)
})
break
case 'style-update':
// check if this is referenced in html via <link>
const el = document.querySelector(`link[href*='${path}']`)
if (el) {
el.setAttribute(
'href',
`${path}${path.includes('?') ? '&' : '?'}t=${timestamp}`
)
break
}
const importQuery = path.includes('?') ? '&import' : '?import'
await import(`${path}${importQuery}&t=${timestamp}`)
console.log(`[vite] ${path} updated.`)
break
case 'js-update':
queueUpdate(updateModule(path, changeSrcPath, timestamp))
break
case 'custom':
const cbs = customUpdateMap.get(payload.id)
if (cbs) {
cbs.forEach((cb) => cb(payload.customData))
}
break
case 'full-reload':
if (path.endsWith('.html')) {
// if html file is edited, only reload the page if the browser is
// currently on that page.
const pagePath = location.pathname
if (
pagePath === path ||
(pagePath.endsWith('/') && pagePath + 'index.html' === path)
) {
location.reload()
}
return
} else {
location.reload()
}
}
}
问题
1、构建工具和打包工具的区别?
构建过程应该包括 预编译、语法检查、词法检查、依赖处理、文件合并、文件压缩、单元测试、版本管理等 。打包工具更注重打包这一过程,主要包括依赖管理和版本管理。
2、Vite有什么缺点?
- 目前 Vite 还是使用的 es module 模块不能直接使用生产环境(兼容性问题)。默认情况下,无论是 dev 还是 build 都会直接打出 ESM 版本的代码包,这就要求客户浏览器需要有一个比较新的版本,这放在现在的国情下还是有点难度的。不过 Vite 同时提供了一些弥补的方法,使用 build.polyfillDynamicImport 配置项配合 @vitejs/plugin-legacy 打包出一个看起来兼容性比较好的版本。
- 生产环境使用 rollup 打包会造成开发环境与生产环境的不一致。
- 很多 第三方 sdk 没有产出 ems 格式的的代码,这个需要自己去做一些兼容。