# Vite Client源码

## Vite

### vite介绍

vite是一种新型前端构建工具，它提供两部分功能：

1. 一个开发服务器，提供快速的模块热更新(HMR)
2. 一套构建指令，使用Rollup打包代码

vite相较于传统的构建工具(例如webpack)，最大的特点就是速度快，无论的冷启动还是热更新，速度都非常的快。

### 为什么vite那么快

vite的速度快体现在冷启动和热更新上。

#### 预构建

vite将代码分为源码和依赖两部分，源码就是用户开发的源代码，而依赖通常是引入的第三方包。

vite会对依赖执行依赖预构建，vite使用了esbuild来实现依赖预构建，并且会将结果缓存到`/node_modules/.vite/`目录下，只有一些特殊情况才会重新预构建(例如修改vite配置文件)。

预构建主要做了两件事，一是将CommonJs模块和UMD模块转换成ESM模块，二是重写裸模块地址。预构建可以加快页面的加载速度。

#### ESM

vite之所以要将CommonJs模块和UMD模块转换成ESM模块，这是因为vite自身的机制就是借助ESM来承担一部分打包工作。

首先在index.html中使用ESM，引入入口文件

```jsx
<script type="module" src="/src/main.tsx"></script>
```

浏览器会识别ESM，自动加载`main.tsx`文件，然后会将`main.tsx`中`import`的文件也加载了，这样就可以将所有依赖的文件都加载下来。

也就是说vite会通过ESM的形式提供源码，让浏览器承担了一部分打包工作，而vite只需要在这个过程中做一些处理工作，例如将非JavaScript文件内容转换成JavaScript能够理解的代码，转换导入文件的地址等。

采用这种方式很明显的一个好处是不需要vite自己去构建依赖关系，并且加载时只加载使用的依赖和源码，没有用到的依赖或源码不会被加载。

#### Http缓存

前面说过vite通过ESM加载文件，而在这个过程vite还借助了http缓存来实现加载优化。

前面说过vite会将应用中的模块分为两类：源码和依赖，对于依赖而言，基本上是不会变动的(例如你引入的组件库)，如果每个更新时都处理成本会很高，因此vite会对依赖模块使用强缓存`Cache-Control: max-age=31536000,immutable`。

对于源码而言，这部分可能是会经常改变的，因此会使用协商缓存`cache-control: no-cache`。

#### 冷启动

vite在开发环境下会启动一台node服务器来提供源码和进行其他操作，vite只会在浏览器请求源码时进行转换并按需提供源码，而对于不会使用到的源码则不会进行处理，而webpack则会将源码和依赖全部构建成bundle，因此启动时速度非常慢。

