这篇文章记录的是一个很具体的问题。
如果你平时喜欢拆前端、盯运行时,或者对“明明官方没给这个入口,我能不能自己把它接出来”这种事情有天然兴趣,那你应该会理解这篇文章的出发点。
看到 Claude Web 没有中文,我最初的反应不是“等官方以后也许会加”,而是:
那我能不能自己把它做出来?
我想给 Claude Web 加一个 简体中文 语言选项,而且不是简单把界面上某几个按钮替换成中文,而是尽量复用 Claude 自己的 i18n 加载链路,让页面像切官方语言一样,在运行时重新加载语言资源。
最后做到的效果是:
- 在 Claude Web 的语言菜单里注入一个
简体中文 - 页面保持官方支持的 locale,不去伪造后端 profile
- 运行时拦截 Claude 的 i18n 请求,返回自定义中文资源
- 最关键的是,找到 Claude Web 内部真正控制语言重载的 runtime 入口,在不刷新页面的情况下触发语言重新加载
这件事表面上看像“做个浏览器扩展”,但中间真正难的不是菜单注入,也不是静态资源服务,而是这个问题:
Claude Web 页面运行时里,到底谁才是真正的语言切换入口?
如果你只想看最后的答案,可以先剧透:
window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride(...)
但如果你真的准备在某个现代前端应用里做类似的事情,我建议你别只抄这行。真正值钱的是我是怎么走到这行代码的。

问题从一开始就不是“翻译文件放哪里”
一开始很容易把问题理解成:
- 我准备一份
zh-CN.json - 让 Claude 去请求它
- 然后把菜单里多塞一个 “简体中文”
如果你也会本能地这么想,不奇怪,我一开始也是这么想的。
但很快就会撞到第一个硬约束:Claude 后端 profile 的 locale 不是任意字符串,而是一个受限枚举。
实际观察到的行为是:
- 选择官方语言时,Claude 会发一个 profile 更新请求
- 后端保存一个
locale字段,比如en-US - 如果强行尝试
zh-CN,后端会直接拒绝
也就是说,这条路根本走不通:
“把 Claude 的官方语言直接改成 zh-CN”
这不是前端 trick 不够的问题,而是后端模型就不接受。
所以从这个节点开始,整个方案必须改成:
- 官方 locale 保持 Claude 支持的值
- 自己额外维护一个扩展侧的 override locale
- 当 override 生效时,拦截 Claude 页面里的 i18n 请求,返回自定义中文语言包
后来我把这个扩展自己的状态固定成了:
localStorage["claude-i18n:locale"] = "zh-CN"
这个设计非常关键,因为它把“扩展语言模式”和“Claude 官方 profile locale”彻底解耦了。
换句话说,我不是在试图说服 Claude “你现在支持中文了”,而是在 Claude 原本语言系统的外面,再加一层自己的 override。
前半段其实是比较正常的扩展工程
项目的基本形态是一个 Chrome Extension,再配一个静态资源仓库给语言包和版本文件。
大体分成几层:
页面注入层
在 Claude 页面里插入自定义的简体中文菜单项。页面 hook 层
在 page context 里拦截fetch/XMLHttpRequest,把 Claude 对/i18n/...的请求接走。service worker 层
负责去远端拿自定义语言资源、读取版本信息、做缓存命中和更新。语言资源静态站点
提供:/i18n/zh-CN.json/i18n/statsig/zh-CN.json
以及版本清单。
其中静态资源站点还有一个看起来很小、但其实很重要的工程问题:不要把扩展目录整个发布出去。
仓库里有 extension/,如果直接把仓库当静态站点推上去,等于把扩展源码和资源一起公开暴露。后来我采用的是很朴素但有效的做法:
- 用构建脚本把允许发布的文件白名单复制到
dist/ - 只发布入口页、404 和语言目录
extension/完全不进产物
这个阶段本身不难,但它决定了后面整套方案能不能以一个比较干净的方式部署。

