小程序国际化方案 - miniprogram-i18n

2,714 阅读7分钟

1 国际化方案选择

国际化的方案有很多

  1. 按照语言种类分别开发前端界面

    这种方式在大型项目中不适用,浪费劳动力,并且维护困难;

  2. 实用配置文件

    使用一套界面,同样的样式文件,调用对应的语言文件进行渲染,该方式可以快速实现,并且只用维护一套前端文件,非常适用于单页应用;

    • 定义国际化配置
    • 根据环境读取配置
    • 将配置展现在页面上

    展开说:

    1. 定义国际化配置:定义的方式有多种,多以文件的形式单独保存,如json,js,properties 等, 并且将配置信息以键值对的形式保存备用
    2. 根据环境读取配置:就是用户选择的标志,形式如下: hash型:#cn; #en; #us saerch型:?lan=cn; ?lan=en; ?lan=us url/meta型: 163.com/cn/; 163.com/en 缓存型:缓存形式多为cookie,默认cn,用户重新设定后将缓存更新
    3. 将配置展现在页面上: 使用三方插件或者自己编写插件将配置信息映射到页面上,基本原理都是做字典查询键值匹配替换。

    以上三步任意组合都可以完成国际化的任务,只是效率各有不同,可根据项目做自由组合

显然,第二种方式是现在国际化的主流,其针对不同技术栈也有不同的适配方案,比如:

  • vue + vue-i18n
  • angular + angular-translate
  • react + react-intl
  • jquery + jquery.i18n.property

本项目是原生小程序项目,小程序官方文档已经给出了国际化方案 ——miniprogram-i18n

2 接入i18n

首先看一下小程序切换语言相关的流程思路:

miniprogram-i18n 的用法主要分为四部分。分别是:构建脚本与i18配置、i18n文本定义、WXML中的用法及JavaScript中的用法。

根据上述流程,开始接入i18n,接入方案如下:

2.1 安装

该方案目前需要依赖 Gulp 并且对源文件目录结构有一定的要求,需要确保小程序源文件放置在特定目录下(例如 src/ 目录)。

  1. 首先在项目根目录运行以下命令安装 gulp 及 miniprogram-i18n 的 gulp 插件。
npm i -D gulp @miniprogram-i18n/gulp-i18n-locales @miniprogram-i18n/gulp-i18n-wxml
  1. 在小程序运行环境下安装国际化运行时并在开发工具"构建npm"。
npm i -S @miniprogram-i18n/core
  1. 在项目根目录新建 gulpfile.js,并编写构建脚本,参考文档: examples/gulpfile.js
const gulpI18nWxml = require('@miniprogram-i18n/gulp-i18n-wxml');
const gulpI18nLocales = require('@miniprogram-i18n/gulp-i18n-locales');

const weappAliasConfig = {
  '@': path.join(__dirname, './src'),
};

gulp.task('compile:less', cb => {
  pump(
    [
      ...
      weappAlias(weappAliasConfig),
      ...
    ],
    cb,
  );
});

/**
 * 国际化,复制国际化文件到dist
 */
gulp.task('mergeAndGenerateLocales', () => {
  return gulp
    .src(sourceRoot + '/**/i18n/*.json')
    .pipe(gulpI18nLocales({ defaultLocale: 'CN', fallbackLocale: 'CN' }))
    .pipe(gulp.dest(dist + '/i18n/'));
});

/**
 * 国际化,遍历所有wxml,进行转换复制到dist
 */
gulp.task('transpileWxml', () => {
  return (
    gulp
      .src('src/**/*.wxml')
      // .pipe(debug())
      .pipe(gulpI18nWxml())
      .pipe(gulp.dest(dist))
  );
});

2.2 i18n文本定义

miniprogram-i18n 目前采用 JSON 文件对 i18n 文本进行定义。使用之前,需要在项目源文件下新建 i18n 目录。

目录结构如下:

├── dist               // 小程序构建目录
├── gulpfile.js
├── node_modules
├── package.json
└── src                // 小程序源文件目录
|   ├── app.js
|   ├── app.json
|   ├── app.wxss
|   ├── i18n           // 国际化文本目录
|   |   ├── en-US.json
|   |   └── zh-CN.json

i18n 目录可以放置在源文件目录下的任意位置,例如可以跟 Page 或 Component 放在一起。但是需要注意的是,多个 i18n 目录下的文件在构建时会被合并打包,因此如果翻译文本有重复的 key 可能会发生覆盖。如果分开多个 i18n 目录定义需要自行确保 key 是全局唯一的。例如使用 page.index.testKey 这样的能确保唯一的名称

/* 定义 */

// src/config/index.ts
// 定义枚举 - 语言类型
export enum LANG { 
  CN = 'CN',
  EN = 'EN',
}
// src/config/constants.ts
// 定义变量 - 语言
export const SYSTEM_LANGUAGE = 'system-language';