![https://cn.vitejs.dev/assets/bundler.37740380.png](https://cn.vitejs.dev/assets/bundler.37740380.png)

![https://cn.vitejs.dev/assets/esm.3070012d.png](https://cn.vitejs.dev/assets/esm.3070012d.png)

#### 热更新

在 Vite 中，HMR 是在原生 ESM 上执行的。当编辑一个文件时，Vite会将被编辑的模块重新导入(`import()`)，并且还借助http缓存优化模块加载策略，依赖使用了强缓存不会被重新加载，而源码也只会对更改过的文件进行重新加载，使得无论应用大小如何，HMR 始终能保持快速更新。

## Vite client原理分析

### vite组成

vite由client和node两部分组成，分别是客户端和服务器端。

### 热更新

vite的热更新是依靠websocket来实现的。vite其实可以看做是一台静态资源服务器，它会监听项目源码文件的改动，如果有文件改动了，则通过ws通知客户端执行对应的回调，回调中会通过`import()`+时间缀的方式重新加载改动后的代码文件。

```jsx
/* /src/client/client.ts */
// 创建websocket连接
const socketProtocol =
  __HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
// 监听服务器消息，从而执行相应的回调
socket.addEventListener('message', async ({ data }) => {
  handleMessage(JSON.parse(data))
})
/* data的数据格式： {
  "type": "update",
  "updates": [
    {
      "type": "js-update",
      "timestamp": 1650103650496,
      "path": "/src/App.tsx",
      "acceptedPath": "/src/App.tsx"
    }
  ]
}
*/
```

type有以下几种值

* connected 初次连接时
* update 更新
* custom 自定义事件
* full-reload 全更新，即刷新页面，浏览器当前所在的html文件被更改时执行
* prune 更新后有模块不再被导入，则清除副作用
* error 出现错误时执行

我们通常更关心update的处理。

```jsx
async function handleMessage(payload: HMRPayload) {
  // ...
  switch (payload.type) {
    case 'update':
      notifyListeners('vite:beforeUpdate', payload)
      /*
      如果这是第一次更新，并且已经存在error overlay，则
      表示打开的页面存在现有服务器编译错误，并且整个
      模块脚本无法加载（因为其中一个模块导入为 500）。
      在这种情况下，正常更新将不起作用，需要完全重新加载。
      */
      if (isFirstUpdate && hasErrorOverlay()) {
        window.location.reload()
        return
      } else {
        clearErrorOverlay()  // 清除错误覆盖，就是出现错误时的提示界面
        isFirstUpdate = false
      }
      payload.updates.forEach((update) => {
        // update type只有两种：css-update和js-update
        // 其中css-update只针对被link标签引入的css
        if (update.type === 'js-update') {
          queueUpdate(fetchUpdate(update))
        } else {
          const { path, timestamp } = update
          const searchUrl = cleanUrl(path)
          // 不能使用`[href*=]`，因为href可能使用了相对路径
          // 这里需要使用link.href来获取完整路径
          const el = Array.from(
            document.querySelectorAll<HTMLLinkElement>('link')
          ).find((e) => cleanUrl(e.href).includes(searchUrl))
          if (el) {
            const newPath = `${base}${searchUrl.slice(1)}${
              searchUrl.includes('?') ? '&' : '?'
            }t=${timestamp}`
            el.href = new URL(newPath, el.href).href
          }
          console.log(`[vite] css hot updated: ${searchUrl}`)
        }
      })
      break
  }
```

css-update只针对通过link引入的css文件，加入是通过import导入css文件，那么仍然是通过js-update来更新。

可以看到vite拿到了update信息后是先执行了`fetchUpdate`函数，接着又执行了`queueUpdate`函数，接下来我们看一下这两个函数做了什么。

```jsx
async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
  // hotModulesMap保存了所有被加载的模块信息
  // mod：{ id: 文件地址, callbacks: [{ deps: 依赖文件路径数组, fn: 定义的回调函数 }] }
  const mod = hotModulesMap.get(path)
  if (!mod) {
    // In a code-splitting project,
    // it is common that the hot-updating module is not loaded yet.
    // https://github.com/vitejs/vite/issues/721
    return
  }

  // 模块信息
  const moduleMap = new Map()
  // 是否是自身更新
  const isSelfUpdate = path === acceptedPath

  // 借助Set的去重特性，确保每个依赖只被导入一次
  const modulesToUpdate = new Set<string>()
  if (isSelfUpdate) {
    // 如果是自身更新，则只需要将自身path添加即可
    modulesToUpdate.add(path)
  } else {
    // 依赖更新时
    for (const { deps } of mod.callbacks) {
      deps.forEach((dep) => {
        if (acceptedPath === dep) {
          modulesToUpdate.add(dep)
        }
      })
    }
  }

  // 在重新导入模块之前找出对应的回调
  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => {
    return deps.some((dep) => modulesToUpdate.has(dep))
  })

  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 {
        const newMod = await import(
          /* @vite-ignore */
          base +
            path.slice(1) +
            `?import&t=${timestamp}${query ? `&${query}` : ''}`
        )
        moduleMap.set(dep, newMod)
      } catch (e) {
        warnFailedFetch(e, dep)
      }
    })
  )

  return () => {
    // 执行回调
    for (const { deps, fn } of qualifiedCallbacks) {
      fn(deps.map((dep) => moduleMap.get(dep)))
    }
    const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
    console.log(`[vite] hot updated: ${loggedPath}`)
  }
}
```

```jsx
/**
 使用队列来确保以与发送加载时相同的顺序来执行fn
 */
async function queueUpdate(p) {
    queued.push(p);
    if (!pending) {
        pending = true;
        await Promise.resolve();
        pending = false;
        const loading = [...queued];
        queued = [];
        (await Promise.all(loading)).forEach((fn) => fn && fn());
    }
}
```

`fetchUpdate`借助`import()`实现了模块的重新导入，而`queueUpdate`则是调用了回调函数来更新渲染，这里使用了队列结构，因为http响应模块文件的顺序不一定就是模块的请求顺序，所以需要用队列确保以与发送加载时相同的顺序来重新渲染。

接下来我们看看浏览器通过ESM请求模块时，vite对模块文件做了什么处理。

我们创建一个vue文件，并导入一些模块

```jsx
import { ref } from "vue";
import { test } from "../utils/common";
```

刷新重新加载，可以在看到浏览器请求得到的文件是这样的：

```jsx
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/components/HelloWorld.vue");
import {ref} from "/node_modules/.vite/deps/vue.js?v=4ffd74a8";
import {test} from "/src/utils/common.js";

// ...

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return (_openBlock(),
    _createElementBlock(_Fragment, null, [_createElementVNode("h1", null, _toDisplayString($props.msg), 1 /* TEXT */
    ), _hoisted_1, _hoisted_2, _createElementVNode("button", {
        type: "button",
        onClick: _cache[0] || (_cache[0] = $event=>($setup.count++))
    }, "count is: " + _toDisplayString($setup.count), 1 /* TEXT */
    ), _hoisted_3], 64 /* STABLE_FRAGMENT */
    ))
}
_sfc_main.__hmrId = "469af010"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)
import.meta.hot.accept(({default: updated, _rerender_only})=>{
    if (_rerender_only) {
        __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)
    } else {
        __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)
    }
}
)
```

可以看出

* vite服务器会对导入的路径进行转换
* vite在每个模块中都添加了`createHotContext`函数，并且将自身模块路径作为参数传入执行。
* vite通过`import.meta.hot.accept`导入了渲染该模块的方法

既然使用到了`createHotContext`函数，那我们就来看一下这个函数的定义。

```jsx
const hotModulesMap = new Map();
const disposeMap = new Map();
const pruneMap = new Map();
const dataMap = new Map();
const customListenersMap = new Map();
const ctxToListenersMap = new Map();
function createHotContext(ownerPath) {
    if (!dataMap.has(ownerPath)) {
        dataMap.set(ownerPath, {});
    }
    // 当文件更新时，创建一个新的上下文
    // 清除旧的回调
    const mod = hotModulesMap.get(ownerPath);
    if (mod) {
        mod.callbacks = [];
    }
    // 清除旧的自定义事件监听器
    const staleListeners = ctxToListenersMap.get(ownerPath);
    if (staleListeners) {
        for (const [event, staleFns] of staleListeners) {
            const listeners = customListenersMap.get(event);
            if (listeners) {
                customListenersMap.set(event, listeners.filter((l) => !staleFns.includes(l)));
            }
        }
    }
    const newListeners = new Map();
    ctxToListenersMap.set(ownerPath, newListeners);
    function acceptDeps(deps, callback = () => { }) {
        const mod = hotModulesMap.get(ownerPath) || {
            id: ownerPath,
            callbacks: []
        };
        mod.callbacks.push({
            deps,
            fn: callback
        });
        hotModulesMap.set(ownerPath, mod);
    }
    const hot = {
        get data() {
            return dataMap.get(ownerPath);
        },
        accept(deps, callback) {
            if (typeof deps === 'function' || !deps) {
                // self-accept: hot.accept(() => {})
                console.log('deps',deps);
                acceptDeps([ownerPath], ([mod]) => deps && deps(mod));
            }
            else if (typeof deps === 'string') {
                // explicit deps
                acceptDeps([deps], ([mod]) => callback && callback(mod));
            }
            else if (Array.isArray(deps)) {
                acceptDeps(deps, callback);
            }
            else {
                throw new Error(`invalid hot.accept() usage.`);
            }
        },
        acceptDeps() {
            throw new Error(`hot.acceptDeps() is deprecated. ` +
                `Use hot.accept() with the same signature instead.`);
        },
        dispose(cb) {
            disposeMap.set(ownerPath, cb);
        },
        // @ts-expect-error untyped
        prune(cb) {
            pruneMap.set(ownerPath, cb);
        },
        // TODO
        // eslint-disable-next-line @typescript-eslint/no-empty-function
        decline() { },
        invalidate() {
            // TODO should tell the server to re-perform hmr propagation
            // from this module as root
            location.reload();
        },
        // 监听自定义事件
        on(event, cb) {
            const addToMap = (map) => {
                const existing = map.get(event) || [];
                existing.push(cb);
                map.set(event, existing);
            };
            addToMap(customListenersMap);
            addToMap(newListeners);
        },
        // 发服务器发送自定义事件
        send(event, data) {
            messageBuffer.push(JSON.stringify({ type: 'custom', event, data }));
            sendMessageBuffer();
        }
    };
    return hot;
}
```

可以看到之前在`fetchUpdate`中用到的`hotModulesMap`是在这里定义的。`createHotContext`函数返回了一个hot对象，事实上这个就是模块中使用到的`import.meta.hot`对象，上面挂载了一些方法，其中比较重要的是`accept`方法，它的作用是当模块更新时执行对应的回调，在vite服务器返回模块文件时其实就会执行这个方法，回调函数就是重新渲染该模块的方法，因此当前面执行重新`import()`后，会调用模块的回调函数来渲染更新，此外accept也被暴露给开发者使用，开发者可以通过`import.meta.hot.accept()`来传入自定义回调函数。

综上，vite的热更新流程大致流程如下

1. vite服务器和浏览器建立websocket连接
2. vite服务器在返回模块文件时会创建热更新上下文，并将该模块的渲染方法传入到回调中
3. 服务器监听文件改动，如果有文件更新则将模块文件path、更新type等信息发给浏览器
4. 浏览器接收到后重新`import()`该模块文件，并执行重新渲染的回调方法
