【源碼】Vue-i18n你知道國際化是怎麼實現的麼?

Vue-i18n 簡單介紹以及使用

你們好,我是 Gopal。目前就任於 Shopee,一家作跨境電商的公司,由於業務涉及到多個國家,因此咱們各個系統都會涉及到國際化翻譯。Vue I18n 是 Vue.js 的國際化插件,它能夠輕鬆地將一些本地化功能集成到你的 Vue.js 應用程序中。javascript

本文的源碼閱讀是基於版本 8.24.4 進行html

咱們來看一個官方的 demovue

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>ES modules browser example</title>
    <script src="../../dist/vue-i18n.js"></script>
  </head>
  <body>
    <div id="app">
      <p>{{ $t('message.hello') }}</p>
    </div>
    <script type="module"> // 若是使用模塊系統 (例如經過 vue-cli),則須要導入 Vue 和 VueI18n ,而後調用 Vue.use(VueI18n)。 import Vue from 'https://unpkg.com/vue@2.6.10/dist/vue.esm.browser.js' Vue.use(VueI18n) new Vue({ // 經過 `i18n` 選項建立 Vue 實例 // 經過選項建立 VueI18n 實例 i18n: new VueI18n({ locale: 'zh', // 設置地區 // 準備翻譯的語言環境信息 // 設置地區信息 messages: { en: { message: { hello: 'hello, I am Gopal' } }, zh: { message: { hello: '你好,我是 Gopal 一號' } } } }) }).$mount('#app') </script>
  </body>
</html>

複製代碼

使用上是比較簡單的,本文咱們深刻了解 Vue-i18n 的工做原理,探索國際化實現的奧祕。包括:java

  • 總體的 Vue-i18n 的架構是怎樣的?
  • 上述 demo 是如何生效的?
  • 咱們爲何能夠直接在模板中使用 $t?它作了什麼?
  • 上述 demo 是如何作到不刷新更新頁面的?
  • 全局組件 <i18n> 和全局自定義指令的實現?

代碼結構以及入口

咱們看一下 Vue-18n 的代碼結構以下node

├── components/
│   ├── interpolation.js // <i18n> 組件的實現
│   └── number.js
├── directive.js // 全局自定義組件的實現
├── extend.js // 拓展方法
├── format.js	// parse 和 compile 的核心實現
├── index.js // 入口文件
├── install.js // 註冊方法
├── mixin.js // 處理各個生命週期
├── path.js
└── util.js
複製代碼

關於 Vue-18n 的總體架構,網上找到了一個比較貼切的圖,以下。其中左側是 Vue-i18n 提供的一些方法、組件、自定義指令等能力,右側是 Vue-i18n 對數據的管理git

入口文件爲 index.js,在 VueI18n 類中的 constructor 中先調用 install 方法註冊github

// Auto install if it is not done yet and `window` has `Vue`.
// To allow users to avoid auto-installation in some cases,
// this code should be placed here. See #290
/* istanbul ignore if */
if (!Vue && typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}
複製代碼

install 方法中,主要作了幾件事,以下代碼註釋,後面還會提到,這裏有一個大體的印象vue-cli

// 在 Vue 的原型中拓展方法,代碼在 extend.js 裏
extend(Vue)
// 在 Vue 中經過 mixin 的方式混入
Vue.mixin(mixin)
// 全局指令
Vue.directive('t', { bind, update, unbind })
// 全局組件
Vue.component(interpolationComponent.name, interpolationComponent)
Vue.component(numberComponent.name, numberComponent)
複製代碼

註冊完成後,會調用 _initVM,這個主要是建立了一個 Vue 實例對象,後面不少功能會跟這個this._ vm 相關聯json

// VueI18n 其實不是一個 Vue 對象,可是它在內部創建了 Vue 對象 vm,而後不少的功能都是跟這個 vm 關聯的
this._initVM({
  locale,
  fallbackLocale,
  messages,
  dateTimeFormats,
  numberFormats
})

_initVM (data: {
         locale: Locale,
         fallbackLocale: FallbackLocale,
         messages: LocaleMessages,
         dateTimeFormats: DateTimeFormats,
         numberFormats: NumberFormats
         }): void {
  // 用來關閉 Vue 打印消息的
  const silent = Vue.config.silent
  Vue.config.silent = true
  this._vm = new Vue({ data }) // 建立了一個 Vue 實例對象
  Vue.config.silent = silent
}
複製代碼

全局方法 $t 的實現

咱們來看看 Vue-i18n 的 $t 方法的實現,揭開國際化翻譯的神祕面紗api

在 extent.js 中,咱們看到在 Vue 的原型中掛載 $t 方法,這是咱們爲何可以直接在模板中使用的緣由。

