小談Angular SSR項目的國際化

特別聲明,本文由Fortnight_許帥博原創,受限於作者能力,文章或存在不足,歡迎大家指出。如需轉載,煩請註明出處。

前言

近日,我一直負責的項目已經成長到了一個較爲穩定的狀態,因此早前被擱下的國際化問題又重新提了出來,爲此,我對ngx-translate這個庫做了一些瞭解,但看完後我感到有些頭疼,因爲項目中的出現的文案文本都需要替換爲語言包文件中對應的鍵名,這是個繁瑣枯燥,又必須細心的工作。儘管ngx-translate有提供相關的包能提取需要翻譯的字符串,但是它也需要開發者在代碼加入一些標記,對於已經開發一段時間的項目而言,這樣的工具意義倒是不大了。所以各位朋友若是也遇到有國際化需求的項目,都應該儘早接入,避免後期再做無意義地重複勞動。當然,抱怨不是本文的主題,閒話少說,我們進入正題吧。

Angular項目的多語言切換

Angular官網提供有一整套的國際化實現方案,初看時我覺得它功能強大,但文檔中的一句話,讓我毫不猶豫地放棄了官方方案:

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.

每適配一種語言就生成和部署一個新的應用對我們目前的項目來說不太實際,因此我選擇了另一個庫——ngx-translate,這是一個非官方但卻使用廣泛的國際化庫。通過這個庫我們可以用service、pipe、directive等形式對文本進行多語言處理,十分方便易用。通過一些簡單的代碼,可以向大家展示如何使用ngx-translate實現Angular項目的多語言切換功能。

首先我們安裝好核心功能包:

npm install @ngx-translate/core --save

爲了能通過http請求獲取語言包,我們需要安裝另一個包:

npm install @ngx-translate/http-loader --save

ngx-translate的使用十分簡單,我們只需在根模塊中導入TranslateModule,引入多語言的核心實現,便可以在模板代碼中使用它的管道或指令對文本進行多語言處理;若要在組件代碼中使用,則只需要注入TranslateService即可調用模塊提供的API對文本進行處理。需要說明的是,對於一個較大的應用來說,將所有語言的語言包寫入代碼裏會增加應用的體積,且不便於管理,因此,我們需要導入HttpClientModule,結合ngx-translate提供的http-loader庫,通過http請求獲取特定的語言包。

我們事先在Angular項目的assets/i18n目錄下,準備兩個Json格式的語言包文件,內容如下:


// en_US.json
{
    "title": "Welcome to {{ title }}!",
    "tip": "Here are buttons to change app’s language:"
}

// zh_CNS.json
{
    "title": "歡迎來到 {{ title }}!",
    "tip": "這裏有一些按鈕可以切換應用的語言:"
}

然後在根模塊中引入必要的庫:

// 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提供的管道或指令進行處理:

<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可以與大家分享:

  1. ngx-translate的v10版本必須要在Angular6及以上的版本中使用,否則會報錯。
  2. 使用管道處理模板中的文本或許會優於使用指令,因爲使用管道可以在語言切換後立刻重新輸出對應語種的文本到模板中。

服務端渲染下的問題與解決方案

在爲項目加入多語言切換功能後,我本以爲難題已經解決,但在後續的開發與調試中我發現了一個奇怪的問題,在頁面初次加載時,總是會看到語言包的鍵被直接渲染在了頁面上的毛刺現象,雖然這種現象轉瞬即逝但也十分顯眼並且難以忍受。

在查看過頁面資源請求後我發現了問題所在。在頁面初次加載時,模板資源的請求要先於語言包文件的請求,所以在頁面在客戶端渲染時,語言包資源實際還沒就緒,因此在那一瞬間填寫在模板中的語言包鍵名便直接被渲染在了頁面中。至此我已經掌握了頁面加載出現這種毛刺現象的根本原因:頁面渲染時語言包資源未到位。

但轉念一想,我們的項目使用了服務端渲染技術,那麼頁面在服務端應該是已經進行過預渲染的,換句話說,頁面在服務端已經完成過一次:獲取語言包——渲染頁面這一流程纔對,那爲何在頁面首次加載時仍然會存在毛刺現象?是否頁面根本沒在服務端完成我們設想的渲染流程呢?帶着疑問我查看了客戶端獲取的頁面模板,果然,客戶端獲取的模板中充斥着原始的語言包鍵名,查看代碼後我發現應用語言的初始化相關操作都被限制在客戶端中運行,這樣的話相當於頁面在服務端渲染時並未將語言包的鍵名替換爲真正的文案文本。

在對代碼稍作調整後,我重新啓動了應用,這時候頁面加載時的效果較之前有了變化,我明顯看到了頁面在最開始的時刻是正常顯示的,但一瞬間後頁面中的文本變成了語言包的鍵名,片刻之後鍵名又再度恢復爲正常的文本,而客戶端獲取的模板文件中填充的分明是正常的文本,那爲何還會出現毛刺現象呢?經過一番瞭解後我得知,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則貼出了完整的代碼示例。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章