前端國際化之Vue-i18n源碼分析
最近的工作當中有個任務是做國際化。這篇文章也是做個簡單的總結。
部分網站的當前解決的方案
-
不同語言對應不同的頁面。在本地開發的時候就分別打包輸出了不同語言版本的靜態及模板文件,通過頁面及資源的 url 進行區分,需要維護多份代碼。
-
在線翻譯
-
統一模板文件,前端根據相應的語言映射表去做文案的替換。
面對的問題
-
語言標識誰來做?
-
頁面完全由服務端直出(所有的數據均由服務端來處理)
-
服務端根據 IP 去下發語言標識字段(前端根據下發的表示字段切換語言環境)
-
前端去根據 useragent.lang 瀏覽器環境語言進行設定
當前項目中入口頁面由服務端來渲染,其他的頁面由前端來接管路由。在入口頁面由服務器下發 lang 字段去做語言標識,在頁面渲染出來前,前端來決定使用的語言包。語言包是在本地編譯的過程中就將語言包編譯進了代碼當中,沒有采用異步加載的方式。
-
-
前端靜態資源翻譯
-
單/復數
-
中文轉英文
-
語言展示的方向
前端靜態資源文案的翻譯使用 vue-i18n 這個插件來進行。插件提供了單復數,中文轉英文的方法。a下文有對 vue-i18n 的源碼進行分析。因為英文的閱讀方向也是從左到右,因此語言展示的方向不予考慮。但是在一些阿拉伯地區國家的語言是從右到左進行閱讀的。
-
-
服務端數據翻譯
-
前端樣式的調整
a.中文轉英文后肯定會遇到文案過長的情況。那么可能需要精簡翻譯,使文案保持在一定的可接受的長度范圍內。但是大部分的情況都是文案在保持原意的情況下無法再進行精簡。這時必須要前端來進行樣式上的調整,那么可能還需要設計的同學參與進來,對一些文案過多出現折行的情況再單獨做樣式的定義。在細調樣式這塊,主要還是通過不同的語言標識去控制不同標簽的 class ,來單獨定義樣式。
-
中文轉英文后部分文案過長
-
圖片
-
第三方插件( 地圖 , SDK 等)
-
-
此外,還有部分圖片也是需要做調整,在C端中,大部分由產品方去輸出內容,那么圖片這塊的話,還需要設計同學單獨出圖。
-
在第三方插件中這個環節當中,因為使用了 騰訊地圖 插件,由于騰訊地圖并未推出國內地圖的英文版,所以整個頁面的地圖部分暫時無法做到國際化。由此聯想到,在你的應用當中使用的其他一些 第三方插件 或者 SDK ,在國際化的過程中需要去解決哪些問題。
-
跨地區xxxx
在一些 支付場景 下, 貨幣符號 , 單位 及 價格 的轉化等。不同國家地區在時間的格式顯示上有差異。
-
貨幣及支付方式
-
時間的格式
-
-
項目的長期維護
當前翻譯的工作流程是拆頁面,每拆一個頁面,FE同學整理好可能會出現的中文文案,再交由翻譯的同學去完成翻譯的工作。負責不同頁面的同學維護著不同的 map 表,在當前的整體頁面架構中,不同功能模塊和頁面被拆分出去交由不同的同學去做,那么通過跳頁面的方式去暫時緩解 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.js Vue的掛載函數,主要是為了將 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
}
})
}
來自:https://segmentfault.com/a/1190000008752459