Vite 为什么快呢,去研究一下

语言: CN / TW / HK

最近在用 vite 搭建项目时,最大的感受就是好快,开发的过程也很舒服。为什么会有这种感受呢,我觉得主要是这两点给人焕然一新的感觉 - 项目启动时快 - 项目热更新快

一直想一探究竟,也看了一些文章,自己再总结下,去看看vite源码,毕竟一直对热更新挺好奇的。

启动

vite

由于现代浏览器支持ES模块,但是不支持裸模块的导入 ```

``` vite通过 esbuild ,执行预构建,将 CommonJS / UMD 转换为 ESM 格式,缓存入当前项目的 node_modules/.vite 中:

image.png

然后重写链接,例如 /node_modules/.vite/vue.js?v=c260ab7b,当浏览器请求资源时,劫持浏览器的http请求,对非JavaScript文件,进行转换(例如 JSX,CSS 或者 Vue/Svelte 组件),然后再返回给浏览器。

image.png

对比webpack

对比webpack是解析模块的依赖关系,打包生成buddle,启动服务器,而vite是通过ES的方式加载模块,在浏览器发送请求是按需提供源码,让浏览器接管了打包程序的部分工作,所以会感觉快。

vite 打包过程

热更新

在vite中,热更新是在原生ESM上执行的。当某个模块内容改变时,让浏览器去重新请求该模块,而不是像webpack重新将该模块的所有依赖重新编译,也会感觉快。

同时vite利用HTTP头来加速整个页面的重新加载,源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。

从源码中了解热更新:

通过 WebSocket 创建浏览器和服务器通信,使用 chokidar 监听文件的改变,当模块内容修改是,发送消息通知客户端,只对发生变更的模块重新加载。

image.png

服务端(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 { // 生成所有配置项,包括vite.config.js、命令行参数等 const config = await resolveConfig(inlineConfig, 'serve', 'development')

// 初始化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() mods.forEach((mod) => { this.invalidateModule(mod, seen) }) } }

invalidateModule( mod: ModuleNode, seen: Set = new Set(), timestamp: number = Date.now() ): void { // 修改时间戳 mod.lastInvalidationTimestamp = timestamp // 使转换结果无效,确保下次请求时重新处理该模块 mod.transformResult = null mod.ssrTransformResult = null invalidateSSRModule(mod, seen) } ```

接着执行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) } }) ) ... }

image.png

问题

在此有个问题,启动服务过程中,浏览器请求'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插件对templatestyle`分别做处理

image.png

``` export function transformRequest( url: string, server: ViteDevServer, options: TransformOptions = {} ): Promise { ... const request = doTransform(url, server, options, timestamp) ... return request }

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包及其作用,如cacchokidar等,以及http缓存的应用,还有发现代码中会有很多map、set数据结构的使用,包括数据增、删、改、是否存在等,带着问题继续探索。

参考文章