// 在 Vue 的原型中掛載 $t 方法,這是咱們爲何可以直接在模板中使用的緣由
// 把 VueI18n 對象實例的方法都注入到 Vue 實例上
Vue.prototype.$t = function (key: Path, ...values: any): TranslateResult {
  const i18n = this.$i18n
  // 代理模式的使用
  return i18n._t(key, i18n.locale, i18n._getMessages(), this, ...values)
}
複製代碼

看到的是調用 index.js 中的 $t 的方法

// $t 最後調用的方法
_t (key: Path, _locale: Locale, messages: LocaleMessages, host: any, ...values: any): any {
  if (!key) { return '' }
  const parsedArgs = parseArgs(...values)
  // 若是 escapeParameterHtml 被配置爲 true,那麼插值參數將在轉換消息以前被轉義。
  if(this._escapeParameterHtml) {
    parsedArgs.params = escapeParams(parsedArgs.params)
  }
  const locale: Locale = parsedArgs.locale || _locale
  // 翻譯
  let ret: any = this._translate(
    messages, locale, this.fallbackLocale, key,
    host, 'string', parsedArgs.params
  )
}
複製代碼

_interpolate

回到主線,當調用 _translate 的時候,接着調用

this._interpolate(step, messages[step], key, host, interpolateMode, args, [key])
複製代碼

並返回

this._render(ret, interpolateMode, values, key)
複製代碼

_render 方法中,能夠調用自定義方法去處理插值對象,或者是默認的方法處理插值對象。

_render (message: string | MessageFunction, interpolateMode: string, values: any, path: string): any {
  // 自定義插值對象
  let ret = this._formatter.interpolate(message, values, path)

  // If the custom formatter refuses to work - apply the default one
  if (!ret) {
    // 默認的插值對象
    ret = defaultFormatter.interpolate(message, values, path)
  }

  // if interpolateMode is **not** 'string' ('row'),
  // return the compiled data (e.g. ['foo', VNode, 'bar']) with formatter
  return interpolateMode === 'string' && !isString(ret) ? ret.join('') : ret
}
複製代碼

咱們主要來看看默認的方法處理,主要是在 format.js 中完成

format.js 中的 parse 和 compile

format.js 實現了 BaseFormatter 類,這裏使用 _caches 實現了一層緩存優化,也是常見的優化手段。下面的 沒有插值對象的話,就直接返回 [message],就完成使命了。

export default class BaseFormatter {
  // 實現緩存效果
  _caches: { [key: string]: Array<Token> }

  constructor () {
    this._caches = Object.create(null)
  }

  interpolate (message: string, values: any): Array<any> {
    // 沒有插值對象的話,就直接返回
    if (!values) {
      return [message]
    }
    // 若是存在 tokens,則組裝值返回
    let tokens: Array<Token> = this._caches[message]
    if (!tokens) {
      // 沒有存在 tokens,則拆分 tokens
      tokens = parse(message)
      this._caches[message] = tokens
    }
    return compile(tokens, values)
  }
}
複製代碼

當遇到以下的使用方式的時候

<p>{{ $t('message.sayHi', { name: 'Gopal' })}}</p>
複製代碼

主要涉及兩個方法,咱們先來看 parse,代碼比較直觀,能夠看到本質上是遍歷字符串,而後遇到有 {} 包裹的,把其中的內容附上類型拿出來放入到 tokens 裏返回。

// 代碼比較直觀,能夠看到本質上是遍歷字符串,而後遇到有 {} 包裹的,把其中的內容附上類型拿出來放入到 tokens 裏返回。
export function parse (format: string): Array<Token> {
  const tokens: Array<Token> = []
  let position: number = 0

  let text: string = ''
  while (position < format.length) {
    let char: string = format[position++]
    if (char === '{') {
      if (text) {
        tokens.push({ type: 'text', value: text })
      }

      text = ''
      let sub: string = ''
      char = format[position++]
      while (char !== undefined && char !== '}') {
        sub += char
        char = format[position++]
      }
      const isClosed = char === '}'

      const type = RE_TOKEN_LIST_VALUE.test(sub)
        ? 'list'
        : isClosed && RE_TOKEN_NAMED_VALUE.test(sub)
          ? 'named'
          : 'unknown'
      tokens.push({ value: sub, type })
    } else if (char === '%') {
      // when found rails i18n syntax, skip text capture
      if (format[(position)] !== '{') {
        text += char
      }
    } else {
      text += char
    }
  }

  text && tokens.push({ type: 'text', value: text })

  return tokens
}
複製代碼

以上的 demo 的返回 tokens 以下:

[
    {
        "type": "text",
        "value": "hi, I am "
    },
    {
        "value": "name",
        "type": "named"
    }
]
複製代碼

還有 parse,就是將上述的組裝起來

