什么是vite
Vite (法语意为 "快速的",发音 /vit/),新型前端构建工具。
为什么用vite
一句话总结:使用vite构建项目,启动的速度要比使用webpack构建更快。
- 之前浏览器是不支持ES Modules的,为了在让浏览器能够运行我们写的代码(es6语法、.jsx/.vue文件),我们需要使用打包工具,例如webpack,来实现代码的转换和优化的过程;
- 在浏览器支持ES Modules后,import、export、
<script type='modules'>
等成为vite出现的条件; - vite 主要对应的场景是开发模式,它只启动一台静态页面的服务器,对文件代码不打包,服务器会根据客户端的请求加载不同的模块处理;
- 底层实现上,Vite 是基于 esbuild 预构建依赖的。当声明一个script标签类型为module时,浏览器将对其内部的import引用发起HTTP请求获取模块内容;
- Vite 劫持了这些请求,并在后端进行相应的处理(如处理.ts文件为.js文件),然后再返回给浏览器;
- 由于浏览器只会对用到的模块发起 HTTP 请求,所以 Vite 没必要对项目里所有的文件先打包后返回,而是只编译浏览器发起 HTTP 请求的模块即可。也就实现了所谓的按需加载。
与常见构建工具比较
与打包工具比较 —— webpack
这篇文章主要介绍vite,所以关于webpack相关的内容会写的比较简单。
冷启动时webpack做了什么
打包过程如下:
- 通过配置文件,找到入口entry;
- 从入口文件开始递归识别模块依赖,也就是遇到类似于require、import时,webpack都会对其分析,从而拿到对应的代码依赖;
- 对拿到的代码进行分析、转换、编译,最后输出浏览器可以识别的代码。整个过程大概如下图(来自vite官网):
冷启动时vite做了什么
在项目根目录下有一个index.html文件,在其script标签设置type='module':
<script type="module" src="/src/index.tsx"></script>
启动项目,vite会启动一个dev server,来实现对前端访问请求的监听。
浏览器会向服务器发起一个GET请求,拿到index.tsx文件:
// http://localhost:3000/src/index.tsx请求index.tsx文件:
// /src/index.tsx文件
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<App />,
document.getElementById('root')
)
浏览器看到index.tsx文件内部含有import,就会对其import引用发起HTTP请求获取该模块的内容文件:
当你访问一个页面,他就会对当前页面的引用模块进行一个递归的请求,而和当前页面无关的模块则不会去请求,这样来实现按需加载。
整个过程大概如下:
对于引用的模块,vite将其分为了 依赖 和 源码 两类,对其进行不同的处理。
先来看一下对于源码的处理。我们打开请求的App.tsx文件,可以看到里面的内容已经不是源码了,而是经过处理后的代码。
// App.tsx
import React from 'react'
function App() {
return (
<div className="App">11111
</div>
)
}
export default App;
我们点开Headers,可以看到返回到文件类型已经是javascript:
从这我们也可以看出,vite在返回源码时,已经对源码做了一层处理,编译为浏览器可以运行的代码。实际上,对于不同的文件后缀,比如.ts、.vue、.tsx、.jsx,vite都会对其做不同的处理。下面是来自官网的内容:
vue文件
- Vue 3 单文件组件支持:@vitejs/plugin-vue
- Vue 3 JSX 支持:@vitejs/plugin-vue-jsx
- Vue 2 支持:underfin/vite-plugin-vue2
.ts文件
对于.ts文件,vite天然支持,其内部使用esbuild将TypeScript 转译到 JavaScript。
.jsx文件、.tsx文件
JSX 的转译同样是通过 esbuild,默认为 React 16 风格。
css文件
导入 .css 文件将会把内容插入到 标签中。
裸模块重写
从下面这张图中,我们可以看到,react.js、react-dom.js这种依赖模块文件名后面是有后缀的,而index.tsx、App.tsx等这种源代码文件是没有的。为什么会有这样的不同呢?这是因为react.js、react-dom.js是裸模块,而浏览器在加载文件时,只能加载相对地址,类似于import React from 'react';
这种裸模块的加载,浏览器是不支持的,所以vite会对其做一层裸模块重写的处理,例如将引入react的url改写为/node_modules/.vite/react.js?v=a941774d
你会发现,引入的react是在.vite文件夹里头,而不是在node_modules的react文件夹中,这是因为vite做了一个优化 —— 依赖预构建。
简单来说,vite会对package.json中的dependencies部分先进行构建,然后把构建后的文件换存在node_modules/.vite文件夹中,当启动项目时,直接请求该缓存内容。
预构建的作用
我们对App.tsx文件做一个小修改,引入lodash-es:
// App.tsx
import React, { useState } from 'react';
import _ from 'lodash-es';
function App() {
const [num, setNum] = useState(0);
const add = () => {
const changeNum = () => {
setNum(num+1);
};
const addNum = _.debounce(changeNum, 500);
addNum();
};
return (
<div className="App">
<button onClick={add}>add</button>
{num}
</div>
);
}
export default App;
然后我们启动项目,会在终端看到这么个东西:
这告诉我们,react、react-dom、lodash这三个包会被预构建。
那预构建有啥作用呢?我们先来看看没有使用预构建的效果。在vite配置文件中设置:
// vite.config.ts
optimizeDeps: {
exclude: ['lodash-es']
}
然后打开页面康康network:
可以看到页面发了652个请求,大部分是和lodash-es相关的请求,一共花了3.68s,这可不是一个小数字,蛮长一时间。为什么会有这么多请求呢?因为通常我们引入的一些依赖,它自己又会引用一些其他依赖,如果没有使用预构建的话,那么就会因为模块间依赖引用过多导致过多的请求次数。
接着我们再把移除预构建的相关配置删掉,再次试试:
可以看到这次和lodash-es相关的请求只有一个,并且所有请求所花时间只有543ms。
这就是预构建给冷启动带来的作用。
预构建实现原理
- **在开启dev server之前进行依赖预构建。**首先,vite会执行一个createServer函数来创建一个dev server:
// vite/src/node/server/index.ts
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
...
if (!middlewareMode && httpServer) {
let isOptimized = false
// 重写listen,确保server启动之前执行。
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
await container.buildStart({})
// 执行预构建
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
} else {
await container.buildStart({})
// 执行预构建
await runOptimize()
}
...
}
从这个函数我们可以看到,在dev server启动之前,vite会先调用runOptimize函数,来执行预构建。我们再看一下runOptimize函数:
// vite/src/node/server/index.ts
const runOptimize = async () => {
if (config.cacheDir) {
// 设置构建状态的标志位为true
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
// 设置构建状态的标志位为flase
server._isRunningOptimizer = false
}
// 返回一个预构建函数可以随时进行预构建
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
这个函数的主要作用是调用optimizeDeps函数。
- **读取项目的package.json和配置文件vite.config.js中optimizeDeps的参数,生成depHash,准备进行第一次预构建。**在node_modules/.vite中,有一个_metadata.json文件,这个文件就是用来提供预构建的相关信息:
{
"hash": "101e2043",
"browserHash": "16c8b19d",
"optimized": {
"react": {
"file": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/.vite/react.js",
"src": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/react/index.js",
"needsInterop": true
},
"react-dom": {
"file": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/.vite/react-dom.js",
"src": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/react-dom/index.js",
"needsInterop": true
},
"lodash-es": {
"file": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/.vite/lodash-es.js",
"src": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/lodash-es/lodash.js",
"needsInterop": false
}
}
}
- hash是根据需要预构建的文件内容生成的,实现一个缓存的效果,在启动dev server时可以避免重复构建相同的依赖;
- browserHash是由hash和在运行时发现额外依赖时生成的,用于使预构建的依赖的浏览器请求无效;
- optimized是一个包含所有需要预构建的依赖的对象,src表示依赖的源文件,file表示构建后所在的路径;
- needsInterop表示是否对CommoJS的依赖引入代码进行重写。比如,当我们在vite项目中使用react时:
import React, { useState } from 'react'
,react的needsInterop为true,所以importAnalysisPlugin 插件的会对导入 react 的代码进行重写:
import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];
因为 CommonJS 的模块并不支持命名方式的导出,所以需要对其进行重写。如果不重写的话,则会看到以下报错:
Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'
接下来就是预构建的核心环节。
- **调用optimizeDeps函数实现预构建。**这个过程主要分为以下几步:
- 调用getDepHash函数获取此时依赖的hash值,并与上次构建信息的hash值做对比,判定是否需要重新于构建:
const dataPath = path.join(cacheDir, '_metadata.json')
// 生成此次构建的hash值
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
// vite配置中的force参数决定是否每次都重新构建
if (!force) {
let prevData
try {
// 加载上次构建信息
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// 前后比对hash,如果相同则直接返回上一次的构建内容
if (prevData && prevData.hash === data.hash) {
return prevData
}
}
- 如果前后hash值不相同,则表示没有命中缓存,vite会删除有缓存作用的cacheDir文件夹,如果是第一次依赖预构建,则创建一个新的cacheDir文件夹:
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
fs.mkdirSync(cacheDir, { recursive: true })
}
- 在服务启动后,如果有新的依赖加入时,会被放在newDeps中。接着vite使用esbuild的scanImports函数来扫描源码,找出与预构建相关的依赖deps对象:
// newDeps参数是在服务启动后加入依赖时传入的依赖信息。
let deps
if (!newDeps) {
// 使用esbuild扫描源码,获取依赖
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
- 加入vite.config.js配置中optimizeDeps.include 相关依赖:
const include = config.optimizeDeps?.include
if (include) {
// ...加入用户指定的include
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
if (!deps[id]) {
const entry = await resolve(id)
if (entry) {
deps[id] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
)
}
}
}
}
- 使用esbuild构建依赖
// 调用esbuild.build打包文件
const result = await build({
// ...
entryPoints: Object.keys(flatIdDeps),// 入口
format: 'esm',// 打包成esm模式
external: config.optimizeDeps?.exclude,// 剔除exclude文件
outdir: cacheDir,// 输出地址
// ...
})
- 预构建完成后,重新写入_metadata.json
// 重新写入_metadata.json
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
整个函数代码大致如下:
function optimizeDeps(config, force=config.server.force,asCommand=false,newDeps?) {
// ...
const dataPath = path.join(cacheDir, '_metadata.json')
// 生成此次构建hash
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
// 用户的force参数决定是否每次都重新构建
if (!force) {
let prevData
try {
// 加载上次构建信息
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// 前后比对hash,相同则直接返回
if (prevData && prevData.hash === data.hash) {
return prevData
}
}
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
fs.mkdirSync(cacheDir, { recursive: true })
}
// ...
// newDeps参数是在服务启动后加入依赖时传入的依赖信息。
let deps
if (!newDeps) {
// 借助esbuild扫描源码,获取依赖
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
// ...
// ...加入用户指定的include
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
if (!deps[id]) {
const entry = await resolve(id)
if (entry) {
deps[id] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${chalk.cyan(id)}`
)
}
}
}
// 扁平化依赖
await init
for (const id in deps) {
flatIdDeps[id]=//...
// ...
}
// ...
// ...加入用户指定的esbuildOptions
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
// 调用esbuild.build打包文件
const result = await build({
// ...
entryPoints: Object.keys(flatIdDeps),// 入口
format: 'esm',// 打包成esm模式
external: config.optimizeDeps?.exclude,// 剔除exclude文件
outdir: cacheDir,// 输出地址
// ...
})
// 重新写入_metadata.json
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
return data
}
热更新时webpack做了什么
总的来说,webpack的热更新就是,当我们对代码做修改并保存后,webpack会对修改的代码块进行重新打包,并将新的模块发送至浏览器端,浏览器用新的模块代替旧的模块,从而实现了在不刷新浏览器的前提下更新页面。相比起直接刷新页面的方案,HMR的优点是可以保存应用的状态。当然,随着项目体积的增长,热更新的速度也会随之下降。
以下是极客时间《玩转webpack》19 | webpack中的热更新及原理分析的截图:
其中,使用webpack冷启动项目的流程是1 -> 2 -> A -> B,热更新的流程是1 -> 2 -> 3 -> 4 -> 5。热更新的大致流程如下:
- 编辑文件并保存后,webpack就会调用Webpack-complier对文件进行编译;
- 编译完后传输给HMR Server,HMR得知某个模块发生变化后,就会通知HMR Runtime;
- HMR Runtime就会加载要更新的模块,从而让浏览器实现更新并不刷新的效果。
热更新时vite做了什么
热更新主要与项目编写的源码有关。前面提到,对于源码,vite使用原生esm方式去处理,在浏览器请求源码文件时,对文件进行处理后返回转换后的源码。vite对于热更新的实现,大致可以分为三步:
- 监听文件变动
- 读取文件内容
- 通知浏览器做相应的更新
热更新的实现原理
- 创建一个websocket服务端。vite执行createWebSocketServer函数,调用ws库创建ws服务端。
-
创建一个ws客户端来接收ws服务端的信息。vite首先会创建一个ws client文件,然后在处理入口文件index.html时,把对ws client文件的引入注入到index.html文件中。当浏览器访问index.html时,就会加载ws client文件并执行,创建一个客户端ws,从而接收ws服务端的信息。
-
服务端监听文件变化,发送websocket消息,通知客户端。
- 服务端调用handleHMRUpdate函数,该函数会根据此次修改文件的类型,通知客户端是要刷新还是重新加载文件。
- 一个小细节:vite对于node_modules的文件做了强缓存,而对我们编写的源码做了协商缓存。
总结:vite为什么比webpack快
- 构建速度快:Webpack 会先将代码打包,然后启动开发服务器,请求服务器时返回打包后的结果;而 Vite 是直接启动开发服务器,请求哪个模块再对该模块进行实时编译,省去了打包的过程。
- 热更新快:相比起webpack,vite会让浏览器帮忙做更多的事情。vite 采用立即编译当前修改文件的办法,当改动了一个模块后,仅需让浏览器重新请求该模块即可。同时 vite 还会使用缓存机制( http 缓存、 vite 内置缓存 ),加载更新后的文件内容。
与非打包解决方案比较 —— snowpack
vite官网中也列举了vite和snowpack的异同。
相同
snowpack和vite都是非构建式原生 ESM 开发服务器。
不同
snowpack和vite的不同,更多的是在构建生产版本中。
vite | snowpack | |
---|---|---|
多页面应用支持 | 支持 | 不支持 |
库模式 | 支持 | 不支持 |
自动分割 CSS 代码 | 支持 | 不支持 |
预优化的异步 chunk 加载 | 支持 | 不支持 |
对动态导入自动 polyfill | 支持 | 不支持 |
依赖预构建速度 | 快 | 相比vite较慢 |
vite的依赖预构建速度更快,是因为vite使用esbuild来实现预构建,而snowpack使用rollup来实现。
总结 —— 如何考虑是否接入vite
本文主要分析了vite的预构建和热更新,都是属于vite在开发环境下的特点,从分析的结果来看,vite确实能给项目开发时带来不小的提速,但实际上,在生产环境模式下,我们需要更注重“稳”。
所以,从我个人的角度来讲,我认为项目是否接入vite,要看该项目的启动时间、项目可承受的风险性等,而在生产环境中,更推荐使用稳定的打包工具,如webpack。所以整体思路可以是,vite用在开发环境,webpack用在生产环境。