# 远端覆写完全替代本地语言包方案(融合版) > 目标:让远端语言包成为唯一数据源,本地 `lang/` 目录可以删除,且运行效果与现在一致。 > 核心思想:**前端 `lang/` 文件作为远端基础数据源,数据库覆写作为增量层,后端合并后返回统一全量 bundle;客户端只认一个接口、一种格式。** --- ## 一、为什么需要远端优先 当前方案是 **本地语言包 + 远端覆写** 的混合模式: - 本地 `lang/zh-cn/`、`lang/en-us/` 是主数据源; - 远端只返回 `overrides` map,用于运营 Hotfix 或多租户定制; - 若删除本地语言包,所有 `import('lang/xx/xxx.js')` 会失败,页面直接显示空串或 raw key。 要满足“删除本地语言包后效果一致”,必须让远端承担主数据源角色,本地只做**启动首屏兜底缓存**。 --- ## 二、融合架构设计 不再区分“方案 A”和“方案 B”,而是把它们合并成一条统一的远端链路: ```text ┌─────────────────────────────────────────────────────────────┐ │ 前端 lang/ 目录(基础数据源) │ │ 部署到服务端,作为默认、完整、可版本化的语言包基线 │ └─────────────────────────┬───────────────────────────────────┘ │ ┌─────────────────────────▼───────────────────────────────────┐ │ Admin 后台 / 数据库(覆写层) │ │ 仅存储需要覆盖或新增的 key │ │ 支持 locale / platform / tenant 维度 │ └─────────────────────────┬───────────────────────────────────┘ │ 合并 ┌─────────────────────────▼───────────────────────────────────┐ │ PHP 后端 API │ │ /api/lang/bundle 读取 lang 文件 + 合并数据库覆写 │ │ /api/lang/diff 返回 base 版本到当前版本的差异 │ └─────────────────────────┬───────────────────────────────────┘ │ HTTPS / CDN ┌─────────────────────────▼───────────────────────────────────┐ │ 客户端 │ │ 1. 启动时优先读本地 storage 缓存 │ │ 2. 异步拉取远端 bundle / diff │ │ 3. 更新缓存并刷新全局语言上下文 │ │ 4. $lang() 只读内存中的合并语言树 │ └─────────────────────────────────────────────────────────────┘ ``` ### 2.1 两层数据源 | 层级 | 来源 | 作用 | 是否必须 | |---|---|---|---| | Base | 服务端部署的前端 `lang/` 文件 | 提供完整默认语言包 | 必须 | | Override | 数据库/配置文件 | 覆盖或补充特定 key | 可选,按需启用 | ### 2.2 合并规则 后端每次响应 `/api/lang/bundle` 时: 1. 读取 `lang//` 下所有 `.js` 文件,归一化并合并成 **base tree**; 2. 从数据库读取当前 `locale + platform + tenant` 的覆写记录,展开成 **override tree**; 3. 用深度合并把 `override tree` 覆盖到 `base tree` 上; 4. 返回合并后的全量 tree。 这样: - 没有覆写时,返回的就是前端语言包本身; - 有覆写时,只改变被覆写的 key,其余保持前端文件内容; - 客户端无需关心某个 key 来自文件还是数据库。 --- ## 三、远端数据格式 统一返回层级树,客户端直接使用,无需展开: ```json { "code": 0, "data": { "version": "1751181600", "locale": "zh-cn", "platform": "h5", "tenant": "default", "source": "merged", "tree": { "common": { "submit": "提交", "cancel": "取消" }, "components": { "payment": { "alipay": "支付宝支付", "wechatpay": "微信支付" } }, "componentsDiy": { "diyArticle": { "viewMore": "查看更多" } }, "order": { "list": { "title": "我的订单", "status": { "waitpay": "待付款" } } } } } } ``` --- ## 四、后端 API 设计 ### 4.1 全量 bundle ```http GET /api/lang/bundle?locale=zh-cn&platform=h5&tenant=default ``` 响应字段: - `version`:bundle 版本,建议用 base 文件最后修改时间或自定义版本号; - `locale` / `platform` / `tenant`:请求维度; - `tree`:合并后的完整语言树。 ### 4.2 增量 diff ```http GET /api/lang/diff?locale=zh-cn&platform=h5&tenant=default&from_version=120 ``` 返回自 `from_version` 以来的变更: ```json { "code": 0, "data": { "version": "1751181600", "changes": { "common.submit": "提交", "order.list.title": null } } } ``` > diff 实现可简可繁:初期可以直接返回全量 `tree` 让客户端覆盖;后期再实现 key 级 diff。 ### 4.3 CDN 与缓存策略 bundle 接口应支持 CDN 缓存和客户端缓存: - **服务端响应头**: - `Cache-Control: public, max-age=300`(5 分钟短缓存,保证更新及时性); - `ETag: `; - `Content-Encoding: gzip/br`(bundle 通常 50~200KB,压缩后显著减小)。 - **CDN 策略**:按 URL(含 locale / platform / tenant / version)缓存,版本变化自动失效; - **客户端缓存**:storage 缓存 bundle,key 带 version; - **缓存优先级**:内存 > storage > CDN > 源站。 ### 4.4 安全与访问控制 - `/api/lang/bundle` 可以设为**公开接口**(语言包不涉及敏感业务数据,且 App 启动前用户可能未登录); - 若业务需要保护 tenant 级文案,可在请求头中携带匿名 token 或签名; - 必须对 `locale`、`platform`、`tenant` 做白名单校验,防止路径遍历或非法查询; - 后端永远不要信任客户端传来的 `version`,diff 接口只做幂等返回。 ### 4.5 签名与完整性校验 `/api/lang/bundle` 虽可公开访问,但仍需防止 CDN 污染或中间人篡改。推荐在响应中加入签名字段: ```json { "code": 0, "data": { "version": "1751181600", "tree": { ... }, "sign": "sha256=xxxxxxxx" } } ``` PHP 生成签名: ```php private function sign($tree, $version, $secret) { $payload = json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return hash_hmac('sha256', $version . $payload, $secret); } ``` 客户端在收到 bundle 后校验签名,校验失败则视为异常并使用缓存或内嵌兜底。签名密钥从配置中心下发或打包时内嵌,避免硬编码在仓库。 ### 4.6 版本控制策略 不建议用文件 `mtime` 作为生产版本号,原因: - 不同服务器部署时间不一致会导致版本号不一致; - CI/CD 并行部署时难以保证原子性。 推荐方案: | 方案 | 实现 | 适用场景 | |---|---|---| | 构建生成 version 文件 | CI 打包时写入 `lang/.version`,PHP 读取该文件 | 推荐,稳定可控 | | Git commit hash | 用当前 commit 短 hash 作为版本 | 适合 DevOps 成熟团队 | | 内容 hash | 对合并后的 tree 做 MD5/SHA1 | 最精确,但计算成本稍高 | 示例:CI 生成 `lang/.version` ```bash # deploy.sh echo "$(date +%s)-$(git rev-parse --short HEAD)" > public/static/lang/.version ``` PHP 读取: ```php private function getBundleVersion($locale) { $versionFile = $this->basePath . '/' . $locale . '/.version'; if (file_exists($versionFile)) { return trim(file_get_contents($versionFile)); } return 'unknown'; } ``` --- ## 五、构建与部署流程 ### 5.1 前端构建时同步 lang 文件 在 `package.json` 中增加构建脚本: ```json { "scripts": { "build:h5": "npm run sync:lang && uni build", "sync:lang": "node scripts/sync-lang-to-server.js" } } ``` `scripts/sync-lang-to-server.js` 示例: ```js const fs = require('fs'); const path = require('path'); const SRC = path.resolve(__dirname, '../lang'); const DEST = path.resolve(__dirname, '../../server/public/static/lang'); function copyDir(src, dest) { if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDir(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } copyDir(SRC, DEST); // 生成 version 文件 const version = `${Date.now()}-${process.env.GIT_COMMIT || 'dev'}`; for (const locale of ['zh-cn', 'en-us']) { fs.writeFileSync(path.join(DEST, locale, '.version'), version); } console.log('lang files synced to server'); ``` ### 5.2 部署原子性 语言包文件必须**全量替换**,不能部分更新: - 使用软链/目录切换:先部署到新目录 `lang.new`,再原子切换软链 `lang -> lang.new`; - 或让 PHP 读取带版本号的目录 `lang/1751181600/`,版本变化时切换配置。 ### 5.3 多环境管理 | 环境 | lang 来源 | 是否启用覆写 | |---|---|---| | 本地开发 | 本地 `lang/` 文件(`source: 'local'`) | 否 | | 测试环境 | 远端 bundle(`source: 'remote-only'`) | 可启用 | | 预发环境 | 远端 bundle | 仅内部租户覆写 | | 生产环境 | 远端 bundle + CDN | 启用 | --- ## 六、PHP 实现示例 ### 6.1 数据表设计(覆写层) ```sql CREATE TABLE lang_overrides ( id INT AUTO_INCREMENT PRIMARY KEY, locale VARCHAR(10) NOT NULL DEFAULT 'zh-cn', platform VARCHAR(20) NOT NULL DEFAULT '', tenant VARCHAR(50) NOT NULL DEFAULT '', namespace VARCHAR(255) NOT NULL, -- 点分 key,如 common.submit value TEXT, -- NULL 表示删除 is_enabled TINYINT NOT NULL DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_key (locale, platform, tenant, namespace) ); ``` ```sql CREATE TABLE lang_bundle_versions ( id INT AUTO_INCREMENT PRIMARY KEY, locale VARCHAR(10) NOT NULL DEFAULT 'zh-cn', platform VARCHAR(20) NOT NULL DEFAULT '', tenant VARCHAR(50) NOT NULL DEFAULT '', version VARCHAR(64) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_version (locale, platform, tenant) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 6.2 Bundle 控制器 ```php basePath = __DIR__ . '/../../public/static/lang'; } /** * GET /api/lang/bundle?locale=zh-cn&platform=h5&tenant=default */ public function bundle() { $locale = $_GET['locale'] ?? 'zh-cn'; $platform = $_GET['platform'] ?? ''; $tenant = $_GET['tenant'] ?? ''; if (!in_array($locale, ['zh-cn', 'en-us'])) { return $this->json(['code' => 400, 'message' => 'invalid locale']); } $localePath = $this->basePath . '/' . $locale; if (!is_dir($localePath)) { return $this->json(['code' => 404, 'message' => 'locale not found']); } // 1. 读取前端 lang 文件作为 base $baseTree = $this->loadLangDir($localePath); // 2. 读取数据库覆写层 $overrideTree = $this->loadOverrides($locale, $platform, $tenant); // 3. 深度合并:override 覆盖 base $mergedTree = $this->deepMerge($baseTree, $overrideTree); // 4. 按平台裁剪(可选) $mergedTree = $this->filterByPlatform($mergedTree, $platform); $this->setCacheHeaders($locale); return $this->json([ 'code' => 0, 'data' => [ 'version' => $this->getBundleVersion($locale), 'locale' => $locale, 'platform' => $platform, 'tenant' => $tenant, 'source' => 'merged', 'tree' => $mergedTree, ] ]); } private function setCacheHeaders($locale) { header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: public, max-age=300'); header('ETag: "' . $this->getBundleVersion($locale) . '"'); } /** * 递归读取 lang 目录,归一化命名空间 */ private function loadLangDir($dir) { $tree = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir) ); foreach ($iterator as $file) { if ($file->isDir() || $file->getExtension() !== 'js') { continue; } $relativePath = str_replace($dir . DIRECTORY_SEPARATOR, '', $file->getPathname()); $relativePath = str_replace('\\', '/', $relativePath); $relativePath = preg_replace('/\.js$/', '', $relativePath); // 跳过 version 文件 if ($relativePath === '.version') continue; $content = file_get_contents($file->getPathname()); $lang = $this->parseLangExport($content); if ($lang === null) continue; $namespace = $this->toNamespace($relativePath); $this->setNestedValue($tree, $namespace, $lang); } return $tree; } /** * 解析 export const lang = { ... } */ private function parseLangExport($content) { if (!preg_match('/export\s+const\s+lang\s*=\s*([\s\S]*?);?\s*$/m', $content, $matches)) { return null; } $json = $matches[1]; // 简单清理 JS 语法为合法 JSON $json = preg_replace('/([\{\,])\s*\/\/[^\n]*/', '$1', $json); $json = preg_replace('/\/\*[\s\S]*?\*\//', '', $json); $json = str_replace("'", '"', $json); $json = preg_replace('/,\s*([\}\]])/', '$1', $json); $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : null; } private function toNamespace($relativePath) { $segments = explode('/', $relativePath); return array_map([$this, 'toCamelCase'], $segments); } private function toCamelCase($name) { return preg_replace_callback('/[-_]([a-z])/', function ($m) { return strtoupper($m[1]); }, $name); } private function setNestedValue(&$tree, $path, $value) { $cursor = &$tree; foreach ($path as $key) { if (!isset($cursor[$key]) || !is_array($cursor[$key])) { $cursor[$key] = []; } $cursor = &$cursor[$key]; } $cursor = $this->deepMerge($cursor, $value); } /** * 从数据库读取覆写层,并展开为层级树 */ private function loadOverrides($locale, $platform, $tenant) { $tree = []; // 按优先级升序查询,后覆盖先 $rows = DB::table('lang_overrides') ->where('locale', $locale) ->where('is_enabled', 1) ->where(function ($q) use ($platform, $tenant) { $q->where(function ($q2) { // global $q2->where('tenant', '')->where('platform', ''); }) ->orWhere(function ($q2) use ($platform) { // platform only $q2->where('tenant', '')->where('platform', $platform); }) ->orWhere(function ($q2) use ($tenant) { // tenant only $q2->where('tenant', $tenant)->where('platform', ''); }) ->orWhere(function ($q2) use ($platform, $tenant) { // tenant + platform $q2->where('tenant', $tenant)->where('platform', $platform); }); }) ->orderByRaw(" CASE WHEN tenant = '' AND platform = '' THEN 1 WHEN tenant = '' AND platform != '' THEN 2 WHEN tenant != '' AND platform = '' THEN 3 ELSE 4 END ASC ") ->get(); foreach ($rows as $row) { $path = explode('.', $row->namespace); if ($row->value === null) { $this->deleteNestedValue($tree, $path); } else { $this->setNestedValue($tree, $path, $row->value); } } return $tree; } /** * 可选:按平台裁剪不需要的 key */ private function filterByPlatform($tree, $platform) { // 示例:若某 key 仅在 mp-weixin 使用,可在此过滤 // 当前项目暂不需要,留作扩展 return $tree; } private function deepMerge($target, $source) { if (!is_array($target)) $target = []; if (!is_array($source)) return $source; foreach ($source as $key => $value) { if (isset($target[$key]) && is_array($target[$key]) && is_array($value)) { $target[$key] = $this->deepMerge($target[$key], $value); } else { $target[$key] = $value; } } return $target; } private function deleteNestedValue(&$tree, $path) { $cursor = &$tree; foreach ($path as $i => $key) { if (!isset($cursor[$key])) return; if ($i === count($path) - 1) { unset($cursor[$key]); } else { $cursor = &$cursor[$key]; } } } private function getBundleVersion($locale) { $versionFile = $this->basePath . '/' . $locale . '/.version'; if (file_exists($versionFile)) { return trim(file_get_contents($versionFile)); } return 'unknown'; } private function json($data) { echo json_encode($data); } } ``` ### 6.3 无数据库时的最小实现 如果暂时不需要 Admin 覆写,可以把 `loadOverrides` 直接返回空数组,整个系统仍然可用。等需要运营 Hotfix 时再启用数据库表。 ### 6.4 Admin 覆写接口设计 后台对覆写层的增删改查接口: ```php class LangOverrideController { /** * POST /api/admin/lang/overrides */ public function store() { $locale = $_POST['locale'] ?? 'zh-cn'; $platform = $_POST['platform'] ?? ''; $tenant = $_POST['tenant'] ?? ''; $namespace = $_POST['namespace'] ?? ''; $value = $_POST['value'] ?? null; // 白名单校验 if (!in_array($locale, ['zh-cn', 'en-us'])) { return $this->json(['code' => 400, 'message' => 'invalid locale']); } if (!$this->isValidNamespace($namespace)) { return $this->json(['code' => 400, 'message' => 'invalid namespace']); } DB::table('lang_overrides') ->updateOrInsert( ['locale' => $locale, 'platform' => $platform, 'tenant' => $tenant, 'namespace' => $namespace], ['value' => $value, 'is_enabled' => 1, 'updated_at' => date('Y-m-d H:i:s')] ); // 更新版本号,触发客户端更新 $this->bumpVersion($locale, $platform, $tenant); return $this->json(['code' => 0, 'message' => 'ok']); } /** * DELETE /api/admin/lang/overrides */ public function destroy() { $id = $_GET['id'] ?? 0; DB::table('lang_overrides')->where('id', $id)->delete(); return $this->json(['code' => 0, 'message' => 'ok']); } /** * GET /api/admin/lang/overrides */ public function index() { $locale = $_GET['locale'] ?? 'zh-cn'; $platform = $_GET['platform'] ?? ''; $tenant = $_GET['tenant'] ?? ''; $rows = DB::table('lang_overrides') ->where('locale', $locale) ->where(function ($q) use ($platform) { if ($platform) $q->where('platform', $platform); }) ->where(function ($q) use ($tenant) { if ($tenant) $q->where('tenant', $tenant); }) ->orderBy('updated_at', 'desc') ->get(); return $this->json(['code' => 0, 'data' => $rows]); } private function bumpVersion($locale, $platform, $tenant) { DB::table('lang_bundle_versions') ->updateOrInsert( ['locale' => $locale, 'platform' => $platform, 'tenant' => $tenant], ['version' => time(), 'updated_at' => date('Y-m-d H:i:s')] ); } } ``` ### 6.5 diff 算法实现 `/api/lang/diff` 用于增量更新。初期可直接返回全量 tree,后期实现 key 级 diff: ```php public function diff() { $locale = $_GET['locale'] ?? 'zh-cn'; $platform = $_GET['platform'] ?? ''; $tenant = $_GET['tenant'] ?? ''; $fromVersion = $_GET['from_version'] ?? ''; // 获取当前版本 $currentVersion = $this->getCurrentVersion($locale, $platform, $tenant); // 如果版本相同,返回空 diff if ($fromVersion === $currentVersion) { return $this->json(['code' => 0, 'data' => ['version' => $currentVersion, 'changes' => []]]); } // 初期:直接返回全量 tree 作为 changes $bundle = $this->buildBundle($locale, $platform, $tenant); $changes = $this->flattenTree($bundle['tree']); return $this->json([ 'code' => 0, 'data' => [ 'version' => $currentVersion, 'changes' => $changes, ] ]); } /** * 把层级树展开为点分 key map */ private function flattenTree($tree, $prefix = '') { $result = []; foreach ($tree as $key => $value) { $path = $prefix ? $prefix . '.' . $key : $key; if (is_array($value)) { $result = array_merge($result, $this->flattenTree($value, $path)); } else { $result[$path] = $value; } } return $result; } /** * 后期:基于版本快照实现真 diff */ private function diffAgainstSnapshot($locale, $platform, $tenant, $fromVersion) { $oldTree = $this->loadSnapshot($locale, $platform, $tenant, $fromVersion); $newTree = $this->buildBundle($locale, $platform, $tenant)['tree']; $oldFlat = $this->flattenTree($oldTree); $newFlat = $this->flattenTree($newTree); $changes = []; foreach ($newFlat as $key => $value) { if (!isset($oldFlat[$key]) || $oldFlat[$key] !== $value) { $changes[$key] = $value; } } foreach ($oldFlat as $key => $value) { if (!isset($newFlat[$key])) { $changes[$key] = null; } } return $changes; } ``` --- ## 七、客户端改造方案 ### 7.1 配置项扩展 ```js { source: 'remote', // 'local' | 'remote' | 'hybrid' | 'remote-only' defaultLang: 'zh-cn', langList: ['zh-cn', 'en-us'], remote: { bundleUrl: '/api/lang/bundle', diffUrl: '/api/lang/diff', // 可选,无 diff 时走全量 bundle storagePrefix: 'lang_', bundleStorageKey: 'lang_bundle', versionStorageKey: 'lang_version', requestTimeout: 8000, bootTimeout: 3000, allowStaleCache: true, cacheMaxAge: 7 * 24 * 3600 * 1000, } } ``` ### 7.2 新增 `common/js/lang-loader.js` ```js // common/js/lang-loader.js import config from './config-external.js'; const STORAGE_KEYS = { bundle: `${config.remote.storagePrefix}bundle`, version: `${config.remote.storagePrefix}version`, }; function readCache() { try { const bundleStr = uni.getStorageSync(STORAGE_KEYS.bundle); const version = uni.getStorageSync(STORAGE_KEYS.version); if (!bundleStr) return null; const parsed = JSON.parse(bundleStr); if (config.remote.cacheMaxAge && parsed.timestamp) { const age = Date.now() - parsed.timestamp; if (age > config.remote.cacheMaxAge && !config.remote.allowStaleCache) { return null; } } return { version, data: parsed.data }; } catch (e) { return null; } } function writeCache(version, data) { try { uni.setStorageSync(STORAGE_KEYS.bundle, JSON.stringify({ data, timestamp: Date.now(), })); uni.setStorageSync(STORAGE_KEYS.version, version); } catch (e) { console.warn('[i18n] cache write failed', e); } } async function fetchBundle(locale, platform, tenant) { const url = `${config.remote.bundleUrl}?locale=${locale}&platform=${platform}&tenant=${tenant}`; return new Promise((resolve, reject) => { uni.request({ url, method: 'GET', timeout: config.remote.requestTimeout, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 0) { const payload = res.data.data; resolve({ version: payload.version, data: payload.tree, }); } else { reject(new Error('bundle fetch failed')); } }, fail: reject, }); }); } async function fetchDiff(locale, platform, tenant, fromVersion) { if (!config.remote.diffUrl) return null; const url = `${config.remote.diffUrl}?locale=${locale}&platform=${platform}&tenant=${tenant}&from_version=${fromVersion}`; return new Promise((resolve, reject) => { uni.request({ url, method: 'GET', timeout: config.remote.requestTimeout, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 0) { resolve(res.data.data); } else { reject(new Error('diff fetch failed')); } }, fail: reject, }); }); } function applyDiff(tree, diff) { const result = JSON.parse(JSON.stringify(tree)); for (const [key, value] of Object.entries(diff)) { const parts = key.split('.'); let cursor = result; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!cursor[part] || typeof cursor[part] !== 'object') { cursor[part] = {}; } cursor = cursor[part]; } if (value === null) { delete cursor[parts[parts.length - 1]]; } else { cursor[parts[parts.length - 1]] = value; } } return result; } export async function loadLanguageTree(locale, platform, tenant) { const cache = readCache(); if (!cache) { const bundle = await fetchBundle(locale, platform, tenant); writeCache(bundle.version, bundle.data); return bundle.data; } // 有缓存:先返回缓存,后台拉取更新 fetchDiff(locale, platform, tenant, cache.version) .then(async (diff) => { if (!diff) { // 无 diff 接口或 diff 为空,直接拉全量 bundle 对比 version const bundle = await fetchBundle(locale, platform, tenant); if (bundle.version !== cache.version) { writeCache(bundle.version, bundle.data); uni.$emit('lang:updated', { locale, version: bundle.version }); } return; } if (diff.changes) { const newTree = applyDiff(cache.data, diff.changes); writeCache(diff.version, newTree); uni.$emit('lang:updated', { locale, version: diff.version }); } }) .catch(() => { // 静默失败,继续使用缓存 }); return cache.data; } ``` ### 7.3 启动时序与首屏优化 remote-only 模式下,启动流程必须保证 `$lang()` 在首屏渲染前可用。推荐在 `App.vue` 的 `onLaunch` 中阻塞式加载 bundle: ```js // App.vue import { loadLanguageTree } from '@/common/js/lang-loader.js'; export default { onLaunch() { const locale = uni.getStorageSync('lang') || 'zh-cn'; const platform = this.getPlatform(); const tenant = this.getTenant(); // 阻塞启动,直到 bundle 就绪 this.$langReady = loadLanguageTree(locale, platform, tenant) .then(tree => { uni.$emit('lang:ready', { locale, tree }); }) .catch(err => { console.error('[i18n] launch bundle failed', err); }); }, onShow() { // 等待 bundle 就绪后再放行首屏 return this.$langReady; } }; ``` 页面级入口同步等待: ```js // pages/index/index.vue export default { async onLoad() { await getApp().$langReady; // 此时 $lang() 已可用 } }; ``` **首屏时间优化**: | 策略 | 说明 | |---|---| | 缓存优先启动 | 先读本地 storage 缓存并渲染,后台异步更新 | | bundle 预加载 | 在启动页或 splash 时并行请求 bundle | | 最小化首屏 key | 内嵌默认 bundle 仅含 TabBar / 启动页 / 错误提示,避免阻塞 | | 请求超时控制 | `bootTimeout` 默认 3s,超时后使用缓存或兜底 | | CDN 边缘缓存 | 5 分钟短缓存 + 版本号变化自动失效 | ### 7.4 修改 `common/js/lang.js` 在 `lang(field)` 的合并逻辑前增加 remote-only 分支: ```js async function getMergedLang(locale, route) { if (langConfig.source === 'remote-only') { const tree = await loadLanguageTree(locale, currentPlatform, currentTenant); return tree; } // 原有 local / hybrid 逻辑 const common = await import(`@/lang/${locale}/common.js`); const shared = await loadSharedPacks(locale); const page = await loadPagePack(locale, route); return deepMerge(common, shared, page, remoteOverrides); } ``` ### 7.5 离线降级策略 完全删除本地语言包后,必须处理以下异常场景: | 场景 | 策略 | |---|---| | 有缓存且远端失败 | 继续使用缓存,记录 warning | | 无缓存且远端失败 | 显示错误页/重试按钮,或fallback到内嵌默认 bundle | | 远端返回非 200 | 使用缓存;无缓存则显示 loading 失败提示 | | 远端返回空 tree | 视为异常,使用缓存或内嵌 bundle | | 首次安装弱网 | 启动页等待 bundle,超时时提示用户 | **内嵌默认 bundle**: 在 `common/js/lang-defaults.js` 中内嵌一份最小化的默认语言包(至少包含 TabBar、启动页、错误提示文案): ```js // common/js/lang-defaults.js export const defaultBundle = { 'zh-cn': { common: { loading: '加载中...', retry: '重试', networkError: '网络异常' }, tabBar: { home: '首页', member: '我的' }, }, 'en-us': { common: { loading: 'Loading...', retry: 'Retry', networkError: 'Network error' }, tabBar: { home: 'Home', member: 'Me' }, }, }; ``` 在 `loadLanguageTree` 中作为最后兜底: ```js if (!cache) { try { const bundle = await fetchBundle(locale, platform, tenant); writeCache(bundle.version, bundle.data); return bundle.data; } catch (e) { console.error('[i18n] bundle fetch failed, fallback to embedded defaults'); return defaultBundle[locale] || defaultBundle['zh-cn']; } } ``` ### 7.6 热更新与页面刷新机制 运营在 Admin 修改文案后,客户端不需要重启 App 即可生效。 **后台发布时生成版本**: ```sql -- 发布覆写时更新 lang_bundle_versions 表 INSERT INTO lang_bundle_versions (locale, platform, tenant, version, created_at) VALUES ('zh-cn', 'h5', 'default', UNIX_TIMESTAMP(), NOW()) ON DUPLICATE KEY UPDATE version = VALUES(version), created_at = VALUES(created_at); ``` **客户端轮询/推送更新**: 1. **定时轮询**:App 回到前台时,调用 `/api/lang/bundle` 对比 `version`; 2. **WebSocket / 长连接推送**:Admin 发布后,服务端向在线客户端推送 `lang:updated` 事件; 3. **页面刷新**:收到更新后,客户端写入缓存并触发全局事件: ```js uni.$on('lang:updated', ({ locale, version }) => { // 通知当前页面刷新 const pages = getCurrentPages(); const current = pages[pages.length - 1]; if (current && current.$vm && current.$vm.$forceUpdate) { current.$vm.$forceUpdate(); } // 或全局提示用户 uni.showToast({ title: '语言包已更新', icon: 'none' }); }); ``` **强制刷新策略**: - 对于 TabBar、导航标题等全局文案,更新后立即 `$forceUpdate`; - 对于长列表中的静态文案,允许下一页加载时自然生效; - 对于支付结果页等一次性页面,不强制刷新,避免用户困惑。 ### 7.7 灰度发布 上线 remote-only 时,不要一次性全量切换: 1. **白名单灰度**:根据 userId / deviceId / 地区,仅对部分用户启用 `source: 'remote-only'`; 2. **版本灰度**:App 新版本先 5% 启用,观察 24 小时后再扩大; 3. **回滚开关**:后端/配置中心保留 `source` 切换能力,出问题立即切回 `local`; 4. **双写校验**:remote-only 用户同时走本地和远端,对比关键 key 是否一致并上报差异。 ### 7.8 平台差异适配 不同平台的请求方式、缓存能力、包体积限制差异较大,需要分别处理。 **微信小程序** | 差异点 | 处理方案 | |---|---| | 包体积限制 | bundle 不打包进小程序,启动时从远端拉取;内嵌默认 bundle 控制在 5KB 以内 | | `uni.request` 域名 | bundle 接口域名必须加入小程序 request 合法域名 | | `uni.getStorageSync` 容量 | 单用户 10MB 足够缓存 bundle;超出时清理旧缓存 | | 并发限制 | `uni.request` 并发 10 条,bundle 请求优先级最高 | | 后台切前台 | `onShow` 中检测版本,静默更新 | | 弱网/无网 | 优先使用缓存,缓存失效时使用内嵌默认 bundle | **H5** | 差异点 | 处理方案 | |---|---| | localStorage | 同源策略下可长期缓存;注意隐身模式不可用 | | Service Worker | 可缓存 bundle,实现离线访问 | | CDN 跨域 | bundle 接口需配置 `Access-Control-Allow-Origin` | | SSR/SEO | SSR 时服务端直接请求 `/api/lang/bundle` 注入 HTML,避免客户端闪烁 | | 刷新页面 | 每次刷新重新请求 bundle,依赖 304/ETag 减少流量 | **App** | 差异点 | 处理方案 | |---|---| | 原生存储 | 优先使用 `plus.storage`,容量大且稳定 | | 推送更新 | 可利用 uni-push 推送语言包更新通知 | | 离线包 | 打包时内置最近一次 bundle,无网时直接使用 | --- ## 八、后台管理系统设计 后台只需管理**覆写层**,不需要管理所有 key: ### 8.1 核心功能 1. **Key 搜索与覆写** - 输入点分 key(如 `order.list.title`); - 填写覆盖后的 value; - 选择生效维度(locale / platform / tenant)。 2. **从 base 导入 key** - 定期或手动把前端 `lang/` 文件同步到后台; - 后台展示所有 base key,方便运营人员选择要覆写的 key。 3. **版本与发布** - 每次发布生成新版本号; - 支持回滚到上一版本; - diff 接口根据版本号计算变更。 4. **多维度隔离** - 优先级:`tenant + platform` > `tenant` > `platform` > `global`。 ### 8.2 为什么只管理覆写层 - 默认文案仍由前端 `lang/` 文件维护,开发流程不变; - 运营只干预需要修改的 key,降低管理复杂度; - 没有覆写时,系统零性能损耗(override tree 为空)。 --- ## 九、测试与校验 ### 9.1 一致性校验脚本 新增 `scripts/verify-remote-bundle.js`,对比远端 bundle 与本地合并结果: ```js const fs = require('fs'); const path = require('path'); const http = require('http'); const BASE_URL = process.env.LANG_BUNDLE_URL || 'http://localhost/api/lang/bundle'; const LOCAL_LANG_DIR = path.resolve(__dirname, '../lang'); async function main() { const locales = ['zh-cn', 'en-us']; let hasError = false; for (const locale of locales) { const remoteTree = await fetchRemote(locale); const localTree = loadLocal(locale); const diff = compare(remoteTree, localTree); if (diff.length) { console.error(`[${locale}] 不一致 key:`, diff); hasError = true; } else { console.log(`[${locale}] 远端 bundle 与本地一致`); } } process.exit(hasError ? 1 : 0); } async function fetchRemote(locale) { return new Promise((resolve, reject) => { http.get(`${BASE_URL}?locale=${locale}&platform=h5`, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { const json = JSON.parse(data); resolve(json.data.tree); }); }).on('error', reject); }); } function loadLocal(locale) { // 复用现有 i18n-check.js 中的本地合并逻辑 // ... } function compare(a, b, path = '') { const diffs = []; // 递归比较... return diffs; } main(); ``` ### 9.2 CI 集成 在 CI 中增加校验步骤: ```yaml # .github/workflows/i18n.yml - name: Sync lang files to test server run: npm run sync:lang --env=test - name: Verify remote bundle run: LANG_BUNDLE_URL=http://test.example.com/api/lang/bundle node scripts/verify-remote-bundle.js - name: Run i18n check run: npm run i18n:check ``` ### 9.3 线上巡检 定时脚本调用 `/api/lang/bundle`,检查: - 返回码是否为 0; - tree 是否非空; - 关键 key(如 `common.submit`、`tabBar.home`)是否存在; - 响应时间是否超过阈值。 ### 9.4 与现有 i18n-check 的整合 项目已有 `npm run i18n:check` 脚本,用于校验 `zh-cn` 与 `en-us` 的 key 一致性。remote-only 模式下,该校验仍然有效,且可以扩展为: 1. **本地校验**:继续校验 `lang/zh-cn/` 与 `lang/en-us/` 的一致性; 2. **远端校验**:同步到服务端后,拉取 `/api/lang/bundle` 校验远端合并结果是否仍然一致; 3. **覆写层校验**:校验 `lang_overrides` 表中是否存在只覆盖了一个语言版本的 key。 扩展脚本 `scripts/verify-remote-bundle.js` 的核心逻辑: ```js async function verifyBundleConsistency(localeA, localeB) { const treeA = await fetchRemote(localeA); const treeB = await fetchRemote(localeB); const flatA = flatten(treeA); const flatB = flatten(treeB); const onlyInA = Object.keys(flatA).filter(k => !(k in flatB)); const onlyInB = Object.keys(flatB).filter(k => !(k in flatA)); if (onlyInA.length || onlyInB.length) { console.error('远端 bundle 语言不一致:', { onlyInA, onlyInB }); process.exit(1); } } ``` 在 CI 中增加: ```yaml - name: Run local i18n check run: npm run i18n:check - name: Verify remote bundle consistency run: node scripts/verify-remote-bundle.js ``` --- ## 十、监控与告警 ### 10.1 客户端埋点 | 事件 | 含义 | 告警条件 | |---|---|---| | `lang_bundle_success` | 成功拉取 bundle | — | | `lang_bundle_fail` | 拉取失败 | 连续 N 次失败 | | `lang_bundle_cache_hit` | 使用缓存启动 | 占比过高可能表示更新不及时 | | `lang_bundle_duration` | bundle 请求耗时 | P99 > 2s | | `lang_key_missing` | $lang() 返回空串 | 命中 raw key/空串 | ### 10.2 服务端监控 - `/api/lang/bundle` QPS、错误率、P99 延迟; - CDN 命中率; - bundle 文件大小趋势; - override 表变更频率。 --- ## 十一、迁移步骤 ### 阶段 1:PHP 后端接入 1. 把前端 `lang/zh-cn/`、`lang/en-us/` 目录复制到服务端; 2. 实现 `/api/lang/bundle`,先让 `loadOverrides` 返回空数组; 3. 配置 CDN 和 gzip/br; 4. 验证返回的 tree 与本地合并结果一致。 ### 阶段 2:客户端改造 1. 新增 `common/js/lang-loader.js`; 2. 修改 `common/js/lang.js`,支持 `source: 'remote-only'`; 3. App 启动流程接入 loader; 4. 内嵌默认语言兜底 bundle。 ### 阶段 3:灰度验证 1. 保留本地 `lang/` 目录; 2. 配置 `source: 'remote'` 或 `hybrid`,对比 remote bundle 与本地结果; 3. 运行 `npm run i18n:check` 和 `node scripts/verify-p2-keys.js`; 4. 对白名单用户启用 `source: 'remote-only'`,观察一周; 5. 在测试环境删除本地语言包,验证功能正常。 ### 阶段 4:正式上线 1. 逐步扩大 remote-only 用户比例; 2. 配置 `source: 'remote-only'` 为默认; 3. 构建脚本中移除 `lang/` 目录打包(或保留但不使用); 4. 监控 bundle 接口成功率、首屏加载时间、缓存命中率。 ### 阶段 5(可选):启用 Admin 覆写 1. 创建 `lang_overrides` 表; 2. 实现 Admin 后台,允许运营人员覆写指定 key; 3. 验证覆写后客户端能正确展示。 --- ## 十二、风险与回退 | 风险 | 影响 | 缓解措施 | |---|---|---| | bundle 接口挂掉 | 新用户无法进入应用 | 内嵌默认语言兜底;启用 CDN;多活部署 | | bundle 体积过大 | 首屏慢、流量高 | 按平台裁剪;gzip/br;diff 更新 | | 后台误改 key | 线上文案错误 | 发布前预览;版本回滚;灰度发布 | | 缓存过期无网 | 老用户打开空白 | `allowStaleCache: true` | | 多租户 key 冲突 | 文案串租户 | 查询时按 tenant / platform 优先级过滤 | | 版本号不一致 | CDN/多机返回不同 bundle | 构建生成 `.version` 文件 | | 本地开发困难 | 后端未启动时无法开发 | 本地环境 `source: 'local'` | | CDN 缓存污染 | 用户看到旧版或被篡改文案 | 签名校验;URL 带版本号;HTTPS | | 首屏 bundle 未就绪 | 页面先渲染后闪变 | App.vue 阻塞加载;内嵌首屏兜底 | | 灰度配置错误 | 全量用户提前进入 remote-only | 配置中心双人复核;白名单逐步放量 | | 覆写只改单一语言 | zh/en 文案不一致 | CI 远端一致性校验;Admin 强制双语言填写 | | 小程序域名未配置 | 请求失败 | 上线前检查 request 合法域名 | | storage 写满 | 缓存失败 | 捕获异常;清理旧缓存;降级内存缓存 | | diff 接口异常 | 增量更新失败 | 回退全量 bundle;客户端自动重试 | | 跨平台文案差异 | 某平台显示 raw key | 平台维度覆写;平台白名单校验 | 回退方案: - 紧急情况下,客户端配置切回 `source: 'local'`,恢复使用本地 `lang/` 目录; - 建议上线初期保留本地语言包,待 remote-only 稳定后再删除。 --- ## 十三、关键结论 融合后的方案满足“删除本地语言包后效果一致”的目标: 1. **前端 `lang/` 文件作为 base**:部署到服务端,提供完整默认语言包; 2. **数据库/Admin 作为 override 层**:只管理需要覆写的 key; 3. **后端合并后返回统一 tree**:客户端只认一个接口、一种格式; 4. **构建/部署/版本/缓存/安全/监控全覆盖**:从开发到上线形成闭环; 5. **启动时序与首屏优化**:App.vue 阻塞加载 + 缓存优先,避免页面闪变; 6. **热更新与页面刷新**:Admin 发布后客户端无需重启即可生效; 7. **平台差异适配**:针对微信小程序、H5、App 分别处理缓存、域名、存储; 8. **签名与完整性校验**:防止 CDN 污染和中间人篡改; 9. **与 i18n-check 整合**:本地 + 远端双重校验,保证 zh/en 一致性; 10. **分阶段上线 + 灰度 + 回退**:降低切换风险; 11. **PHP 后端最小改动**:核心 bundle 接口几十行代码即可跑起来。