// 把一切都組裝起來
export function compile (tokens: Array<Token>, values: Object | Array<any>): Array<any> {
  const compiled: Array<any> = []
  let index: number = 0

  const mode: string = Array.isArray(values)
    ? 'list'
    : isObject(values)
      ? 'named'
      : 'unknown'
  if (mode === 'unknown') { return compiled }

  while (index < tokens.length) {
    const token: Token = tokens[index]
    switch (token.type) {
      case 'text':
        compiled.push(token.value)
        break
      case 'list':
        compiled.push(values[parseInt(token.value, 10)])
        break
      case 'named':
        if (mode === 'named') {
          compiled.push((values: any)[token.value])
        } else {
          if (process.env.NODE_ENV !== 'production') {
            warn(`Type of token '${token.type}' and format of value '${mode}' don't match!`)
          }
        }
        break
      case 'unknown':
        if (process.env.NODE_ENV !== 'production') {
          warn(`Detect 'unknown' type of token!`)
        }
        break
    }
    index++
  }

  return compiled
}
複製代碼

以上 demo 最後返回 ["hi, I am ", "Gopal"],最後再作一個簡單的拼接就能夠了,至此,翻譯就能夠成功了

Vue-i18n 是如何避免 XSS ?

上面 _t 方法中有一個 _escapeParameterHtml 。這裏談談 escapeParams,實際上是 Vue-i18n 爲了防止 xss 攻擊作的一個處理。若是 escapeParameterHtml 被配置爲 true,那麼插值參數將在轉換消息以前被轉義。

// 若是escapeParameterHtml被配置爲true,那麼插值參數將在轉換消息以前被轉義。
if(this._escapeParameterHtml) {
  parsedArgs.params = escapeParams(parsedArgs.params)
}
複製代碼
/** * Sanitizes html special characters from input strings. For mitigating risk of XSS attacks. * @param rawText The raw input from the user that should be escaped. */
function escapeHtml(rawText: string): string {
  return rawText
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;')
}

/** * Escapes html tags and special symbols from all provided params which were returned from parseArgs().params. * This method performs an in-place operation on the params object. * * @param {any} params Parameters as provided from `parseArgs().params`. * May be either an array of strings or a string->any map. * * @returns The manipulated `params` object. */
export function escapeParams(params: any): any {
  if(params != null) {
    Object.keys(params).forEach(key => {
      if(typeof(params[key]) == 'string') {
        // 處理參數,防止 XSS 攻擊
        params[key] = escapeHtml(params[key])
      }
    })
  }
  return params
}
複製代碼

如何作到無刷新更新頁面

咱們發現,在 demo 中,我不管是修改了 locale 仍是 message 的值,頁面都不會刷新,但頁面也是會更新數據。這個功能相似 Vue 的雙向數據綁定,它是如何實現的呢?

new

這裏 Vue-i18n 採用了觀察者模式,咱們上面提到過的 _initVM 方法中,咱們會將翻譯相關的數據 data 經過 new Vue 傳遞給 this._vm 實例。如今要作的就是去監聽這些 data 的變化

Vue-i18n 的這一塊的邏輯主要是在 mixin.js 文件中,在 beforeCreate 中調用 watchI18nData 方法,這個方法的實現以下:

// 爲了監聽翻譯變量的變化
watchI18nData (): Function {
  const self = this
  // 使用 vue 實例中的 $watch 方法,數據變化的時候,強制刷新
  // 組件的 data 選項是一個函數。Vue 在建立新組件實例的過程當中調用此函數。它應該返回一個對象,而後 Vue 會經過響應性系統將其包裹起來,並以 $data 的形式存儲在組件實例中
  return this._vm.$watch('$data', () => {
    self._dataListeners.forEach(e => {
      Vue.nextTick(() => {
        e && e.$forceUpdate()
      })
    })
  }, { deep: true })
}
複製代碼

其中 _dataListeners,我理解是一個個的實例(但我沒想到具體的場景,在系統中使用 vue-18n new 多個實例?)。subscribeDataChangingunsubscribeDataChanging 就是用來添加和移除訂閱器的函數

// 添加訂閱器,添加使用的實例
subscribeDataChanging (vm: any): void {
  this._dataListeners.add(vm)
}

// 移除訂閱器
unsubscribeDataChanging (vm: any): void {
  remove(this._dataListeners, vm)
}
複製代碼

它們會在 mixin.js 中的 beforeMountbeforeDestroy 中調用

