🚀 手把手教你实现 React Native 远程多语言热更新(保姆级教程)

5 阅读7分钟

🚀 手把手教你实现 React Native 远程多语言热更新(保姆级教程)

你是否遇到过“仅仅因为改了一个错别字,就要重新打包、提交审核、等待上架”的崩溃时刻?😭

这篇文章将手把手教你搭建一套 “文案热更新系统”不用懂原理,跟着做,复制粘贴代码,你也能拥有“改完后台,App 立刻生效”的魔法!✨


📚 目录

  1. 第一步:安装依赖
  2. 🚑 呼叫后端:你需要这两个接口
  3. 第二步:复制核心文件
  4. 第三步:接入到 App
  5. 第四步:在页面中使用
  6. 第五步:如何切换语言
  7. 小白原理课:它到底怎么运行的?

第一步:安装依赖

打开你的终端,进入项目目录,运行以下命令。我们需要安装 i18next(多语言核心)和 react-native-mmkv(超快的本地存储)。

yarn add i18next react-i18next i18next-chained-backend i18next-http-backend react-native-mmkv react-native-localize

(如果你的项目还没用 yarn,用 npm install 也可以)


🚑 呼叫后端:你需要这两个接口

在开始写代码前,请把这段发给你的后端同事,让他帮你提供 2 个简单的接口

1. 获取版本号接口

  • 作用:App 启动时会调用它,问问服务器“现在最新的翻译是第几版?”
  • 地址示例GET /api/language/versions
  • 返回格式
    [
      { "cultureName": "zh-Hans", "version": "2" },
      { "cultureName": "en", "version": "1" }
    ]
    

2. 获取翻译包接口

  • 作用:如果 App 发现有新版本,就会调用这个接口下载具体的 JSON。
  • 地址示例GET /api/language/json?lang=zh-Hans
  • 返回格式
    {
      "common": {
        "title": "欢迎",
        "submit": "提交"
      }
    }
    

提示:如果没有后端支持,你也可以把这些 JSON 文件放在 OSS / S3 / GitHub Pages 上,直接通过 URL 访问静态文件也是一样的!


第二步:复制核心文件

请在你的 src 目录下创建一个 i18n 文件夹和一个 context 文件夹。 结构如下:

src/
  ├── i18n/
  │   ├── MMKVBackend.js    <-- 待会新建
  │   └── index.js          <-- 待会新建
  └── context/
      └── RemoteI18nContext.js <-- 待会新建

1. 创建缓存后端 (MMKVBackend.js)

作用:告诉 i18n,“请把下载好的翻译存在 MMKV 里,下次直接读,别老去下载”。

👉 直接复制以下代码到 src/i18n/MMKVBackend.js

import { MMKV } from 'react-native-mmkv';

// 创建一个专门存翻译的库
const storage = new MMKV({ id: 'i18n-storage' });

class MMKVBackend {
  static type = 'backend';

  constructor(services, options = {}) {
    this.init(services, options);
  }

  init(services, options = {}) {
    this.services = services;
    this.options = {
      prefix: 'i18n_', 
      expirationTime: 30 * 24 * 60 * 60 * 1000, // 默认缓存30天
      defaultVersion: '1',
      versions: {},
      ...options,
    };
  }

  read(language, namespace, callback) {
    const key = `${this.options.prefix}${language}_${namespace}`;
    try {
      const data = storage.getString(key);
      if (!data) return callback(null, null); // 没缓存,去找下一个后端

      const { value, version } = JSON.parse(data);
      // 这里可以加版本校验逻辑,简单起见先直接返回
      console.log(`[i18n] Cache hit: ${language}/${namespace}`);
      return callback(null, value);
    } catch (err) {
      return callback(null, null);
    }
  }

  // 保存翻译到本地
  save(language, namespace, data) {
    const key = `${this.options.prefix}${language}_${namespace}`;
    try {
      const payload = {
        value: data,
        timestamp: Date.now(),
        version: this.options.versions?.[language] || '1',
      };
      storage.set(key, JSON.stringify(payload));
      console.log(`[i18n] Saved: ${language}/${namespace}`);
    } catch (err) {}
  }
}

export default MMKVBackend;

2. 初始化 i18n (index.js)

作用:启动 i18n,配置好“先读缓存,读不到再去网络”的策略。

