特別聲明,本文由 Fortnight_許帥博原創,受限於做者能力,文章或存在不足,歡迎你們指出。如需轉載,煩請註明出處。
近日,我一直負責的項目已經成長到了一個較爲穩定的狀態,所以早前被擱下的國際化問題又從新提了出來,爲此,我對ngx-translate這個庫作了一些瞭解,但看完後我感到有些頭疼,由於項目中的出現的文案文本都須要替換爲語言包文件中對應的鍵名,這是個繁瑣枯燥,又必須細心的工做。儘管ngx-translate有提供相關的包能提取須要翻譯的字符串,可是它也須要開發者在代碼加入一些標記,對於已經開發一段時間的項目而言,這樣的工具意義卻是不大了。因此各位朋友如果也遇到有國際化需求的項目,都應該儘早接入,避免後期再作無心義地重複勞動。固然,抱怨不是本文的主題,閒話少說,咱們進入正題吧。html
Angular官網提供有一整套的國際化實現方案,初看時我以爲它功能強大,但文檔中的一句話,讓我堅決果斷地放棄了官方方案:node
The command replaces the original messages with translated text, and generates a new version >of the app in the target language.You need to build and deploy a separate version of the app for each supported language.git
每適配一種語言就生成和部署一個新的應用對咱們目前的項目來講不太實際,所以我選擇了另外一個庫——ngx-translate,這是一個非官方但卻使用普遍的國際化庫。經過這個庫咱們能夠用service、pipe、directive等形式對文本進行多語言處理,十分方便易用。經過一些簡單的代碼,能夠向你們展現如何使用ngx-translate實現Angular項目的多語言切換功能。github
首先咱們安裝好核心功能包:typescript
npm install @ngx-translate/core --save
爲了能經過http請求獲取語言包,咱們須要安裝另外一個包:shell
npm install @ngx-translate/http-loader --save
ngx-translate的使用十分簡單,咱們只需在根模塊中導入TranslateModule,引入多語言的核心實現,即可以在模板代碼中使用它的管道或指令對文本進行多語言處理;若要在組件代碼中使用,則只須要注入TranslateService便可調用模塊提供的API對文本進行處理。須要說明的是,對於一個較大的應用來講,將全部語言的語言包寫入代碼裏會增長應用的體積,且不便於管理,所以,咱們須要導入HttpClientModule,結合ngx-translate提供的http-loader庫,經過http請求獲取特定的語言包。npm
咱們事先在Angular項目的assets/i18n目錄下,準備兩個Json格式的語言包文件,內容以下:json
// en_US.json { "title": "Welcome to {{ title }}!", "tip": "Here are buttons to change app’s language:" } // zh_CNS.json { "title": "歡迎來到 {{ title }}!", "tip": "這裏有一些按鈕能夠切換應用的語言:" }
而後在根模塊中引入必要的庫:bootstrap
// app.module.ts import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { HttpClient, HttpClientModule } from '@angular/common/http'; import { AppComponent } from './app.component'; // 提供必備的loader方法 export function HttpLoaderFactory(http: HttpClient) { return new TranslateHttpLoader(http, '/assets/i18n/', '.json'); } @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, HttpClientModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, deps: [HttpClient] // deps中的元素須要與HttpLoaderFactory方法的參數順序一致 } }) ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
引入必需的模塊後,咱們將模板中的文本都使用語言包的鍵代替,並使用ngx-transalte提供的管道或指令進行處理:api
<div style="text-align:center"> <h1> {{'title' | translate: {'title': title} }} </h1> </div> <h2>{{'tip' | translate}}</h2> <button (click)="changeLang('zh')">中文</button> <button (click)="changeLang('en')">English</button>
在模板中作了多語言處理是不夠的,咱們還須要在組件中注入TranslateService,使用其中的一些API實現諸如切換應用語言、獲取瀏覽器語言、處理多語言文本等功能。以下例所示,咱們對應用的語言類型作了初始化,並提供了一個簡易的語言切換功能。
export class AppComponent implements OnInit{ title = 'Translate Demo'; langs = { zh: 'zh_CNS', en: 'en_US' } constructor(private translate: TranslateService) { } ngOnInit() { const defaultLang = this.langs[this.translate.getBrowserLang() || 'zh']; this.translate.getTranslation(defaultLang).subscribe(res => { res ? this.translate.use(defaultLang) : alert('獲取語言文件失敗'); }) } changeLang(lang: string) { const langKey = this.langs[lang] || 'zh_CNS'; this.translate.use(langKey) } }
示例十分簡單,經過這樣的簡單示例能夠看出,ngx-translate確實是一個易用的庫,而且,細心的人必定會發現ngx-translate是支持插值表達式的。在大部分的場景中,咱們的產品的文本都是靜態的,這樣的文本進行多語言處理較爲簡單,但總有一些時候咱們不可避免地須要使用到動態文本,而ngx-translate對於插值表達式的支持則解決了動態文本進行多語言處理難的問題。
在示例以外,ngx-translate還有其餘強大的用法與API,想要了解更多的話能夠閱讀ngx-translate的官方文檔。
另外在本段結束前,有幾個小tips能夠與你們分享:
在爲項目加入多語言切換功能後,我本覺得難題已經解決,但在後續的開發與調試中我發現了一個奇怪的問題,在頁面初次加載時,老是會看到語言包的鍵被直接渲染在了頁面上的毛刺現象,雖然這種現象轉瞬即逝但也十分顯眼而且難以忍受。
在查看過頁面資源請求後我發現了問題所在。在頁面初次加載時,模板資源的請求要先於語言包文件的請求,因此在頁面在客戶端渲染時,語言包資源實際還沒就緒,所以在那一瞬間填寫在模板中的語言包鍵名便直接被渲染在了頁面中。至此我已經掌握了頁面加載出現這種毛刺現象的根本緣由:頁面渲染時語言包資源未到位。
但轉念一想,咱們的項目使用了服務端渲染技術,那麼頁面在服務端應該是已經進行過預渲染的,換句話說,頁面在服務端已經完成過一次:獲取語言包——渲染頁面這一流程纔對,那爲什麼在頁面首次加載時仍然會存在毛刺現象?是否頁面根本沒在服務端完成咱們設想的渲染流程呢?帶着疑問我查看了客戶端獲取的頁面模板,果真,客戶端獲取的模板中充斥着原始的語言包鍵名,查看代碼後我發現應用語言的初始化相關操做都被限制在客戶端中運行,這樣的話至關於頁面在服務端渲染時並未將語言包的鍵名替換爲真正的文案文本。
在對代碼稍做調整後,我從新啓動了應用,這時候頁面加載時的效果較以前有了變化,我明顯看到了頁面在最開始的時刻是正常顯示的,但一瞬間後頁面中的文本變成了語言包的鍵名,片刻以後鍵名又再度恢復爲正常的文本,而客戶端獲取的模板文件中填充的分明是正常的文本,那爲什麼還會出現毛刺現象呢?通過一番瞭解後我得知,Angular目前的服務端渲染並不支持DOM hydration,通俗地說,Angular服務端渲染所產生的預渲染DOM並無在客戶端複用,所以在客戶端會重建全部的DOM,即預渲染的頁面在客戶端又重渲染了一遍,因而咱們回到了最初的起點:頁面在客戶端渲染時語言包資源仍然未就緒。
既然Angular的服務端渲染自己沒法實現首次刷新無毛刺的效果,那麼咱們稍微變換一下思路,可否將語言包資源與模板同時返回給客戶端呢?答案是確定的。經過Angular提供的狀態轉移功能,咱們能夠在服務端獲取語言包,並將其與模板一同返回給客戶端,如此客戶端在渲染模板時便能直接獲取到鍵值對應的文本,從而避免鍵值直接渲染在頁面中的問題。
解決這個問題的核心技術就是Angular的TransferState,除此以外咱們還須要結合ngx-translate的自定義loader功能。
首先咱們須要先創建兩個自定義loader,分別處理服務端與客戶端的語言包獲取,具體實現代碼以下:
// translate-server-loader.service.ts export class TranslateServerLoader implements TranslateLoader { constructor( private prefix: string = 'i18n', private suffix: string = '.json', private transferState: TransferState ) { } /** * 實現TranslateLoader的類必需要提供getTranslation方法,並返回一個Observable實例 */ public getTranslation(lang: string): Observable<any> { return Observable.create(observer => { // 拼接語言包文件所在的目錄 const assets_folder = join(process.cwd(), 'dist', 'browser', this.prefix); // 讀取目錄下的語言包文件 const jsonData = JSON.parse(fs.readFileSync(`${assets_folder}/${lang}${this.suffix}`, 'utf8')); // 將語言包內容存儲在 transferState 中 const key: StateKey<number> = makeStateKey<number>('transfer-translate-' + lang); this.transferState.set(key, jsonData); observer.next(jsonData); observer.complete(); }); } }
在服務端處調用的loader的將會以文件讀取的方式得到當前應用所使用的語言包,並經過transfer-state傳遞至客戶端,保證模板與語言包同時回到客戶端。
// translate-browser-loader.service.ts export class TranslateBrowserLoader implements TranslateLoader { constructor( private prefix: string = 'i18n', private suffix: string = '.json', private transferState: TransferState, private http: HttpClient ) { } public getTranslation(lang: string): Observable<any> { const key: StateKey<number> = makeStateKey<number>('transfer-translate-' + lang); const data = this.transferState.get(key, null); // 檢查transfer-state是否存在傳入語言的語言包內容, 不存在則請求相應的語言包資源 if (data) { return Observable.create(observer => { observer.next(data); observer.complete(); }); } else { // 使用網絡請求獲取語言包資源 return new TranslateHttpLoader(this.http, this.prefix, this.suffix).getTranslation(lang); } } }
在客戶端所使用的loader中,咱們優先獲取transfer-state中的語言包內容,而這時咱們只要保證首次加載時客戶端與服務端會使用同一個語言便可完美規避頁面刷新時出現語言包中的鍵的問題。在咱們的項目中,我使用cookie存儲應用的語言類型,方便保持服務端與客戶端語言類型的一致性。
接下來須要在客戶端根模塊中引入TranslateModule
模塊:
// app.module.ts // 參數須要與loader配置中的deps數組元素一一對應 const browserLoaderFactory = (http: HttpClient, transferState: TransferState): TranslateLoader => { return new TranslateBrowserLoader('/assets/i18n/', '.json', transferState, http); }; @NgModule({ declarations: [ AppComponent, ...LayoutComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'xxxxxx' }), HttpClientModule, SharedModule, BrowserTransferStateModule, // 引入此模塊保證transfer-state正常工做 TransferHttpCacheModule, CoreModule, Routing, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: browserLoaderFactory, deps: [HttpClient, TransferState] // 將HttpClient、TransferState做爲依賴供loader內部使用 } }), CookieModule.forRoot() ], bootstrap: [AppComponent], }) export class AppModule { constructor() { } }
類似地,在服務端根模塊也以下引入TranslateModule
/** * 定義語言文件加載方法 */ const serverLoaderFactory = (transferState: TransferState): TranslateLoader => { return new TranslateServerLoader('/assets/i18n/', '.json', transferState); }; @NgModule({ imports: [ AppModule, ServerModule, ModuleMapLoaderModule, ServerTransferStateModule, // 引入此模塊保證transfer-state正常工做 TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: serverLoaderFactory, deps: [TransferState] // TransferState依舊須要做爲依賴項 } }) ], bootstrap: [AppComponent], }) export class AppServerModule { }
到此,爲了消除刷新頁面時所出現的毛刺現象所作的工做已經算是完成了,以後只須要正常使用ngx-translate便可。如下是我寫在項目根組件中的多語言處理代碼。貼出來供你們參考。
export class AppComponent implements OnInit, OnDestroy { langLoaded = false; isBrowser = false; $langUpdate: Subscription; $params: Subscription; constructor( private messageService: MessageService, private translate: TranslateService, private staticApi: StaticApi, private injector: Injector, private cookieService: CookieService, @Inject(PLATFORM_ID) private readonly platformId: any ) { this.isBrowser = isPlatformBrowser(platformId); } ngOnInit() { if (this.isBrowser) { if (!this.langLoaded) this.switchLang(this.getDefaultLang()); } else { let lang; // 獲取node端所傳遞的COOKIE信息 const cookie = this.injector.get('COOKIE'); // 獲取cookies中的語言類型 if (cookie) { const reg = new RegExp(/(custom-lang=)([^&#;]*)/g); const matchArray = reg.exec(cookie); if (matchArray && matchArray.length > 0) { lang = matchArray[2]; } } // 在服務端獲取語言包 this.translate.getTranslation(lang || 'zh'); } } ngOnDestroy() { this.$langUpdate && this.$langUpdate.unsubscribe(); this.$params && this.$params.unsubscribe(); } /** * 獲取默認語言 */ getDefaultLang() { const browserLang = this.translate.getBrowserLang(); const cookieLang = this.cookieService.getItem('custom-lang'); return cookieLang || browserLang; } /** * 設置應用使用的語言 */ switchLang(lang: string) { this.langLoaded = true; // 加載語言文件 this.translate.getTranslation(lang) .subscribe((res: any) => { res ? this.translate.use(lang) : this.messageService.error('加載語言文件失敗'); }); // 監測語言類型更新 this.$langUpdate = this.translate.onLangChange .subscribe((res: any) => { this.cookieService.setItem('custom-lang', res.lang); this.updateLang(res.lang); }); } /** * 更新html中的 - lang屬性 */ updateLang(value: string) { const lang = document.createAttribute('lang'); lang.value = value; this.el.nativeElement .parentElement .parentElement .attributes .setNamedItem(lang); } }
事出必有因,在遇到莫名其妙的問題時,咱們更須要沉下心去思考問題背後的緣由;當問題看似沒法解決時,變換一下思路可能就會柳暗花明。當咱們以爲問題古怪時,可能須要審視自身是否足夠了解這個技術,如本文所解決的問題,看似是資源請求時機不當,但只有對服務端渲染有必定的原理了解,纔會意識到這其中所牽涉的Angular服務端渲染的「缺陷」。固然人力有限,善用GitHub,善用搜索引擎,問題老是能解決的,哈哈哈。
特別鳴謝:ngx-translate/core issue #754 中的@peterpeterparker 與 @ocombe,@ocombe指出了問題的根本緣由,@peterpeterparker則貼出了完整的代碼示例。