vite(轻量,轻快的意思) 是一个由原生 ES Module
驱动的 Web 开发前端构建工具。
浏览器原生 ESM:浏览器支持的 JavaScript 模块化标准,可以直接使用 标签加载模块,无需打包或转译。
在开发环境下基于浏览器原生 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
的开发服务流程图。
由于 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
规范引入:
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
完成的。
早期 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
作为依赖打包工具了。
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
在 vite
中的重要性一点也不亚于 esbuild
,它既是 vite
用作生产环境打包的核心工具,同时为了在生产环境中也能取得优秀的产物性能。在打包阶段 vite
到底基于 rollup
做了哪些事情?
如果某个异步模块中引入了一些 CSS
代码,vite
就会自动将这些 CSS
抽取出来生成单独的文件,这个 CSS
文件将在该异步 chunk
加载完成时自动通过一个 标签载入,该异步
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 B
被导入时,浏览器将必须请求和解析B,然后它才能弄清楚它需要共用 index.ts
。通过控制台可以看到额外的网络往返。
vite
将使用一个预加载步骤自动重写代码来分割动态导入调用,以实现当B被请求时 index.ts
也将同时被请求。
对于一次完整的构建过程而言, rollup
会先进入到 build
阶段,解析各模块的内容及依赖关系,然后进入 output
阶段,完成打包及输出的过程。对于不同的阶段,rollup
插件会有不同的插件工作流程,拆解一下 rollup
插件在 build
和 output
两个阶段的详细工作流程。
在这个阶段主要进行模块代码的转换、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
阶段结束。
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('', '')
},
// 执行自定义HMR更新,可以通过ws往客户端发送自定义的事件
handleHotUpdate (ctx) {
ctx.server.ws.send({
type: 'custom',
event: 'test',
data: {
text: 'hello vite'
}
})
}
}
}
在执行 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
}
第一次启动时,对项目依赖进行构建这里就会使用 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
会执行插件中的钩子。对于不同的资源会有不同的插件去处理。
vite的内置插件:
路径解析插件(packages/vite/src/node/plugins/resolve.ts)
路径解析插件 是 vite 中比较核心的插件,几乎所有重要的 vite 特性都离不开这个插件的实现,诸如依赖预构建、HMR、SSR 等等。
CSS 编译插件(packages/vite/src/node/plugins/css.ts)
import 分析插件(packages/vite/src/node/plugins/importAnalysis.ts)
重写import语句,如 import Vue from 'vue'
导入路径会重写为预构建文件夹的路径;注入HMR客户端脚本。
esbuild 转译插件(packages/vite/src/node/plugins/esbuild.ts)
用来进行 .js
、.ts
、.jsx
和.tsx
,代替了传统的 Babel
或者 TSC
的功能,这也是 vite 开发阶段性能强悍的一个原因。
打包工具实现热更新的思路都大同小异:主要是通过 WebSocket
创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。
以上针对 vite
从简单的原理、优点、源码、钩子函数、生命周期,经行了一个大体的介绍,文章字数较多,阅读需要花费较长的时间,但是读完之后一定会让你对 vite
这个前端构建工具有了更深层次的了解。由于篇幅限制具体的配置可以参考官网。