// 精簡後的代碼 
// 在保證了_i18n 對象生成以後,beforeMount 和 beforeDestroy 裏就能增長移除監聽了
beforeMount (): void {
  const options: any = this.$options
  options.i18n = options.i18n || (options.__i18n ? {} : null)

  this._i18n.subscribeDataChanging(this)
},


  beforeDestroy (): void {
    if (!this._i18n) { return }
    const self = this
    this.$nextTick(() => {
      if (self._subscribing) {
        // 組件銷燬的時候,去除這個實例
        self._i18n.unsubscribeDataChanging(self)
        delete self._subscribing
      }
    })
}
複製代碼

總結一下,在 beforeCreate 會去 watch data 的變化,並在 beforeMount 中添加訂閱器。假如 data 變化,就會強制更新相應的實例更新組件。並在 beforeDestroy 中移除訂閱器,防止內存溢出,總體流程以下圖所示

全局自定義指令以及全局組件的實現

在 extent.js 中,咱們提到了註冊全局指令和全局組件,咱們來看下如何實現的

// 全局指令
Vue.directive('t', { bind, update, unbind })
// 全局組件
Vue.component(interpolationComponent.name, interpolationComponent)
Vue.component(numberComponent.name, numberComponent)
複製代碼

全局指令 t

關於指令t的使用方法,詳情參考官方文檔

如下是示例:

<!-- 字符串語法:字面量 -->
<p v-t="'foo.bar'"></p>

<!-- 字符串語法:經過數據或計算屬性綁定 -->
<p v-t="msg"></p>

<!-- 對象語法: 字面量 -->
<p v-t="{ path: 'hi', locale: 'ja', args: { name: 'kazupon' } }"></p>

<!-- 對象語法: 經過數據或計算屬性綁定 -->
<p v-t="{ path: greeting, args: { name: fullName } }"></p>

<!-- `preserve` 修飾符 -->
<p v-t.preserve="'foo.bar'"></p>
複製代碼

directive.js 中,咱們看到其實是調用了 t 方法和 tc 方法,並給 textContent 方法賦值。(textContent 屬性表示一個節點及其後代的文本內容。)

// 主要是調用了 t 方法和 tc 方法
if (choice != null) {
  el._vt = el.textContent = vm.$i18n.tc(path, choice, ...makeParams(locale, args))
} else {
  el._vt = el.textContent = vm.$i18n.t(path, ...makeParams(locale, args))
}
複製代碼

在 unbind 的時候會清空 textContent

全局組件 i18n

i18n 函數式組件 使用以下:

<div id="app">
  <!-- ... -->
  <i18n path="term" tag="label" for="tos"> <a :href="url" target="_blank">{{ $t('tos') }}</a> </i18n>
  <!-- ... -->
</div>
複製代碼

其源碼實現 src/components/interpolation.js,其中tag 表示外層標籤。傳 false,則表示不須要外層。

export default {
  name: 'i18n',
  functional: true,
  props: {
    // 外層標籤。傳 false,則表示不須要外層
    tag: {
      type: [String, Boolean, Object],
      default: 'span'
    },
    path: {
      type: String,
      required: true
    },
    locale: {
      type: String
    },
    places: {
      type: [Array, Object]
    }
  },
  render (h: Function, { data, parent, props, slots }: Object) {
    const { $i18n } = parent

    const { path, locale, places } = props
    // 經過插槽的方式實現
    const params = slots()
    // 獲取到子元素 children 列表
    const children = $i18n.i(
      path,
      locale,
      onlyHasDefaultPlace(params) || places
        ? useLegacyPlaces(params.default, places)
        : params
    )

    const tag = (!!props.tag && props.tag !== true) || props.tag === false ? props.tag : 'span'
    // 是否須要外層標籤進行渲染
    return tag ? h(tag, data, children) : children
  }
}
複製代碼

注意的是: places 語法會在下個版本進行廢棄了

function useLegacyPlaces (children, places) {
  const params = places ? createParamsFromPlaces(places) : {}

  if (!children) { return params }

  // Filter empty text nodes
  children = children.filter(child => {
    return child.tag || child.text.trim() !== ''
  })

  const everyPlace = children.every(vnodeHasPlaceAttribute)
  if (process.env.NODE_ENV !== 'production' && everyPlace) {
    warn('`place` attribute is deprecated in next major version. Please switch to Vue slots.')
  }

  return children.reduce(
    everyPlace ? assignChildPlace : assignChildIndex,
    params
  )
}
複製代碼

總結

整體 Vue-i18n 代碼不復雜,但也花了本身挺多時間,算是一個小挑戰。從 Vue-i18n 中,我學習到了

  • 國際化翻譯 Vue-i18n 的架構組織和 $t 的原理,當遇到插值對象的時候,須要進行 parse 和 compile
  • Vue-i18n 經過轉義字符避免 XSS
  • 經過觀察者模式對數據進行監聽和更新,作到無刷新更新頁面
  • 全局自定義指令和全局組件的實現

參考

相關文章
相關標籤/搜索