Files
lucky_shop/docs/i18n-remote-only-solution.md
ZF sun 32db276e68 docs: add remote-only i18n solution docs and reference
新增远端完全替代本地语言包的国际化方案文档,并在原指南中添加对应引用,补充新文档到文档列表中。
2026-06-29 17:47:24 +08:00

44 KiB
Raw Permalink Blame History

远端覆写完全替代本地语言包方案(融合版)

目标:让远端语言包成为唯一数据源,本地 lang/ 目录可以删除,且运行效果与现在一致。 核心思想:前端 lang/ 文件作为远端基础数据源,数据库覆写作为增量层,后端合并后返回统一全量 bundle;客户端只认一个接口、一种格式。


一、为什么需要远端优先

当前方案是 本地语言包 + 远端覆写 的混合模式:

  • 本地 lang/zh-cn/、lang/en-us/ 是主数据源;
  • 远端只返回 overrides map,用于运营 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 时:

  1. 读取 lang/<locale>/ 下所有 .js 文件,归一化并合并成 base tree;
  2. 从数据库读取当前 locale + platform + tenant 的覆写记录,展开成 override tree;
  3. 用深度合并把 override tree 覆盖到 base tree 上;
  4. 返回合并后的全量 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);

客户端轮询/推送更新:

  1. 定时轮询:App 回到前台时,调用 /api/lang/bundle 对比 version;
  2. WebSocket / 长连接推送:Admin 发布后,服务端向在线客户端推送 lang:updated 事件;
  3. 页面刷新:收到更新后,客户端写入缓存并触发全局事件:
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 与本地合并结果:

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 模式下,该校验仍然有效,且可以扩展为:

  1. 本地校验:继续校验 lang/zh-cn/ 与 lang/en-us/ 的一致性;
  2. 远端校验:同步到服务端后,拉取 /api/lang/bundle 校验远端合并结果是否仍然一致;
  3. 覆写层校验:校验 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/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 接口几十行代码即可跑起来。