Vite 为什么快呢,去研究一下
最近在用 vite 搭建项目时,最大的感受就是好快,开发的过程也很舒服。为什么会有这种感受呢,我觉得主要是这两点给人焕然一新的感觉 - 项目启动时快 - 项目热更新快
一直想一探究竟,也看了一些文章,自己再总结下,去看看vite源码,毕竟一直对热更新挺好奇的。
启动
vite
由于现代浏览器支持ES模块,但是不支持裸模块的导入 ```
``` vite通过 esbuild ,执行预构建,将 CommonJS / UMD 转换为 ESM 格式,缓存入当前项目的 node_modules/.vite 中:
然后重写链接,例如 /node_modules/.vite/vue.js?v=c260ab7b
,当浏览器请求资源时,劫持浏览器的http请求,对非JavaScript文件,进行转换(例如 JSX,CSS 或者 Vue/Svelte 组件),然后再返回给浏览器。
对比webpack
对比webpack是解析模块的依赖关系,打包生成buddle,启动服务器,而vite是通过ES的方式加载模块,在浏览器发送请求是按需提供源码,让浏览器接管了打包程序的部分工作,所以会感觉快。
热更新
在vite中,热更新是在原生ESM上执行的。当某个模块内容改变时,让浏览器去重新请求该模块,而不是像webpack重新将该模块的所有依赖重新编译,也会感觉快。
同时vite利用HTTP头来加速整个页面的重新加载,源码模块的请求会根据 304 Not Modified
进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable
进行强缓存,因此一旦被缓存它们将不需要再次请求。
从源码中了解热更新:
通过 WebSocket
创建浏览器和服务器通信,使用 chokidar
监听文件的改变,当模块内容修改是,发送消息通知客户端,只对发生变更的模块重新加载。
服务端(node)
在node文件夹中,cli.ts中通过createServer
, 启动服务。
首先通过 cac (一个JavaScript库,用于构建应用的CLI),创建命令行交互,当在项目中执行npm run dev
时,会执行cli.action中的回调函数:
// dev
cli
.command('[root]', 'start dev server') // default command
.alias('serve') // the command is called 'serve' in Vite's API
.alias('dev') // alias to align with the script name
.option('--host [host]', `[string] specify hostname`)
...
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
// output structure is preserved even after bundling so require()
// is ok here
const { createServer } = await import('./server')
try {
// 创建服务
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options)
})
...
await server.listen()
...
// 输出启动信息
server.printUrls()
} catch (e) {
...
}
在 createServer
的时候vite做了一些工作,包括启动服务、监听文件变化、生成模块依赖关系、拦截浏览器请求、对返回文件进行处理等。
```
export async function createServer(
inlineConfig: InlineConfig = {}
): Promise
// 初始化connect中间件 const middlewares = connect() as Connect.Server const httpServer = middlewareMode ? null : await resolveHttpServer(serverConfig, middlewares, httpsOptions) const ws = createWebSocketServer(httpServer, config, httpsOptions)
// 初始化文件监听 const watcher = chokidar.watch(path.resolve(root), { ignored: [ '/node_modules/', '/.git/', ...(Array.isArray(ignored) ? ignored : [ignored]) ], ignoreInitial: true, ignorePermissionErrors: true, disableGlobbing: true, ...watchOptions }) as FSWatcher
// 生成模块依赖关系,快速定位模块,进行热更新 const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) => container.resolveId(url, undefined, { ssr }) )
// 生成所有插件配置 const container = await createPluginContainer(config, moduleGraph, watcher)
// 监听修改文件内容 watcher.on('change', async (file) => { file = normalizePath(file) if (file.endsWith('/package.json')) { return invalidatePackageDjianata(packageCache, file) } // invalidate module graph cache on file change moduleGraph.onFileChange(file) if (serverConfig.hmr !== false) { try { // 执行热更新 await handleHMRUpdate(file, server) } catch (err) { ws.send({ type: 'error', err: prepareError(err) }) } } })
// 监听新增文件 watcher.on('add', (file) => { handleFileAddUnlink(normalizePath(file), server) })
// 监听删除文件 watcher.on('unlink', (file) => { handleFileAddUnlink(normalizePath(file), server, true) })
// 主要中间件,请求文件转换,返回给浏览器可以识别的js文件 middlewares.use(transformMiddleware(server))
...
return server
}
当监听到文件内容变化 `change` 时,先执行 `moduleGraph.onFileChange(file)`
onFileChange(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
const seen = new Set
invalidateModule(
mod: ModuleNode,
seen: Set
接着执行handleHMRUpdate
函数,通过moduleGraph.getModulesByFile(file)
,获取需要更新的模块,调用updateModules
函数,此时会对一些文件特殊处理,比如是 .env 配置文件、html文件等情况,ws
发送full-reload
,页面刷新。
- handleHMRUpdate ``` const isEnv = config.inlineConfig.envFile !== false && (file === '.env' || file.startsWith('.env.')) ...
// 通过文件获取所包含模块 const mods = moduleGraph.getModulesByFile(file) ...
if (!hmrContext.modules.length) {
if (file.endsWith('.html'){
...
ws.send({
type: 'full-reload',
path: config.server.middlewareMode
? '*'
: '/' + normalizePath(path.relative(config.root, file))
})
}
}
- **updateModules**
if (needFullReload) {
config.logger.info(colors.green(page reload
) + colors.dim(file), {
clear: true,
timestamp: true
})
ws.send({
type: 'full-reload'
})
} else {
config.logger.info(
updates
.map(({ path }) => colors.green(hmr update
) + colors.dim(path))
.join('\n'),
{ clear: true, timestamp: true }
)
ws.send({
type: 'update',
updates
})
}
```
客户端(client)
在client文件夹中,在客户端 ws
接收到更新类型,执行相应操作
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
// 通信连接
case 'connected':
...
setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
break
// 更新部分代码
case 'update':
...
break
// 自定义事件
case 'custom': {
...
break
}
// 全更新
case 'full-reload':
...
location.reload()
...
break
// 热更新后清除
case 'prune':
...
break
// 错误
case 'error': {
...
break
}
default: {
const check: never = payload
return check
}
}
}
在部分更新时会调用fetchUpdate
函数
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
...
await Promise.all(
Array.from(modulesToUpdate).map(async (dep) => {
const disposer = disposeMap.get(dep)
if (disposer) await disposer(dataMap.get(dep))
const [path, query] = dep.split(`?`)
try {
// import更新文件,浏览器发送get请求,返回新结果
const newMod = await import(
/* @vite-ignore */
base +
path.slice(1) +
`?import&t=${timestamp}${query ? `&${query}` : ''}`
)
moduleMap.set(dep, newMod)
} catch (e) {
warnFailedFetch(e, dep)
}
})
)
...
}
问题
在此有个问题,启动服务过程中,浏览器请求'App.vue',vite是怎么处理该组件的呢,比如 - 怎么利用浏览器缓存来做优化的? - 怎么请求和处理组件里面的样式文件呢?
我们来看下在启动时提到的 transformMiddleware
里面有哪些动作:
```
export function transformMiddleware(
server: ViteDevServer
): Connect.NextHandleFunction {
const {
config: { root, logger },
moduleGraph
} = server
...
// 返回一个执行函数
return async function viteTransformMiddleware(req, res, next) {
// 不是get请求直接跳过
if (req.method !== 'GET' || knownIgnoreList.has(req.url!)) {
return next()
}
...
// 判断url,正则匹配,其中isJSRequest的正则为:
// const knownJsSrcRE = /\.((j|t)sx?|mjs|vue|marko|svelte|astro)($|\?)/
if (
isJSRequest(url) ||
isImportRequest(url) ||
isCSSRequest(url) ||
isHTMLProxy(url)
) {
...
// http协商缓存:
// 通过比对if-none-match的上一次etag值,如果变化返回一个完整响应内容,在响应头上添加新的etag值,否则返回 304,使用浏览器缓存
const ifNoneMatch = req.headers['if-none-match']
if (
ifNoneMatch &&
(await moduleGraph.getModuleByUrl(url, false))?.transformResult
?.etag === ifNoneMatch
) {
isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`)
res.statusCode = 304
return res.end()
}
// 依赖vite插件进行解析转换,返回code
// 如果是npm依赖,会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
})
if (result) {
const type = isDirectCSSRequest(url) ? 'css' : 'js'
const isDep = DEP_VERSION_RE.test(url) || isOptimizedDepUrl(url)
return send(req, res, result.code, type, {
etag: result.etag,
// allow browser to cache npm deps!
cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
headers: server.config.server.headers,
map: result.map
})
}
...
next()
}
``
在上一步处理请求时,调用
transformRequest函数,比如vue文件,会通过
@vite/plugin-vue插件对
template、
style`分别做处理
```
export function transformRequest(
url: string,
server: ViteDevServer,
options: TransformOptions = {}
): Promise
async function doTransform( url: string, server: ViteDevServer, options: TransformOptions, timestamp: number ) { ... // 插件命中 const id = (await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url ... // 加载 const loadResult = await pluginContainer.load(id, { ssr }) ... // 转换 const transformResult = await pluginContainer.transform(code, id, { inMap: map, ssr }) ... // 返回处理结果 // 启用协商缓存,在响应头中带上etag const result = ssr ? await ssrTransform(code, map as SourceMap, url) : ({ code, map, etag: getEtag(code, { weak: true }) } as TransformResult)
return result
}
在 `@vite/plugin-vue` 插件中命中`vue`文件执行转换
export async function transformMain(
code: string,
filename: string,
options: ResolvedOptions,
pluginContext: TransformPluginContext,
ssr: boolean,
asCustomElement: boolean
) {
...
// script
const { code: scriptCode, map } = await genScriptCode(
descriptor,
options,
pluginContext,
ssr
)
// template const hasTemplateImport = descriptor.template && !isUseInlineTemplate(descriptor, !devServer)
let templateCode = '' let templateMap: RawSourceMap | undefined if (hasTemplateImport) { ;({ code: templateCode, map: templateMap } = await genTemplateCode( descriptor, options, pluginContext, ssr )) }
// styles const stylesCode = await genStyleCode( descriptor, pluginContext, asCustomElement, attachedProps ) ... } ``` 将他们转换成es模块引入,浏览器发送请求,在通过中间件处理返回给浏览器。
总结
主要了解了下关于vite启动过程中,文件处理、热更新的执行过程,源代码中有很多细节处理,有兴趣你可以去深入研究一下,在此过程中,了解了一些npm包及其作用,如cac
、chokidar
等,以及http
缓存的应用,还有发现代码中会有很多map、set
数据结构的使用,包括数据增、删、改、是否存在等,带着问题继续探索。