1. Vite 是什么?
Vite 是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:
- 开发环境提供一个开发服务器,它基于 原生 ES 模块 提供了丰富的内建功能。在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。
- 生产中利用 Rollup 作为打包工具,并且它是预配置的,可输出用于生产环境的高度优化过的静态资源。
它具有以下特点:
- 快速的冷启动
- 即时的模块热更新
- 真正的按需编译
2. 跟 webpack 的对比
- webpack运行原理
Webpack在启动时,会先构建项目模块的依赖图,如果在项目中的某个地方改动了代码,Webpack则会对相关的依赖重新打包,随着项目的增大,其打包速度也会下降。
- Vite 运行原理
Vite相比于Webpack而言,没有打包的过程,而是直接启动了一个开发服务器devServer。Vite劫持浏览器的HTTP请求,根据请求进行按需编译,在后端进行相应的处理之后再返回给浏览器(整个过程没有对文件进行打包编译)。所以编译速度很快。
核心原理
依赖预构建
为什么要进行依赖预构建?
-
CommonJS 和 UMD 兼容性: 在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
-
性能: 为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
- 有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,
lodash-es 有超过 600 个内置模块!当我们执行import { debounce } from 'lodash-es'时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。 - 通过将
lodash-es预构建成单个模块,现在我们只需要一个HTTP请求!
- 有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,
Vite 会扫描您的源代码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules 中解析),并将这些依赖项作为预构建的入口点。使用 esbuild 进行预构建,然后将构建后的文件缓存在内存中(node_modules/.vite 文件下);
核心代码
async function createDepsOptimizer(){
// 缓存判断,命中缓存直接返回
const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr)
if (!cachedMetadata) {
if (!isBuild) {
depsOptimizer.scanProcessing = new Promise((resolve) => {
;(async () => {
try {
// 扫描并获取依赖
discover = discoverProjectDependencies(config)
const deps = await discover.result
// 开始依赖打包
optimizationResult = runOptimizeDeps(config, knownDeps)
}
})()
})
}
}
export function runOptimizeDeps() {
// 初始化依赖的 metadata 信息
const metadata = initDepsOptimizerMetadata(config, ssr)
// 生成预构建的上下文
const preparedRun = prepareEsbuildOptimizerRun(
resolvedConfig,
depsInfo,
ssr,
processingCacheDir,
optimizerContext,
)
const runResult = preparedRun.then(({ context, idToExports }) => {
// ...
return context
// 执行 esbuild 进行预购建
.rebuild()
.then((result) => {
// ...
return successfulResult
})
})
const successfulResult: DepOptimizationResult = {
metadata,
cancel: cleanUp,
commit: async () => {
// 把 meta 信息写入 _metadata.json 文件、把预构建完成的内容缓存到 node_modules/.vite 文件下;
const dataPath = path.join(processingCacheDir, '_metadata.json')
fs.writeFileSync(
dataPath,
stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)
},
}
}
- lockfileHash:找文件夹中有没有 package-lock.json、 bun.lockb、pnpm-lock.yaml、yarn.lock 文件,正常应该是取 package-lock.json 里面的内容生成hash值;
- configHash:vite 默认的配置内容生成 hash 值;
- hash:lockfileHash + configHash 两个结合生成;
- browserHash:hash + deps 结合生成;
{
"hash": "5f2e5297",
"configHash": "c9640bbc",
"lockfileHash": "3d39ff16",
"browserHash": "650152f3",
"optimized": {
"lodash-es": {
"src": "../../lodash-es/lodash.js",
"file": "lodash-es.js",
"fileHash": "8e3058de",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "7383d4da",
"needsInterop": false
}
},
"chunks": {}
}
HMR
服务器:
启动 Vite 服务之前,Vite 会先创建一个用于 HMR 的 websocket 服务,同时也会创建一个监听对象 watcher 用于对文件修改进行监听,这里的文件监听是通过 chokidar这个库来实现,并且在监听回调中执行 HMR相关逻辑。
const ws = createWebSocketServer(httpServer, config, httpsOptions)
const watcher = (chokidar.watch(
[root, ...config.configFileDependencies, config.envDir],
resolvedWatchOptions,
) as FSWatcher);
watcher.on('change', async (file) => {
...
await onHMRUpdate(file, false)
})
onHMRUpdate 实际执行的是 handleHMRUpdate 方法;
handleHMRUpdate 模块主要是监听文件的更改,进行处理和判断通过WebSocket给前端发送消息通知前端去请求新的模块代码。
export async function handleHMRUpdate(
file: string,
server: ViteDevServer,
configOnly: boolean,
): Promise<void> {
...
// 发送热更新-变更模块信息
updateModules(shortFile, hmrContext.modules, timestamp, server)
}
updateModules 计算 HMR 边界,并向浏览器发送需要更新的模块;
ws.send({
type: 'update',
updates,
})
什么是 HMR边界 呢?
“接受” 热更新的模块被认为是 HMR 边界。
假设有两个文件,关系如下
App.vue 引入了 index.ts;因为 vue 自带了热更新逻辑(vite 的 vitejs/plugin-vue 插件,在编译模块时加入了 vue 热更新的代码),而我们写的 ts 文件,没有热更新逻辑;
当 index.ts 被修改时,这时候是会刷新页面吗?
答案是不会的。vue 组件依赖的 ts 文件被修改,可以对这个 vue 文件进行热更新,重新加载组件。如果刷新页面,那开发体验就不太好了。
这时候,App.vue 就被称为热更新边界——最近的可接受热更新的模块
沿着依赖树,往上找到最近的一个可以热更新的模块,即热更新边界,对其进行热更新即可
为什么有时候修改代码可以热更新,有时候却是刷新页面?例如在 vue 项目中修改 main.ts
修改 main.ts 时,因为往上找不到可以热更新的模块了,vite 不知道如何进行热更新,因此只能刷新页面
如果其他 ts 文件,能找到热更新边界,就可以直接进行热更新;
浏览器:
当我们启动HMR功能的时候,Vite给客户端注入 @vite/client.js 脚本;
const CLIENT_PUBLIC_PATH = '/@vite/client'
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: path.posix.join(base, CLIENT_PUBLIC_PATH),
},
injectTo: 'head-prepend',
},
],
}
@vite/client 脚本会向客户端注入一段默认的代码,代码中执行的 setupWebSocket 方法会创建一个 websocket 服务用于监听服务端发送的热更新信息,接收到的信息会通过 handleMessage 方法处理;
function setupWebSocket(
protocol: string,
hostAndPath: string,
onCloseWithoutOpen?: () => void,
) {
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
let isOpened = false
// 开启事件
socket.addEventListener(
'open',
() => {
isOpened = true
notifyListeners('vite:ws:connect', { webSocket: socket })
},
{ once: true },
)
socket.addEventListener('message', async ({ data }) => {
// 接收并处理服务端的热更新信息
handleMessage(JSON.parse(data))
})
return socket
}
handleMessage 方法主要是根据不同的类型执行不同的操作;
async function handleMessage(payload) {
switch (payload.type) {
//...
case 'update':
//...
payload.updates.forEach((update) => {
if (update.type === 'js-update') {
// 批量任务处理
queueUpdate(fetchUpdate(update));
}
//...
});
break;
case 'full-reload':{
//...
//刷新页面
location.reload();
break;
}
//...
}
}
fetchUpdate 方法是执行客户端热更新的主要逻辑,有 4 个步骤
- 通过 hotModulesMap 获取 HMR 边界模块相关信息
- 获取需要执行的更新回调函数
- 对将要更新的模块进行失活操作(disposer),并通过动态 import 拉去最新的模块信息
- 返回函数,用来执行所有回调
async function fetchUpdate({
path,
acceptedPath,
timestamp,
explicitImportRequired,
}: Update) {
// 1. 获取 HMR 边界模块相关信息
const mod = hotModulesMap.get(path)
if (!mod) return
let fetchedModule: ModuleNamespace | undefined
const isSelfUpdate = path === acceptedPath
// 2. 需要执行的更新回调函数
// mod.callbacks 为 import.meta.hot.accept 中绑定的更新回调函数
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
deps.includes(acceptedPath),
)
// 3. 对将要更新更新的模块进行失活操作,并通过动态 import 拉去最新的模块信息
if (isSelfUpdate || qualifiedCallbacks.length > 0) {
const disposer = disposeMap.get(acceptedPath)
if (disposer) await disposer(dataMap.get(acceptedPath))
const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
try {
fetchedModule = await import(
base +
acceptedPathWithoutQuery.slice(1) +
`?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
query ? `&${query}` : ''
}`
)
}
}
// 4. 返回函数,用来执行所有回调
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
}
}
}