Vite 需要 Node.js 版本 14.18+,16+。然而,有些模板需要依赖更高的 Node 版本才能正常运行,当你的包管理器发出警告时,请注意升级你的 Node 版本。
背景
webpack
支持多种模块化,将不同模块的依赖关系构建成依赖图来进行统一处理,当构建的项目越来越大时,需要处理的JS
代码也越来越多,通常需要很长时间才可以启动开发服务器,即使使用模块热替换(HMR
),修改文件也需要几秒钟才能在浏览器中反映出来,影响了开发效率和幸福感。
Vite
可以解决上述问题,它支持ESM
规范,所以并不需要遍历依赖图,而是按需加载各种文件。
初体验
mkidr vite-demo
cd vite-demo
npm init -y
npm i lodash
<!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 demo</title>
<link rel="stylesheet" href="./index.css">
</head>
<script src="./index.js" type="module"></script>
<body>
<h1>Hello Vite</h1>
</body>
</html>
// index.js
import { name } from './test'
console.log(name)
// test.js
import _ from 'lodash'
export const name = 'Armouy';
console.log(_)
html, body{
margin: 0;
padding: 0;
}
如果借助vscode
中的live server
插件启动这个项目,会报错:
GET http://127.0.0.1:5500/test net::ERR_ABORTED 404 (Not Found)
借助vite
:
npm i vite --save-dev
修改package.json
:
"scripts": {
"dev": "vite",
"build": "vite build"
},
执行npm run dev
,控制台输出成功~
Vite
组成
vite
主要有以下两个功能:
- 一个开发服务器,基于原生
ESM
模块,省略了编译耗时,提供高效的模块热更新; - 使用
Rollup
打包,支持配置,可输出用于生成环境的高度优化过的静态资源。
特点
vite
的最大的优点就是快,主要体现在以下两个方面:启动时间快和请求效率高。
启动时间快的原因
- 无需在启动前构建依赖关系图:
vite
采用ESM
规范方式提供源码,只有在浏览器请求时才会进行转换,即按需提供; - 预构建:
vite
在启动服务时会先预构建源码,将遇到的CommonJS
或者UMD
等模块化代码转为ESM
规范,并保存在node_module/.vite/deps
文件夹中;遇到多个ESM
模块,会转为一个模块,比如lodash-es
,不进行预构建,会一次性请求600次,预构建之后只会请求一次。
p.s. 对于node_moduls
,浏览器时不支持ESM
规范去请求它们的,如果支持的话会带来很大的网络性能问题,对于ESM
里面具有其他依赖ESM
的话,那么浏览器将会无限制地请求依赖库。
p.s. 在vite
中也可以借助optimizeDeps.exclude
字段来忽视某些内容的预构建。
请求效率高的原因
- 前面提到过,预构建的时候会将构建好结果缓存在
node_module/.vite/deps
文件夹中,vite
中利用了HTTP
头来优化加载效率,源码模块的请求会根据304 Not Modified
进行协商缓存,而依赖模块请求则会通过Cache-Control: max-age=31536000,immutable
进行强缓存,因此一旦被缓存它们将不需要再次请求; HMR
热更新:(跟webpack
一样,vite
也有热更新机制,做法有些不一样,我们后面再讲)当编辑一个文件时,vite
只需要精确地使已编辑模块与最近的HMR
边界直接的链失活,就可以保持快速更新。
Vite
如何识别文件
webpack
中通过借助loader
去读取各种文件。而vite
对于css
的处理有自己的一套方式,在vite
搭建vue+ts
的项目中,css
的引入最后都会转为这种模式:
首先vite
会使用fs
模块读取.css
文件的内容,然后创建一个style
标签,将内容都怼到style
标签内,再将这个标签插入到index.html
中,最后还会将.css
文件的内容转为JS
脚本,便于css
模块化和热更新,还避免了第三方工具对css的处理,提高了编译性能。
对于其他静态资源文件,除了svg
,vite
都是做到了开箱即用,引入即可使用(svg
会对路径进行处理,需要区分是按照图片加载,还是按照svg
加载)。
npm run dev
的源码分析
本来想先写热更新等其他内容,想了想,还是先把源码分析写完,方便后面进行解释。
首先先说一下调试的步骤,这里直接在已有的项目中,配置了调试文件launch.json
,program
指向了vite
所在的路径(是的我用的vscode
):
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动程序",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}\\node_modules\\vite\\bin\\vite.js",
"outFiles": [
"${workspaceFolder}/**/*.js"
]
}
]
}
然后打断点开始调试,指向npm run dev
的时候会指向vite/bin/vite.js
,其中又指向了dist/node/cli.js
,根据源码可知,入口文件是哪个了~
async function createServer(inlineConfig = {}) {
return _createServer(inlineConfig, { ws: true });
}
async function _createServer(inlineConfig = {}, options) {
const config = await resolveConfig(inlineConfig, 'serve');
// ...
}
故一路分析下来,可以从resolveConfig
开始看源码了:
async function resolveConfig(inlineConfig, command, defaultMode = 'development', defaultNodeEnv = 'development') {
// ...
/**
* 先加载项目目录的配置文件,
* 即vite.config.js、vite.config.mjs、vite.config.ts、vite.config.cjs其中一个
* 如果找不到会报错
* 如果找到了会对自定义配置和用户配置做合并
*/
const loadResult = await loadConfigFromFile(configEnv, configFile, config.root, config.logLevel);
if (loadResult) {
config = mergeConfig(loadResult.config, config);
configFile = loadResult.path;
configFileDependencies = loadResult.dependencies;
}
// ...
/**
* 调整插件顺序,在vite中可以通过enforce: 'pre | post'来指定顺序,一般插件的顺序如下
* 1. alias
* 2. 带有enforce: pre的用户插件
* 3. vite核心插件
* 4. 没有带enforce值的用户插件
* 5. vite构建用的插件
* 6. 带有enforce: post的用户插件
* 7. vite后置构建插件(最小化、manifest、报告等)
*/
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins);
// 这里的config字段,可以在自定义插件的时候改写vite的一些配置
config = await runConfigHook(config, userPlugins, configEnv);
// If there are custom commonjsOptions, don't force optimized deps for this test
// even if the env var is set as it would interfere with the playground specs.
if (!config.build?.commonjsOptions &&
process.env.VITE_TEST_WITHOUT_PLUGIN_COMMONJS) {
config = mergeConfig(config, {
optimizeDeps: { disabled: false },
ssr: { optimizeDeps: { disabled: false } },
});
config.build ?? (config.build = {});
config.build.commonjsOptions = { include: [] };
}
// ...
/**
* 处理alias,比如我配置了@指向src的配置,那么最后结果就是
* find:
* '@' replacement: 'D:\\code\\new\\vue-admin\\src'
*/
const resolvedAlias = normalizeAlias(mergeAlias(clientAlias, config.resolve?.alias || []));
// ...
/**
* 设置环境变量:
* 读取环境变量,读取的优先级分别是 .env.[mode].local、.env.[mode]
* 如果不存在对应 mode 的配置文件,则会尝试去寻找 .env.local、.env 配置文件
* 读取到配置文件后,使用 doteenv 将环境变量写入到项目中;如果这些环境变量配置文件都不存在的话,则会返回一个空对象
*/
const userEnv = inlineConfig.envFile !== false && loadEnv(mode, envDir, resolveEnvPrefix(config));
// ...
// 整理构建配置
const resolvedBuildOptions = resolveBuildOptions(config.build, logger, resolvedRoot);
// ...
// resolvedConfig 是最后要导出的配置项
const resolvedConfig = { /** 省略 */ }
const resolved = {
...config,
...resolvedConfig,
};
// ...
return resolved;
}
分析完resolveConfig
可以回到_createServer
方法:
async function _createServer(inlineConfig = {}, options) {
// 拿到了配置项
const config = await resolveConfig(inlineConfig, 'serve');
// ...以下讲解忽略服务端渲染
// 如果不是服务渲染,会创建一个http server用户本地开发调试,同时创建一个webscoket用于热重载
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions);
const ws = createWebSocketServer(httpServer, config, httpsOptions);
// ...
// 监听本地项目文件的变动
const watcher = chokidar.watch([root, ...config.configFileDependencies, config.envDir], resolvedWatchOptions);
// 创建了一个server对象
const server = { /** 可以启动本地开发服务,也可以负责热重载 */ }
/**
* 当文件发生改变的时候就会被wather监听到,然后调用onHMRUpdate方法
* 而onHMRUpdate内部调用了handleHMRUpdate
* handleHMRUpdate会触发插件热更新的钩子,去编译更新文件
* 如果是需要全量更新则发送full-reload,否则发送update
* 当客户端接收到full-reload则启动本地刷新,通过http加载全部资源(这里做了协商缓存)
* 如果客户端接收的是update,则表示启动hmr,浏览器按需加载对饮的模块就行
*/
watcher.on('change', async (file) => {
file = normalizePath$3(file);
// invalidate module graph cache on file change
moduleGraph.onFileChange(file);
await onHMRUpdate(file, false);
});
// 在不同的生命周期指调用不同的插件...
// 对内部中间件的处理...通过 connect 库提供开发服务器,通过中间件机制实现多项开发服务器配置
// 最后就是进行预构建依赖,使用esbuild预构建它们,并将 CommonJS / UMD 转换为 ESM 格式
const initServer = async () => {
// ..
if (isDepsOptimizerEnabled(config, false)) {
await initDepsOptimizer(config, server);
}
// ..
})();
return initingServer;
};
// ...
}
关于npm run build
,其实流程也差不多,源码就不逐行分析了(有兴趣自行查看~):
- 使用
resolveConfig
收集了命令行配置、读取配置文件并合并配置; - 处理插件的的顺序、合并插件并设置环境变量;
- 生成
rollup
构建配置; - 使用
rollup
编译产物并输出到指定目录。
预构建原理
vite
借助esbuild
进行了预构建,转换ts、jsx、tsx
等,预构建有两种操作方式:
- 可以通过命令行
vite optimize
手动预解析 - 在
createServer
(npm run dev
会执行)中也会进行预构建
从源码上可以看出,主要流程简单概括即:
- 对项目中的依赖进行扫描,并存放在一个
deps
中; - 先把
deps
拍平成一维数组,调用build.context
进行打包,输出打包的映射对象result
; - 最后得到的依赖结果会被缓存起来,并存放在
node_module/.vite/deps
目录下。
在node_module/.vite/deps
下有一个_metadata.json
,这个文件可以根据请求路径,找到对应的预构建之后的文件:
{
"hash": "2266f73a",
"browserHash": "20efa1a5",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "32dcf906",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "2061b9bb",
"needsInterop": false
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "123c11eb",
"needsInterop": false
},
//...
},
"chunks": {
"chunk-5OBJFL24": {
"file": "chunk-5OBJFL24.js"
},
// ...
}
}
热更新(HMR
)原理
热更新指的是自动对页面上更改的模块进行替换,以达到刷新页面数据的效果,这个效果甚至是无感的。由上面的源码不难看出:
vite
先执行createWebSocketServer
创建一个webscoket
服务端,并监听change
事件;vite
在创建client.mjs
文件时,会合并合并UserConfig
配置,通过transformIndexHtml
钩子函数,在转换index.html
,这时会将client
的代码注入到index.html
中,这样浏览器访问index.html
就会加载client
生成代码,创建client
和webscoket
的的链接,便于接收webscoket
服务端消息;- 当服务端监听文件变化的时候,就会给
client
发送消息,同时调用服务端调用onHMRUpdate
函数,该函数会根据此次修改文件的类型,通知客户端是要刷新还是重新加载文件。
打包不选择Esbuild
的原因
由于生产环境中,ESM
的机制会导致额外的网络往返导致效率低下,故最好还是将代码进行进行 tree-shaking
、懒加载和 chunk
分割(以获得更好的缓存)。Vite
目前的插件 API
与使用 esbuild
作为打包器并不兼容。尽管 esbuild
速度更快,但 Vite
采用了 Rollup
灵活的插件 API
和基础建设, Rollup
提供了更好的性能与灵活性方面的权衡。
性能优化
分包策略
正常情况下我们项目打包的时候,业务代码会跟第三方代码糅合咋一起,如果业务代码经常变,就会导致每次打包都会重新打包所有文件,会造成浏览器缓存失效,如果使用分包策略,将第三方代码单独打包,那么就可以解决上述问题:
// vite.config.js
export default defineConfig({
...
build:{
rollupOptions:{
output:{
assetFileNames:"[name]-[hash].[ext]",
manualChunks:(id)=>{
if(id.indexOf('node_modules') !== -1){
return 'vendor'
}
}
}
},
},
})
gzip
压缩策略
服务端对资源进行压缩,浏览器对资源进行解压。但是要注意,如果如果文件不是很大的话,开启这个插件,反而会由于浏览器解压文件,浪费时间。
import viteCompression from 'vite-plugin-compression';
export default defineConfig ({
...
plugins: [viteCompression()],
})
cdn
加速
cdn
策略简单讲就是多台服务器具有该资源,用户请求的时候会优先请求距离你距离最近的一台资源。跟webpack
的优化策略一样,这里的前提是公司比较有钱哈哈哈,不然靠免费的cdn可能会遇到“挂掉”的风险。
总结
本文学习了vite
的优点、简单分析了优点背后实现的原理,以及还有性能优化的方案。
参考
- vite官网
- vite源码
- 晒兜斯(他的Vite系列很干货)
- 前端构建工具vite进阶系列
如有错误,欢迎指出,感谢阅读~