👉 直接复制以下代码到 src/i18n/index.js

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import ChainedBackend from 'i18next-chained-backend';
import HttpBackend from 'i18next-http-backend';
import MMKVBackend from './MMKVBackend';
import * as RNLocalize from 'react-native-localize';

// 你的默认本地翻译文件(兜底用,万一没网也没缓存)
const enCommon = { common: { title: "Welcome" } }; 
const zhCommon = { common: { title: "欢迎" } };

i18n
  .use(ChainedBackend) // 关键:链式后端
  .use(initReactI18next)
  .init({
    compatibilityJSON: 'v4',
    // 本地兜底资源
    resources: {
      en: enCommon,
      'zh-Hans': zhCommon,
    },
    lng: RNLocalize.getLocales()[0]?.languageCode || 'en',
    fallbackLng: 'en',
    ns: ['common'],
    defaultNS: 'common',
  
    backend: {
      // 顺序很重要:先找 MMKV,找不到再找 HTTP
      backends: [MMKVBackend, HttpBackend],
      backendOptions: [
        { prefix: 'i18n_' }, // MMKV 配置
        { loadPath: 'https://你的服务器地址/api/language/{{lng}}/{{ns}}' } // HTTP 配置 (其实主要靠Context手动下载)
      ]
    },
  });

export default i18n;

3. 创建热更新逻辑 (RemoteI18nContext.js)

作用:这是核心大脑。它负责在后台悄悄检查版本、下载新翻译、更新缓存、刷新界面。

👉 复制并修改以下代码到 src/context/RemoteI18nContext.js

⚠️ 注意:代码中标记 // TODO: 的地方需要换成你自己的 API 请求函数。

import React, { createContext, useContext, useEffect } from 'react';
import { MMKV } from 'react-native-mmkv';
import i18n from '../i18n'; // 引入刚才写的 i18n

// 专门存翻译的库(跟 MMKVBackend 保持一致)
const i18nStorage = new MMKV({ id: 'i18n-storage' });
const appStorage = new MMKV(); // 存版本号用

const RemoteI18nContext = createContext(null);

export const RemoteI18nProvider = ({ children, isSignedIn }) => {
  
  // 1. App 启动时:马上加载本地缓存,保证不白屏
  useEffect(() => {
    loadFromCache();
  }, []);

  // 2. 登录后:静默检查更新
  useEffect(() => {
    if (isSignedIn) {
      syncTranslations();
    }
  }, [isSignedIn]);

  // 从缓存加载(保证离线可用)
  const loadFromCache = () => {
    const keys = i18nStorage.getAllKeys();
    keys.forEach(key => {
      if (!key.startsWith('i18n_')) return;
      // key 格式: i18n_zh-Hans_common
      const parts = key.split('_'); 
      const lang = parts[1]; // zh-Hans
      const ns = parts[2];   // common
    
      const data = i18nStorage.getString(key);
      if (data) {
        const { value } = JSON.parse(data);
        // 注入到内存
        i18n.addResourceBundle(lang, ns, value, true, true);
      }
    });
  };

  // 检查服务端是否有新版本
  const syncTranslations = async () => {
    try {
      // TODO: 替换成你的 API,获取服务端最新版本列表
      // 假设返回结构: [{ cultureName: 'zh-Hans', version: '2' }, ...]
      const serverVersions = await mockApiGetVersions(); 

      // 读取本地保存的版本
      const localVersionsStr = appStorage.getString('LANGUAGE_VERSIONS');
      const localVersions = localVersionsStr ? JSON.parse(localVersionsStr) : {};

      for (const item of serverVersions) {
        const { cultureName, version } = item;
        const localV = localVersions[cultureName] || '0';

        // 如果服务器版本 > 本地版本
        if (Number(version) > Number(localV)) {
          console.log(`发现新版本 ${cultureName}: ${version}`);
          await fetchAndApply(cultureName, version);
        
          // 更新本地版本记录
          localVersions[cultureName] = version;
        }
      }
      appStorage.set('LANGUAGE_VERSIONS', JSON.stringify(localVersions));
    } catch (e) {
      console.error('同步失败', e);
    }
  };

  // 下载并应用新翻译
  const fetchAndApply = async (lang, version) => {
    try {
      // TODO: 替换成你的 API,下载具体的 JSON 文件
      const data = await mockApiGetJson(lang);
    
      // 1. 更新 i18n 内存
      i18n.addResourceBundle(lang, 'common', data, true, true);
    
      // 2. 存入 MMKV 供下次启动用
      const cacheKey = `i18n_${lang}_common`;
      i18nStorage.set(cacheKey, JSON.stringify({
        value: data,
        version: version,
        timestamp: Date.now()
      }));

      // 3. 强制刷新当前界面
      if (i18n.language === lang) {
        i18n.changeLanguage(lang);
      }
      console.log(`语言包 ${lang} 更新成功!`);
    } catch (e) {
      console.error('下载失败', e);
    }
  };

  // --- Mock API (实际使用请删除) ---
  const mockApiGetVersions = async () => [
    { cultureName: 'zh-Hans', version: '2' }, // 模拟服务器说中文包是 v2
    { cultureName: 'en', version: '1' }
  ];
  const mockApiGetJson = async (lang) => {
    if (lang === 'zh-Hans') return { common: { title: "欢迎(这是热更新后的文案!)" } };
    return { common: { title: "Welcome (Updated)" } };
  };
  // ------------------------------

  return (
    <RemoteI18nContext.Provider value={{ syncTranslations }}>
      {children}
    </RemoteI18nContext.Provider>
  );
};

