小谈Angular SSR项目的国际化

Fortnight
特别声明,本文由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则贴出了完整的代码示例。
阅读 3.2k
35 声望
1 粉丝
0 条评论
35 声望
1 粉丝
文章目录
宣传栏