简介
vite(轻量,轻快的意思) 是一个由原生 ES Module
驱动的 Web 开发前端构建工具。
浏览器原生 ESM:浏览器支持的 JavaScript 模块化标准,可以直接使用 <script type="module">
标签加载模块,无需打包或转译。
在开发环境下基于浏览器原生 ES Module
的支持实现了 no-bundle
服务。另一方面借助 esbuild
超快的编译速度来做第三方库构建和 ts/jsx
语法编译,从而能够有效提高开发效率。在生产环境下基于 rollup
打包来构建代码。
除了开发效率,在其他维度上 vite
也表现不错:
-
模块化方面:
vite
基于浏览器原生 ESM 的支持实现模块加载,并且无论是开发环境还是生产环境,都可以将其他格式的产物(如CommonJS
)转换为 ESM。 -
语法转译方面:
vite
内置了对TypeScript
、JSX
、Sass
等高级语法的支持,也能够加载各种各样的静态资源,如image
、Worker
等等。 -
产物质量方面:
vite
基于成熟的打包工具Rollup
实现生产环境打包,同时可以配合Terser
、Babel
等工具链,可以极大程度保证构建产物的质量。
优点
工具名称 | 开发环境(Dev) | 热更新 (HMR) | 生产环境(Production) |
---|---|---|---|
Webpack | 会先打包生成 bundle,再启动开发服务器 | HMR 时需要把改动模块及相关依赖全部编译 | 打包生成 bundle |
vite | 先启动开发服务器,利用新一代浏览器的 ESM 能力,无需打包,直接请求所需模块并实时编译 | HMR时只需让浏览器重新请求该模块,同时利用浏览器的缓存(源码模块协商缓存,依赖模块强缓存)来优化请求;使得无论应用大小如何,HMR 始终能保持快速更新。 | 通过成熟的 rollup 打包工具来生成 bundle |
vite
在开发环境下冷启动无需打包,无需分析模块之间的依赖,同时也无需在启动开发服务器前进行编译,启动时还会使用 esbuild
来进行预构建。下图基于原生 ESM 的开发服务流程图。
webpack
在启动后会做一堆事情,经历一条很长的编译打包链条,从入口开始需要逐步经历语法解析、依赖收集、代码转译、打包合并、代码优化,最终将高版本的、离散的源码编译打包成低版本、高兼容性的产物代码,这些都是 IO 操作,在 Node 运行时下性能必然是有问题。下图基于 bundle
的开发服务流程图。
性能利器—esbuild
格式转换
由于 vite 是基于浏览器原生的 ESM 规范来实现的,这就要求整个项目中涉及的所有源代码必须符合 ESM 规范。而在实际开发过程中,业务代码我们可以严格按照 ESM
规范来编写,但第三方依赖就无法保证了。
举个🌰:作为一个流行的 JavaScript 实用工具库 lodash 是以 CommonJS
的规范导出的。在开发环境下 vite 会对 lodash
进行依赖转换,这里我们可以通过配置 optimizeDeps: { exclude: ['lodash']}
在预构建中强制排除依赖项。配置完之后,编写一段测试代码:
// App.vue
import _ from 'lodash'
console.log(_.cloneDeep({}))
我们在 App.vue
中以 ESM
的方式导入 lodash
并调用其中的方法,通过控制台可以看到由于导出规范的不同使用 ESM
方式导入会报错。
依赖转换之后可以使用 ESM
规范引入:
减少 HTTP 请求数量
vite 会将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。例如,lodash-es 有超过 600 个内置模块。当我们执行 import { debounce } from 'lodash-es'
时,浏览器同时发出 600 多个 HTTP 请求。如果不做处理就直接使用,那么就会引发请求瀑布流,这对页面性能来说,简直就是一场灾难。通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求。
文件缓存系统
默认情况下,预构建结果会保存到 node_modules/.vite/deps
目录下。它根据几个源来决定是否需要重新运行预构建步骤: package.json 中的 dependencies 列表、包管理器的 lockfile,例如 package-lock.json, yarn.lock ,或者 pnpm-lock.yaml 可能在 vite.config.js 相关字段中配置过的。如果这些都没有变,那么 vite 会复用上一次 预构建的结果。如果不想让 vite 复用上一次预构建的结果,我们可以通过配置 server: { force: true }
,使得每次启动的时候都强制进行预构建。
依赖模块强缓存
vite 会将项目中的依赖库通过外部引入的方式加载,而不是将其打包到最终的构建文件中。这样可以利用浏览器缓存,减少资源请求,提高页面加载速度。在请求时,可以通过修改请求的版本号 v=xxxx 来规避强缓存。
其中 immutable
的含义是就算用户刷新页面,浏览器也不会发起请求去服务,浏览器会直接从本地磁盘或者内存中读取缓存并返回 200 状态。
源码模块协商缓存
vite 会对源码模块采用协商缓存策略。
其中 no-cache
并不意味着“不缓存”,而是允许缓存,但是必须首先向源服务器提交验证请求,并且通常是通过使用 ETag
完成的。
Bundler 工具
早期 vite 1.x
版本中使用 rollup
来做这件事情,但 esbuild
的性能实在是太恐怖了,vite 2.x
果断采用 esbuild
来完成第三方依赖的预构建,至于性能到底有多强。这里引用一张来自 esbuild 官网的图片。
从上图可以看出来相较于其他的打包工具 esbuild
完全是碾压的存在,既然 esbuild
性能这么出众那为什么 vite 不把它用做生产环境的打包工具呢?具体有如下几个原因:
-
vite 当前的插件 API 与使用 esbuild 作为打包器并不兼容。
rollup
的插件 API 和基础设施更加完善,因此在生产环境中,使用rollup
打包会更稳定。 -
不提供操作打包产物的接口,像
rollup
中灵活处理打包产物的能力(如renderChunk
钩子)在esbuild
当中完全没有。 -
不支持自定义
Code Splitting
策略。传统的Webpack
和rollup
都提供了自定义拆包策略的 API,而esbuild
并未提供,从而降级了拆包优化的灵活性。
尽管 esbuild
有如此多的局限性,但依然不妨碍 vite
在开发阶段使用它成功启动项目并获得极致的性能提升,生产环境处于稳定性考虑当然是采用功能更加丰富、生态更加成熟的 rollup
作为依赖打包工具了。
TS(X) 和 JS(X) 编译工具
vite 已经将 esbuild 的 Transformer 能力用到了生产环境
在 TS(X)/JS(X)
单文件编译上面,vite 也使用 esbuild
进行语法转译,也就是将 esbuild
作为 Transformer
来用。需要注意的是 esbuild
并没有实现 TS 的类型系统,在编译 TS(X)/JS(X)
文件时仅仅抹掉了类型相关的代码,暂时没有能力实现类型检查。
代码压缩工具
vite 从 2.6 版本开始默认使用
esbuild
来进行生产环境的代码压缩,包括 JS 代码和 CSS 代码
这里有一个🌰比较不同最新版本
JavaScript minifiers
在压缩速度和压缩质量方面的区别。
由于优异的性能 esbuild
(基于 Golang 开发)完成,而不是传统的 webpack/rollup
,也不会有明显的打包性能问题,反而是 vite
项目启动飞快(秒级启动)的一个核心原因。总的来说,vite
将 esbuild
作为自己的性能利器,将 esbuild
各个垂直方向的能力(Bundler、Transformer、Minifier
)利用的淋漓尽致,给 vite
的高性能提供了有利的保证。
构建基石—rollup
rollup
在 vite 中的重要性一点也不亚于 esbuild
,它既是 vite 用作生产环境打包的核心工具,同时为了在生产环境中也能取得优秀的产物性能。在打包阶段 vite 到底基于 rollup
做了哪些事情?
CSS 代码分割
如果某个异步模块中引入了一些 CSS 代码,vite 就会自动将这些 CSS 抽取出来生成单独的文件,这个 CSS 文件将在该异步 chunk 加载完成时自动通过一个 <link>
标签载入,该异步 chunk
会保证只在 CSS 加载完毕后再执行,避免发生页面先加载出来,样式后加载出来,导致出现闪屏的状况。举个🌰:
// About.vue
<template>
<div @click="switchTab(tab)" v-for="(tab, index) in tabData" :key="index" >
{{ tab.name }}
</div>
<component :is="currentTab.tabComp"></component>
</template>
<script setup lang="ts">
import { reactive, markRaw, defineAsyncComponent } from 'vue';
const A = defineAsyncComponent(() => import('./A.vue'))
const B = defineAsyncComponent(() => import('./B.vue'))
const tabData = reactive([
{ name: 'A组件', tabComp: markRaw(A) },
{ name: 'B组件', tabComp: markRaw(B) },
]);
const currentTab = reactive({
tabComp:tabData[1].tabComp
})
const switchTab = (tab) => {
currentTab.tabComp = tab.tabComp;
};
</script>
// A.vue
<template>
<div class="name">
我是A组件的内容
</div>
</template>
<script setup lang="ts">
import '../styles/index.css'
import '../utils/index'
</script>
// B.vue
<template>
<div class="name">
我是B组件的内容
</div>
</template>
<script setup lang="ts">
import '../utils/index'
</script>
// utils/index.js
console.log('我是模块C的内容')
// styles/index.css
.name {
color: red;
}
异步 Chunk 加载优化
在实际项目中,通常会存在共用 chunk
(被两个或以上的其他 chunk
共享的 chunk
)。
以上面的代码为🌰:在无优化的情境下,当异步 chunk B
被导入时,浏览器将必须请求和解析B,然后它才能弄清楚它需要共用 index.ts
。通过控制台可以看到额外的网络往返。
vite 将使用一个预加载步骤自动重写代码来分割动态导入调用,以实现当B被请求时 index.ts
也将同时被请求。
构建过程
对于一次完整的构建过程而言, rollup
会先进入到 build
阶段,解析各模块的内容及依赖关系,然后进入 output
阶段,完成打包及输出的过程。对于不同的阶段,rollup
插件会有不同的插件工作流程,拆解一下 rollup
插件在 build
和 output
两个阶段的详细工作流程。
build 阶段工作流
在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块级别,也就是单文件级别。
-
首先经历
options
钩子进行配置的转换,得到处理后的配置对象。 -
随之
rollup
会调用buildStart
钩子,正式开始构建流程。(从 input 配置指定的入口文件开始) -
rollup
先进入到resolveId
钩子中解析文件路径。 -
rollup
通过调用load
钩子加载模块内容。 -
紧接着
rollup
执行所有的transform
钩子来对模块内容进行进行自定义的转换,比如babel
转译。 -
现在
rollup
拿到解析后的模块内容,进行AST
分析,得到所有的import
内容,调用moduleParsed
钩子:-
如果是普通的
import
,则执行resolveId
钩子,继续回到步骤3。 -
如果是动态
import
,则执行resolveDynamicImport
钩子解析路径,如果解析成功,则回到步骤4加载模块,否则回到步骤3通过resolveId
解析路径。
-
-
直到所有的
import
都解析完毕,rollup
执行buildEnd
钩子,build
阶段结束。
output 阶段工作流
ouput Hook
(官方称为 Output Generation Hook
),则主要进行代码的打包,对于代码而言,操作粒度一般为 chunk级别(一个 chunk 通常指很多文件打包到一起的产物)。
-
执行所有插件的
outputOptions
钩子函数,对output
配置进行转换。 -
执行
renderStart
钩子,正式开始打包。 -
并发执行所有插件的
banner、footer、intro、outro
钩子,这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。 -
从入口模块开始扫描,针对动态
import
语句执行renderDynamicImport
钩子,来自定义动态import
的内容。 -
对每个即将生成的
chunk
,执行augmentChunkHash
钩子,来决定是否更改chunk
的哈希值。 -
如果没有遇到
import.meta
语句,则进入下一步,否则:-
对于
import.meta.url
语句调用resolveFileUrl
来自定义url
解析逻辑 -
对于其他
import.meta
属性,则调用resolveImportMeta
来进行自定义的解析。
-
-
接着
rollup
会生成所有chunk
的内容,针对每个chunk
会依次调用插件的renderChunk
方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。 -
最后会调用
generateBundle
钩子,这个钩子的入参里面会包含所有的打包产物信息,包括chunk
(打包后的代码)、asset
(最终的静态资源文件)。你可以在这里删除一些chunk
或者asset
,最终这些内容将不会作为产物输出。
插件系统
由于 vite 的插件有一套简单的顺序控制机制,用户可以通过 enforce: 'pre'
(在核心插件之前被调用) 和 enforce: 'post'
(在核心插件之后被调用) 是用来强制修改插件的执行顺序。如果没有定义 enforce
属性那么插件将在 vite
核心插件之后被调用。
plugins: [
testPlugin('post'),
testPlugin(),
testPlugin('pre')
],
import type { PluginOption } from 'vite'
export default function vitePluginTemplate(enforce?: 'pre' | 'post'): PluginOption {
return {
// 插件名称
name: 'rollup-plugin-test',
enforce,
// 在每次开始构建时调用,只会调用一次
buildStart (options) {
console.log('buildStart', enforce)
},
// 在每个传入模块请求时被调用,创建自定义确认函数,可以用来定位第三方依赖
resolveId (source, importer, options) {
console.log('resolveId', enforce, source)
},
// 服务器关闭时
buildEnd () {
console.log('buildEnd', enforce)
},
// 指明它们仅在 'build' 或 'serve' 模式时调用
apply: 'serve', // apply 亦可以是一个函数
// 可以在 vite 被解析之前修改 vite 的相关配置。钩子接收原始用户配置 config 和一个描述配置环境的变量env
config (config, env) {
return {
resolve: {
alias: {
'@aaa': enforce ? enforce : '/src/styles'
}
}
}
},
// 在解析 vite 配置后调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它很有用
configResolved (config) {
console.log(config.resolve)
},
// 主要用来配置开发服务器,为 dev-server (connect 应用程序) 添加自定义的中间件
configureServer (server: any) {
server.middlewares.use((req, res,next) => {
if(req.url === '/test') {
res.end('hello vite plugin')
} else {
next()
}
})
},
// 转换 index.html 的专用钩子。钩子接收当前的 HTML 字符串和转换上下文
transformIndexHtml (html) {
console.log(html)
return html.replace('<div id="app"></div>', '<div id="root"></div>')
},
// 执行自定义HMR更新,可以通过ws往客户端发送自定义的事件
handleHotUpdate (ctx) {
ctx.server.ws.send({
type: 'custom',
event: 'test',
data: {
text: 'hello vite'
}
})
}
}
}
源码浅析
createServer
在执行 npm run dev
时在源码内部会调用 createServer
方法创建一个服务,这个服务利用中间件(第三方)支持了多种能力(如 跨域、静态文件服务器等),并且内部创建了 watcher
持续监听着文件的变更,进行实时编译和热重载。
// packages/vite/src/node/server/index.ts
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
// 加载项目目录的配置文件 vite.config.js
// 如果没有找到配置文件,则直接会中止程序。
const config = await resolveConfig(inlineConfig, 'serve')
// 创建http服务
const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions)
// 创建ws服务
const ws = createWebSocketServer(httpServer, config, httpsOptions)
// 创建watcher,设置代码文件监听
const watcher = chokidar.watch(
path.resolve(root),
resolvedWatchOptions,
) as FSWatcher
// 文件监听变动,websocket向前端通信
watcher.on('change', async (file) => {
// 进行实时编译和热重载
await handleHMRUpdate(file, server)
})
// 创建插件容器,用于在构建的各个阶段调用插件的钩子
// PluginContainer 内部调用每个插件的 buildStart 方法
const container = await createPluginContainer(config, moduleGraph, watcher)
// 创建server对象
const server: ViteDevServer = {
config,
middlewares,
httpServer,
watcher,
ws,
listen,
...
}
// 注册各种中间件
// request timer
if (process.env.DEBUG) {
middlewares.use(timeMiddleware(root))
}
const initServer = async () => {
initingServer = (async function () {
await container.buildStart({})
// optimize: 预构建
await initDepsOptimizer(config, server)
})()
return initingServer
}
// 监听端口,启动服务
httpServer.listen = (async (port: number, ...args: any[]) => {
await initServer()
return listen(port, ...args)
}) as any
return server
}
runOptimizeDeps
第一次启动时,对项目依赖进行构建这里就会使用 esbuild.build
去编译文件,其中 esbuildDepPlugin
就是打包的插件
import { build } from 'esbuild'
export async function runOptimizeDeps() {
const plugins = [...pluginsFromConfig]
if (external.length) {
plugins.push(esbuildCjsExternalPlugin(external, platform))
}
plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr))
const result = await build()
}
浏览器请求
可以看到 pluginContainer
会执行插件中的钩子。对于不同的资源会有不同的插件去处理。
HMR流程
打包工具实现热更新的思路都大同小异:主要是通过 WebSocket
创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。
总结
以上针对 vite 从简单的原理、优点、源码、钩子函数、生命周期,经行了一个大体的介绍,文章字数较多,阅读需要花费较长的时间,但是读完之后一定会让你对 vite 这个前端构建工具有了更深层次的了解。由于篇幅限制具体的配置可以参考官网。