第三步:接入到 App

找到你的 App.js(入口文件),把 RemoteI18nProvider 包裹在最外层。

import React from 'react';
import { RemoteI18nProvider } from './src/context/RemoteI18nContext';
import Navigation from './src/navigation'; // 你的导航组件

const App = () => {
  // 假设你有一个状态判断用户是否登录
  const isSignedIn = true; 

  return (
    <RemoteI18nProvider isSignedIn={isSignedIn}>
      <Navigation />
    </RemoteI18nProvider>
  );
};

export default App;

第四步:在页面中使用

完全不用改习惯! 就像平时用 i18n 一样写代码。

import React from 'react';
import { Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';

const HomeScreen = () => {
  const { t } = useTranslation();

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      {/* 这里的文案会自动变成热更新后的版本 */}
      <Text style={{ fontSize: 20 }}>{t('common.title')}</Text>
    </View>
  );
};

export default HomeScreen;

第五步:如何切换语言

在设置页或登录页,你可能需要让用户手动切换语言。非常简单,直接调用 i18n.changeLanguage 即可。

import React from 'react';
import { Button, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n'; // 引入我们初始化的 i18n 实例

const SettingsScreen = () => {
  const { t } = useTranslation();

  const changeToChinese = () => {
    i18n.changeLanguage('zh-Hans'); // 切换到中文
    // 提示:切换后,RemoteI18nContext 会自动去 MMKV 找有没有下载好的中文包,有就用,没有就用本地兜底
  };

  const changeToEnglish = () => {
    i18n.changeLanguage('en'); // 切换到英文
  };

  return (
    <View>
      <Button title="中文" onPress={changeToChinese} />
      <Button title="English" onPress={changeToEnglish} />
      <Text>{t('common.title')}</Text>
    </View>
  );
};

小白原理课:它到底怎么运行的?

想象一下,你的 App 是一个餐厅,文案就是菜单

  1. 以前的做法(发版): 菜单印在桌子上(写死在包里)。如果要改菜名,必须把所有桌子扔了,重新买桌子(重新发版用户下载)。

  2. 现在的做法(热更新)

    • MMKV (本地缓存):我们在每张桌子上放了一张手写菜单
    • App 启动 (loadFromCache):客人坐下,直接看手写菜单点菜(秒开,不用等)。
    • 静默同步 (syncTranslations):服务员(后台线程)悄悄去厨房问大厨(服务器):“今天菜单有变吗?”
    • 热生效:大厨说:“‘宫保鸡丁’改名叫‘辣子鸡’了”。服务员立马把桌子上的手写菜单改了,客人下一眼看到的就是新菜名。
  3. 首次安装(无缓存)

    • 客人第一次来餐厅,桌子上还没手写菜单。没关系,我们有印刷菜单(本地兜底 common.json)。客人先看印刷菜单点菜,等服务员从厨房拿来最新的手写菜单,下次来就能用新的了。

总结:用户感觉不到任何下载过程,但你随时可以在后台改文案,所有人的 App 几秒钟后自动变身!🚀