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

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

  • 项目启动时快
  • 项目热更新快

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

启动

vite

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

<script type="module">
    import { createApp } from "/node_modules/.vite/vue.js?v=c260ab7b";

    // 不支持
    import { createApp } from 'vue';
</script>
复制代码

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<ViteDevServer> {
  // 生成所有配置项,包括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<ModuleNode>()
      mods.forEach((mod) => {
        this.invalidateModule(mod, seen)
      })
    }
}

invalidateModule(
    mod: ModuleNode,
    seen: Set<ModuleNode> = 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<TransformResult | null> {
  ...
  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数据结构的使用,包括数据增、删、改、是否存在等,带着问题继续探索。

参考文章

猜你喜欢

转载自juejin.im/post/7078262306743779336