Vite 源码分析 - client (下)| 8月更文挑战

2,504 阅读5分钟

这是我参与8月更文挑战的第20天,活动详情查看:8月更文挑战

前言

上篇主要介绍了 client 下 envoverlay 两个文件, client 最主要的处理逻辑 client/client.ts 中,本篇继续分析。

client/client.ts

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')
const base = __BASE__ || '/'

首先,创建了一个 WebSocket 的客户端,并且定义了 ws 的协议、命名空间。

socket.addEventListener('message', async ({ data }) => {
  handleMessage(JSON.parse(data))
})

然后通过监听 message 事件,把客户端收到的事件传入 handleMessage 进行处理。

socket.addEventListener('close', async ({ wasClean }) => {
  if (wasClean) return
  console.log(`[vite] server connection lost. polling for restart...`)
  await waitForSuccessfulPing()
  location.reload()
})

通过监听 close 事件,调用 waitForSuccessfulPing,然后刷新页面。

展开一下 waitForSuccessfulPing 的实现。

async function waitForSuccessfulPing(ms = 1000) {
  while (true) {
    try {
      await fetch(`${base}__vite_ping`)
      break
    } catch (e) {
      await new Promise((resolve) => setTimeout(resolve, ms))
    }
  }
}

可以看到 waitForSuccessfulPing 通过请求 /__vite_ping 来检查当前 WebSocket 是否还处于连接状态,也就是 ws 应用中的心跳检测。

当请求失败时,会在 1000 ms 后进行重试,这个方法通过 whilecatch 部分的 setTimeout 实现了无限请求直到某个条件触发停止请求的功能。在实现其他类似功能的时候可以借鉴。

当服务端发来 WebSocket 消息时,可以看到会触发 handleMessage 方法,具体介绍一下这个方法。

async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
    case 'connected':
      console.log(`[vite] connected.`)
      setInterval(() => socket.send('ping'), __HMR_TIMEOUT__)
      break
    // ...
  }
}

首先检查消息类型是否是 connected 状态,如果是 connected 状态,则开启定时发送 ping 的操作。这次 send 操作的目的就是 心跳检测

继续看其他的消息类型。

case 'update':
  notifyListeners('vite:beforeUpdate', payload)
  if (isFirstUpdate && hasErrorOverlay()) {
    window.location.reload()
    return
  } else {
    clearErrorOverlay()
    isFirstUpdate = false
  }
  payload.updates.forEach((update) => {
    if (update.type === 'js-update') {
      queueUpdate(fetchUpdate(update))
    } else {
      let { path, timestamp } = update
      path = path.replace(/\?.*/, '')
      const el = (
        [].slice.call(
          document.querySelectorAll(`link`)
        ) as HTMLLinkElement[]
      ).find((e) => e.href.includes(path))
      if (el) {
        const newPath = `${base}${path.slice(1)}${
          path.includes('?') ? '&' : '?'
        }t=${timestamp}`
        el.href = new URL(newPath, el.href).href
      }
      console.log(`[vite] css hot updated: ${path}`)
    }
  })
  break

如果收到的是 update 类型时,首先通过 notifyListeners 触发 vite:beforeUpdate 监听事件。

然后如果是首次更新且当前有异常报错时,执行刷新页面的操作。

这一步的目的是,清除上一次的报错信息,重新执行当前的代码逻辑。

否则,请求报错信息,并更新是否是首次更新的变量标记。

然后遍历需要更新的 updates,分别进行 queueUpdate 或重新拼接 css 的 link 标签,重新请求 最新的 css 资源

这段代码包含了两个方法,分别是 notifyListenersqueueUpdate

function notifyListeners(event: string, data: any): void {
  const cbs = customListenersMap.get(event)
  if (cbs) {
    cbs.forEach((cb) => cb(data))
  }
}

可以看到,notifyListeners 其实就是一个简单的发布订阅模式的实现,通过 customListenersMap 存储事先绑定好的事件及回调,当调用 notifyListeners 时进行批量通知。

另外有意思的是,可以看下 notifyListenersTypescript 函数签名。

function notifyListeners(
  event: 'vite:beforeUpdate',
  payload: UpdatePayload
): void
function notifyListeners(event: 'vite:beforePrune', payload: PrunePayload): void
function notifyListeners(
  event: 'vite:beforeFullReload',
  payload: FullReloadPayload
): void
function notifyListeners(event: 'vite:error', payload: ErrorPayload): void
function notifyListeners<T extends string>(
  event: CustomEventName<T>,
  data: any
): void

