vite 浅析

166 阅读6分钟

1. Vite 是什么?

Vite 是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

  • 开发环境提供一个开发服务器,它基于 原生 ES 模块 提供了丰富的内建功能。在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。
  • 生产中利用 Rollup 作为打包工具,并且它是预配置的,可输出用于生产环境的高度优化过的静态资源。

它具有以下特点:

  • 快速的冷启动
  • 即时的模块热更新
  • 真正的按需编译

2. 跟 webpack 的对比

  • webpack运行原理

Webpack在启动时,会先构建项目模块的依赖图,如果在项目中的某个地方改动了代码,Webpack则会对相关的依赖重新打包,随着项目的增大,其打包速度也会下降。

  • Vite 运行原理

Vite相比于Webpack而言,没有打包的过程,而是直接启动了一个开发服务器devServer。Vite劫持浏览器的HTTP请求,根据请求进行按需编译,在后端进行相应的处理之后再返回给浏览器(整个过程没有对文件进行打包编译)。所以编译速度很快。

核心原理

依赖预构建

为什么要进行依赖预构建?
  1. CommonJS 和 UMD 兼容性: 在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。

  2. 性能: 为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。

    1.   有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。
    2.   通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!

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 会先创建一个用于 HMRwebsocket 服务,同时也会创建一个监听对象 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 个步骤

  1. 通过 hotModulesMap 获取 HMR 边界模块相关信息
  2. 获取需要执行的更新回调函数
  3. 对将要更新的模块进行失活操作(disposer),并通过动态 import 拉去最新的模块信息
  4. 返回函数,用来执行所有回调
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)))
    }
  }
}