先说结论:绘制快,不等于用户能开始干活
最近我们在给 Owll Translator 的 Web 实时语音工具做首屏优化。做之前我看了一眼数据,LCP 中位数 416 ms,按大多数性能面板的标准,这已经是“绿色、优秀”了。
但我自己打开页面的体感完全不是这么回事:标题和说明文字很快就出来了,可真正要用的那个工具——语言选择、原文/译文面板、Start 按钮——要等好几秒才出现。在受控测试里,旧版“工具首次出现”的中位数是 5557 ms。
改完之后,同样条件下:
- 工具首次出现中位数:5557 ms → 293 ms
- LCP 中位数:416 ms → 324 ms
这里我必须先把话说清楚,免得被误读:这次最大的改善是工具界面出现的时机,不是 LCP。LCP 对应的数字是 416→324 ms,提升有,但不是重点。5557→293 这组数字,是我们自定义的“工具可见”探针测出来的,跟 LCP 不是一回事。
另外,标题里的“LCP 很好”也只是中位数现象。旧版三次样本的 LCP 分别是 6672、416、376 ms,其中一次明显不好。我不打算假装旧版的 LCP 一直很优秀。
这篇文章想讲的核心问题其实很朴素:浏览器的绘制指标衡量的是绘制事件,而用户关心的是任务界面什么时候出现、操作有没有响应、什么时候能开始完成任务。 三者有关,但不是同一个时间点。
顺带一提,我们 App 端语音链路的延迟优化之前写过一篇英文的:Shaving Latency Off Real-Time Speech Translation。那篇讲的是“说完话到听到译文”的链路,这篇只讲 Web 页面的启动。
旧版到底卡在哪
一条被异步过程“包住”的渲染链
旧版的客户端组件大致是这样跑的:
SSR 显示标题与 "Preparing" 文案
→ 客户端加载 controller / transport / API adapter
→ 创建 controller
→ bootstrap:查询语言目录和剩余额度
→ bootstrap 结束后才 setController
→ 首次挂载真正的工作区
精简后的结构大概是这样:
Promise.all([loadController(), loadTransport(), loadApi()])
.then(([controllerModule, transportModule, api]) => {
const controller = createController(controllerModule, transportModule, api);
controller.bootstrap().finally(() => setController(controller));
});
// 没有 controller 时只显示 Preparing;有了才渲染 Workspace
有人第一反应会是“接口串行了吧”。不是。语言目录和余额本来就是并行请求的,这次的改善也不是靠把串行改并行得来的。
真正的问题是:“检查还没完成”被实现成了“主要工具还没出现”。 语言和额度检查必须有,这没错;错在把整个工作区的渲染挂在了这条异步链的最后面。模块慢、网络慢、接口失败,任何一环都会推迟工具出现。
为什么 LCP 没报警
LCP 并没有固定绑定到工作区上。在旧版样本里,LCP 候选元素有 p 也有 img。较早绘制出来的段落文字就能产生一个很低的 LCP,而这时候工具还在等。
我不想把这个案例泛化成“LCP 总是只测标题”,也没有证据说每次 LCP 都来自标题。准确的说法是:在这个页面上,LCP 低,和用户能看到工具,没有必然关系。
先把三种时间分清楚
动手前,我先把要优化的“时间”定义清楚,不然很容易优化错目标。
| 时间或状态 | 本次含义 | 能证明什么 |
|---|---|---|
| FCP / LCP | 浏览器 Paint Timing / LCP 事件 | 有内容绘制了,或采样窗口内最大的候选内容绘制了 |
| 工具外壳首次可见 | 自定义探针首次观察到原文、译文标题和主按钮有可见布局 | 主要工具结构出现了,不代表已经允许录音 |
| 服务就绪 | 真实控制器、语言目录、可用试用额度和合法语言方向都确认后,Start 可用 | 用户可以显式开始会话,不代表会话已连接 |
目标很明确:把第二项大幅提前,尽量不伤第一项和交互性能,同时第三项必须保持真实。 不能为了“看起来马上可用”去伪造语言目录、额度或者服务就绪状态。
改造:完整工具先渲染,服务在后台检查
新的加载流程
服务端 HTML:完整工具 + 禁用的 Start
→ 首次 hydration:同一个工作区 + 初始快照
→ 后台动态加载模块
→ 创建并挂接 controller
→ bootstrap:目录和额度检查
→ 快照有效:配置语言,启用相关操作
→ 快照无效:工作区保留,显示错误和显式重试
客户端组件不再根据“controller 存不存在”切换整个页面,而是始终返回同一个工作区,把一个可能为空的 controller 和初始化状态传进去。controller 一创建就先挂接给界面,然后再等 bootstrap:
async function initialize() {
if (!ownedController) {
const modules = await loadOptionalVoiceModules();
ownedController = createController(modules);
setController(ownedController);
}
await ownedController.bootstrap();
setInitializationState('ready');
}
首屏显示的是同一套语言控件、Original/Translation 面板、Start 和 Settings。加载过程中变化的只有状态文案、已确认的语言名称和启用状态。我们没有拿一张不可交互的图片冒充工具,也没有塞假语言、假余额。
这里顺便澄清一个常见误解:Next.js 里的 use client 是客户端边界声明,不等于这个组件就没有服务端 HTML。我们直接关掉 JavaScript 去看,页面上能看到真实的控件和字幕面板,说明初始工作区确实已经在 HTML 里了。
用稳定的初始快照保证 hydration 一致
工作区通过 useSyncExternalStore 订阅 controller。现在首屏时 controller 可以是 null,所以需要一个内容一致、引用稳定、不会被修改的初始快照:
const INITIAL_VOICE_SNAPSHOT = Object.freeze({
phase: 'idle',
configuration: Object.freeze({
mode: 'manual', sourceLanguage: null, targetLanguage: null,
}),
activeRunId: null,
languages: null,
transcripts: Object.freeze([]),
remainingSeconds: null,
trialAvailability: 'unknown',
backendAvailability: 'unavailable',
audioBlocked: false,
autoPlayEnabled: true,
error: null,
});
这几个字段的取值是刻意的:
languages: null表示目录还没确认,不假装只有固定两种语言;remainingSeconds: null表示额度未知,不先默认“还剩 120 秒”;phase: 'idle'只是还没有会话,不代表服务可用;- 初始化文案是 “Checking voice service”,Start 保持禁用。
订阅部分精简后是这样:
const getServerSnapshot = () => INITIAL_VOICE_SNAPSHOT; // 放在组件外
const subscribe = useCallback(
(listener) => controller ? controller.subscribe(listener) : () => undefined,
[controller],
);
const getSnapshot = useCallback(
() => controller?.getSnapshot() ?? INITIAL_VOICE_SNAPSHOT,
[controller],
);
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
一个容易踩的坑:getServerSnapshot 要返回同一个模块级对象,别每次读取都 new 一个。服务端和首次 hydration 用相同内容,controller 挂上之后再订阅真实状态;引用稳定还能避免不停产生新对象带来的多余更新。
为了确认“同一个工作区”不是嘴上说说,浏览器测试会先保存 H1、工作区、语言按钮、字幕面板、状态节点和 Start 的 DOM 引用,等 bootstrap 完成后逐个比较,确认主要节点没有被替换。
提前显示,不等于提前允许录音
这是我觉得整件事里最不能偷懒的地方。
Start 的实际启用条件包括:初始化流程结束、存在真实 controller、后端和试用状态可用、真实语言目录非空、余额已确认且大于零、源语言和目标语言都存在且不同,并且没有“会话创建结果未知”的状态。
加载期间,用户可以打开 Settings 看看工具结构;但语言选择、开始、会影响会话的设置,依然受能力检查约束。麦克风和创建会话只在用户点 Start 之后才触发。
还有个细节特别容易写错:controller.bootstrap() 内部可能会 catch 错误、把失败写进 snapshot,然后让 Promise 正常 resolve。所以 initializationState = 'ready' 只代表“初始化流程跑完了”,不代表后端可用。工作区还会再看真实的 backend / trial / error 快照,决定是否显示错误和重试。把 Promise resolve 当成业务成功,是这类改造里很典型的 bug 来源。
别把渲染等待变成输入卡顿
这次没有新增依赖,也没有重写 SDK,主要靠三类约束。
保留动态模块边界
controller、transport、API adapter 仍然在后台动态 import,没有全部塞进首屏同步执行,也没有挪到 Start 的点击处理器里同步加载。首屏工作区不需要等它们就能显示。
但我得诚实一点:动态加载不等于没有下载、解析和主线程成本。这次主线程长任务总时长中位数从 182 ms 变成了 191 ms,多了 9 ms。这个数据我保留,不能把“异步 import”说成“消除了 SDK 开销”。
关闭的语言选择器不做排序和渲染
后端语言目录有 136 个语言/地区选项。选择器没打开时直接返回一个稳定的空数组,打开时才排序、过滤、挂载选项:
const sortedLanguages = useMemo(
() => pickerField ? [...languages].sort(compareLanguageLabels) : EMPTY_LANGUAGES,
[languages, pickerField],
);
const matchingLanguages = useMemo(() => {
if (!pickerField) return EMPTY_LANGUAGES;
const query = languageSearch.trim().toLocaleLowerCase('en');
return sortedLanguages.filter((language) => !query ||
`${language.label} ${language.code}`.toLocaleLowerCase('en').includes(query));
}, [languageSearch, pickerField, sortedLanguages]);
这样关闭状态下不会构造选项 DOM,会话快照更新时也不会反复处理目录。代价是排序挪到了第一次打开时,可能让“打开”变慢一点,所以必须实测,不能凭这个改法就宣称 INP 一定更好。我们也没上虚拟列表、Web Worker 或新的状态库——没必要。
能用原生交互就用原生
Settings 用的是 <details> / <summary>,语言选择用 <dialog> 和原生列表控件。关掉 JavaScript 时 Settings 仍然能展开。不过我只说这次的行为,不断言原生控件在所有设备上自动满足全部可访问性要求。
控件尺寸要稳,别让数据把面板挤下去
只把工作区提到首屏还不够。手机上语言按钮的文字宽度有限,“Choose language” 和 “English (United States)”、“Chinese (Simplified)” 换行数不同,数据一返回就可能把字幕面板往下推。
我们给名称区域预留了 min-h-[3.75rem],配合 line-clamp-3,测试环境下预留高度是 60 px,完整名称保留在 title 和选择器选项里。一开始审查时用的是两行预留,后来发现不够,改成了三行——这是一次真实的修正,而不是靠把名称缩短来掩盖问题。
验证方式:受控慢初始化测试在 360×780 和 1280×900 两个视口下,比较源语言值、目标语言值、字幕区、原文面板、译文面板的 x/y/width/height。服务确认前后差值都不超过 1 px,语言值高度都是 60→60 px。三次移动端性能样本里,原文/译文标题和 Start 从首次显示到服务就绪,y 坐标差值都是 0。
失败和重试,要和首屏一起设计
工具提前出来了,那接口失败时怎么办?这部分如果不一起设计,首屏优化就是在制造新 bug。
- 模块加载有 20 秒的有界等待,组件卸载时会解除等待、清掉计时器;bootstrap 和 API 原有的约 10 秒超时继续生效。注意,浏览器原生 dynamic import 的下载并不能被 AbortSignal 真正取消,取消的是等待和后续的实例/状态使用。
- 重试复用已有 controller,通过一个
initializationPromise保持 single-flight。快速双击不会创建两个 controller,也不会重复发起一批 bootstrap。 - 重试只是重新检查服务:不会自动开始录音、不会换游客身份、不会恢复已耗尽的额度,也不会对创建结果未知的会话自动再发一次创建。
- 页面 hidden / pagehide 和组件卸载时,仍会停止或销毁已有控制器。没有为了首屏快一点删掉媒体清理逻辑。
我们在受控测试里分别制造了目录失败和余额失败,然后双击 Retry:每个接口只新增一次 bootstrap 请求,身份保持不变,没有触发麦克风、创建会话或轮询。
怎么测:前后条件必须一致
实验设置
对比用的是两个确切版本的生产构建,分别在本地跑,并核对代码版本、构建 ID 和组件内容哈希,确认跑的就是对应代码。
| 条件 | 设置 |
|---|---|
| 浏览器 | Chromium 153 |
| Node | 20 |
| 构建模式 | Production build,不用 dev/HMR 对比 |
| 移动视口 | 360×780,deviceScaleFactor=1 |
| CPU | CDP 节流 4× |
| 每版本样本 | 3 个新建浏览器上下文 |
| 接口 | 同一份 136 项目录 fixture 和 120 秒余额 fixture |
| 接口延迟 | 每个 POST 固定延迟 5000 ms,两个检查保持并行 |
| 会话操作 | 不点 Start,不采麦克风,不建房,不轮询 |
| 交互脚本 | 20 次 Settings 切换,打开源语言选择器,真实按键输入 Japanese、清空、Escape |
局限也要摆在台面上:新建 context 只隔离浏览器状态,不代表系统、服务端和全部缓存都清空了;三次本地测量不能代表所有设备和网络;CPU 4× 也不等于某款具体手机。另外,这次性能对比没有用真实后端和真实麦克风,跟 App 端那篇用真实链路的实验是两回事。
“工具可见”探针是怎么定义的
脚本用 requestAnimationFrame 检查三个元素:Original 标题、Translation 标题、主按钮。要求节点存在、矩形非零、样式不是隐藏或透明,第一次满足时记录 performance.now():
function hasVisibleLayout(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return rect.width > 0 && rect.height > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity) > 0;
}
它只是一个“有可见布局”的 DOM 探针,不是标准的 paint timing,没有完整检查视口相交、遮挡或像素是否已经呈现。所以 293 ms 不能说成“整套工具已经像素级绘制完成”,更不能当成 Start 已可用。我们用截图、无 JS 的 HTML 和 DOM 保留/几何断言做补充验证。
其他指标的口径
- LCP 取第一次输入前的最后一个候选,记录元素类型和大小,是本地受控窗口,不是 CrUX。
- 布局偏移排除 hadRecentInput,累计第一次输入前的值。这是代理量,没有按标准 CLS 的 session window 计算。
- Event Timing 只观察 duration ≥ 16 ms 的事件,再按 interactionId 分组,不是标准 INP。
- Playwright 的 Settings 点击耗时包含测试框架自动等待的成本。
结果:好的、变差的,都放出来
| 指标 | 旧版 | 新版 | 说明 |
|---|---|---|---|
| 工具可见探针,中位数 | 5557 ms | 293 ms | 主要改善,约提前 5.26 秒 |
| FCP,中位数 | 416 ms | 324 ms | 绘制指标 |
| 输入前 LCP,中位数 | 416 ms | 324 ms | 别和工具显示时间混用 |
| 输入前 LCP,样本最大值 | 6672 ms | 772 ms | 三样本最大值,不是 field p95 |
| 输入前布局偏移累计,中位数 | 0.293568 | 0.000218 | 实验室代理量,非标准 CLS |
| 输入前长任务总时长,中位数 | 182 ms | 191 ms | 多了 9 ms |
| Settings 脚本点击 p98 | 79 ms | 82 ms | 多了 3 ms,含 Playwright 成本 |
| Event Timing 原始事件 p98 | 120 ms | 72 ms | 事件粒度,非 INP |
| interactionId 分组 p98 | 64 ms | 64 ms | 受控交互代理量 |
三次独立样本:
| 版本 | 工具探针 ms | FCP ms | 输入前 LCP ms 与候选 |
|---|---|---|---|
| 旧版 | 6333 / 5557 / 5503 | 1188 / 416 / 376 | 6672 img / 416 p / 376 p |
| 新版 | 345 / 293 / 266 | 376 / 324 / 296 | 772 img / 324 p / 296 p |
有一点要特别说明:所有旧版的工具标记都晚于 fixture 接口响应,所有新版的都早于响应。新版的服务确认仍然是在 5 秒接口延迟之后才完成的——不存在“后台 5 秒的接口现在 0.29 秒就返回了”这种事。我们改的是用户看到工具的时机,不是接口速度。
关于交互,我能负责任说的是:在这组受控交互里,interactionId 分组 p98 保持 64 ms,没看到明显退化。真实用户的 INP 我们还没有足够数据,不能说新版在所有设备上 INP 都不变。
桌面端新版另有一个单次样本:工具探针约 310 ms、FCP 344 ms、LCP 756 ms、布局偏移累计 0。但没有桌面基线,所以只当观察,不说桌面提升了多少。
功能上我们也确认了:关 JS 时 HTML 里有真实语言按钮、Original/Translation、禁用的 Start 和 “Checking voice service”;接口卡 5 秒时工作区完整、Settings 可响应、语言和 Start 禁用;Node 20 类型检查、生产构建、74 项语音单测和相关浏览器回归都通过。
几个我认为值得带走的判断
- 提前显示的应该是真实工作区,未知能力保持 disabled。 用户能理解页面结构,也知道系统正在检查什么。
- 一个工作区 + 一个稳定初始快照,好过两套 loading/ready 界面。 两套界面迟早会漂移。
- 动态加载可以保留,但别承诺它消除了成本。 长任务多了 9 ms,就写多了 9 ms。
- 惰性处理会把成本挪到别处。 选择器关闭时不排序,代价转到第一次打开,所以要用真实点击和输入去测。
- 动态数据的布局稳定性要单独验。 一个语言名称的换行,就能把面板推下去。
- Promise resolve 和业务成功是两件事。 失败快照要能驱动明确的重试界面。
- 同条件测量,保留异常样本和变差的数据。 一个平均分替代不了用户的任务体验。
还没做的事我也列一下:标准 web-vitals 采集和真实用户的 INP/CLS、更多设备和网络样本、更严格的基于视口和绘制的工具显示指标、服务就绪的业务指标。这些是后面的工作,现在还谈不上有完整的 RUM 体系。
最后回到标题。LCP 是个好指标,但它衡量的是“画出了什么”,而不是“用户能不能开始干活”。对一个工具型页面来说,把真实工作区先端上来、把服务确认放进一个明确的后台状态,用户看到工具的时间可以从 5 秒多缩到 0.3 秒左右——前提是你同时守住交互、布局和功能的正确性。
如果你想亲自感受一下现在的启动体验,可以打开 Owll Translator 试试。