Multilingual ใน Angular อีกที

ได้มีโอกาสมาลองเรื่องการทำ multilingual application บน angular อีกที ก็เจอว่า ของเก่าที่เคยทำไว้ มันทำไว้ตั้งแต่ angular 5 เลยเอามาลองปัดฝุ่นดูอีกที

กลับไปย้อนดูของเดิม เจอว่าตอนนั้นเขียนไว้ด้วย impure pipe ซึ่งจะมีการอัพเดทค่าอัตโนมัติทุกครั้งเมื่อเกิด event อะไรก็ตามบน app เช่น mouse move, click ฯลฯ นั่นคือมันจะไปทำให้เกิด change detection ตลอดเวลา ถ้าเรามี activity เยอะ ๆ บน app แน่นอนว่ามันจะส่งผลกระทบกับ user experience แน่ ๆ ข้อมูลนี้ทาง angular เองก็บอกไว้ใน document เช่นกัน

ก็เลยลองหาวิธีอื่นดูว่าจะทำยังไงได้บ้าง เพื่อให้สามารถเปลี่ยนภาษาได้ เมื่อค่าภาษาที่เลือกใน service มันเปลี่ยนไป เช่น ถ้าจะไม่ใช้ impure ได้หรือเปล่า ก็เจอว่า ถ้าจะใช้เป็น pure pipe มันจะอัพเดทค่าเฉพาะตอนที่ input มีการเปลี่ยนแปลงเท่านั้น ดังนั้นเราจะต้องส่งอะไรบางอย่างที่มันเปลี่ยนเข้ามาใน pipe ด้วย เช่น {{ 'hello' | translate: lang }} แต่วิธีนี้มันไม่สวย เพราะเราต้องไปดึง lang มาในทุก ๆ component มาเป็น member ของ component

หรือลองใช้ observable มาช่วย detect ว่าภาษามันเปลี่ยน แล้วลองให้มันเรียก translate อีกที ก็เจอว่า เพราะว่ามันเป็น pure pipe มันจะทำครั้งเดียว แล้วไม่อัพเดทอีกเลย จนกว่า

ไปหาเพิ่มมาก็เจอว่ามีคนแนะนำให้ใช้เป็น directive แทน เลยเอามาลองทำเพิ่มเข้าไปดู ได้วิธีใช้ออกมาแบบนี้ <translate>hello</translate> หรือ <span translate>hello</span> เออ ดูโอเคดีเหมือนกัน

Translation Table

เริ่มจาก translation table อยู่ใน directory ./app/i18n เหมือนเดิม แต่ปรับหน้าตาใหม่หน่อยให้มันอ่านเข้าใจง่ายขึ้น

./app/i18n/en.ts

export const En = {
  'hello': 'Hello',
  'what_is_your_name': 'What is your name?'
};

./app/i18n/th.ts

export const Th = {
  'hello': 'สวัสดี',
  'what_is_your_name': 'คุณชื่ออะไร?'
};

./app/i18n/translation-table.ts

import { En } from './en';
import { Th } from './th';

export interface Dictionary { [lang: string]: { [key: string]: string } }
export const TranslationTable: Dictionary = {
  en : En,
  th: Th
};

จบขั้นตอนนี้เรามี Dictionary ที่เป็นจะเป็น type ของ entry ใน dictionary และ TranslationTable จะเป็น dictionary ที่มี content ของทุกภาษาแล้ว

การเพิ่มเติมชุดคำแปล สามารถเข้าไปเพิ่มใน ./app/i18n/en.ts และ ./app/i18n/th.ts ได้เลย ส่วนการเพิ่มภาษาใหม่ ก็สามารถเพิ่มไฟล์ใหม่ลงใน directory ./app/i18n ได้

Service

Service ตัวนี้จะต่างจากตัวเดิมเล็กน้อย คือ นอกจากจะทำหน้าที่ translate ตรงไปตรงมาเหมือนเดิมแล้ว ยังมี method ที่คอยบอกว่ามีการเปลี่ยนภาษาเกิดขึ้นเมื่อไหร่ด้วย

import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
import { TranslationTable, Dictionary } from '../i18n/translation-table';
import { Observable, Subject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class TranslationService {
  private currentLanguage: string;
  private translationTable: Dictionary​​;
  private subject: Subject<any>;

  constructor() {
    this.subject = new Subject<void>();
    this.translationTable = TranslationTable;
    this.setLanguage(environment.default.language);
  }

  setLanguage(lang: string) {
    if (this.currentLanguage !== lang) {
      this.currentLanguage = lang;
      this.subject.next();
    }
  }

  getLanguage(): string {
    return this.currentLanguage;
  }

  setTranslationTable(table: Dictionary) {
    this.translationTable = table;
  }

  translate(key: string): string {
    if (!this.translationTable.hasOwnProperty(this.currentLanguage)
    || !this.translationTable[this.currentLanguage].hasOwnProperty(key)) {
      return key;
    }
    return this.translationTable[this.currentLanguage][key];
  }

  translationChanged(): Observable<void> {
    return this.subject.asObservable();
  }
}

การดักว่ามีการเปลี่ยนแปลงภาษาเกิดขึ้น ใช้ผ่าน translationChanged() ที่ return observable กลับมาเมื่อมีการเปลี่ยนแปลงภาษาเกิดขึ้น ดังนั้นเราสามารถ subscribe กับ observable นี้เพื่อดักการเปลี่ยนภาษาได้เลย

Pipe

ใน pipe ยังใช้ impure pipe เหมือนเดิม ดังนั้นทุกอย่างเลยไม่ได้มีอะไรเปลี่ยนแปลง

import { Pipe, PipeTransform } from '@angular/core';
import { TranslationService } from '../services/translation.service';

@Pipe({
  name: 'translate',
  pure: false
})
export class TranslatePipe implements PipeTransform {

  constructor(private translator: TranslationService) {
  }

  transform(text: string): string {
    return this.translator.translate(text);
  }

}

Directive

ใน directive จะเอาไปผูกกับ element ด้วย tag name และ attribute ที่ชื่อ translate

import { Directive, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { TranslationService } from '../services/translation.service';

@Directive({
  selector: 'translate, [translate]'
})
export class TranslateDirective implements OnInit, OnDestroy {
  private translationChanged$: Subscription;
  private key: string;

  constructor(private el: ElementRef,
    private translator: TranslationService) { }

  ngOnInit(): void {
    this.key = this.el.nativeElement.innerText;
    this.el.nativeElement.innerText = this.translator.translate(this.key);
    this.translationChanged$ = this.translator.translationChanged().subscribe(_ => {
      this.el.nativeElement.innerText = this.translator.translate(this.key);
    })
  }

  ngOnDestroy(): void {
    if (this.translationChanged$) {
      this.translationChanged$.unsubscribe();
    }
  }

}

จะเห็นได้ว่า directive จะทำการ subscribe ไปกับ translationChanged() เพื่อเช็คว่าเกิดการเปลี่ยนแปลงขึ้นหรือไม่ ถ้าเปลี่ยนแปลง ก็จะทำการแปลใหม่อีกที

โค้ดตัวอย่าง

เข้าไปดูโค้ดตัวอย่างและ demo ได้ที่ github

Leave a Reply

Your email address will not be published. Required fields are marked *