/* 定义文本 */
// i18n/EN.json
{
  "plainText": "This is a plain text",
  "withParams": "{value} is what you pass in"
}
// i18n/CN.json
{
  "plainText": "这是一段纯文本",
  "withParams": "你传入的值是{value}"
}

2.3 在storage中保存/读取语言类型

/* basePage.ts */

import { getI18nInstance } from '@miniprogram-i18n/core';

export const getSystemLanguage = () => getStorage(SYSTEM_LANGUAGE) || LANG.CN;

export const setSystemLanguage: (params: string) => void = lang => {
  const _lang = lang || LANG.CN;
  setStorage(SYSTEM_LANGUAGE, _lang);
  const i18n = getI18nInstance();
  i18n.setLocale(_lang); // 设置i18n语言类型
};

2.4 设置请求拦截器,将language加入请求头

/* HTTPClient.ts */

//添加请求拦截器
// 请求拦截器中的request对象结构如下:
httpClient.interceptors.request.use(request => {
  // 如果构建参数中有泳道,header头添加泳道字段
  if (tversion) request.headers['tversion'] = tversion;

  // 获取小程序消息订阅配置时,小程序还未实例化,因此这边加了个条件判断
  if (getApp() === undefined) {
    return request;
  }
  const token = getToken();
  const systemLanguage = getSystemLanguage();
  // 检测token 的过期时间
  const invalidToken = checkTokenInvalid();
  request.headers['system-language'] = systemLanguage; // 加入语言请求头
  
  ...
  
  return request;
});

2.5 抽取语言切换按钮组件

import { BaseComponent } from '../../../core/base/baseComponent';
import { wxComponent } from '../../../core/decorator/index';
import { getSystemLanguage, setSystemLanguage } from '@/utils/base';
import { setLanguage } from '@/services/membership/index';
import { getI18nInstance, I18n } from '@miniprogram-i18n/core';
import Toast from '@/core/base/helpers/toast';

@wxComponent()
export default class extends BaseComponent {
  behaviors = [I18n];

  properties = {
    isAuth: {
      type: Boolean,
      default: false,
    },
    langType: {
      type: String,
      default: 'membership',
    },
  };

  data = {
    language: this.$global('lang') || 'CN', // 中文:CN,英文:EN
  };

  attached() {
    const lang = getSystemLanguage(); // 获取本地 storage 语言
    this.setData({
      language: lang,
    });

    // 当检测到本地语言类型改变时触发 ( 如静默登录后语言改变 )
    getI18nInstance().onLocaleChange(val => {
      this.setData({
        language: val,
      });
    });
  }

  handleButtonPress(e: any) {
    let lang;
    // 判断用户是否登录,已登录则设置语言类型
    if (this.properties.isAuth) { 
      lang = e.target.dataset.lang;
    } else {
      lang = e.detail.language;
    }
    const language = getSystemLanguage();
    
    // 判断所选语言是否是当前语言
    if (language !== lang) {
      this.handleSetLang(lang);
    }
    this.handleChange();
  }

  // 设置语言
  handleSetLang(lang: string) {
    // 发送语言设置请求
    setLanguage({ language: lang }).then(data => {
      const { code } = data;
      if (code === 'Success') {
        // 请求成功修改本地语言类型
        setSystemLanguage(lang);
        getApp().globalData.events.emit('srm:switchLang', lang);
        this.toast(this.t('components.BizChangeLangSuccess'));
        this.setData({
          language: lang,
        });
      }
    });
  }
}
/* 未登陆时进入language-button-container组件 */
@wxComponent()
export default class PhoneContainer extends BaseComponent {
  properties = {
   ...
  };
  attached() {
    getLoginCodeWrap().then(code => {
      this.setData({
        code,
      });
    });
  }

  data = {
    code: undefined,
  };
  // 已经授权
  handleClick() {
    this.triggerEvent('getuserinfo', {
      isBind: true,
      language: this.properties.language,
      type: this.properties.type,
      url: this.properties.url,
      toBuy: this.properties.toBuy,
    });
  }
  // @getWxCode()
  getUserInfo(
    e: WechatMiniprogram.Event<
      WechatMiniprogram.GetUserInfoSuccessCallbackResult
    >,
  ) {
    const { code } = this.data;
    // 用户成功授权
    wx.getSetting({
      success: res => {
        // 授权了
        if (res.authSetting['scope.userInfo']) {
          const { session }: { session: SessionType } = getApp().globalData;
          const hasUser: boolean = session.hasUser();
          // 有用户信息
          if (hasUser) {
            this.triggerEvent('getuserinfo', {
              ...e.detail,
              isBind: false,
              code,
              language: this.properties.language,
              type: this.properties.type,
              url: this.properties.url,
            });
          } else {
            session.authLogin({ ...e.detail, code }).then((isBind: boolean) => {
              this.triggerEvent('getuserinfo', {
                ...e.detail,
                isBind,
                code,
                language: this.properties.language,
                type: this.properties.type,
                url: this.properties.url,
              });
            });
          }
        }
      },
      complete: res => console.log('请求设置信息:', res),
    });
  }
}

