44 KiB
远端覆写完全替代本地语言包方案(融合版)
目标:让远端语言包成为唯一数据源,本地
lang/目录可以删除,且运行效果与现在一致。 核心思想:前端lang/文件作为远端基础数据源,数据库覆写作为增量层,后端合并后返回统一全量 bundle;客户端只认一个接口、一种格式。
一、为什么需要远端优先
当前方案是 本地语言包 + 远端覆写 的混合模式:
- 本地
lang/zh-cn/、lang/en-us/是主数据源; - 远端只返回
overridesmap,用于运营 Hotfix 或多租户定制; - 若删除本地语言包,所有
import('lang/xx/xxx.js')会失败,页面直接显示空串或 raw key。
要满足“删除本地语言包后效果一致”,必须让远端承担主数据源角色,本地只做启动首屏兜底缓存。
二、融合架构设计
不再区分“方案 A”和“方案 B”,而是把它们合并成一条统一的远端链路:
┌─────────────────────────────────────────────────────────────┐
│ 前端 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 时:
- 读取
lang/<locale>/下所有.js文件,归一化并合并成 base tree; - 从数据库读取当前
locale + platform + tenant的覆写记录,展开成 override tree; - 用深度合并把
override tree覆盖到base tree上; - 返回合并后的全量 tree。
这样:
- 没有覆写时,返回的就是前端语言包本身;
- 有覆写时,只改变被覆写的 key,其余保持前端文件内容;
- 客户端无需关心某个 key 来自文件还是数据库。
三、远端数据格式
统一返回层级树,客户端直接使用,无需展开:
{
"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
GET /api/lang/bundle?locale=zh-cn&platform=h5&tenant=default
响应字段:
version:bundle 版本,建议用 base 文件最后修改时间或自定义版本号;locale/platform/tenant:请求维度;tree:合并后的完整语言树。
4.2 增量 diff
GET /api/lang/diff?locale=zh-cn&platform=h5&tenant=default&from_version=120
返回自 from_version 以来的变更:
{
"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: <version>;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 污染或中间人篡改。推荐在响应中加入签名字段:
{
"code": 0,
"data": {
"version": "1751181600",
"tree": { ... },
"sign": "sha256=xxxxxxxx"
}
}
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
# deploy.sh
echo "$(date +%s)-$(git rev-parse --short HEAD)" > public/static/lang/.version
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 中增加构建脚本:
{
"scripts": {
"build:h5": "npm run sync:lang && uni build",
"sync:lang": "node scripts/sync-lang-to-server.js"
}
}
scripts/sync-lang-to-server.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 数据表设计(覆写层)
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)
);
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
class LangBundleController
{
private $basePath;
public function __construct()
{
$this->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 覆写接口设计
后台对覆写层的增删改查接口:
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:
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 配置项扩展
{
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
// 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:
// 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;
}
};
页面级入口同步等待:
// 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 分支:
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、启动页、错误提示文案):
// 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 中作为最后兜底:
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 即可生效。
后台发布时生成版本:
-- 发布覆写时更新 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);
客户端轮询/推送更新:
- 定时轮询:App 回到前台时,调用
/api/lang/bundle对比version; - WebSocket / 长连接推送:Admin 发布后,服务端向在线客户端推送
lang:updated事件; - 页面刷新:收到更新后,客户端写入缓存并触发全局事件:
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 时,不要一次性全量切换:
- 白名单灰度:根据 userId / deviceId / 地区,仅对部分用户启用
source: 'remote-only'; - 版本灰度:App 新版本先 5% 启用,观察 24 小时后再扩大;
- 回滚开关:后端/配置中心保留
source切换能力,出问题立即切回local; - 双写校验: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 核心功能
-
Key 搜索与覆写
- 输入点分 key(如
order.list.title); - 填写覆盖后的 value;
- 选择生效维度(locale / platform / tenant)。
- 输入点分 key(如
-
从 base 导入 key
- 定期或手动把前端
lang/文件同步到后台; - 后台展示所有 base key,方便运营人员选择要覆写的 key。
- 定期或手动把前端
-
版本与发布
- 每次发布生成新版本号;
- 支持回滚到上一版本;
- diff 接口根据版本号计算变更。
-
多维度隔离
- 优先级:
tenant + platform>tenant>platform>global。
- 优先级:
8.2 为什么只管理覆写层
- 默认文案仍由前端
lang/文件维护,开发流程不变; - 运营只干预需要修改的 key,降低管理复杂度;
- 没有覆写时,系统零性能损耗(override tree 为空)。
九、测试与校验
9.1 一致性校验脚本
新增 scripts/verify-remote-bundle.js,对比远端 bundle 与本地合并结果:
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 中增加校验步骤:
# .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 模式下,该校验仍然有效,且可以扩展为:
- 本地校验:继续校验
lang/zh-cn/与lang/en-us/的一致性; - 远端校验:同步到服务端后,拉取
/api/lang/bundle校验远端合并结果是否仍然一致; - 覆写层校验:校验
lang_overrides表中是否存在只覆盖了一个语言版本的 key。
扩展脚本 scripts/verify-remote-bundle.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 中增加:
- 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/bundleQPS、错误率、P99 延迟;- CDN 命中率;
- bundle 文件大小趋势;
- override 表变更频率。
十一、迁移步骤
阶段 1:PHP 后端接入
- 把前端
lang/zh-cn/、lang/en-us/目录复制到服务端; - 实现
/api/lang/bundle,先让loadOverrides返回空数组; - 配置 CDN 和 gzip/br;
- 验证返回的 tree 与本地合并结果一致。
阶段 2:客户端改造
- 新增
common/js/lang-loader.js; - 修改
common/js/lang.js,支持source: 'remote-only'; - App 启动流程接入 loader;
- 内嵌默认语言兜底 bundle。
阶段 3:灰度验证
- 保留本地
lang/目录; - 配置
source: 'remote'或hybrid,对比 remote bundle 与本地结果; - 运行
npm run i18n:check和node scripts/verify-p2-keys.js; - 对白名单用户启用
source: 'remote-only',观察一周; - 在测试环境删除本地语言包,验证功能正常。
阶段 4:正式上线
- 逐步扩大 remote-only 用户比例;
- 配置
source: 'remote-only'为默认; - 构建脚本中移除
lang/目录打包(或保留但不使用); - 监控 bundle 接口成功率、首屏加载时间、缓存命中率。
阶段 5(可选):启用 Admin 覆写
- 创建
lang_overrides表; - 实现 Admin 后台,允许运营人员覆写指定 key;
- 验证覆写后客户端能正确展示。
十二、风险与回退
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 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 稳定后再删除。
十三、关键结论
融合后的方案满足“删除本地语言包后效果一致”的目标:
- 前端
lang/文件作为 base:部署到服务端,提供完整默认语言包; - 数据库/Admin 作为 override 层:只管理需要覆写的 key;
- 后端合并后返回统一 tree:客户端只认一个接口、一种格式;
- 构建/部署/版本/缓存/安全/监控全覆盖:从开发到上线形成闭环;
- 启动时序与首屏优化:App.vue 阻塞加载 + 缓存优先,避免页面闪变;
- 热更新与页面刷新:Admin 发布后客户端无需重启即可生效;
- 平台差异适配:针对微信小程序、H5、App 分别处理缓存、域名、存储;
- 签名与完整性校验:防止 CDN 污染和中间人篡改;
- 与 i18n-check 整合:本地 + 远端双重校验,保证 zh/en 一致性;
- 分阶段上线 + 灰度 + 回退:降低切换风险;
- PHP 后端最小改动:核心 bundle 接口几十行代码即可跑起来。