Web 多语言(i18n)技术方案详解

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 直接读文件:

  1. 递归扫描 src/base + src/project/acs;
  2. 对 .js 文件:读成字符串,用正则匹配所有形如 com.xxx / acs.xxx 的字符串字面量(不是只找 $t(...),而是找任何引号里这种前缀的字符串 ),每个 key 拿去和「所有 SimpChinese.txt 合并出的基准表」比对,缺 key(missKey)或值为空(missValue)就记下来;
  3. 对 .txt 文件:剥掉花括号换行再 JSON.parse,解析失败就是格式错误;非基准语言的 txt 再和基准 SimpChinese 比对:基准有的 key 你不能缺(可以多);
  4. 查完生成报告,按 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 之前执行。

相关推荐
biu_biubiu1 小时前
我开源了 Codebook
前端·vue.js
下山2 小时前
怎么把带自研插件的 DeepSeek Harness 部署到云服务器
前端
彭亚川Allen2 小时前
985 在职研究生到底怎么样?是不是水硕?是不是.....?
前端·后端·程序员
YufJi2 小时前
两棵树,两个线程:一个小程序引擎的架构拆解
前端
super_Omni2 小时前
Chrome 插件入驻实战:从 Manifest V3 到过审上架的完整路径
大数据·前端·chrome
腾讯数据架构师2 小时前
任务跑完怎么直接出图表?CubeStudio 任务可视化 ECharts 数据格式实操(CSV/JSON + metric.json,11 种图表一键渲染)
前端·json·echarts·数据可视化
小小小米粒2 小时前
不是在构造方法「之前」修饰 Bean、注入;是构造方法跑完之后,才做属性注入,再跑 @PostConstruct。
前端
小李科技3 小时前
【性能优化】事件委托:目标定位与 React/Vue 实战
前端·vue.js·react.js·面试·性能优化·前端框架
QianhangQianping3 小时前
一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构
前端·低代码·架构·编辑器·数据可视化·大屏可视化