前端国际化之Vue-i18n源码分析

asdfjklzj 3年前
   <p>最近的工作当中有个任务是做国际化。这篇文章也是做个简单的总结。</p>    <h2>部分网站的当前解决的方案</h2>    <ol>     <li> <p>不同语言对应不同的页面。在本地开发的时候就分别打包输出了不同语言版本的静态及模板文件,通过页面及资源的 url 进行区分,需要维护多份代码。</p> </li>     <li> <p>在线翻译</p> </li>     <li> <p>统一模板文件,前端根据相应的语言映射表去做文案的替换。</p> </li>    </ol>    <h2>面对的问题</h2>    <ol>     <li> <p>语言标识谁来做?</p>      <ul>       <li> <p>页面完全由服务端直出(所有的数据均由服务端来处理)</p> </li>       <li> <p>服务端根据 IP 去下发语言标识字段(前端根据下发的表示字段切换语言环境)</p> </li>       <li> <p>前端去根据 useragent.lang 浏览器环境语言进行设定</p> <p>当前项目中入口页面由服务端来渲染,其他的页面由前端来接管路由。在入口页面由服务器下发 lang 字段去做语言标识,在页面渲染出来前,前端来决定使用的语言包。语言包是在本地编译的过程中就将语言包编译进了代码当中,没有采用异步加载的方式。</p> </li>      </ul> </li>     <li> <p>前端静态资源翻译</p>      <ul>       <li> <p>单/复数</p> </li>       <li> <p>中文转英文</p> </li>       <li> <p>语言展示的方向</p> <p>前端静态资源文案的翻译使用 vue-i18n 这个插件来进行。插件提供了单复数,中文转英文的方法。a下文有对 vue-i18n 的源码进行分析。因为英文的阅读方向也是从左到右,因此语言展示的方向不予考虑。但是在一些阿拉伯地区国家的语言是从右到左进行阅读的。</p> </li>      </ul> </li>     <li> <p>服务端数据翻译</p> </li>     <li> <p>前端样式的调整</p> <p>a.中文转英文后肯定会遇到文案过长的情况。那么可能需要精简翻译,使文案保持在一定的可接受的长度范围内。但是大部分的情况都是文案在保持原意的情况下无法再进行精简。这时必须要前端来进行样式上的调整,那么可能还需要设计的同学参与进来,对一些文案过多出现折行的情况再单独做样式的定义。在细调样式这块,主要还是通过不同的语言标识去控制不同标签的 class ,来单独定义样式。</p>      <ul>       <li> <p>中文转英文后部分文案过长</p> </li>       <li> <p>图片</p> </li>       <li> <p>第三方插件( 地图 , SDK 等)</p> </li>      </ul> </li>    </ol>    <p> </p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/b6ea59b7dd7cc636b259ebe85b06e9c3.jpg"></p>    <ol>     <li> <p>此外,还有部分图片也是需要做调整,在C端中,大部分由产品方去输出内容,那么图片这块的话,还需要设计同学单独出图。</p> </li>     <li style="text-align:center"> <p style="text-align:left">在第三方插件中这个环节当中,因为使用了 腾讯地图 插件,由于腾讯地图并未推出国内地图的英文版,所以整个页面的地图部分暂时无法做到国际化。由此联想到,在你的应用当中使用的其他一些 第三方插件 或者 SDK ,在国际化的过程中需要去解决哪些问题。</p> <img src="https://simg.open-open.com/show/0f287b0a41d54ab18139d989c4a79caf.jpg"></li>     <li> <p>跨地区xxxx</p> <p>在一些 支付场景 下, 货币符号 , 单位 及 价格 的转化等。不同国家地区在时间的格式显示上有差异。</p>      <ul>       <li> <p>货币及支付方式</p> </li>       <li> <p>时间的格式</p> </li>      </ul> </li>     <li> <p>项目的长期维护</p> <p>当前翻译的工作流程是拆页面,每拆一个页面,FE同学整理好可能会出现的中文文案,再交由翻译的同学去完成翻译的工作。负责不同页面的同学维护着不同的 map 表,在当前的整体页面架构中,不同功能模块和页面被拆分出去交由不同的同学去做,那么通过跳页面的方式去暂时缓解 map 表的维护问题。如果哪一天页面需要收敛,这也是一个需要去考虑的问题。如果从整个项目的一开始就考虑到国际化的问题并采取相关的措施都能减轻后期的工作量及维护成本。同时以后一旦 map 表内容过多,是否需要考虑需要将 map 表进行异步加载。</p>      <ul>       <li> <p>翻译工作</p> </li>       <li> <p>map 表的维护</p> </li>      </ul> </li>    </ol>    <h2>Vue-i18n的基本使用</h2>    <pre>  <code class="language-javascript">// 入口main.js文件      import VueI18n from 'vue-i18n'            Vue.use(VueI18n)            // 通过插件的形式挂载            const i18n = new VueI18n({          locale: CONFIG.lang,    // 语言标识          messages: {              'zh-CN': require('./common/lang/zh'),   // 中文语言包              'en-US': require('./common/lang/en')    // 英文语言包          }      })            const app = new Vue({          i18n,          ...App      }).$mout('#root')            // 单vue文件      <template>          <span>{{$t('你好')}}</span>      </template></code></pre>    <p>Vue-i18n 是以插件的形式配合 Vue 进行工作的。通过全局的 mixin 的方式将插件提供的方法挂载到 Vue 的实例上。</p>    <h2>具体的源码分析</h2>    <p>其中 install.js Vue的挂载函数,主要是为了将 mixin.js 里面的提供的方法挂载到 Vue 实例当中:</p>    <pre>  <code class="language-javascript">import { warn } from './util'  import mixin from './mixin'  import Asset from './asset'    export let Vue    // 注入root Vue  export function install (_Vue) {     Vue = _Vue      const version = (Vue.version && Number(Vue.version.split('.')[0])) || -1    if (process.env.NODE_ENV !== 'production' && install.installed) {      warn('already installed.')      return    }    install.installed = true      if (process.env.NODE_ENV !== 'production' && version < 2) {      warn(`vue-i18n (${install.version}) need to use Vue 2.0 or later (Vue: ${Vue.version}).`)      return    }      // 通过mixin的方式,将插件提供的methods,钩子函数等注入到全局,之后每次创建的vue实例都用拥有这些methods或者钩子函数    Vue.mixin(mixin)      Asset(Vue)  }</code></pre>    <p>接下来就看下在 Vue 上混合了哪些 methods 或者 钩子函数 . 在 mixin.js 文件中:</p>    <pre>  <code class="language-javascript">/* @flow */    // VueI18n构造函数  import VueI18n from './index'  import { isPlainObject, warn } from './util'      // $i18n 是每创建一个Vue实例都会产生的实例对象  // 调用以下方法前都会判断实例上是否挂载了$i18n这个属性  // 最后实际调用的方法是插件内部定义的方法  export default {    // 这里混合了computed计算属性, 注意这里计算属性返回的都是函数,这样就可以在vue模板里面使用{{ $t('hello') }}, 或者其他方法当中使用 this.$t('hello')。这种函数接收参数的方式    computed: {      // 翻译函数, 调用的是VueI18n实例上提供的方法      $t () {        if (!this.$i18n) {          throw Error(`Failed in $t due to not find VueI18n instance`)        }        // add dependency tracking !!        const locale: string = this.$i18n.locale          // 语言配置        const messages: Messages = this.$i18n.messages    // 语言包        // 返回一个函数. 接受一个key值. 即在map文件中定义的key值, 在模板中进行使用 {{ $t('你好') }}        // ...args是传入的参数, 例如在模板中定义的一些替换符, 具体的支持的形式可翻阅文档https://kazupon.github.io/vue-i18n/formatting.html        return (key: string, ...args: any): string => {          return this.$i18n._t(key, locale, messages, this, ...args)        }      },        // tc方法可以单独定义组件内部语言设置选项, 如果没有定义组件内部语言,则还是使用global的配置      $tc () {        if (!this.$i18n) {          throw Error(`Failed in $tc due to not find VueI18n instance`)        }        // add dependency tracking !!        const locale: string = this.$i18n.locale        const messages: Messages = this.$i18n.messages        return (key: string, choice?: number, ...args: any): string => {          return this.$i18n._tc(key, locale, messages, this, choice, ...args)        }      },        // te方法      $te () {        if (!this.$i18n) {          throw Error(`Failed in $te due to not find VueI18n instance`)        }        // add dependency tracking !!        const locale: string = this.$i18n.locale        const messages: Messages = this.$i18n.messages        return (key: string, ...args: any): boolean => {          return this.$i18n._te(key, locale, messages, ...args)        }      }    },      // 钩子函数    // 被渲染前,在vue实例上添加$i18n属性    // 在根组件初始化的过程中:    /**     * new Vue({     *   i18n   // 这里是提供了自定义的属性 那么实例当中可以通过this.$option.i18n去访问这个属性     *   // xxxx     * })     */    beforeCreate () {      const options: any = this.$options      // 如果有i18n这个属性. 根实例化的时候传入了这个参数      if (options.i18n) {        if (options.i18n instanceof VueI18n) {          // 如果是VueI18n的实例,那么挂载在Vue实例的$i18n属性上          this.$i18n = options.i18n          // 如果是个object        } else if (isPlainObject(options.i18n)) {     // 如果是一个pobj          // component local i18n          // 访问root vue实例。          if (this.$root && this.$root.$i18n && this.$root.$i18n instanceof VueI18n) {            options.i18n.root = this.$root.$i18n          }          this.$i18n = new VueI18n(options.i18n)  // 创建属于component的local i18n          if (options.i18n.sync) {            this._localeWatcher = this.$i18n.watchLocale()          }        } else {          if (process.env.NODE_ENV !== 'production') {            warn(`Cannot be interpreted 'i18n' option.`)          }        }      } else if (this.$root && this.$root.$i18n && this.$root.$i18n instanceof VueI18n) {        // root i18n        // 如果子Vue实例没有传入$i18n方法,且root挂载了$i18n,那么子实例也会使用root i18n        this.$i18n = this.$root.$i18n      }    },      // 实例被销毁的回调函数    destroyed () {      if (this._localeWatcher) {        this.$i18n.unwatchLocale()        delete this._localeWatcher      }        // 组件销毁后,同时也销毁实例上的$i18n方法      this.$i18n = null    }  }</code></pre>    <p>这里注意下这几个方法的区别:</p>    <p>$tc 这个方法可以用以返回翻译的复数字符串, 及一个 key 可以对应的翻译文本,通过 | 进行连接:</p>    <p>例如:</p>    <pre>  <code class="language-javascript">// main.js      new VueI18n({          messages: {              car: 'car | cars'          }      })            // template      <span>{{$tc('car', 1)}}</span>   ===>>>  <span>car</span>      <span>{{$tc('car', 2)}}</span>   ===>>>  <span>cars</span></code></pre>    <p>$te 这个方法用以判断需要翻译的 key 在你提供的 语言包(messages) 中是否存在.</p>    <p>接下来就看看 VueI18n 构造函数及原型上提供了哪些可以被实例继承的属性或者方法</p>    <pre>  <code class="language-javascript">/* @flow */    import { install, Vue } from './install'  import { warn, isNull, parseArgs, fetchChoice } from './util'  import BaseFormatter from './format'    // 转化函数 封装了format, 里面包含了template模板替换的方法  import getPathValue from './path'    import type { PathValue } from './path'    // VueI18n构造函数  export default class VueI18n {    static install: () => void    static version: string      _vm: any    _formatter: Formatter    _root: ?I18n    _sync: ?boolean    _fallbackRoot: boolean    _fallbackLocale: string    _missing: ?MissingHandler    _exist: Function    _watcher: any      // 实例化参数配置    constructor (options: I18nOptions = {}) {      const locale: string = options.locale || 'en-US'    // vue-i18n初始化的时候语言参数配置      const messages: Messages = options.messages || {}   // 本地配置的所有语言环境都是挂载到了messages这个属性上      this._vm = null                 // ViewModel      this._fallbackLocale = options.fallbackLocale || 'en-US'  // 缺省语言配置      this._formatter = options.formatter || new BaseFormatter()  // 翻译函数      this._missing = options.missing      this._root = options.root || null      this._sync = options.sync || false         this._fallbackRoot = options.fallbackRoot || false        this._exist = (message: Object, key: string): boolean => {        if (!message || !key) { return false }        return !isNull(getPathValue(message, key))      }        this._resetVM({ locale, messages })    }      // VM     // 重置viewModel    _resetVM (data: { locale: string, messages: Messages }): void {      const silent = Vue.config.silent      Vue.config.silent = true      this._vm = new Vue({ data })      Vue.config.silent = silent    }      // 根实例的vm监听locale这个属性    watchLocale (): any {      if (!this._sync || !this._root) { return null }      const target: any = this._vm      // vm.$watch返回的是一个取消观察的函数,用来停止触发回调      this._watcher = this._root.vm.$watch('locale', (val) => {        target.$set(target, 'locale', val)      }, { immediate: true })      return this._watcher    }      // 停止触发vm.$watch观察函数    unwatchLocale (): boolean {      if (!this._sync || !this._watcher) { return false }      if (this._watcher) {        this._watcher()        delete this._watcher      }      return true    }      get vm (): any { return this._vm }      get messages (): Messages { return this._vm.$data.messages }                  // get 获取messages参数    set messages (messages: Messages): void { this._vm.$set(this._vm, 'messages', messages) }  // set 设置messages参数      get locale (): string { return this._vm.$data.locale }                        // get 获取语言配置参数    set locale (locale: string): void { this._vm.$set(this._vm, 'locale', locale) }     // set 重置语言配置参数      get fallbackLocale (): string { return this._fallbackLocale }                 //  fallbackLocale 是什么?    set fallbackLocale (locale: string): void { this._fallbackLocale = locale }      get missing (): ?MissingHandler { return this._missing }    set missing (handler: MissingHandler): void { this._missing = handler }      get formatter (): Formatter { return this._formatter }                          // get 转换函数    set formatter (formatter: Formatter): void { this._formatter = formatter }      // set 转换函数      _warnDefault (locale: string, key: string, result: ?any, vm: ?any): ?string {      if (!isNull(result)) { return result }      if (this.missing) {        this.missing.apply(null, [locale, key, vm])      } else {        if (process.env.NODE_ENV !== 'production') {          warn(            `Cannot translate the value of keypath '${key}'. ` +            'Use the value of keypath as default.'          )        }      }      return key    }      _isFallbackRoot (val: any): boolean {      return !val && !isNull(this._root) && this._fallbackRoot    }      // 插入函数    _interpolate (message: Messages, key: string, args: any): any {      if (!message) { return null }        // 获取key对应的字符串      let val: PathValue = getPathValue(message, key)      if (Array.isArray(val)) { return val }      if (isNull(val)) { val = message[key] }      if (isNull(val)) { return null }      if (typeof val !== 'string') {        warn(`Value of key '${key}' is not a string!`)        return null      }          // TODO ?? 这里的links是干什么的?      // Check for the existance of links within the translated string      if (val.indexOf('@:') >= 0) {        // Match all the links within the local        // We are going to replace each of        // them with its translation        const matches: any = val.match(/(@:[\w|.]+)/g)        for (const idx in matches) {          const link = matches[idx]          // Remove the leading @:          const linkPlaceholder = link.substr(2)          // Translate the link          const translatedstring = this._interpolate(message, linkPlaceholder, args)          // Replace the link with the translated string          val = val.replace(link, translatedstring)        }      }        // 如果没有传入需要替换的obj, 那么直接返回字符串, 否则调用this._format进行变量等的替换      return !args ? val : this._format(val, args)    // 获取替换后的字符    }      _format (val: any, ...args: any): any {      return this._formatter.format(val, ...args)    }      // 翻译函数    _translate (messages: Messages, locale: string, fallback: string, key: string, args: any): any {      let res: any = null      /**       * messages[locale] 使用哪个语言包       * key 语言映射表的key       * args 映射替换关系       */      res = this._interpolate(messages[locale], key, args)      if (!isNull(res)) { return res }        res = this._interpolate(messages[fallback], key, args)      if (!isNull(res)) {        if (process.env.NODE_ENV !== 'production') {          warn(`Fall back to translate the keypath '${key}' with '${fallback}' locale.`)        }        return res      } else {        return null      }    }      // 翻译的核心函数    /**     * 这里的方法传入的参数参照mixin.js里面的定义的方法     * key map的key值 (为接受的外部参数)     * _locale 语言配置选项: 'zh-CN' | 'en-US' (内部变量)     * messages 映射表 (内部变量)     * host为这个i18n的实例 (内部变量)     *     */    _t (key: string, _locale: string, messages: Messages, host: any, ...args: any): any {      if (!key) { return '' }            // parseArgs函数用以返回传入的局部语言配置, 及映射表      const parsedArgs = parseArgs(...args)   // 接收的参数{ locale, params(映射表) }      const locale = parsedArgs.locale || _locale   // 语言配置            // 字符串替换      /**       * @params messages  语言包       * @params locale  语言配置       * @params fallbackLocale 缺省语言配置       * @params key 替换的key值       * @params parsedArgs.params 需要被替换的参数map表       */      const ret: any = this._translate(messages, locale, this.fallbackLocale, key, parsedArgs.params)      if (this._isFallbackRoot(ret)) {        if (process.env.NODE_ENV !== 'production') {          warn(`Fall back to translate the keypath '${key}' with root locale.`)        }        if (!this._root) { throw Error('unexpected error') }        return this._root.t(key, ...args)      } else {        return this._warnDefault(locale, key, ret, host)      }    }      // 转化函数    t (key: string, ...args: any): string {      return this._t(key, this.locale, this.messages, null, ...args)    }      _tc (key: string, _locale: string, messages: Messages, host: any, choice?: number, ...args: any): any {      if (!key) { return '' }      if (choice !== undefined) {        return fetchChoice(this._t(key, _locale, messages, host, ...args), choice)      } else {        return this._t(key, _locale, messages, host, ...args)      }    }      tc (key: string, choice?: number, ...args: any): any {      return this._tc(key, this.locale, this.messages, null, choice, ...args)    }      _te (key: string, _locale: string, messages: Messages, ...args: any): boolean {      const locale = parseArgs(...args).locale || _locale      return this._exist(messages[locale], key)    }      te (key: string, ...args: any): boolean {      return this._te(key, this.locale, this.messages, ...args)    }  }    VueI18n.install = install  VueI18n.version = '__VERSION__'    // 如果是通过CDN或者外链的形式引入的Vue  if (typeof window !== 'undefined' && window.Vue) {    window.Vue.use(VueI18n)  }</code></pre>    <p>另外还有一个比较重要的库函数 format.js :</p>    <pre>  <code class="language-javascript">/**   *  String format template   *  - Inspired:   *    https://github.com/Matt-Esch/string-template/index.js   */    // 变量的替换, 在字符串模板中写的站位符 {xxx} 进行替换  const RE_NARGS: RegExp = /(%|)\{([0-9a-zA-Z_]+)\}/g    /**   * template   *   * @param {String} string   * @param {Array} ...args   * @return {String}   */    // 模板替换函数  export function template (str: string, ...args: any): string {    // 如果第一个参数是一个obj    if (args.length === 1 && typeof args[0] === 'object') {      args = args[0]    } else {      args = {}    }      if (!args || !args.hasOwnProperty) {      args = {}    }      // str.prototype.replace(substr/regexp, newSubStr/function) 第二个参数如果是个函数的话,每次匹配都会调用这个函数    // match 为匹配的子串    return str.replace(RE_NARGS, (match, prefix, i, index) => {      let result: string        // match是匹配到的字符串      // prefix ???      // i 括号中需要替换的字符换      // index是偏移量        // 字符串中如果出现{xxx}不需要被替换。那么应该写成{{xxx}}      if (str[index - 1] === '{' &&        str[index + match.length] === '}') {        return i      } else {        // 判断args obj是否包含这个key值        // 返回替换值, 或者被匹配上的字符串的值        result = hasOwn(args, i) ? args[i] : match        if (isNull(result)) {          return ''        }          return result      }    })  }</code></pre>    <p> </p>    <p>来自:https://segmentfault.com/a/1190000008752459</p>    <p> </p>