可以看到,notifyListeners 可以接收不同类型的 event 和对应的 payload,具体的对应关系也可以直接通过函数签名看到。

接下来说说 queueUpdate(fetchUpdate(update))

async function fetchUpdate({ path, acceptedPath, timestamp }: Update) {
  const mod = hotModulesMap.get(path)
  if (!mod) {
    return
  }

  const moduleMap = new Map()
  const isSelfUpdate = path === acceptedPath

  const modulesToUpdate = new Set<string>()
  if (isSelfUpdate) {
    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))
  })
  // ...
}

fetchUpdate 方法中,首先查看 hotModulesMap 中是否存储了当前更新文件的路径,如果不存在则直接结束。

由于文件的更新一定是由一个源头的文件更改,进而引起引用了该文件的其他文件发生的变动,所以这里先判断源头文件是否和当前文件相同,如果相同时,则将当前文件添加到 modulesToUpdate。否则查看依赖了源头文件的所有文件,即 mod.callbacks 每一项中的 deps,如果符合需要更新的条件,则也添加到 modulesToUpdate

最后遍历 mod.callbacks 来将已经添加到 modulesToUpdate 中的文件进行筛选,最后赋值给 qualifiedCallbacks

值得一提的是,其实这两个过程是可以合二为一的,在一次循环中实现,但是为了代码更加清晰易懂,被分成了两部分。这一点其实在开发的过程中也是可以借鉴的,绝大多数的应用和逻辑对性能并没有那么高要求。

相反,如果牺牲一点性能而换来代码更好的可读性也许更好。

接着看 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 {
        const newMod = await import(
          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}`)
  }
}

通过 动态引入 的方式,请求 Vite 的开发服务器,来获取对应的最新文件资源。

  • 获取失败时,执行请求失败的打印操作。
  • 获取成功时,存入 moduleMap

最后返回了一个函数,执行这个函数会将模块的最新内容,即 moduleMap 存储的内容,传入 callback 的 fn 回调函数中,然后打印当前 Vite 已经更新的文件路径。

下面接着说其他的 handleMessage 事件类型处理。

 case 'full-reload':
  notifyListeners('vite:beforeFullReload', payload)
  if (payload.path && payload.path.endsWith('.html')) {
    const pagePath = location.pathname
    const payloadPath = base + payload.path.slice(1)
    if (
      pagePath === payloadPath ||
      (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)
    ) {
      location.reload()
    }
    return
  } else {
    location.reload()
  }
break

关于 full-reload 事件,会验证是否传入的文件是否是 index.html 或其他 非 html 页面 ,是则执行刷新页面。

再说说 error 事件。

case 'error': {
  notifyListeners('vite:error', payload)
  const err = payload.err
  if (enableOverlay) {
    createErrorOverlay(err)
  } else {
    console.error(
      `[vite] Internal Server Error\n${err.message}\n${err.stack}`
    )
  }
  break
}

还记得上篇提到的 ErrorOverlay 组件吗,当触发 error 事件时,会通过 createErrorOverlay 创建一个 ErrorOverlay 组件。

具体的实现就是向 body 中添加一个 ErrorOverlay 的实例:document.body.appendChild(new ErrorOverlay(err))

其他的事件都是 notifyListeners 类似的处理,这里不再赘述。

还有还有关于样式的处理,主要是通过 new CSSStyleSheet,并调用对应的 replaceSync 更新样式内容,及 removeChild 移除样式代码。

client.ts 的结尾,有一段代码:

function injectQuery(url: string, queryToInject: string): string {
  if (!url.startsWith('.') && !url.startsWith('/')) {
    return url
  }

  const pathname = url.replace(/#.*$/, '').replace(/\?.*$/, '')
  const { search, hash } = new URL(url, 'http://vitejs.dev')

  return `${pathname}?${queryToInject}${search ? `&` + search.slice(1) : ''}${
    hash || ''
  }
}

这段代码的作用是,传入 url 和需要添加到 url 上的参数,最后返回拼接后的 url 结果。

可以看到,首先判断是否是以 ./ 开头,直接符合这两种情况任一种,才需要进行注入。

其他情况时,通过正则把 # 的 hash 参数,以及 ? 后的 url 参数剔除。

然后传入 new URL(),这一步的目的是利用浏览器提供的 URL api,来将 url 中存在的 hash? 后的参数 提取出来,最后再拼接上开发者想要拼上的参数。

这块可以借鉴的思路在于,可以利用现成或浏览器已存在的 api 去避免手动实现类似的功能。

到此,关于 Vite 源码中 client 的部分已经分析完了。