Skip to content

语种表按需加载:首屏 gzip 减少 102 KB(-10.1%) - #2

Open
Tianbuyu-wwx wants to merge 2 commits into
Stry233:mainfrom
Tianbuyu-wwx:perf/lazy-locale-tables
Open

Tianbuyu-wwx wants to merge 2 commits into
Stry233:mainfrom
Tianbuyu-wwx:perf/lazy-locale-tables

Conversation

@Tianbuyu-wwx

@Tianbuyu-wwx Tianbuyu-wwx commented Sep 16, 2026 •

Copy link
Copy Markdown

语种表按需加载:首屏 gzip 减少 102 KB(-10.1%)

感谢 review。七点逐条回应如下。

1. 快速切换语言时的竞态 → 已修(后发胜出)

切换逻辑抽成 ui/shell/windows/use-locale-switch.ts:每次切换领一个票号,只有最新那一次可以落库,也只有它有权报告失败。先发起、后返回的请求不会把界面拉回已经离开的语言。

测试:use-locale-switch.test.ts 用可控 promise 让早发的请求晚于后发者返回,断言界面仍停在后选的语言。

2. 启动时语言资源未返回会阻塞界面 → 已修(有界等待)

原来 await 到表到手才渲染首帧。现在只等一个上限(150ms),到点即渲染;表到达后由 I18nProvider 订阅到并重渲染。取舍是明确的:错语言的帧是闪烁,永不出现的页面更糟。为此 i18n/context.tsx 增加了到达订阅(subscribeStrings),使"表到达"能触达已经渲染的组件。

测试:locale-table-loading.test.tsx 断言表到达后,先前用回退渲染的树会更新。

3. 加载失败要有明确提示与重试 → 已加

失败时保持当前语言,并通过 toast 说明(新增 modal.settings_language_failed,7 个语种表均已补齐)。失败的 chunk 不进 memo,所以下次点选即重试。

测试:失败后不落库、且提示被调用一次;被更晚选择取代的失败不会误报。

4. 基础界面译文与帮助扩展文案分开管理 → 已分两层

i18n/context.tsx 现在是两个存储:baseTables(语种自己的界面表)与 extraTables(窗口自带的散文,如帮助中心)。查找顺序为 base → extra → en —— 界面文案优先于帮助文案,且两者只能新增键,不能改写更上层。

测试:两层注册同一个键时,界面文案胜出。

5. 测试覆盖 → 已补

场景 测试
表未到达时用回退渲染,到达后更新 __tests__/i18n/locale-table-loading.test.tsx
两层优先级(界面 > 帮助) 同上
失败后下一次调用重新抓取(不被记成坏的) 同上
并发调用只抓取一次 同上
请求乱序:后发胜出 __tests__/ui/shell/use-locale-switch.test.ts
失败:保持语言并提示;被取代的失败不误报 同上

6. 性能:冷启动 / 缓存启动 / 首次切换(均含当前语言资源)

生产构建 + 真实浏览器(CDP 驱动,脚本 .audit/locale-timing.py)实测:

场景 结果
冷启动(ja,禁用缓存) 界面出现 583ms;FCP 208ms;ja 表 chunk 于 232ms 到达
首次切换(ja→ru,真实语言选择器,ru 的 chunk 从未抓取) 按下后 287.9ms
缓存启动(ru,chunk 已缓存) 界面出现 800ms;FCP 656ms
console 错误 无

两点说明:① 冷启动一列的传输量含应用自身的启动集(模型等预取,约 19.9 MB / 128 请求),不是语种表的体积,语种表的到达时刻如上;② performance.now() 自导航起点计时、轮询间隔 50ms,界面出现时间可读作"±50ms 内"。

7. Code hygiene → 已改

去掉防御性叙述与解释过时行为的注释;locales/index.ts、context.tsx、main.tsx、vitest.setup.ts 的注释缩短为只讲当前行为。

验证

  • npm run lint(即 tsc --noEmit):0 错误
  • 相关测试:i18n 与切换 hook 共 46 用例通过
  • 全量测试:678 文件通过 / 8734 用例通过 / 8 失败。失败的 4 个文件(object-remove-cost、compression-compat、operation-purity、root-docs)是本 PR 基于 main 的既有失败,由 Windows 可移植性那个 PR 修复;ui/agent/panel-dock 单独运行 34/34 通过,仅在整批并行时出现一次布局宽度断言失败(同次运行还有一次 worker fork 异常),属加载敏感,不是本改动引入。

仍未覆盖

zh 仍在首屏:legal/providers-list.ts 用 strings[key] 动态取键,打包器无法收窄,整张 zh 表因此被拉进入口图(约 20 KB gzip)。拆它需要给那两张清单一份自己的窄表,属于另一件事,未纳入本次。