如果你未来也要做这种“浏览器扩展 + 页面注入 + 自己托管静态资源”的混合系统,我建议不要小看这些基础工程问题。很多项目后面跑不稳,不是死在逆向上,而是死在边界一开始就没划清楚。
注入菜单并不算最难,真正难的是“看起来切了”不等于“真的切了”
Claude Web 的语言菜单不是一个静态 DOM,而是 Radix UI 的 portal / popper 菜单。
我最开始在 DevTools 里确认到的关键结构是:
- 悬浮左侧
Language项后,右侧 submenu 会动态插入到document.body - submenu 的外层节点是:
<div data-radix-popper-content-wrapper>
- 真正的菜单节点是:
<div data-radix-menu-content role="menu">
- 这个 submenu 和左侧 trigger 的关联,不是靠文本,而是:
- submenu 上有
aria-labelledby - 左侧 trigger 有匹配的
id - 同时 trigger 的
aria-controls指回 submenu
- submenu 上有
这一步很重要,因为我一开始也走过“看文字猜是不是语言菜单”的路线。那条路能工作一会儿,但很快会在 profile 菜单、设置菜单上误注入,完全不稳。
真正可靠的识别方式,是利用 Radix 自己建立的结构关系。
这里其实有一个很通用的经验:
优先相信组件库自己维护的结构关系,不要优先相信显示给用户看的文案。

把 简体中文 菜单项注入进去之后,还有一堆 UI 细节问题:
- hover 高亮没有
- 勾选图标不对
- clone 到已选语言项时,会把选中态一起复制过去
- 布局容易歪掉
这些都能修,但它们其实都不是真正的难点。
真正的难点在于:就算你把 简体中文 这个菜单项做得跟原生一模一样,也不代表 Claude 真的完成了语言切换。
这个坑很值得单独强调,因为很多扩展最后都折在这里:
你以为你在改“功能”,其实你改的只是“表象”。
我后来遇到的一个典型 bug 是这样的:
- 当前官方语言其实还是
en-US - 我点了扩展注入的
简体中文 - 视觉上中文项勾上了,英文项取消了
- 但 Claude 内部真正的“当前官方语言”并没有变
结果就是:
- 页面看起来像中文模式
- 但再点
English切回去时,Claude 自己不一定会走完整的语言切换链路 - 勾选态和内部真实状态开始脱节
这一刻我才意识到:DOM 勾选态不是控制源。
spa:locale 也不是控制源
另一个很自然的想法是直接写 localStorage["spa:locale"]。
我确实验证过,改它有时会影响一部分可见状态,甚至会让某些地方短暂表现出“语言变了”的样子。
但它不是真正的 runtime 控制入口,原因有两个:
- Claude 的页面逻辑并不会在每次菜单 hover 或每次渲染时都把它当成唯一真值重新读取。
- 即使你把它改成
zh-CN,页面也不会自动走完整的 i18n 重载流程。
所以 spa:locale 的地位大概更像:
- 某个持久化痕迹
- 某些初始化状态的输入
但绝不是:
“我改了它,Claude 就会无刷新切语言”
这个判断让我后面少走了很多弯路。
如果你读到这里已经感觉这件事开始不像普通前端开发,而更像前端逆向了,对,就是从这里开始变味的。

真正关键的问题:Claude Web 到底怎么在运行时加载语言
后面的工作开始从“扩展工程”变成“前端逆向”。
我的核心目标被收敛成一句话:
找到 Claude Web 页面运行时里那个真正可调用的语言覆盖入口。
我当时已经掌握了一条非常关键的源码线索。Claude 的 i18n loader 大概长这样:
function rsn() {
const { locale: e } = L()
, { activeOrganization: t } = LI()
, n = t?.uuid
, s = lW(e => e.localeOverride)
, a = s ?? e
, r = lW(e => e.setGatedMessages)
, i = lW(e => e.clearGatedMessages)
, { data: o } = f({
queryKey: ["i18n_public", a],
queryFn: async () => {
const e = encodeURIComponent(a)
, t = "en-US" !== a
, [n, s, r] = await Promise.all([
fetch(`/i18n/${e}.json`),
fetch(`/i18n/statsig/${e}.json`),
t ? fetch(`/i18n/${e}.overrides.json`).catch(() => null) : Promise.resolve(null)
]);
...
}
})
...
}
以及与之配套的 Zustand-like store:
const oW = S(e => ({
messages: {},
messagesLocale: null,
gates: [],
isLoaded: false,
localeOverride: null,
setGatedMessages: ...,
setLocaleOverride: t => e({ localeOverride: t }),
clearGatedMessages: ...
}))
这两个片段已经把真相暴露得差不多了:
- 页面真正用来决定要加载哪种语言的是:
localeOverride ?? locale
- 只要
localeOverride被改掉,后面的 i18n 请求就会跟着改 - 真正的可调用入口不是菜单 click handler,也不是
spa:locale - 而是 store 上的:
setLocaleOverride(...)
后来我在正确的运行时作用域里手动调用过一次:
window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride("zh-CN")
结果非常直接:
- 页面无刷新
- 立刻重新请求:
/i18n/zh-CN.json/i18n/statsig/zh-CN.json/i18n/zh-CN.overrides.json
到这里,最大的技术问题其实已经从“能不能无刷新切语言”变成了:
扩展怎么稳定拿到这个 store?
这时候问题的质地已经完全变了。
前面的问题还是“我怎么实现一个功能”,到这里已经变成:
我怎么在一个不是我写的现代前端应用里,
稳定拿到一个藏在运行时闭包里的真实控制对象?
为什么我没有继续在 React 树里盲挖
很多人第一反应都会是:
- 扫 React fiber
- 扫 hook 链
- 扫全局对象
- 扫 webpack cache
这些我都碰过,而且不是完全没结果,但都不够稳。
如果你做过类似的事情,你应该很熟悉这种感觉:
- 好像每次都快找到了
- 好像每次都能 work 一点
- 但你心里知道,这种东西一旦做成产品,迟早会炸
不稳的原因大概有几类:
React 树太嘈杂
Claude Web 页面很复杂,fiber 和 hook 链里候选对象太多,很难把“有一点像 store 的对象”和“真正的 i18n store”可靠区分开。运行时结构漂移
即使某一版里某个组件名、某条 hook 链路能用,换个构建很可能就变了。它们都是“事后搜尸体”
页面都已经跑起来了,你再去盲扫,很难知道自己看到的是不是那个真正控制语言切换的对象。
我后来很明确地把这些路线降级成“辅助观察手段”,而不是主路线。
这是我很想和未来读者强调的一点:
能帮你观察到真相的工具,不一定适合成为最终方案。
我试过两条更激进的路,最后都放弃了
1. 替换顶层 index-*.js
这个思路很诱人:
- 在
document_start抢到 Claude 顶层的type="module"入口脚本 - 拉原始源码
- 注入一行把 i18n store 泄露到全局
- 再用一个 patched 的
blob:module 替掉原始入口
理论上这很优雅,因为你能在应用最早期改源码。
而且老实说,这种方案一开始会给人一种“我已经拿到上帝视角”的错觉。
问题是 Claude 用的是 Vite ESM 运行时,这种“整包入口替换”会悄悄改变一堆语义:
- 相对 import 的解析基准
import.meta.url- 模块图的启动顺序
- 某些 runtime 初始化假设
我后来即使把相对模块改写成绝对 URL,把 import.meta.url 也尽量补回去了,应用启动还是会逐渐跑歪,最后出现过类似:
Must call inside CurrentAccountProvider
这种错误非常说明问题:
不是某个 import 单独坏了,而是整体启动语义被破坏了。
所以这条路最后被我定性为:
太侵入,不适合作为产品方案
2. chrome.debugger
第二条可行但不能发布的路,是借 Chrome DevTools Protocol。
它技术上非常有效:
- 扩展通过
chrome.debugger附到 Claude tab - 等目标
index-*.js被解析 - 用
Debugger.getScriptSource拿源码 - 按我已知的 i18n loader 特征定位到断点位置
- 在正确 call frame 里把 store 泄露到全局
这条路甚至已经能稳定把状态暴露成:
window.__CLAUDE_I18N_DEBUGGER_STATUS__
并且能自动拿到 store 变量名。
但它有一个产品上完全不能接受的问题:Chrome 会显示非常显眼的横幅,告诉用户这个扩展正在调试页面。
这对开发探针是可以的,对普通用户绝对不行。
这种时刻特别容易让人掉进一个误区:技术上已经可行,于是下意识觉得事情已经解决了。
其实没有。
对于一个真正要给别人装的扩展来说,“会不会弹一个像安全告警一样的横幅”本身就是技术约束。
所以它最后被保留下来的价值,只是:
- 帮我验证 runtime 锚点
- 帮我确认真正的入口确实是
setLocaleOverride(...)
而不是最终实现本身。
关键转折:别再找变量名了,去抓“store 被创建出来的瞬间”
真正把事情做成的转折点,是我换了一个问题。
之前的问题一直是:
“怎么在页面跑起来以后找到那个叫 lW 或 oW 的东西?”
这个问题本身就不稳,因为变量名会漂移。
也就是说,我之前一直在找“一个名字”,但真正该找的是“一个时机”。
后来我换成了另一个问题:
“这个 store 最初是怎么长出来的?”
答案是:它是一个 Zustand bound store。
Zustand 很常见的一种绑定方式,是把一个可调用 hook 函数和 store API 合并在一起,变成一个“既能当 hook 用、又带 getState / setState / subscribe”的对象。这个过程通常会经过类似:
Object.assign(useBoundStore, api)
也就是说,只要我能在这个瞬间拦一下 Object.assign,我就有机会在 store 绑定完成的那一刻把目标对象抓住。
说得更直白一点:
我不需要知道它以后会叫 oW、lW 还是别的什么名字。
我只需要在它刚被“做出来”的那一刻认出它。
这比任何事后 blind scan 都更稳,因为它抓的是“出生现场”。
最终方案:document_start + 一次性 Object.assign 劫持
最后成功的实现非常克制。
不是:
- 替换整个 bundle
- 长期 monkey patch 一堆运行时 API
- 反复扫 React 内部结构
而是:
- 用 content script 在
document_start注入 page hook - page hook 只临时替换一次
Object.assign - 每次
Object.assign(target, ...sources)时,只检查极窄的一种对象形态 - 一旦命中目标 store:
- 把它暴露到
window.__CLAUDE_I18N_STORE__ - 立即恢复原始
Object.assign - 后续通过暴露出来的句柄调用
setLocaleOverride(...)
- 把它暴露到
我的识别条件不是变量名,而是 store state / action 的结构:
state &&
typeof state === "object" &&
"localeOverride" in state &&
"messagesLocale" in state &&
typeof state.setLocaleOverride === "function" &&
typeof state.setGatedMessages === "function" &&
typeof state.clearGatedMessages === "function"
这几个条件合在一起,指向性已经非常强了。
命中后我把它保存成:
window.__CLAUDE_I18N_STORE__
同时还加了两层保护:
- 命中后立刻恢复原始
Object.assign - 如果一段时间内没命中,也自动超时恢复,避免长期污染页面运行时
这个方案的好处非常明显:
- 不依赖
lW/oW这种漂移符号名 - 不依赖 React fiber / hook 树
- 不替换顶层入口,不破坏 Vite 运行时语义
- 不用
chrome.debugger,没有用户可见横幅 - 注入点足够早,但修改面足够窄
它本质上不是“黑魔法越用越多”,反而是把改动面收缩到了最小。
这一点我非常喜欢。
很多 runtime hack 往后做会变成一个越来越大的泥球,而这次恰恰相反:越往后走,方案越克制,越窄,越像一个能活下来的工程实现。

验证成功的那一刻
做完这个原型以后,我在页面里先看状态:
window.__CLAUDE_I18N_RUNTIME_STATUS__
拿到的是:
{
"stage": "ready",
"captured": true,
"reason": "assign-target",
"keys": [
"messages",
"messagesLocale",
"gates",
"isLoaded",
"localeOverride",
"setGatedMessages",
"setLocaleOverride",
"clearGatedMessages"
],
"localeOverride": "zh-CN",
"messagesLocale": null
}
这其实已经说明抓到的就是它了。

然后我做了最关键的一步验证:
window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride("ja-JP")
页面当场无刷新切成了日语。
那一刻事情就定了。
如果你也做过逆向或者运行时注入,你应该懂这种时刻有多爽。
不是因为“页面变成日语了”这件事本身有多重要,而是因为你知道:
- 入口找对了
- 假设闭环了
- 方案从“实验”跨进了“工程可用”

这不是“在 DevTools 某个幸运作用域里手调成功一次”,而是:
- 扩展自动抓到了真正的 runtime i18n store
- 这个句柄可以被稳定调用
- Claude 自己的 i18n 重载链路被正常触发了
也就是说,真正的入口可以正式写成:
window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride(...)
更准确地说,产品级入口不是这个全局变量名本身,而是:
通过一次性运行时捕获拿到的 i18n store 句柄,然后调用它的 setLocaleOverride(...)
我最后学到的其实不是“怎么 hack Claude”,而是“怎么缩小注入面”
回头看这整个过程,最值得记住的不是某个具体命令,而是几条方法论:
1. 先区分“视觉状态”和“真实控制源”
菜单勾选态不是控制源。spa:locale 不是控制源。
后端 profile locale 也不是你能随便扩展的控制源。
真正控制语言重载的是运行时 i18n store 里的 localeOverride。
2. 不要迷信能扫到一切的 blind scan
如果一个对象是模块内部闭包状态,那你事后去扫描整个运行时,只会得到一堆不稳定的候选对象。
比起“到处搜”,更重要的是找到它的创建时机或必经边界。
3. 整包 patch 往往不是最聪明的办法
很多时候最先想到的方案是“我把整个 bundle 改了不就行了”。
这在现代前端应用里经常是最危险的方案。
不是不能做,而是你很容易改坏:
- 模块解析
- 初始化时序
- 运行时上下文
尤其在 Vite ESM 这种环境里,入口替换的副作用非常大。
4. 真正好的注入点通常更窄
这次最终成功,不是因为 patch 得更狠了,而是因为 patch 得更窄了:
- 只动一次
Object.assign - 只盯一类极窄对象形态
- 命中立刻恢复
这比很多“通用逆向技巧”都更工程、更能发布。
如果这篇文章最后只能留下一句话,我希望是这句:
在现代前端应用里做 runtime 注入,最强的方案往往不是改得最多的那个,而是侵入最小、命中最早、恢复最快的那个。
这件事现在还剩下什么
到这里,最大的不确定性已经被清掉了。
已经确认的事情包括:
- Claude Web 的真正 runtime 语言覆盖入口是
setLocaleOverride(...) - 这个入口在页面里确实能无刷新触发语言重载
- 不能硬编码压缩变量名
- 可以通过低侵入的一次性运行时工厂劫持,稳定拿到目标 store
剩下的工作更偏工程收口:
- 把当前原型封装成正式的页面桥接 API
- 和菜单注入、请求拦截、缓存逻辑整合
- 处理 override 激活 / 清除时的状态同步
- 继续验证不同 Claude 构建版本下这条捕获条件是否足够稳
但最难的那个问题已经解决了。
不是“中文资源能不能送进去”,而是:
Claude Web 运行时里,到底有没有一个真正可调用、能无刷新切语言的入口?
答案是:有。
而且最后找到它的方式,不是最重的那条路,而是最窄的那条路。
如果你未来真的打算在别的站点上做类似的事情,我希望这篇记录能帮你少走一点我走过的弯路。
至少下次当你又准备开始扫 fiber、扫 hooks、扫 webpack cache 的时候,也许会先停一下,问自己一个更好的问题:
我到底是在找一个名字,还是在找一个时机?