Web 多语言(i18n)技术方案详解
本文讲嵌入式设备 Web 界面的多语种方案。
第一部分:先分清三种场景(最容易混的地方)
很多人看完多语言方案会懵,是因为没分清下面三种场景。先看这张表,后面所有细节都对应到这张表的某一行:
| 场景 | 谁在跑 | 字符串从哪来 | 字符串在产物里吗 |
|---|---|---|---|
① 开发环境 (npm run dev) |
本地 Node + webpack | webpack 编译时合并本地四层 txt,写到 public/lang/ |
在(dev server 把 public/ 对外提供) |
② 生产构建 (npm run build) |
CI/打包机 Node + webpack | webpack 编译时一样合并本地四层 txt,写到 build/lang/,但紧接着被插件删掉 |
不在(最终 zip 不带 lang) |
| ③ 运行时(用户访问设备 IP) | 浏览器 | 浏览器请求 /lang/*.txt,从设备固件文件系统取(设备应用自己维护的一套字符串) |
---(运行时不编译,只下载) |
1.1 几条关键认知(对应常见疑问)
1. "生产环境没有编译流程"------分两种理解:
- 如果指"生产构建(场景②)"------其实有 编译流程,webpack 照样跑、MergeLangPlugin 照样合并四层 txt 写到
build/lang/,只是写完立刻被 FileManagerPlugin 删了,所以你看最终产物(zip/dist)里没有字符串文件。 - 如果指"设备上运行时(场景③)"------那确实没有编译,浏览器只是下载已经打好的 js + 从设备固件取字符串。运行时永远不编译。
2. 本地字符串 vs 设备字符串------两套,不同步:
- 本地
src/project/acs/lang/*.txt:前端开发时npm run txtFix从公司字符串系统拉的,只用于 dev 调试。 - 设备固件里的
/lang/*.txt:设备应用打包时从同一个字符串系统 拉的,打进固件文件系统,生产运行时真正生效的就是这套。 - 两者都源自字符串系统,但拉取时机不同(本地随时拉、设备应用打包时拉一次),所以可能不同步。真正生效的是设备应用那套。
- 但设备的字符串也是前端负责维护:你更新字符串系统 → 设备应用下次打包时拉到新的 → 覆盖进固件。所以你要对字符串系统负责,而不是直接改设备上的文件。
3. "我项目里有设备端 /lang/*.txt 这个路径吗?"------没有。 设备固件怎么提供 /lang/*.txt 是设备应用(固件)侧的 web server 写的逻辑,不在你的 web 项目代码里 。你搜遍 src/ 只会找到各项目自己的 lang/*.txt(开发用的副本),找不到"运行时设备返回 /lang/ 的逻辑"------那部分是设备固件团队维护的。你的 web 项目只负责"请求 /lang/SimpChinese.txt 这个 URL",设备返回什么就用什么。开发时这个 URL 由 dev server 用 public/lang/ 里的合并产物应答,生产时由设备固件文件系统应答。
4. "main.js 在哪?我项目里找不到"------因为它不存在于源码里。 你源码的入口是 src/project/acs/index.js,webpack 把它和它 import 的所有文件打包、压缩、合并成一个 js 文件,输出到 build/static/js/<项目名>.<chunkhash>.js(如 acs.a1b2c3d4.js,文件名规则见 config/webpack.config.prod.js 的 output.filename: 'static/js/[name].[chunkhash:8].js')。所以"main.js"只是产物的俗称,源码里当然找不到。dev 时产物在 dev server 内存里,不落盘。
5. "构建期"和"运行期"分别指什么:
- 构建期 = 场景①和②(webpack 在跑)。
- 运行期 = 场景③(浏览器在跑)。
- 开发构建和生产构建的差别只在最后一步(删不删
build/lang),前面的编译流程是一样的。
第二部分:字符串住在哪 + language.json
2.1 四层语言包
字符串不是放在一个大文件里,是四层,按优先级从低到高:
① 组件库语言包 node_modules/@sdd-web/design/lang/*.txt ← 优先级最低(按钮、弹窗这类通用控件字符串)
② base 公共(设备) src/base/lang/device/*.txt
③ base 公共(web) src/base/lang/web/*.txt
④ 项目私有 src/project/acs/lang/*.txt ← 优先级最高(项目业务字符串,能覆盖上面同 key 的)
合并就是 JS 扩展运算符,后写的盖掉先写的:
js
let res = {
...commonLang[cur], // ①
...PublicDe[cur], // ②
...PublicWeb[cur], // ③
...PrivateLang[cur] // ④ 同 key 后者覆盖前者
};
为什么分四层:组件库字符串全产品线共用放①;base 层通用字符串多项目共用放②③;项目特有业务字符串放④且能盖掉上面三层。既复用又可定制。
2.2 language.json------只在打包环节起作用
每个项目 lang/ 文件夹里有个 language.json:
json
{ "language": ["English", "SimpChinese", "Arabic", "Czech", ...] }
它在代码里到底起什么作用 :被 MergeLangPlugin 的构造函数读取,存成一个语种名数组 this.langMap,emit 阶段就遍历这个数组------数组里有几个语种,就合并、写出几个产物 txt。
看构造函数(new MergeLangPlugin({...}) 这一刻就执行):
js
// node_modules/@sdd-web/webpack-plugins/dist/webpack.bundle.js:204-217
this.langMap = ['English', 'SimpChinese']; // 默认值(万一读不到 language.json 的兜底)
try{
this.langMap = [];
this.languageEnvs.forEach((envPath) => { // envPath = src/project/acs/lang/
let languageJson = fs$3.readFileSync(path$2.join(envPath,'/language.json'),'utf8');
languageJson = JSON.parse(languageJson);
this.langMap = [...new Set(this.langMap.concat(languageJson.language))];
// ↑ 把 language.json 的 language 数组拼进来,去重
});
}catch(e){
console.log("languageJsonerror", e);
}
读完 this.langMap 就是 ['English', 'SimpChinese', 'Arabic', ...] 这个数组。然后 emit 阶段就拿它当循环范围:
js
// webpack.bundle.js:227-272(emit 阶段)
handleCallback (compiler, next) {
// getLang(目录, langMap) 内部就是 langMap.reduce:对每个语种读 <语种>.txt
let PublicWeb = getLangStrate['Public'](this.publicWebPath, this.langMap);
let PublicDe = getLangStrate['Public'](this.publicDePath, this.langMap);
let commonLang = getSddDesign({ PrivatePath: this.PrivatePath, langMap: this.langMap });
let PrivateLang = getLangStrate['Private'](this.PrivatePath, this.langMap);
// 对 langMap 里每个语种做一次四层合并
let langObj = this.langMap.reduce((pre, cur) => {
let res = {...commonLang[cur], ...PublicDe[cur], ...PublicWeb[cur], ...PrivateLang[cur]};
pre[cur] = res;
return pre;
}, {});
// langObj 有几个 key(= langMap 有几个语种),就写几个 txt
Object.keys(langObj).forEach(item => {
let fileName = path.join(this.buildPath, `/${item}.txt`); // build/lang/<语种>.txt
fs$3.writeFileSync(fileName, JSON.stringify(langObj[item], null, '\t'), 'utf8');
});
}
具体例子:
language.json里 40 个语种 →this.langMap长度 40 → emit 遍历 40 次 →build/lang/下 40 个 txt;- 改成只有
["English", "SimpChinese"]→ 只生成 2 个 txt; - 读不到(文件缺失/格式错)→ 用默认值
['English', 'SimpChinese']→ 只生成 2 个。
"只在打包环节用吗?"------是的,运行时不用它。 运行时浏览器下载的是已经合并好的单个 txt,跟 language.json 无关。language.json 是"这次打包要生成哪些语种"的开关。
"生产环境打包应该没影响吧,毕竟产物都删了?"------要分清两个层面:
- 生成 txt 这一步照样受它控制 :生产构建时 MergeLangPlugin 构造函数照样读
language.json、emit 阶段照样生成对应数量的 txt 到build/lang/,这一步受language.json影响。 - 但生成的 txt 随后被 FileManagerPlugin 删了,所以从最终产物(zip)看,确实没留下 txt。
- 所以准确说法是:
language.json在生产构建时照样被执行 (生成 txt),只是 txt 最终没进产物。如果哪天改了配置不删 lang(或 cloud 模式),language.json就会直接影响产物里 txt 的数量。它不是"对生产打包没影响",而是"对生产最终产物没留下 txt"。
2.3 字符串系统是权威数据源
本地 src/.../lang/*.txt 不是"原始数据"。原始数据在公司内部的字符串系统 (一个 Web 平台),翻译团队在上面维护。本地有脚本 npm run txtFix 走 API 把系统上最新翻译拉下来覆盖本地 txt(见第七部分)。所以手改本地 txt 没用 ------下次任何人跑 txtFix 都会被系统数据覆盖。设备应用打包时也从同一个系统拉,所以两边都源自系统,只是拉取时机不同(见第一部分认知2)。
第三部分:webpack 给小白(含插件怎么被调用)
3.1 webpack 是什么
浏览器只会下载文件,但源码 src/ 里有几百个 .js/.jsx/.less/.txt,浏览器看不懂 JSX 也下不了那么多。webpack 就是把这些合并、转换、压缩成浏览器能用的产物:
src/(源码) ──webpack 打包──▶ build/(产物)
├─ index.html
├─ static/js/acs.xxxxxxxx.js ← 几百个 js 合并压缩成一个(不是叫 main.js!)
└─ lang/SimpChinese.txt ← 多语言 txt 也要出现在这里(生产构建随后会删)
webpack 从一个入口文件 (本项目是 src/project/acs/index.js)开始顺着 import 找所有用到的文件,收进依赖图,逐个转换压缩,最后写进 build/。关键推论:没被任何 js import 的文件,webpack 不认识、不会处理。 lang/*.txt 就是没人 import 的,它进产物靠的是"插件"。
入口和输出配置(config/webpack.config.prod.js):
js
// 166-169:入口(key 是项目名 acs,value 是源码入口路径)
entry: {
babelPolyfill: '@babel/polyfill',
setPrototypeOf: require.resolve('./setPrototypeOf'),
[projectName]: path.join(paths.appSrc, `/${projectPath}`) // src/project/acs/index.js
},
// 171-178:输出
output: {
path: paths.appBuild, // build/
filename: 'static/js/[name].[chunkhash:8].js', // → build/static/js/acs.a1b2c3d4.js
chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
publicPath: publicPath,
}
[name] 就是 entry 的 key(acs),[chunkhash:8] 是内容哈希前 8 位。所以产物叫 acs.xxxxxxxx.js,不叫 main.js。
3.2 webpack 生命周期
webpack 一次编译是条有固定阶段的生命周期:
beforeRun 编译开始前
↓
compilation 建模块依赖图、loader 转换(产物开始在内存里成形)
↓
emit 所有产物已生成在内存的 compilation.assets 里,准备写磁盘 ← 还能加/改文件
↓
afterEmit 已写到磁盘
↓
done 本次编译结束
3.3 插件类写完后怎么被调用(重点讲清)
下面是项目里真实存在的插件类(不是编的示例):
js
// node_modules/@sdd-web/webpack-plugins/dist/webpack.bundle.js:195-226(真实代码)
class MergeLangPlugin {
constructor({ buildPath, publicDePath, publicWebPath, PrivatePath, languageEnvs, thirdPartyBusinessPath } = {}) {
this.buildPath = buildPath;
this.PrivatePath = PrivatePath;
// ...读 language.json 存 this.langMap(见 2.2)
}
apply(compiler) {
compiler.hooks.emit.tapAsync({name:'MergeLangPlugin',stage:0}, this.handleCallback.bind(this));
// ↑ 在 emit 阶段注册回调
}
handleCallback(compiler, next) {
// ...合并四层语言包,fs.writeFileSync 写 txt
next();
}
}
写了这个类不会自动跑。要经过三步它才生效:
第一步:在 webpack 配置文件顶部 require 引入这个类
js
// config/webpack.config.prod.js:29,31-34
const CheckTxtPlugin = require('@sdd-web/plugin-lang-check'); // 校验插件
const {
ImageReplacePlugin,
MergeLangPlugin // 合并插件(上面那个真实类)
} = require('@sdd-web/webpack-plugins');
第二步:在配置的 plugins 数组里 new 出实例(传 options)
js
// config/webpack.config.prod.js:705-713(plugins 数组里的两项)
new CheckTxtPlugin({ dir: `src/${projectPath}` }),
new MergeLangPlugin({
buildPath: path.join(__dirname, '../', 'build/lang/'),
PrivatePath: path.join(__dirname, '../src/', `/${projectPath}/lang/`),
publicDePath: path.join(__dirname, '../', 'src/base/lang/device'),
publicWebPath: path.join(__dirname, '../', 'src/base/lang/web')
}),
new MergeLangPlugin({...}) 这一刻就执行构造函数------也就是 2.2 里读 language.json 那段,就是在这儿跑的。
第三步:webpack 启动时自动调用 apply,你不用手动调
webpack 启动后的内部流程:
读 webpack.config.prod.js
→ 拿到 plugins 数组 [new CheckTxtPlugin(...), new MergeLangPlugin(...), ...]
→ 对数组里每个实例调用 实例.apply(compiler) ← webpack 自动调,不是你调
→ apply 里 compiler.hooks.emit.tapAsync(...) 这些注册的回调被挂到 compiler 上
→ 编译跑到 beforeRun 阶段 → 调 CheckTxtPlugin 注册的回调
→ 编译跑到 emit 阶段 → 调 MergeLangPlugin 注册的回调
"是不是只要写了 apply(compiler) 就会自动调用?"------不是。 光写 class 不放进 plugins 数组,什么都不会发生。必须是"放进 plugins 数组"这件事发生后,webpack 启动时才会自动遍历这个数组、对每个实例调 apply。apply 是 webpack 调的(不是你调),但前提是这个实例在 plugins 数组里。
三个时机要分清:
new的时候 → 跑构造函数(读配置文件);apply→ webpack 启动时调(注册回调);- 回调本身 → 编译跑到对应阶段时才调。
3.4 重点:emit 钩子到底是什么(容易混,单独讲)
你可能听过两种 "beforeEmit",必须分清:
compiler.hooks.emit(webpack 原生)HtmlWebpackPlugin.getHooks(compilation).beforeEmit(HtmlWebpackPlugin 插件自定义)
先讲 webpack 原生 emit :webpack 编译的每个产物文件,在内存里都对应 compilation.assets[文件名] 里的一个对象。编译完成时这些 assets 还在内存里,没写磁盘 。写磁盘是 webpack 在 emit 钩子所有回调跑完后,自己遍历 compilation.assets 调文件系统 API 去写的。
所以 compiler.hooks.emit 的准确含义是:
"所有产物已经在内存里准备好了,但我(webpack)还没开始写磁盘,你们插件趁现在还能动手脚。"
插件在这里通常干两类事:
- 标准做法 :往
compilation.assets['新文件名']塞一个新文件,webpack 在 emit 之后自动写磁盘; - 绕过做法 :不管
compilation.assets,直接用 Node 的fs.writeFileSync自己写磁盘。
我们的 MergeLangPlugin 用的是绕过做法 (见 3.3 真实代码),它没往 compilation.assets 里加任何东西,webpack 自己写磁盘时根本不知道有这些 txt------这也解释了为什么"改 txt 不会触发重新编译":webpack 的依赖图里压根没有这些 txt。
再讲 HtmlWebpackPlugin 的 beforeEmit:
js
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tap('MyPlugin', (htmlPluginData, next) => { ... });
这是 HtmlWebpackPlugin 这个插件自己定义的钩子 (不是 webpack 原生),触发时机是该插件已经把 HTML 字符串生成好、准备把这个 HTML 作为文件塞进 compilation.assets 之前。它针对的是"该插件把 HTML 加进 assets 之前",让你有机会改 HTML 内容(比如往 <head> 注入 CSP meta 标签)。
两者对比:
| 钩子 | 谁定义的 | 触发时机 | 作用对象 |
|---|---|---|---|
compiler.hooks.emit |
webpack 原生 | 所有 assets 准备好、webpack 即将把 assets 整体写磁盘之前 | 整个产物集(所有文件) |
HtmlWebpackPlugin.getHooks(compilation).beforeEmit |
HtmlWebpackPlugin 插件 | 该插件生成的 HTML 字符串准备塞进 assets 之前(发生在 emit 阶段内部) | 仅该插件生成的那个 HTML |
一句话记:webpack 原生 emit = 写磁盘前的最后窗口;HtmlWebpackPlugin 的 beforeEmit = 该插件塞 HTML 进 assets 前的窗口。
补充回答:HtmlWebpackPlugin 是原生的还是项目自己写的?
都不是------它是第三方 npm 包 (包名 html-webpack-plugin),需要 npm install 安装。webpack 本身只提供"插件机制"(compiler.hooks 这套钩子),不内置任何具体插件;HtmlWebpackPlugin 是 webpack 生态里最常用的第三方插件,几乎所有 webpack 项目(包括 CRA 脚手架)都用它,专门生成 index.html 并把打包后的 js/css 自动注入进去。本项目证据:
js
// config/webpack.config.prod.js:6
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 从 node_modules 引第三方包
// config/webpack.config.prod.js:578(plugins 数组里 new)
new HtmlWebpackPlugin({ inject: true, template: paths.appHtml }),
json
// package.json:190
"html-webpack-plugin": "4.0.0-alpha.2",
它能"自己定义钩子"是因为 webpack 底层用了一个叫 tapable 的库,这个库允许任何插件 用 new SyncHook() 等定义自己的钩子再暴露出去。不是 webpack 给它的特权。
第四部分:构建期细节
4.1 两个插件
| 插件 | 挂在哪个生命周期 | 干什么 |
|---|---|---|
CheckTxtPlugin |
beforeRun(编译开始前) |
校验 txt 和源码里的 key,按 interrupt 级别决定是否中断编译 |
MergeLangPlugin |
emit(写磁盘前) |
合并四层语言包,用 fs 直接写出每种语言的产物 txt |
CheckTxtPlugin:打包前的体检
@sdd-web/plugin-lang-check/core/checkTxt.js,完全不走 webpack 模块系统 ,就是 Node fs 直接读文件:
- 递归扫描
src/base+src/project/acs; - 对 .js 文件:读成字符串,用正则匹配所有形如
com.xxx/acs.xxx的字符串字面量(不是只找$t(...),而是找任何引号里这种前缀的字符串 ),每个 key 拿去和「所有 SimpChinese.txt 合并出的基准表」比对,缺 key(missKey)或值为空(missValue)就记下来; - 对 .txt 文件:剥掉花括号换行再
JSON.parse,解析失败就是格式错误;非基准语言的 txt 再和基准 SimpChinese 比对:基准有的 key 你不能缺(可以多); - 查完生成报告,按
interrupt级别决定是否中断编译:
| interrupt 级别 | 中断条件 |
|---|---|
'format'(acs 当前配置) |
只有 txt 格式错误才 throw 中断;源码/其他语言缺 key 只出报告,不影响编译 |
'error' |
格式错误 + js 里用了基准表没有的 key,都中断 |
'warning' |
以上 + 其他语言 txt 缺 key / 有空值,也中断 |
'off' |
永不中断,只出报告 |
中断的实现就是 throw Error(...)------beforeRun 回调里抛异常,webpack 本次编译直接失败。
MergeLangPlugin:四层合并生成产物
原理就一句话:emit 阶段读四层 txt,用扩展运算符按优先级合并,每个语种 fs.writeFileSync 一个产物 txt 到 build/lang/(加 BOM 头)。源码已在 3.3 贴过。
读 txt 时用 new Function('return ' + 文件内容)(),把 txt 文本当 JS 对象字面量解析(所以 txt 内容必须是合法的 JS 对象字面量,这也是 CheckTxtPlugin 校验"格式错误"的依据)。
组件库语言读取时还有一层名字映射 (
webpack.bundle.js:144-167):读项目checkTxt.json的languages字段转成 Map(如Spanish → Spanish(SouthAmerica)),因为组件库里的文件名是Spanish(SouthAmerica).txt,和项目里叫法不同。
4.2 开发构建 vs 生产构建的差别(关键!)
开发构建和生产构建的前面流程一样(都跑 CheckTxtPlugin + MergeLangPlugin),差别只在最后:
开发构建(npm run dev):
- MergeLangPlugin 的
buildPath配置成public/lang/(见config/webpack.config.dev.js); - dev server 把
public/目录原样对外提供访问; - 所以浏览器请求
/lang/SimpChinese.txt→ dev server 返回public/lang/SimpChinese.txt(合并后的产物); - dev 时字符串就是本地合并的那套。
生产构建(npm run build):
- MergeLangPlugin 的
buildPath配置成build/lang/; - emit 阶段写出
build/lang/*.txt; - 但紧接着 FileManagerPlugin 在
onEnd阶段把build/lang整个删掉:
js
// config/webpack.config.prod.js:689-704(FileManagerPlugin,onEnd = afterEmit 之后)
new FileManagerPlugin({
onEnd: {
delete: [
path.join(__dirname, '../build/.svn'),
!isCloud && path.join(__dirname, '../build/lang'), // ← 设备版:把 build/lang 删掉
isMobile && path.join(__dirname, '../build/less.min.js')
].filter(Boolean),
mkdir: [path.join(__dirname, '../zips')],
archive: [{ ... }] // 打 zip
}
})
- 所以最终产物(zip/dist)里没有字符串文件------这就是你说的"打包产物中的 dist 就没有字符串文件"。
那运行时去哪下字符串? ------从设备固件文件系统取。设备应用打包时从字符串系统拉了一套字符串打进固件,路径就是 /lang/SimpChinese.txt。web 部署到设备后,浏览器请求 /lang/SimpChinese.txt,设备 web server 直接从固件文件系统返回------web 包里没有也不影响。
注意:设备固件怎么提供
/lang/*.txt不在你的 web 项目代码里(见第一部分认知3)。你 web 项目只负责"请求这个 URL",设备返回什么就用什么。
设备还支持 /custom_lang/SimpChinese.txt 定制包(客户定制翻译),运行时会优先下它,下不到再降级到 /lang/ 标准包。
一句话:开发时用本地合并的字符串,生产运行时用设备固件里的字符串,两套都源自字符串系统但拉取时机不同可能不同步,真正生效的是设备固件那套。
4.3 改 txt 不会触发重新打包
记住结论:改 txt 不会触发 webpack 重新编译 。原因是 MergeLangPlugin 用 fs 读 txt、用 fs 写产物,这些 txt 不在 webpack 模块依赖图里,webpack 感知不到它们变了。
- dev 改字符串想立刻看效果:随便改个 js 文件保存(加个空格)触发一次重新编译,或重启
npm run dev; - 取巧办法:直接改
public/lang/SimpChinese.txt(合并后的产物)刷新页面立即生效,但下次任何一次编译都会被覆盖,只能临时调试用。
第五部分:运行期(从输入 IP 到登录页的全链路)
这一部分从用户在浏览器输入设备 IP 开始,一直讲到带多语言的登录页渲染出来。完整顺序:
① 浏览器输入设备 IP → 下载 index.html → 下载 acs.xxxxxxxx.js(产物的俗称)
② acs.js 执行 src/project/acs/index.js,模块顶层直接调用 init()(第326行,非 IIFE)
③ init() 里依次做:langConfig.set → 会话外请求 → getPreLanguage(存 localStorage)→ getCurrentLanguage(下载 txt 字典)→ translate 造 $t → dispatch 进 Redux → setCanActivate 注册路由守卫 → 用 localObj 覆盖 antd 文案 → createApp 组装组件树 → ReactDOM.render
④ createApp 组装:HashRouter → Redux Provider → PersistGate → antd ConfigProvider → LangProvider → 路由
⑤ 路由守卫:未登录访问非'/'路径 → 重定向到 '/'(登录页)
⑥ 登录页组件懒加载,用 LangConsumer 从 React Context 拿 $t,渲染带多语言的登录页
5.1 第①步:输入 IP,下载 acs.js
浏览器 ──HTTP──▶ 设备 ──返回──▶ index.html
浏览器 ──HTTP──▶ 设备 ──返回──▶ static/js/acs.xxxxxxxx.js ← webpack 打包后的代码
acs.js 里全是 $t('com.Ok') 这种 key,没有一句中文/英文翻译 。还有一部分是 import 'moment/locale/zh-cn' 这类静态 import(见第六部分),这些语言包打包时就打进 acs.js 了,和 txt 不同。
5.2 第②③步:init() 的完整流程(像对文盲科普)
acs/index.js 第 326 行 init(); 模块加载时直接执行。init 是 async 函数(第 215 行定义)。下面把每一段都用大白话讲清楚:
js
async function init() {
// ═════════ 第1段:langConfig.set 把多语言要用的东西全塞进库单例 ═════════
// 目的:后面 getPreLanguage、getCurrentLanguage、createApp 都要从这个库单例里取配置,
// 所以先把配置一次性塞进去。langConfig 是 @sdd-web/design 库暴露的单例对象。
langConfig.set({
txtPath, // 字典下载地址:['/custom_lang/SimpChinese.txt?...', '/lang/SimpChinese.txt?...']
// 第3段 getCurrentLanguage 用它下 txt(先试 custom_lang 定制包,失败再 lang 标准包)
prePath: [ // 问设备语言的接口地址,第3段 getPreLanguage 用:
`/current_config/preLanguage?...`, // 设备型号A的语言接口
`/web_caps/preLanguage?...` // 设备型号B的语言接口(两个地址兼容不同设备)
],
moment, // moment 日期库实例,库内部要调 moment.locale 切语言
localeMap, // 语言名 → antd locale 对象 的 Map(如 'SimpChinese' → zhCN)
// 给 antd 的 ConfigProvider 用,决定 antd 组件(日期选择器等)显示什么语言
momentMap, // 语言名 → moment locale 字符串 的 Map(如 'SimpChinese' → 'zh-cn')
// 库内部调 moment.locale 时用
reqMethod: 'POST', // HTTP 请求方法
ProivderComponent: ConfigProvider, // antd 的 ConfigProvider,库内部组装组件树时包装用
reduxStore: store, // redux store,库内部可能要往里 dispatch
env: process.env.NODE_ENV, // 环境,库内部判断 dev 时补组件库字符串
langPublicExtends: process.env.NODE_ENV === 'development' ? designLanguage : null
// 仅 dev:把组件库(@sdd-web/design/lang)的字符串补进来
// 因为 dev 没有完整 build 合并组件库字符串这一步,所以要手动补;生产构建靠 MergeLangPlugin 合并
});
// 总结:langConfig.set 就是"配置注入"------把字典地址、语言接口、locale 映射表、moment 实例等
// 一次性塞进库,后面多语言流程的每一步都从里面取。
// ═════════ 第2段:URL 参数 + 会话外请求(和登录前要准备的数据有关) ═════════
const urlParams = getUrlParams(); // 从浏览器 URL 取参数(如设备标识等)
localStorage.setItem('_urlParams', JSON.stringify(urlParams)); // 存起来,后面业务用
await getAsyncRequest(preLoginRequests, store);
// 并发发"会话外请求"(不需要登录就能发的请求):
// preLoginRequests 定义在 src/project/acs/caps/loadConfig.js + src/base/caps/loadConfig.js
// 干的事:① 拿设备能力集 caps(设备支持哪些功能,决定显示/隐藏哪些页面/菜单)
// ② 查设备初始化状态(决定显示登录页还是初始化向导)
// 结果 dispatch 进 redux
cacheStore({ store, viewMenu, settingMenu }); // 缓存 store(路由菜单相关,供后续路由用)
// ═════════ 第3段:问设备语言 + 下字典 + 造 $t(多语言核心) ═════════
const preLanguage = await getPreLanguage(); // 问设备 /current_config/preLanguage → "SimpChinese" → 存 localStorage
if (!preLanguage) return; // 没拿到语言直接退出(语言是渲染前提)
const translationString = await getCurrentLanguage(); // 用第1段存的 txtPath,下 /lang/SimpChinese.txt → 翻译表
createRequestQueue.pipe(afterLoginRequests, store);
// 缓存"会话内请求"(登录后才能发的请求,如拿用户权限、系统时间):
// afterLoginRequests 定义在 src/base/caps/loadConfig.js,先注册好,等用户登录后自动发
store.dispatch({ type: '_SET_LANG', data: translate(translationString.data) });
// translate(翻译表) 柯里化造出查表函数 $t,dispatch 进 redux 的 publicState
// 之后所有 connect 过的业务组件都能从 props.publicState.$t 拿到
setCanActivate(canActivateGenerator(store)); // 注册路由守卫(见第5段,决定未登录跳哪)
// ═════════ 第4段:用我们的翻译覆盖 antd 内置文案 ═════════
const localObj = localeMap.get(preLanguage); // 拿当前语言的 antd locale 对象(如 zhCN)
if (localObj && localObj.DatePicker) {
localObj.DatePicker.lang.ok = translate(translationString.data)('com.Ok');
// 日期选择器的"确定"按钮,用我们的翻译覆盖 antd 自带的
}
if (localObj) {
localObj.Image = localObj.Image || {}; // 兜底:某些语言没 Image 字段,先建空对象防报错
localObj.Modal = localObj.Modal || {};
localObj.Image.preview = translate(translationString.data)('com.Previews'); // 图片预览的"预览"字样
localObj.Modal.okText = translate(translationString.data)('com.Ok'); // 弹窗的 OK 按钮
}
// 目的:保证 antd 组件的内置文案和我们业务文案统一(都来自我们的 txt,而不是 antd 自带的)
// ═════════ 第5段:RTL 右对齐 ═════════
if (['Arabic', 'Hebrew'].includes(preLanguage)) {
document.body.className = 'rtl'; // 阿语/希语从右往左排版
}
// ═════════ 第6段:组装 App 组件树 + 挂载到页面 ═════════
const App = createApp({ store, routes: mainMenu, persistor, translationString });
// createApp(src/base/app/initApp/createApp.js)组装组件树(从外到内):
// HashRouter → Redux Provider → SddConfigProvider → PersistGate(持久化)
// → antd ConfigProvider(注入 antd locale)→ LangProvider(React Context 传翻译表)
// → 路由(routes = mainMenu,登录页是 /)
ReactDOM.render(<App />, document.getElementById('root')); // 挂载到 index.html 里的 #root
// ═════════ 第7段:超时保活 + 超时重登(和登录态生命周期有关) ═════════
rpc.attachAliveActive(); // 监听鼠标点击和键盘事件,判断用户是否活跃(保活,防 session 过期)
const attachOvertimeCallback = () => { // 超时后执行的函数
const { publicState: { $t }, webCaps } = store.getState(); // 从 redux 拿 $t 和能力集
if (webCaps && webCaps.TimeOutNoStream) {
// 能力集里 TimeOutNoStream=true:超时后直接刷新重登,不等用户点击
message.error($t('com.ErrorAuthorizeReloginTip')); // 弹"登录超时"提示
setTimeout(() => {
sessionStorage.setItem('session', ''); // 清 session
window.location.reload(); // 刷新页面 → 回到登录页
}, 2000);
} else {
// 其他设备:等用户点击后再刷新重登
document.addEventListener('click', () => {
message.error($t('com.ErrorAuthorizeReloginTip'));
setTimeout(() => {
sessionStorage.setItem('session', '');
window.location.reload();
}, 2000);
});
}
plugin.logout(); // 退出插件登录(防 ping 码登录开启时插件无限登录)
};
rpc.attachOvertime(attachOvertimeCallback); // 给 rpc 通道注册超时回调
ezapi.attachOvertime(attachOvertimeCallback); // 给 ezapi 通道也注册(另一套请求通道)
// ═════════ 第8段:切 moment 语言 ═════════
moment.locale(momentMap.get(preLanguage)); // 如 'zh-cn',日期格式切对应语言
}
几个要点:
getPreLanguage问设备/current_config/preLanguage,结果存localStorage('_localLanguage')。语言是设备侧配置,web 只读不设。详见src/base/utils/util.js:345。getCurrentLanguage用第1段存的txtPath,把语言名替换进去(俄语 →/lang/Russian.txt),先试/custom_lang/定制包,失败降级到/lang/标准包。URL 带时间戳防缓存。translate(翻译表)柯里化:返回一个口袋里装着翻译表的函数,这就是$t。$t('com.Ok')就是查表,查不到返回 key 本身(兜底不白屏)。- 没拿到语言直接 return------所以语言是渲染的前提。
- 能力集(caps/webCaps) :设备的"功能清单"JSON,描述这台设备支持哪些功能(如
TimeOutNoStream、NoSF等),前端根据它决定显示/隐藏哪些页面。第7段的超时重登就用到了webCaps.TimeOutNoStream。
5.3 第④步:createApp 组装组件树
src/project/acs/index.js:279 调 createApp({...},实现在 src/base/app/initApp/createApp.js。它的 render() 从外到内组装:
<HashRouter> ← react-router-dom(URL 用 # 路由)
<Provider store={store}> ← react-redux,注入 Redux
<SddConfigProvider> ← @sdd-web/design 全局配置
<PersistGate persistor={persistor}> ← redux-persist 持久化门控
<ConfigProvider direction={isRtl...}> ← antd 的 ConfigProvider,注入 antd locale
<LangProvider lang store translationString> ← 多语言 Provider,通过 React Context 传翻译表
<RoutePersistor> ← 路由持久化(排除 '/')
<SupportRouteNavgation>
<Support3rdRoutePathCache>
<Router routes={routes} /> ← 实际路由渲染
createApp 的 componentDidMount 还监听了 pubsub.subscribe('changeLanguage', ...),语言切换时更新 state.lang 触发重渲染。
5.4 第⑤步:路由守卫------未登录跳登录页
路由表 mainMenu 在 src/project/acs/router/mainMenu.js:
| 路径 | 组件 |
|---|---|
/ |
base/pages/Login ← 默认/未登录时渲染的登录页 |
/index |
base/pages/HomePage |
/forget |
base/pages/PasswordReset |
/init |
base/pages/Init |
/configtool |
base/pages/ConfigTool |
组件通过 loadBase(() => import(...)) 懒加载(src/base/utils/loadView.js,基于 react-loadable)。
路由渲染在 src/base/app/initApp/createRouter.js:
js
const Router = props => {
const { routes } = props;
return (
<Switch>
{routes.map(item => (
<RestrictedNewRoute path={item.path} exact={...} component={item.component} key={...} />
))}
<Redirect to='/' /> // ← 未匹配的路由全部重定向到 '/'(登录页)
</Switch>
);
};
每个路由用 RestrictedNewRoute 包裹(带守卫)。守卫逻辑在 src/project/acs/router/guard.js:
js
export const canActivateGenerator = store => (key, path) => {
if (process.env.NODE_ENV !== 'development') {
const { isLogin, isInit } = store.getState().publicState;
if (!isInit && path !== '/init') return '/init'; // 未初始化 → 跳 /init
if (isInit && !isLogin && path !== '/') return '/'; // 已初始化但未登录 → 跳 '/'(登录页)
}
return true; // 开发环境直接放行
};
总结 :未登录访问非 / 路径 → 守卫返回 '/' → RestrictedNewRoute 渲染 <Redirect to='/' /> → 回到登录页。登录页 / 本身不受守卫拦截(path !== '/' 时才检查)。
5.5 第⑥步:登录页怎么拿 $t(注意:这里用的是 LangConsumer,不是 Redux)
登录页实际组件在 src/base/pages/Login/login.js。它的导出链:
js
// src/base/pages/Login/login.js
const { LangConsumer } = LangContext; // 从 @sdd-web/design 取 LangConsumer
// ...Login 组件内部 const { $t } = this.props;
const LoginComponent = LangConsumer(Login); // ← LangConsumer 把 $t 作为 prop 注入
export const LoginTest = withRouter(connect(mapStateToProps, mapDispatchToProps)(LoginComponent));
包装顺序(从内到外):
Login(函数组件,渲染 LoginForm,内部 const { $t } = this.props)
→ LangConsumer(Login) ← 注入 $t(来自 LangProvider 的 React Context)
→ connect(...)(...) ← 注入 publicState、webCaps 等 Redux state
→ withRouter(...) ← 注入 router props
重要修正 :登录页用 LangConsumer(React Context) 拿
$t,不是 Redux connect。这是因为登录发生在登录前,Redux 里$t的注入时机和登录页渲染时机可能有差,用 Context 更稳。登录后的业务页面才走 Redux(props.publicState.$t)。所以"业务页面走 Redux"这句要加限定:登录页是例外,走 LangConsumer。
5.6 运行期全流程一图回顾
浏览器输入设备 IP
↓ 下载 index.html + acs.xxxxxxxx.js(里面全是 key 没翻译,还有静态 import 进来的 moment/antd locale)
↓ acs.js 执行 src/project/acs/index.js
↓ 模块顶层直接调 init()(第326行)
↓
init() 内部:
① langConfig.set() 配置存进库单例(txtPath/prePath/localeMap/momentMap...)
② getAsyncRequest 会话外请求、能力集预加载(拿设备功能清单+初始化状态)
③ getPreLanguage() GET /current_config/preLanguage → "SimpChinese" → 存 localStorage
④ getCurrentLanguage() 下载 /lang/SimpChinese.txt → JSON.parse → 翻译表
⑤ translate(翻译表) 造出查表函数 $t
⑥ dispatch _SET_LANG $t 放进 Redux publicState
⑦ setCanActivate 注册路由守卫
⑧ localObj 覆盖 用我们的翻译覆盖 antd 的 DatePicker.ok / Image.preview / Modal.okText
⑨ RTL 阿语/希语 document.body.className='rtl'
⑩ createApp 组装 HashRouter+Provider+ConfigProvider+LangProvider+Router
⑪ ReactDOM.render 挂载到 #root
⑫ rpc.attachAliveActive 监听鼠标键盘判断活跃(保活防 session 过期)
⑬ rpc.attachOvertime 注册超时重登回调(超时弹提示+刷新回登录页)
⑭ moment.locale 切 moment 语言
↓
路由守卫: 未登录访问非'/' → 重定向到 '/'
↓
'/' 匹配 → 懒加载 src/base/pages/Login/login.js
↓
Login → LangConsumer(注入$t) → connect(注入Redux state) → withRouter
↓
LoginForm 从 this.props.$t 取翻译函数 → 渲染带多语言的登录页
第六部分:moment/antd locale 的 import 写法 + localObj 覆盖
这一部分单独讲 acs/index.js 里那两段容易看不懂的写法。
6.1 import 'moment/locale/zh-cn' 这种写法
js
// src/project/acs/index.js:29-67(部分)
import 'moment/locale/zh-cn';
import 'moment/locale/en-gb'; // 英式英语
import 'moment/locale/ar-dz'; // 阿拉伯语
import 'moment/locale/ru'; // 俄语
import 'moment/locale/es'; // 西班牙语
import 'moment/locale/fr'; // 法语
// ... 40 来个
特征 :只 import 不绑定变量(没有 import xxx from ...)。
作用 :这种 import 叫"副作用导入",目的是把这些语言包注册进 moment 库 。moment 的语言包是按需的,你不 import,moment 库里就没这个语言包,调 moment.locale('zh-cn') 会 fallback 到英文。import 之后这些语言包就被打进 acs.js 了,之后 moment.locale('zh-cn') 才能切到中文日期格式。
和 txt 的区别 :这些 moment 语言包是打包时就打进 acs.js 的(静态 import 会被 webpack 收进依赖图),不是运行时下载的。txt 是运行时按设备语言下载的。
为什么 import 这么多 :因为设备可能切任何一种语言,每种语言都要对应的 moment 语言包。提前全 import 进来,运行时 moment.locale(momentMap.get(preLanguage)) 才能切到对应语言。
6.2 import zhCN from 'antd/lib/locale-provider/zh_CN' 这种写法
js
// src/project/acs/index.js:69-106(部分)
import zhCN from 'antd/lib/locale-provider/zh_CN'; // 中文
import arEG from 'antd/lib/locale-provider/ar_EG'; // 阿拉伯语
import ruRU from 'antd/lib/locale-provider/ru_RU'; // 俄语
import esES from 'antd/lib/locale-provider/es_ES'; // 西班牙语
// ... 40 来个
特征 :import 绑定变量(import zhCN from ...)。
作用 :导入的是 antd 各语言的 locale 对象 。这个对象里包含 antd 组件的内置文案(如 DatePicker 的星期几、Modal 的 OK/Cancel 按钮文字、Pagination 的"每页条数"等)。导入后存进 localeMap:
js
// src/project/acs/index.js:126-166
const localeMap = new Map([
['SimpChinese', zhCN],
['English', EN],
['Arabic', arEG],
// ...
]);
然后 createApp 里 <ConfigProvider locale={localeMap.get(preLanguage)}> 用它,antd 组件就显示对应语言的内置文案。同样,这些 locale 对象也是打包时打进 acs.js 的,不是运行时下载的。
6.3 localObj 覆盖 antd 内置文案的写法
js
// src/project/acs/index.js:264-274
// 本地 OK 翻译覆盖antd组件翻译
const localObj = localeMap.get(preLanguage); // 拿到当前语言的 antd locale 对象
if (localObj && localObj.DatePicker) {
localObj.DatePicker.lang.ok = translate(translationString.data)('com.Ok');
}
if (localObj) {
localObj.Image = localObj.Image || {};
localObj.Modal = localObj.Modal || {};
localObj.Image.preview = translate(translationString.data)('com.Previews');
localObj.Modal.okText = translate(translationString.data)('com.Ok');
}
这是在干什么:
localeMap.get(preLanguage)拿到 antd 的 locale 对象(如zhCN);- 直接改这个对象的属性 :
DatePicker.lang.ok、Image.preview、Modal.okText; - 用我们的翻译(
$t('com.Ok'))覆盖 antd 内置的"确定/预览"等文案。
为什么要这么写 :antd 的组件(DatePicker 的"确定"按钮、Image 预览的"预览"字样、Modal 确认框的 OK 按钮)有内置文案,这些文案在 antd 的 locale 对象里。但我们要保证全应用文案统一------antd 的"确定"和我们业务页面的"确定"必须是同一份翻译(来自我们的 txt 字典,而不是 antd 自带的)。所以直接改 locale 对象的属性,用我们的翻译盖掉。
为什么用 || {} 兜底 :有些语言的 locale 对象可能没有 Image / Modal 字段(antd 不同版本字段不一定全),所以先 localObj.Image = localObj.Image || {} 保证有这个对象,再赋值,避免 Cannot set property 'preview' of undefined 报错。
这段代码运行时机在 init() 里、createApp 之前。因为 createApp 里的
<ConfigProvider locale={localObj}>会用这个被改过的 locale 对象,所以必须先改再组装组件树。
6.4 三类字符串的存放方式对比(小结)
| 字符串类型 | 存放位置 | 何时打进 bundle | 运行时怎么拿 |
|---|---|---|---|
业务翻译(com.Ok 等) |
txt 文件(字符串系统) | 不打进 bundle | HTTP 下载 txt → translate 造 $t 查表 |
| moment 语言包 | moment/locale/* |
静态 import 打进 acs.js | moment.locale('xx') 切换 |
| antd locale 对象 | antd/lib/locale-provider/* |
静态 import 打进 acs.js | <ConfigProvider locale={...}> 注入 |
第七部分:字符串维护与拉取脚本
7.1 新增/修改一个字符串的完整流程
1. 代码里写 $t('acs.NewFeature')
2. 上公司字符串系统登记该 key 的中文(labels 对应业务线,见 checkTxt.json)
3. 翻译团队在系统上完成 30+ 语种翻译
4. 本地执行 npm run txtFix
→ fixTxt-scripts(node_modules/@sdd-web/plugin-lang-check/bin/fixTxt.js)
→ 按 checkTxt.json 的 languages 映射(如 ["Spanish","Spanish(SouthAmerica)"])
→ 走 API 从字符串系统拉取最新翻译,刷新 src/project/acs/lang/*.txt
5. 构建时:CheckTxtPlugin 校验(基准 SimpChinese 不能缺 key)
MergeLangPlugin 合并 → build/lang/<语言>.txt(生产构建随后被删)
6. 运行时按设备语言下载对应 txt(设备固件里的那套)→ $t 渲染
为什么正式流程不能直接改本地 txt :字符串系统是权威数据源,下次任何人跑 txtFix 都会用系统数据覆盖本地 txt,你手改的内容会丢;而且 contrast:lang 对比时本地和系统不一致会报出来。设备应用打包时也从系统拉,所以你更新系统后,设备应用下次打包就能拉到新的。
7.2 相关命令
| 命令 | 作用 |
|---|---|
npm run txtCheck |
校验 txt 与源码 key 一致性(不改文件) |
npm run txtFix |
从字符串系统 API 拉取刷新本地 txt("专门拉取字符串的脚本"就是它) |
npm run txtTagCheck |
检查 key 标签 |
npm run contrast:lang |
按根目录 langContrast.json 与字符串系统对比出报告 |
npm run langFile |
config/formatLang.js:按 lang/borrow.json 借语种 + 引号格式化,产出待上传系统的文件 |
7.3 checkTxt.json 的角色
src/project/acs/checkTxt.json 同时承担三件事:
json
{
"baseTxtName": "SimpChinese", // 基准语言(其他语言不能缺它的 key)
"interrupt": "format", // CheckTxtPlugin 的中断级别
"labels": ["前端基础", "智能楼宇", ...], // 字符串系统里的业务线标签(txtFix 拉取用)
"languages": ["SimpChinese", "English", ["Spanish", "Spanish(SouthAmerica)"], ...]
// ↑ 语言名映射:本地叫 Spanish,字符串系统里叫 Spanish(SouthAmerica)
}
- 校验时它提供基准语言名和中断级别;
- 拉取(txtFix)时它提供业务线标签和语言名映射;
- 组件库读取时(MergeLangPlugin 的
getSddDesign)也用它做语言名映射,去node_modules/@sdd-web/design/lang/找对应文件名的 txt。
第八部分:速答版
30 秒版:
设备版控制器 Web 要支持 40+ 语种。分三种场景:开发环境 npm run dev(webpack 编译,合并本地四层 txt 到 public/lang,浏览器从 dev server 下);生产构建 npm run build(webpack 一样编译合并,但 build/lang 被 FileManagerPlugin 删掉,最终 zip 不带字符串);运行时用户访问设备 IP(浏览器下 acs.js + 从设备固件文件系统下 /lang/*.txt,设备固件提供 /lang 的逻辑不在 web 项目里)。
字符串的"家"在公司字符串系统,本地 txt 和设备固件 txt 都从系统拉但时机不同可能不同步,真正生效是设备固件那套,但都由前端维护。
代码里只写 key(t('com.Ok')),翻译在打包时合并成每种语言一个 txt,运行时按设备语言下载对应 txt,translate 柯里化造出 t,dispatch 进 Redux + LangProvider 经 React Context 传组件库,组件渲染 t('com.Ok') 查表显示"确定"。登录页特殊,用 LangConsumer 拿 t。
webpack 插件:CheckTxtPlugin(beforeRun)校验 key;MergeLangPlugin(emit)合并四层用 fs 写 txt。moment/antd locale 是静态 import 打进 acs.js 的,不是运行时下载的。
追问预案:
- Q:开发构建和生产构建差别? 前面编译流程一样(都跑 CheckTxtPlugin + MergeLangPlugin),差别只在最后:dev 写到 public/lang 对外提供;prod 写到 build/lang 但 FileManagerPlugin 在 onEnd 删掉,最终 zip 不带字符串,运行时从设备固件取。
- Q:本地字符串和设备字符串关系? 都源自字符串系统,本地 txtFix 随时拉(dev 用),设备应用打包时拉一次打进固件(生产运行时用),两边可能不同步,真正生效是设备固件那套,但都由前端维护(更新系统 → 设备应用下次打包拉到新的)。
- Q:设备固件提供 /lang/*.txt 的逻辑在 web 项目里吗? 不在。那是设备应用(固件)侧 web server 的逻辑,web 项目只负责"请求这个 URL",设备返回什么用什么。开发时这个 URL 由 dev server 用 public/lang 应答。
- Q:main.js 在哪? 源码里没有。它是 webpack 打包后的产物,实际文件名是 build/static/js/<项目名>..js(如 acs.a1b2c3d4.js),入口是 src/project/acs/index.js。dev 时在 dev server 内存里不落盘。
- Q:language.json 只在打包用吗?生产打包没影响吧? 只在打包环节用,运行时不用。但"生产打包没影响"不准确:生产构建时 MergeLangPlugin 照样读它生成对应数量 txt,只是 txt 随后被 FileManagerPlugin 删了,所以对最终产物没留下 txt;但"生成多少个 txt"这一步还是受它控制。
- Q:插件 apply 是不是写了就自动调? 不是。必须 new 进 plugins 数组,webpack 启动时自动遍历数组调 apply,apply 里 tapAsync 注册回调,编译到对应阶段 webpack 才调回调。三个时机:new 跑构造函数、apply 是 webpack 启动调、回调是阶段到了才调。
- Q:emit 钩子是写磁盘前还是后? 写磁盘前。webpack 原生 emit 触发时 assets 在内存里还没写磁盘,回调跑完 webpack 才写。和 HtmlWebpackPlugin.getHooks().beforeEmit 区分:后者是插件自定义钩子,针对"该插件把 HTML 塞进 assets 之前",发生在 emit 阶段内部。HtmlWebpackPlugin 是第三方 npm 包不是 webpack 自带。
- Q:改 txt 为什么不触发重新编译? MergeLangPlugin 用 fs 直接读写 txt,这些 txt 不在 webpack 模块依赖图里,webpack 感知不到它们变了。
- Q:组件库组件和业务页面分别怎么拿翻译? 组件库走 React Context(LangProvider 传 translationString);登录页走 LangConsumer(也是 Context);登录后的业务页面走 Redux(props.publicState.$t)。
- Q:init 里 langConfig.set 塞那么多参数干嘛? 是"配置注入"------把后续多语言流程要用的东西(字典下载地址 txtPath、问设备语言的接口 prePath、语言→antd locale 的 localeMap、语言→moment locale 的 momentMap、moment 实例等)一次性塞进库的单例仓库,后面 getPreLanguage、getCurrentLanguage、createApp 都从里面取,不用每次重新传。
- Q:init 里那段超时重登逻辑干嘛的? rpc.attachAliveActive 监听鼠标键盘判断用户活跃防 session 过期;attachOvertimeCallback 是超时后弹"登录超时"提示然后清 session 刷新页面回登录页;按能力集 webCaps.TimeOutNoStream 决定直接刷新还是等用户点击再刷新;rpc/ezapi 两套请求通道都注册了同一个超时回调。
- Q:moment locale 和 antd locale 那些 import 是干嘛的? 静态 import,打包时打进 acs.js。moment 的是副作用导入注册语言包,antd 的是导入 locale 对象存进 localeMap 给 ConfigProvider 用。和 txt 不同,不是运行时下载的。
- Q:localObj 那段覆盖代码干嘛的? 直接改 antd locale 对象的属性(DatePicker.lang.ok、Image.preview、Modal.okText),用我们的翻译覆盖 antd 内置文案,保证全应用文案统一。在 init 里 createApp 之前执行。