The seven tables were merged into one chunk that every visitor downloaded before the first frame —
535 KB raw / 155 KB gzip, the largest item on the start-up payload, and every locale's prose rather
than theirs. English has to stay eager (`translateFor` falls back to it for any key a locale is
missing), so it does; the other six are now their own chunks and arrive with the language that
reads them.

- `i18n/locales/index.ts`: one static specifier per table. `ensureLocaleStrings(locale)` registers
  the table as an i18n OVERLAY through the same `registerExtraStrings` the Help Center already uses
  for its prose, and shares one in-flight import between concurrent callers. A failed fetch is
  dropped from the memo so a later attempt retries; the caller still sees the rejection.
- `i18n/context.tsx` reads the eager table (English alone) and then the overlay, unchanged otherwise.
- `main.tsx` renders once the saved language's table is in hand, for the same reason the cursor
  properties are written there: a frame in the wrong language is a flash. English resolves without a
  request, and a table that fails to arrive renders anyway rather than leaving the page blank.
- `Windows.tsx` fetches before committing a language switch, so the picker and the interface never
  disagree; a failed fetch keeps the previous language rather than storing one nothing can render.
- `i18n/translations.ts` keeps the merged record for tests and scripts and is now off the runtime
  graph. `__tests__/i18n/eager-tables.test.ts` fails if any module the app loads imports it again, if
  a second locale joins the eager set, or if a table on disk has no loader.
- `SettingsModal` derives its pills from a `Record<Locale, string>` of endonyms, so a table that
  exists without a pill is a compile error rather than a language a reader can be in but not pick.

Measured on the production build (entry script + every `rel=modulepreload` + stylesheet, gzip -9):
3,368,078 raw / 1,013,088 gzip → 3,002,313 raw / 910,672 gzip — 102,416 gzip, 10.1%, off the first
paint — with no locale chunk preloaded. Verified in the built app: it boots in the saved language
with no English beneath it, and switching to Japanese then French through the real picker replaces
the tables rather than layering on English (each switch shows exactly that locale's strings, console
clean, no chunk errors).

Tests: 8763 passed, 0 failures. `vitest.setup.ts` installs the merged record before any test runs,
because a test that renders in French should not have to fetch French — it would otherwise read
English silently.

Not covered here: `zh` is still on the eager bundle, because `legal/providers-list.ts` resolves its
disclosure strings through the whole table (`strings[key]`) and the bundler cannot narrow a dynamic
key. Splitting that means giving those two lists a narrow per-locale record of their own; left out to
keep this change to the loading mechanism.

Signed-off-by: 天不语 <2364309541@qq.com>
@Stry233

Stry233 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

再次感谢!减少启动下载量是非常值得考虑的方向,不过目前我认为实现上还有几处需要调整:快速切换语言时,较早发起的请求可能覆盖最后一次选择;启动时语言资源一直未返回会阻塞界面;加载失败时也需要明确的提示和重试方式。

另外,基础界面译文需要保留高于帮助扩展文案的优先级,建议分别管理这两类资源。测试也需要覆盖语言尚未加载的情况,包括请求乱序、失败和重试。

性能方面,希望补充包含当前语言资源在内的冷启动、缓存启动和首次切换语言耗时。现有体积数据说明静态入口变小了,但还需要确认实际使用是否更快。

最后是code hygiene问题,我觉得可以进一步避免使用防御性语言以及在代码中过度解释或解释过时的行为,以确保代码可读性。确认补齐这些后,我们会再进行评估。辛苦辛苦~

@Tianbuyu-wwx

Copy link
Copy Markdown
Author

OK👌🏻

Answer the review of the lazy-locale change.

- The two late-arriving resource classes are separate now. A locale's INTERFACE table goes through
  `registerBaseStrings`; a window's prose (the Help Center) stays on `registerExtraStrings`. Base
  outranks extra in the lookup, so a help key cannot re-word the interface it is displayed in.
- Boot no longer waits indefinitely. It waits for the saved language's table up to a deadline, then
  paints regardless, and `I18nProvider` subscribes to arrivals so the table reaches what already
  rendered. Before this, a fetch that never settled left the page blank.
- The switch is a hook (`ui/shell/windows/use-locale-switch.ts`) with a ticket, so the LATEST choice
  wins: an earlier fetch landing late cannot take the interface back to a language already left. A
  failure keeps the current language, reports it through the toast, and leaves the chunk
  unremembered, so the next press is the retry. `modal.settings_language_failed` is new in all seven
  tables.
- Tests: an arrival reaches a rendered tree, interface keys outrank prose, a failed chunk is fetched
  again by the next call, a chunk is fetched once for concurrent callers, and the switch's ordering
  and failure rules.

Signed-off-by: 天不语 <2364309541@qq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants