侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

vite 5 源码分析(六): 热更新

lumozx
2024-04-23 / 0 评论 / 0 点赞 / 9 阅读 / 22491 字

本文使用vite 5.2.6版本

在上文中,我们了解了模块依赖图。并了解了它的转译模块功能,接下来,我们来看看它所服务的另一个功能——热更新。

ws

说到热更新,首先得需要一个webSocket来进行支持,所以我们先看看Vite里面的webSocket是如何被创建出来的。

_createServer中,使用createWebSocketServer创建了webSocket

首先,会尝试获取config.server.hmr中的配置

  • hmr.server,如果这个配置存在,Vite 将会通过所提供的的服务器来处理 HMR 连接,否则就使用默认的httpServer来当做wsServer

  • hmr.port,热更新端口,如果没有被定义,那么使用24678

一般情况下,我们已经有了个webSocket所依托的wsServer了,那么接下来,就使用ws这个包,创建一个webSocketServer。但是,所采用的却是noServer模式。

wss = new WebSocketServerRaw({ noServer: true })

这样子webSocketServerwsServer进行了解耦,ws实例就不依赖于具体的 http 服务器实例。

然后使用hmrServerWsListener,将wsServer与创建出来的wss进行绑定。

hmrServerWsListener = (req, socket, head) => {
  if (
    req.headers["sec-websocket-protocol"] === HMR_HEADER &&
    req.url === hmrBase
  ) {
    wss.handleUpgrade(req, socket as Socket, head, (ws) => {
      wss.emit("connection", ws, req)
    })
  }
}

wsServer.on('upgrade', hmrServerWsListener)

hmrServerWsListener中,会通过校验请求头的sec-websocket-protocol字段是否是vite-hmr,来确认当前的请求是否是客户端建立 WebSocket 连接的请求。

如果是的话,会调用wss.handleUpgrade()方法,将 HTTP 连接升级为 WebSocket 连接,并通过传递请求对象reqsocket和请求头head,以及一个回调函数。

在回调函数中触发wssconnection事件,将 WebSocket 连接对象ws和原始的 HTTP 请求对象req 传递进去,完成 WebSocket 服务器和客户端的绑定,从而建立起 WebSocket 连接。

最后返回一个包装的WebSocketServer对象。

hot

上文中我们已经得到了一个WebSocketServer对象,在之前的版本中,Vite会直接将它当做热更新对象,但当前版本中,热更新通道并非只能选择一个,我们甚至可以定义自己的热更新逻辑。

因此在这里,我们需要将默认创建的WebSocketServer加入到HMRChannel之中。

const hot = createHMRBroadcaster()
    .addChannel(ws)

通过createHMRBroadcaster闭包创建出一个对象,使用这个对象的addChannel方法,将热更新通道加入到内部数组——channels中。

而它的返回值hot对象,有着WebSocketServer上所有的方法,所有方法实现逻辑就是使用channels.forEach遍历执行已存通道上的所有对应方法。

listen进行举例:

listen() {
      channels.forEach((channel) => channel.listen())
    }

同时,我们可以通过config.server.hmr.channels自定义我们自己的热更新通道。它们同样会被addChannel加入到channels中。

if (typeof config.server.hmr === 'object' && config.server.hmr.channels) {
    config.server.hmr.channels.forEach((channel) => hot.addChannel(channel))
  }

最后,这个hot会被挂载到server上,从而后续可以从server拿到。

handleHMRUpdate

我们思考一下,最基本的热更新需要实现什么功能?

那就是根据文件的增加、删除、修改让浏览器的页面发对应的变化。

那么什么东西可以监听文件的增加、删除、修改呢?

答案是chokidar。我们在chokidar那一节讲过,它留下了完美的接口,让我们来根据文件的变动进行对应的修改。

我们回忆一下,它都做了什么:

  const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
    // 略 这里处理了publicFile 使它比模块依赖图的etag优先
    await handleFileAddUnlink(file, server, isUnlink)
    await onHMRUpdate(file, true)
  }
  watcher.on('change', async (file) => {
    // 使用文件从模块依赖图对应模块获取对应的模块,做失效化处理
    moduleGraph.onFileChange(file)
    await onHMRUpdate(file, false)
  })

  watcher.on('add', (file) => {
    onFileAddUnlink(file, false)
  })
  watcher.on('unlink', (file) => {
    onFileAddUnlink(file, true)
  })

其中onFileChange的作用是将对应模块做失效标记,并且使用null清空模块的transformResult

这里需要注意,我们在模块依赖图讲过,文件是可以分成多个模块的——比如vue文件,就可以分出stylejs等,所以尽管这里文件只有一个,但这里获取的模块可能是复数个。这里会清空所有模块的transformResult

从而使得获取模块transformResultgetCachedTransformResult返回一个无效值。既然是无效值,那么就会重新走模块转译逻辑,这个我们在上一节讲的比较清楚。

从源码可以看到,不管是增加、删除还是修改,都是针对模块依赖图进行修改,然后调用了onHMRUpdate方法。而它实际上是handleHMRUpdate的包装,如果没有手动禁止serverConfig.hmr,那么就会调用handleHMRUpdate

那么我们看看handleHMRUpdate做了什么。

  1. 这个函数会判断是否是env文件或者vite配置文件被修改,如果是的话,就重启本地服务器。

  2. 如果是文件新增或者删除,那么就直接return。因为如果可达的模块被新增或者删除,必定伴随着模块的修改,因此新增、删除文件的逻辑被合并到模块修改后的重新转译里面了。之后的逻辑都是修改逻辑。

  3. 如果被修改的是注入的client文件,那么重新刷新页面——此文件不允许被修改。

  4. 根据文件地址,从模块依赖图的获取模块映射的集合——注意当前这些模块的transformResultnull。并被标记为失效模块。

  5. 然后将这些模块组成一个上下文对象,放到插件流水线中,触发handleHotUpdate钩子。

  6. 如果模块走完插件流水线后,处理结果为空,并且是html,那么就刷新页面

  7. 其他情况,调用updateModules

可以看出 handleHMRUpdate 是针对不同的更新场景做了分流、合并,当调用updateModules的时候,只有一种操作——更新。

updateModules

那么目光来到updateModules这边。updateModules支持多个模块进行热更新,因此会遍历模块进行处理。

针对每一个模块,首先它会通过propagateUpdate收集热更新的边界。

如果最后propagateUpdate返回true,那么就会直接刷新整个页面,结束函数执行。

否则将propagateUpdate填充的boundaries数组复制到待更新数组updates之中。

最后遍历结束,如果updates存在有效长度,那么就通过server.hotupdates发送给客户端。

因此这么看起来,整个核心逻辑就是propagateUpdate,它是如何确定更新边界、并且为什么返回truefalse

propagateUpdate

首先,这函数会检查过有没有处理过当前模块,如果处理过会返回false,否则就将当前模块标记为已处理。

如果当前模块接受自身更新,那么就把当前模块推入boundaries数组。

