前端國際化之Vue-i18n源碼分析

最近的工做當中有個任務是作國際化。這篇文章也是作個簡單的總結。javascript

部分網站的當前解決的方案

  1. 不一樣語言對應不一樣的頁面。在本地開發的時候就分別打包輸出了不一樣語言版本的靜態及模板文件,經過頁面及資源的url進行區分,須要維護多份代碼。html

  2. 在線翻譯前端

  3. 統一模板文件,前端根據相應的語言映射表去作文案的替換。vue

面對的問題

  1. 語言標識誰來作?java

    • 頁面徹底由服務端直出(全部的數據均由服務端來處理)git

    • 服務端根據IP去下發語言標識字段(前端根據下發的表示字段切換語言環境)github

    • 前端去根據useragent.lang瀏覽器環境語言進行設定
      當前項目中入口頁面由服務端來渲染,其餘的頁面由前端來接管路由。在入口頁面由服務器下發lang字段去作語言標識,在頁面渲染出來前,前端來決定使用的語言包。語言包是在本地編譯的過程當中就將語言包編譯進了代碼當中,沒有采用異步加載的方式。後端

  2. 前端靜態資源翻譯瀏覽器

    • 單/複數前端框架

    • 中文轉英文

    • 語言展現的方向
      前端靜態資源文案的翻譯使用vue-i18n這個插件來進行。插件提供了單複數,中文轉英文的方法。a下文有對vue-i18n的源碼進行分析。由於英文的閱讀方向也是從左到右,所以語言展現的方向不予考慮。可是在一些阿拉伯地區國家的語言是從右到左進行閱讀的。

  3. 服務端數據翻譯

  4. 前端樣式的調整

    • 中文轉英文後部分文案過長

    • 圖片

    • 第三方插件(地圖,SDK等)

    a.中文轉英文後確定會遇到文案過長的狀況。那麼可能須要精簡翻譯,使文案保持在必定的可接受的長度範圍內。可是大部分的狀況都是文案在保持原意的狀況下沒法再進行精簡。這時必需要前端來進行樣式上的調整,那麼可能還須要設計的同窗參與進來,對一些文案過多出現折行的狀況再單獨作樣式的定義。在細調樣式這塊,主要仍是經過不一樣的語言標識去控制不一樣標籤的class,來單獨定義樣式。
    圖片描述

    1. 此外,還有部分圖片也是須要作調整,在C端中,大部分由產品方去輸出內容,那麼圖片這塊的話,還須要設計同窗單獨出圖。

    2. 在第三方插件中這個環節當中,由於使用了騰訊地圖插件,因爲騰訊地圖並未推出國內地圖的英文版,因此整個頁面的地圖部分暫時沒法作到國際化。由此聯想到,在你的應用當中使用的其餘一些第三方插件或者SDK,在國際化的過程當中須要去解決哪些問題。
      圖片描述

  5. 跨地區xxxx

    • 貨幣及支付方式

    • 時間的格式

    在一些支付場景下,貨幣符號單位價格的轉化等。不一樣國家地區在時間的格式顯示上有差別。

  6. 項目的長期維護

    • 翻譯工做

    • map表的維護

    當前翻譯的工做流程是拆頁面,每拆一個頁面,FE同窗整理好可能會出現的中文文案,再交由翻譯的同窗去完成翻譯的工做。負責不一樣頁面的同窗維護着不一樣的map表,在當前的總體頁面架構中,不一樣功能模塊和頁面被拆分出去交由不一樣的同窗去作,那麼經過跳頁面的方式去暫時緩解map表的維護問題。若是哪一天頁面須要收斂,這也是一個須要去考慮的問題。若是從整個項目的一開始就考慮到國際化的問題並採起相關的措施都能減輕後期的工做量及維護成本。同時之後一旦map表內容過多,是否須要考慮須要將map表進行異步加載。

Vue-i18n的基本使用

// 入口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>

Vue-i18n是以插件的形式配合Vue進行工做的。經過全局的mixin的方式將插件提供的方法掛載到Vue的實例上。

具體的源碼分析

其中install.jsVue的掛載函數,主要是爲了將mixin.js裏面的提供的方法掛載到Vue實例當中:

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)
}

接下來就看下在Vue上混合了哪些methods或者鉤子函數. 在mixin.js文件中:

/* @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
  }
}

這裏注意下這幾個方法的區別:

$tc這個方法能夠用以返回翻譯的複數字符串, 及一個key能夠對應的翻譯文本,經過|進行鏈接:

例如:

// 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>

$te這個方法用以判斷須要翻譯的key在你提供的語言包(messages)中是否存在.

接下來就看看VueI18n構造函數及原型上提供了哪些能夠被實例繼承的屬性或者方法

/* @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)
}

另外還有一個比較重要的庫函數format.js

/**
 *  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
    }
  })
}

總結

這個頁面是使用vue做爲前端框架,使用vue-i18n做爲國際化的工具:

  • 和後端同窗約定好語言標識字段

  • 前端根據後端下發的語言標識字段來調用不一樣的語言包

  • 文本內容使用vue-i18n進行替換

  • 圖片內容須要視覺同窗提供多語言版本

  • 樣式須要根據多語言進行定製。好比在body上添加多語言的標識class屬性

  • 第三方的SDK插件的國際化推進

相關文章
相關標籤/搜索