Skip to content

记一次 Top-Level Await 引发的线上“白屏案”

嘿,各位同道中人!👋 我是你们的老朋友,一个混迹在代码世界、善于于解决各种疑难杂症的前端架构师。今天,我想跟大家聊一次前不久刚经历的、堪称“惊心动魄”的线上问题排查。主角是谁呢?就是那个让我们又爱又恨的家伙——top-level await(简称 TLA)。

准备好你的咖啡 ☕️,让我们一起回到那个“案发现场”。

前言:Top-Level Await 是个啥?

在开始我们的故事之前,先给可能不太熟悉的朋友们简单科普一下 TLA。

在以前,如果我们想在 JavaScript 模块的顶层(也就是不在任何 async 函数内部)使用 await,浏览器会无情地抛出一个 SyntaxError。这逼得我们不得不写出下面这种有点丑陋的“立即调用的异步函数表达式”(IIAFE)来进行异步初始化操作:

javascript
// 丑陋的过去
(async () => {
  const config = await fetch('/api/config');
  // ... 接下来才能愉快地玩耍
})();

而 top-level await 的出现,彻底解放了我们!它允许我们直接在模块的顶层 await 一个 Promise,代码变得无比清爽和直观:

javascript
// 优雅的现在
const config = await fetch('/api/config');
// ... 直接开始玩耍,爽!

这个特性对于处理动态模块加载、资源初始化等场景简直是神器。然而,正是这个“神器”,差点让我们的 UAT 环境翻车...

主体:UAT 环境的“白屏刺客”

那是一个平平无奇的下午,测试同学突然在群里 @ 我:“大佬,UAT 环境上不去了!打开就是一片白,控制台啥也没有!”

我的第一反应是:“不可能,绝对不可能!” 毕竟本地开发环境跑得好好的,提测前也反复确认过。但事实胜于雄辩,我打开测试链接,果然,熟悉的页面变成了一片刺眼的白,F12 打开控制台,空空如也,没有一个 console.log,更没有一个 error。Network 面板显示所有资源都已 200 加载成功。

情况瞬间变得棘手起来。没有错误信息,就像在伸手不见五指的黑夜里寻找一个不存在的敌人。但作为一名老兵,我知道慌张是没用的,必须冷静下来,一步步排查。

第一步:信息收集与初步诊断

我首先拉着测试同学确认了几个关键信息:

  • 影响范围:并非所有人都无法访问,只有一部分使用特定版本浏览器的同事复现了问题。经过统计,出问题的几乎清一色是某个旧版本的 Chrome(89以下)
  • 环境差异:本地开发环境 (vite dev) 正常,打包后的预览环境 (vite preview) 正常,只有部署到 UAT 的生产构建包 (vite build) 出了问题。

线索开始清晰起来:问题出在生产构建的产物上,并且与特定的浏览器版本强相关。

第二步:二分法定位“真凶”

没有错误日志,最笨也最有效的方法就是“二分定位法”。我开始注释掉项目入口文件 main.js 里的代码,一块一块地放开,然后重新打包部署,观察页面是否恢复。

这个过程是枯燥的,但非常有效。最终,我将问题锁定在了一个刚刚重构过的模块上。这个模块负责初始化我们应用的一些核心服务,里面用到了一个非常酷的特性——没错,就是 top-level await。

javascript
// a-service.js
import { someAsyncSetup } from './utils';

// 我们在这里 await 了一个异步的设置函数
const serviceConfig = await someAsyncSetup();

export function getServiceConfig() {
  return serviceConfig;
}
javascript
// b-service.js
import { anotherAsyncSetup } from './utils';

// 这里也 await 了一个
const anotherConfig = await anotherAsyncSetup();

export function getAnotherConfig() {
  return anotherConfig;
}
javascript
// main.js
import { getServiceConfig } from './a-service.js';
import { getAnotherConfig } from './b-service.js';

// ... a lot of code

当我把这些 await 调用全部去掉,换成旧的 .then() 写法后,奇迹发生了——UAT 环境在问题浏览器上恢复了正常!

