特别声明,本文由 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则贴出了完整的代码示例。