if (node.isSelfAccepting) { // 如果当前节点可以接受自身的更新
    boundaries.push({ // 将当前节点添加到 boundaries 数组中
      boundary: node, 
      acceptedVia: node,
      isWithinCircularImport: isNodeWithinCircularImports(node, currentChain), // 是否在循环依赖
    })

同时,检查它的引用者是不是css文件,如果是的话且没处理过,那么将使用propagateUpdate递归它的引用者。因为在PostCSS等插件中,可以将任何文件注册为css文件的依赖项,因此依赖项变动,css文件也需要进行更新。最后返回false

for (const importer of node.importers) { // 遍历当前节点的引用数组
  if (isCSSRequest(importer.url) && !currentChain.includes(importer)) { // 如果引用是 CSS 请求并且不在当前链中
    propagateUpdate( // 略,递归引用者)
  }
}

如果当前模块不接受自身更新,那么就找它的引用者,如果没有引用者,那么就返回true直接刷新。

if (!node.importers.size) {
  // 如果当前节点没有导入者
  return true // 返回 true,表示存在死锁
}

如果它的所有引用者都是css并且当前模块不是css,那么显然无法很优雅知道需要更新多少模块——因为这种文件一般是通过PostCSS注册的。Vite不能去完全适配PostCSS的逻辑,所有干脆刷新得了。因此返回true

if (
  !isCSSRequest(node.url) && // 如果不是 CSS 请求
  [...node.importers].every((i) => isCSSRequest(i.url)) // 并且所有引用者都是 CSS 请求
) {
  return true
}

如果引用者没有被处理过,那么就使用propagateUpdate递归引用者,如果某个递归返回了true,并且不在当前导入链中,那么它最终也返回true

if (
  !currentChain.includes(importer) && // 如果当前链中不包含引用者
  propagateUpdate(importer, traversedModules, boundaries, subChain) // 递归调用 propagateUpdate
) {
  return true
}

其他情况返回false

我们可以简单总结一下propagateUpdate:

  • 如果当前模块接受自更新,那么当前模块就被推入boundaries

  • 如果当前模块接受自更新,且引用者是css。那么递归它的引用者。

  • 如果当前模块不接受自更新,那么就将它的引入者推入boundaries

  • 如果当前模块不接受自更新,并且它的引入者都是css,刷新页面。

  • 如果当前模块不接受自更新还没有引入者,刷新页面。

最后形成的boundaries会被包装成updates数组发送给客户端。

// 发送 updates
hot.send({
  type: "update",
  updates,
})
// 发送 刷新页面
hot.send({
  type: "full-reload",
  triggeredBy: path.resolve(config.root, file),
})

所以我们看看send的逻辑。

send(...args: any[]) {
  let payload: HMRPayload
  if (typeof args[0] === 'string') { // 如果第一个参数的类型是字符串
    payload = {
      type: 'custom', // 类型为自定义
      event: args[0], // 事件名称为第一个参数
      data: args[1], // 数据为第二个参数
    }
  } else {
    payload = args[0] // 直接使用第一个参数作为 payload
  }

  //略 错误处理

  const stringified = JSON.stringify(payload)
  wss.clients.forEach((client) => { // 遍历所有 WebSocket 客户端
    if (client.readyState === 1) { // 如果客户端的 readyState 为 1(即连接已打开)
      client.send(stringified) // 向客户端发送序列化后的消息
    }
  })
}

这个函数根据参数的类型创建一个消息对象,并将其发送给客户端。

如果消息类型是 'error',并且当前没有客户端连接,那么它会将这条消息缓存起来,而不是发送出去。

最终,它会将消息序列化为 JSON 字符串,并发送给所有已连接的客户端。

那么我们看看客户端是怎么接受的。

client

其实我们在很早就接触到了client,在第一篇文章,我们就提到了@vite/client,其实它就是热更新的客户端模块。

而它在devHtmlHook中,被挂载到html上。

// 略 CLIENT_PUBLIC_PATH就是/@vite/client
return {
    html,
    tags: [
      {
        tag: 'script',
        attrs: {
          type: 'module',
          src: path.posix.join(base, CLIENT_PUBLIC_PATH),
        },
        injectTo: 'head-prepend',
      },
    ],
  }

我们来看看注入的是个什么东西。

首先,调用了setupWebSocket来启动一个WebSocket。并传递vite-hmr,与后台建立链接。

const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')

然后,监听message事件,这个交给handleMessage来处理,handleMessage是客户端热更新的入口方法。

socket.addEventListener('message', async ({ data }) => {
    handleMessage(JSON.parse(data))        // 处理接收到的消息
  })

我们看看handleMessage做了什么。

首先,根据传过来的type,响应不同的行为,如果typeconnected。那么会在一个具体的时间重复发送ping

case 'connected':
      console.debug(`[vite] connected.`)
      setInterval(() => {
        if (socket.readyState === socket.OPEN) {
          socket.send('{"type":"ping"}')
        }
      }, __HMR_TIMEOUT__)
      break

其中__HMR_TIMEOUT__server.hmr.timeout注入替代,默认为30s。

我们记得上文的full-reload吗?这里也有对应的处理逻辑。

case 'full-reload':
      if (payload.path && payload.path.endsWith('.html')) {
        // 如果编辑了 html 文件,则只在浏览器当前位于该页面时重新加载页面。
        const pagePath = decodeURI(location.pathname)
        const payloadPath = base + payload.path.slice(1)
        if (
          pagePath === payloadPath ||
          payload.path === '/index.html' ||
          (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)
        ) {
          pageReload()
        }
        return
      } else {
        pageReload()
      }
      break

上面的逻辑表示,通过读取payload.path,识别引起刷新页面操作的是否是html的变化。

如果是,还需要判断当前所在页面是否是那个html。或者那个html是否是首页。

如果有一个,判断通过,则调用pageReload

其它情况,直接调用pageReload.

pageReload实现比较简单,就是一个50ms防抖的location.reload()

那么我们来看看文件更新,也就是case 'update'的情况。

case 'update':
      // 如果这是第一次更新并且存在错误遮罩,则意味着页面打开时存在服务器编译错误,并且整个模块脚本加载失败
      // (因为其中一个嵌套的导入文件是 500)。在这种情况下,普通的更新不起作用,需要完全重新加载。
      if (isFirstUpdate && hasErrorOverlay()) {
        window.location.reload()
        return
      } else {
        clearErrorOverlay()
        isFirstUpdate = false
      }
      await Promise.all(
        payload.updates.map(async (update): Promise<void> => {
          if (update.type === 'js-update') {
            return hmrClient.queueUpdate(update)
          }

          // css-update
          // 当使用 <link> 引用的 css 文件被更新时才会发送此消息
          const { path, timestamp } = update
          const searchUrl = cleanUrl(path)
          // 这里不能使用带有 `[href*=]` 的 querySelector,因为链接可能使用相对路径,
          // 所以我们需要使用 link.href 来获取完整的 URL 进行检查。
          const el = Array.from(
            document.querySelectorAll<HTMLLinkElement>('link'),
          ).find(
            (e) =>
              !outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl),
          )

          if (!el) {
            return
          }

          const newPath = `${base}${searchUrl.slice(1)}${
            searchUrl.includes('?') ? '&' : '?'
          }t=${timestamp}`

          // 不要直接交换现有标签的 href,而是创建一个新的 link 标签。
          // 一旦新样式表加载完成,我们将删除现有的 link 标签。
          // 这样做可以消除直接交换标签 href 时出现的样式未加载导致内容闪烁,
          // 因为新样式表尚未加载完成。
          return new Promise((resolve) => {
            const newLinkTag = el.cloneNode() as HTMLLinkElement
            newLinkTag.href = new URL(newPath, el.href).href
            const removeOldEl = () => {
              el.remove()
              console.debug(`[vite] css hot updated: ${searchUrl}`)
              resolve()
            }
            newLinkTag.addEventListener('load', removeOldEl)
            newLinkTag.addEventListener('error', removeOldEl)
            outdatedLinkTags.add(el)
            el.after(newLinkTag)
          })
        }),
      )
      break

首先,它检查是否是第一次更新且页面存在错误遮罩。

如果是,则意味着页面是以服务器编译错误的情况下打开的,并且整个模块脚本加载失败。

在这种情况下,为了确保正确加载,直接执行了 window.location.reload()

如果不是。它会遍历 payload.updates 数组——还记得上文,我们寻找热更新边界,找出需要重新加载的模块,然后将它们填充进updates数组吗,这个数组包含了被清空transformResult的模块。

针对数组每个模块,它会调用 hmrClient.queueUpdate(update) 将更新排队以应用到客户端代码中。

对于css更新,它会在页面中查找与更新相关的 <link> 元素,并创建一个新的 <link> ,来加载新的样式表。一旦新的样式表加载完成,就会删除旧的 <link> 元素,以避免出现样式闪烁。

这里我们发现使用了hmrClient,它是什么?

HMRClient

hmrClient是模块替换的核心功能,使用HMRClientclient代码加载的时候使用new进行初始化。

const hmrClient = new HMRClient(
  console,
  {
    isReady: () => socket && socket.readyState === 1,
    send: (message) => socket.send(message),
  },
  importUpdatedModule
)

它的构造函数接受三个参数,最重要的就是第三个参数——模块替换逻辑importUpdatedModule

async function importUpdatedModule({
  acceptedPath, 
  timestamp, 
  explicitImportRequired,
  isWithinCircularImport, 
}) {
  const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`); 

  // 异步导入更新后的模块
  const importPromise = import(
    base +
      acceptedPathWithoutQuery.slice(1) +  // 删除路径中的第一个字符(通常是斜杠)
      `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}` // 构建带时间戳的导入路径
  );
  return await importPromise; // 返回导入的 Promise 对象
}

可以看出来,importUpdatedModule是处理新模块的主要逻辑,它将时间戳拼到模块后缀,以此来保证获取的最新的模块,并返回导入的Promise对象。

queueUpdate做了什么呢?

public async queueUpdate(payload: Update): Promise<void> {
  // 将更新任务添加到更新队列中
  this.updateQueue.push(this.fetchUpdate(payload));
  
  // 如果没有挂起的更新队列,则执行以下操作
  if (!this.pendingUpdateQueue) {
    this.pendingUpdateQueue = true; // 标记为存在挂起的更新队列
    await Promise.resolve(); // 等待,收集更新
    this.pendingUpdateQueue = false; // 标记为没有挂起的更新队列

    // 将更新队列中的任务进行加载
    const loading = [...this.updateQueue];
    this.updateQueue = []; // 清空更新队列
    (await Promise.all(loading)).forEach((fn) => fn && fn()); // 执行更新任务
  }
}

这个方法主要是对多个热更新进行缓冲,以确保它们按照发送的顺序执行。

首先它会将当前模块使用fetchUpdate包装,然后推入updateQueue,之后检查队列是否挂起,如果没有,那么标记为挂起,并使用await Promise.resolve(),来等待其它queueUpdate的执行,从而让updateQueue收集到本次推入的所有模块。

我们注意到hmrClient.queueUpdate(update)并没有使用await改为同步,这个技巧在模块依赖图用过不少次。

因此多个模块的情况下,第一个queueUpdate会在await Promise.resolve()之前暂停执行,然后执行剩余的queueUpdate,由于pendingUpdateQueue被第一个标记为true,所以剩余的queueUpdate有效代码只剩下updateQueue.push

当后续queueUpdate执行完毕,回到第一个queueUpdate继续执行,会将推入队列的所有模块,使用Promise.all同步加载。

那么剩下一个问题,queueUpdate使用队列执行fetchUpdate,那么fetchUpdate是什么。

private async fetchUpdate(update: Update): Promise<(() => void) | undefined> {
  const { path, acceptedPath } = update; // 获取更新路径和接受的路径
  const mod = this.hotModulesMap.get(path); // 获取与更新路径对应的模块

  if (!mod) {
    // 如果模块不存在,可能是因为在代码分割项目中,热更新的模块尚未加载
    return;
  }

  let fetchedModule: ModuleNamespace | undefined;
  const isSelfUpdate = path === acceptedPath; // 判断是否为自身更新

  // 查找  import.meta.hot.accept 中绑定的更新回调函数
  const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
    deps.includes(acceptedPath),
  );

  // 如果是自身更新或者有合格的回调函数存在,则执行以下逻辑
  if (isSelfUpdate || qualifiedCallbacks.length > 0) {
    const disposer = this.disposeMap.get(acceptedPath); // 获取清理副作用
    if (disposer) await disposer(this.dataMap.get(acceptedPath));
    try {
      fetchedModule = await this.importUpdatedModule(update); // 导入更新后的模块
    } catch (e) {
       //略 如果导入失败,则记录警告信息
    }
  }

  // 返回一个回调函数
  return () => {
    // 对合格的回调函数进行遍历调用
    for (const { deps, fn } of qualifiedCallbacks) {
      fn(
        deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)),
      );
    }
  };
}

fetchUpdate首先会获取pathacceptedPath————一些情况下,这两个参数并不相同,比如某些模块不接受自更新,因此path是这个模块的父模块。

如果path所对应的模块并没有在hotModulesMap注册,那么无事发生。返回。

如果存在,那么查找path对应的模块是否接受自身更新,或者在accept注册过回调函数。

如果有,那么就先尝试清理旧模块的副作用。

然后使用importUpdatedModule引入新的模块——还记得importUpdatedModule吗,在new HMRClient的时候传进来的第三个参数。

最后返回一个依次执行所有回调函数的函数,这些回调会执行importUpdatedModule的结果。

所以updateQueue队列里面存的就是这些函数。

也就是说是,updateQueue队列的函数并非是清理旧模块、引用新模块,这个操作在fetchUpdate包装完当前模块的时候,就已经执行完毕了,它实际上是import.meta.hot.accept注册的回调函数。

同时我们注意到,这在这里使用hotModulesMapdisposeMap等集合。

实际上它们就是Vite的热更新apiacceptdispose所形成的的集合,这些api给第三方框架使用,以此来实现第三方框架的热更新。

importUpdatedModule重新引入新的模块后,会被中间件拦截,然后根据模块依赖图查找它们的transformResult,因为改动的文件对应的模块被清除了transformResult,因此重新走模块转译逻辑,最后通过中间件返回转译后的代码。

返回后的代码会被import.meta.hot.accept注册的回调函数所处理,比如给对应的元素重新赋值等等,至于详细怎么处理,这是第三方框架的事情了。

结束

我们这次讲了Vite的热更新大致逻辑,并没有逐行解析,因为它的热更新并非招牌,我们将在下一章讲到剩下最后一个坑--预构建。

0
博主关闭了所有页面的评论