真凶终于浮出水面:top-level await!

第三步:追根溯源,寻找证据

定位到问题后,我并没有立刻动手改代码。作为一名有追求的架构师,我必须搞清楚“为什么”。为什么 TLA 会在特定版本的 Chrome 上导致白屏?

我立刻开始在网上搜索,关键词包括:“top-level await chrome white screen”、“vite top-level await bug”、“chrome module graph issue”。

很快,我在一些 Chromium 的 bug 报告和 Stack Overflow 的帖子里找到了蛛丝马迹。TLA 的实现极大地改变了 JavaScript 引擎(V8)处理模块图的机制。它从一个同步的、确定性的过程,变成了一个复杂的异步状态机。

在这个复杂的机制下,一些边缘情况(edge case)就可能导致 bug。我推测,在一些旧版本的 Chrome(89版本之前)所搭载的 V8 引擎中,存在一个 bug:当模块图中存在多个并行的 top-level await 时,引擎在处理模块依赖和执行顺序上可能陷入了某种死锁或无限等待的状态,最终导致整个应用的执行流程被中断,但又没有抛出任何异常。

下面是我绘制的整个排查与解决流程图:

第四步:亮出“杀手锏”—— vite-plugin-top-level-await

知道了原因,解决方案就清晰了。我们不可能要求所有用户都立刻升级浏览器。我们也不想手动把所有 TLA 代码都改回丑陋的 IIAFE 模式。那么,最好的办法就是在构建阶段,让工具自动帮我们做这个转换。

这时候,社区的强大就体现出来了。我找到了一个完美的 Vite 插件:vite-plugin-top-level-await

它的工作原理非常简单:在 vite build 的时候,它会遍历你的代码,找到所有 top-level await 的用法,然后巧妙地将它们转换成一个能被旧版本浏览器兼容的异步 IIFE 包裹起来。对我们开发者来说,这个过程是完全透明的!

集成步骤如下:

  1. 安装依赖

    bash
    # pnpm
    pnpm add -D vite-plugin-top-level-await
    
    # npm
    npm install vite-plugin-top-level-await --save-dev
    
    # yarn
    yarn add vite-plugin-top-level-await --dev
  2. 修改 vite.config.js

    javascript
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    import topLevelAwait from 'vite-plugin-top-level-await'; // 引入插件
    
    export default defineConfig({
      plugins: [
        vue(),
        topLevelAwait({
          // The export name of top-level await promise for each chunk module
          promiseExportName: '__tla',
          // The function to generate import names of top-level await promise in each chunk module
          promiseImportName: i => `__tla_${i}`
        })
      ],
    });

配置好之后,我立刻重新打包并部署到 UAT 环境。然后,我让测试同学在问题浏览器上再次验证——熟悉的页面回来了! 至此,这场“白屏血案”宣告破案。

总结:拥抱新技术,但心存敬畏

这次的经历让我感触颇深:

  1. 新技术是双刃剑:top-level await 这样的新特性确实能极大地提升我们的开发体验和代码质量,但它们通常也伴随着更高的风险。在享受便利的同时,我们必须意识到其底层实现的复杂性,以及在不同环境(尤其是旧环境)中可能存在的兼容性问题。
  2. 排查问题的思路很重要:面对没有头绪的问题,保持冷静,建立一套科学的排查流程(信息收集 -> 大胆假设 -> 小心求证 -> 解决问题 -> 追根溯源)至关重要。
  3. 善用社区和工具:我们不是一个人在战斗。开源社区和强大的构建工具生态是我们最坚实的后盾。遇到问题时,一个好的插件往往能帮我们省下大量的时间和精力。
  4. 对浏览器兼容性心存敬畏:作为前端开发者,我们永远不能想当然地认为用户都在使用最新版的浏览器。对目标浏览器进行充分的测试,并准备好兼容性回退方案,是职业素养的体现。

好了,今天的分享就到这里。希望我这次的“踩坑”经历能对大家有所帮助。让我们一起交流,共同进步!我们下次再见!🚀