3 如何使用

3.1 普通用法

WXML中的用法

定义好 i18n 文本之后,就可以在 WXML 文件里使用了。

 <view class="membership-one {{activeIndex === 0 ? 'active': ''}}" data-index="0" bind:tap="clickTab">
     {{t('membership.vip')}}
 </view>
<input placeholder="{{ t('withParams', {value}) }}"></input>

JavaScript 中的用法

在 JavaScript 里可以直接引用 @miniprogram-i18n/core 这个 NPM 包来获取翻译文本。

import { getI18nInstance } from '@miniprogram-i18n/core'

const i18n = getI18nInstance()
@wxPage()
export default class extends BasePage {
}
Component({
  onLoad() {
    const text = i18n.t('withParams', { value: 'Test' })
    console.log(text)    // Test is what you pass in
  }
})

这种用法每个页面都会生成一个i18n实例,可以进行优化,如下:

3.2 在Page中使用

注意:这里建议 Page 以及 Component 都采用 Component 构造器进行定义,这样可以使用 I18n 这个 Behavior。如果需要在 Page 构造上使用 I18n 则需要引入 I18nPage 代替 Page 构造器。

import { wxApp, wxDecoratorConfig } from '@/core/decorator/index';
import { I18nPage } from '@miniprogram-i18n/core';

// 系统启动更新环境
@wxApp()
export default class MyApp extends BaseApp {
  async onLaunch(options) {
    ...
    wxDecoratorConfig.Page = (...args) => {
      // 因为没有使用插件,sr和emonitor是可以直接默认使用
      return I18nPage(...args);
    };
    ...
  }
 ...
}

页面装饰器增加页面标题国际化配置,封装到wxPage

import { getI18nInstance } from '@miniprogram-i18n/core';

export function wxPage(decoratorOptions?: DecoratorOptions) {
  const { storeBindingOptions, title } = decoratorOptions || {};
  const i18n = getI18nInstance();
  return function(constructor: new () => BasePage): void {
    class WxPage extends constructor {
      storeBindings: any;

      constructor(..._args: any[]) {
        super();
      }
     ...
     // 设置页面头部标题
      async onLoad(options?: any) {
        if (title) {
          wx.setNavigationBarTitle({
            title: i18n.t(title),
          });
          i18n.onLocaleChange(() => {
            wx.setNavigationBarTitle({
              title: i18n.t(title),
            });
          });
        }
  			...
      }
      ...
    }
    const current = new WxPage();
    const obj = toObject(current);
    wxDecoratorConfig.Page(obj);
  };
}

设置好后在.ts页面,可以通过调用this.t('xxx'),来获取文案

3.3 在Component中使用

增加配置,增加I18n behavior,封装到wxComponent

import { I18n } from '@miniprogram-i18n/core';

export function wxComponent(decoratorOptions?: DecoratorOptions) {
  return function(constructor: new () => BaseComponent): void {
    class WxComponent extends constructor {
      storeBindings: any;

      constructor(..._args: any[]) {
        super();
      }
  		...
    }

    const current = new WxComponent();
    // console.log(current);
    const obj = toComponent(toObject(current));
    obj.behaviors = wxDecoratorConfig.extendBehaviors.concat(
      obj.behaviors || [],
      [I18n],
    );
    Component(obj);
  };
}

// 设置后即可在页面通过调用this.t('xxx'),来获取文案内容

this.toast(this.t('components.BizChangeLangSuccess'));

4 几种插值用法

4.1 文本插值

/*EN.json*/
{
  "key": "Inserted value: {value}"
}
/*CN.json*/
{
  "key": "插入的值是: {value}"
}
i18n.t('key', { value: 'Hello!' })  // Inserted value: Hello!

为了方便调用深层嵌套的对象,当前支持使用 . 点语法来访问对象属性。

/*CN.json*/
{
   "dotted": "嵌套的值是: { obj.nested.value }"
}

/*EN.json*/
{
   "dotted": "Nested value is: { obj.nested.value }"
}
const value = {
  obj: {
    nested: {
      value: 'Catch you!'
    }
  }
}
i18n.t('dotted', value)  // Nested value is: Catch you!

4.2 select 语句

/*EN.json*/
{
  "key": "{gender, select, male {His inbox} female {Her inbox} other {Their inbox}}"
}
i18n.t('key', { gender: 'male' })    // His inbox
i18n.t('key', { gender: 'female' })  // Her inbox
i18n.t('key')  

select 语句支持子语句文本插值:

/*EN.json*/
{
  "key": "{mood, select, good {{how} day!} sad {{how} day.} other {Whatever!}}"
}

/*xxx.ts*/
i18n.t('key', { mood: 'good', how: 'Awesome'  })  // Awesome day!
i18n.t('key', { mood: 'sad', how: 'Unhappy'  })   // Unhappy day.
i18n.t('key')                                     // Whatever!

注:select 语句支持子句嵌套 select 语句