2019年7月3日

[筆記] 使用 react-intl 在 React 實作多語系功能 i18n, internationalization

Photo by Aaron Burden on Unsplash

keywords: i18n, Internationalization, intl, format
這篇文章主要說明如何透過 React Intl 來實作網頁上的 i18n,先備知識必須要已經基本會使用 React。
⚠️ 文章使用的是 react-intl @ 2.9,部分功能在 3.0 後不再支援(文章最下方會說明)。

開始使用 react-intl

我們直接用 create-react-app 的頁面來做練習:
create-react-app

建立 React 專案

直接透過 create-react-app 建立 React 專案:
$ create-react-app react-intl-sandbox
$ cd react-intl-sandbox

安裝 react-intl

$ npm install react-intl
接著就可以透過 npm run start 啟動專案。

使用 IntlProvider

IntlProvider @ react-intl components
<IntlProvider> 包在最外層,讓所有內部 component 都可以存取到它提供的屬性與方法。這裡方便操作,把它多包成一個名為 Root 的 functional component:
imgur

取得使用者語系並帶入 IntlProvider

接著:
  • 透過 navigator.language 取得使用者在瀏覽器上所使用的語系,並且將它代入 IntlProvider 的 local 屬性中
  • 為了讓語系變動後可以重新渲染 React 組件,因此也將取得的語系帶入 key 屬性中(參考:dynamic language selection
  • 若有需要可以定義 defaultLocale 屬性
react-intl
這時候打開瀏覽器會看到錯誤訊息:
react-intl
表示它載不到預設的地區資料。

載入需要的語系資料

在 react-intl 中已經定義好許多地區的語系資料,這些資料會定義某個語系是否有其父子語系的關係,例如 zh-TW 是在 zh-Hant 底下,而 zh-Hant 又在 zh 底下這種關係。除此之外,裡面還定義了關於「時間」、「複數型態」時要怎麼呈現的資訊。
關於各地區的語系,最標準的可以參考 BCP 47 所定義的 locale code 規格文件。
react-intl 所定義好的語系檔都在安裝好的套件內,路徑為 node_modules/react-intl/locale-data/,裡面列出了各語系的定義:
react-intl
例如說這裡我們可以載入英文、中文和日文的語系資料,並且透過 addLocaleData 將語系檔載入:
react-intl
⚠️ 在 react-intl 3 之後不在支援 addLocaleData 的方法,而是直接使用原生的 Intl API,關於使用方式可以參考 Migrate to using native Intl APIs

定義多語系字典檔

做好上述設定後,就可以來定義多語系用的字典檔。假設我們要做 i18n 的內容是範例頁面中的「Learn React」這個部分:
create-react-app
這時候就可以先定義這個字典檔。首先在 ./src 資料夾內再新增一個 i18n 的資料夾,裡面分別放入 en.js, zh.js, ja.js
react-intl
接著分別定義每支字典檔的內容,這裡的 app.learn 可以視為 id,之後的使用 react-intl 提供的方法或組件時,他會根據你提供的這個 id 來帶入不同的內容,而 { } 刮起來的 name 表示它是動態的變數:
// ./src/i18n/en.js
const en = { 'app.learn': 'Learn {name}' };
export default en;
// ./src/i18n/zh.js
const zh_TW = { 'app.learn': '學習 {name}' };
export default zh_TW;
// ./src/i18n/ja.js
const ja_JP = { 'app.learn': '学び {name}' };
export default ja_JP;

撰寫切換語言的功能

因為一開始使用的是 functional component,想要添加狀態(state)的話,可以匯入 useState 近來用:
// ./src/index.js
import React, { useState } from 'react';

// ...
const Root = () => {
  // 使用 useState 定義 locale 這個 state
  const [locale, setLocale] = useState(navigator.language);

  return (
    <IntlProvider
      locale={locale}
      key={locale}
      defaultLocale="en"
    >
      <!-- 將 setLocale 的方法傳到 App 內 -->
      <App setLocale={setLocale} />
    </IntlProvider>
  );
};
在 App.js 的地方,撰寫三個按鈕可以來切換 locale 的狀態:
// ./src/App.js
import React from 'react';
import logo from './logo.svg';
import './App.css';

// 從 props 取得從 index 傳入的 setLocale 方法
function App({ setLocale }) {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <div>
          <!-- 建立三顆按鈕可以切換 local 的狀態 -->
          <button onClick={() => setLocale('en')}>英文</button>
          <button onClick={() => setLocale('zh-Hant')}>中文</button>
          <button onClick={() => setLocale('ja')}>日文</button>
        </div>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;
這時候的畫面會像這樣:
react-intl

文字訊息多語系

這裡說明的是最一般用來轉換不同語系字串的作法,react-intl 提供了:

根據不同的語言傳入不同的語系檔

可以切換 locale 的狀態後,最後就可以根據使用者選擇的語言傳入不同的語系檔。
先將剛剛撰寫好的語系檔載入:
// ./src/index.js
// ...
import en from './i18n/en.js';
import zh from './i18n/zh.js';
import ja from './i18n/ja.js';
接著定義 messages 這個變數,透過 if 判斷使用者切換到的語言為何,最後將 messages 傳入 <IntlProvider>
// ./src/index.js
import React, { useState } from 'react';
// ...

import en from './i18n/en.js';
import zh from './i18n/zh.js';
import ja from './i18n/ja.js';

addLocaleData([...zhLocaleData, ...enLocaleData, ...jaLocaleData]);

const Root = () => {
  const [locale, setLocale] = useState(navigator.language);
  let messages;

  // 根據使用者選擇的語系 locale 切換使用不同的 messages
  if (locale.includes('zh')) {
    messages = zh;
  } else if (locale.includes('ja')) {
    messages = ja;
  } else {
    messages = en;
  }

  return (
    <!-- 將 messages 傳入 IntlProvider -->
    <IntlProvider
      locale={locale}
      key={locale}
      defaultLocale="en"
      messages={messages}
    >
      <App setLocale={setLocale} />
    </IntlProvider>
  );
};

ReactDOM.render(<Root />, document.getElementById('root'));

在組件中使用語系檔

index.js 中,使用者在切換語言後,我們會提供不同的語系檔到 <IntlProvider>messages 屬性內。現在,在 App.js 中就可以使用 react-intl 提供的 <FormattedMessage> 方法來顯示。
<FormattedMessage /> 中,id 會對應到先前寫在 ./src/i18n/ 資料夾內的語系檔中定義的屬性 app.learn,而 values 是因為在語系檔中有定義
// ./src/App.js
// ...
// 匯入 FormattedMessage 組件
import { FormattedMessage } from 'react-intl';

function App({ setLocale }) {
  return (
    <div className="App">
      <header className="App-header">
        <!-- ... -->
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          <!-- 使用 FormattedMessage -->
          <FormattedMessage id="app.learn" values={{ name: 'React' }} />
        </a>
      </header>
    </div>
  );
}
這時候就完成了簡單的 i18n 語系替換:
react-intl

時間日期多語系

在 react-intl 中也提供了時間、日期的多語系功能,包含:
import {
  FormattedDate,
  FormattedTime,
} from 'react-intl';

function App({ setLocale }) {
  return (
    <div className="App">
      <header className="App-header">
        <!-- ... -->
        <br />
        <!-- 使用多語系的日期格式 -->
        <FormattedDate
          value={new Date()}
          year="numeric"
          month="long"
          day="numeric"
          weekday="long"
        />
        <!-- 使用多語系的時間格式 -->
        <FormattedTime value={new Date()} />
      </header>
    </div>
  );
}
react-intl
完成的結果如下:
react-intl
如果有需要也可以使用 <FormattedRelativeTime>,這個方法會回傳距今的時間:
// ./src/App.js
// ⚠️ 在 2.x 版時使用的是 FormattedRelative
import {
  FormattedRelative,
} from 'react-intl';

function App({ setLocale }) {
  return (
    <div className="App">
      <header className="App-header">
        <!-- ... -->
        <br />
        <!-- ... -->
        <FormattedRelative value={new Date() - 60 * 10}/>
      </header>
    </div>
  );
}

⚠️ 在 v2.x 前使用的是 <FormattedRelative>
顯示的結果如下:
react-intl

數值多語系

若有需要針對數值進行多語系的切換,react-intl 則提供了:

更新到最新 3.x beta 版

務必參考 Upgrade Guide @ react-intl
安裝當前最新版本(非穩定版):
# 撰寫本文時最新的版本為 v3.0.0-beta-11
$ npm install react-intl@next
注意事項:
  • <FormattedRelative> 改為 <FormattedRelativeTime>,且 value 的使用方式不同,改成是用「差距(delta)」,參考 FormattedRelativeTime
  • 改用瀏覽器原生的語系資料,移除 addLocaleData 的方法,參考 Migrate to using native Intl APIs
因此,我們不需要在 addLocaleData
react-intl

2019年7月2日

[筆記] WebRTC 網路影音 -實作篇(demo of media, video, audio)

Photo by freestocks.org on Unsplash
以下內容完全為整理自 30天之即時網路影音開發攻略(小白本) by 我是小馬克 @ iThome 的筆記,無原創內容。

建立串流點播工具

將影音檔轉成 HLS 用的串流檔

透過 ffmpeg 即可將影音檔(例如,mp4)轉成 .m3u8.ts 適用於 HLS 的串流檔。
在 Node.js 中可以使用 fluent-ffmpeg 這個工具進行轉換:
// ./ffmpeg-helper.js
// 把 mp4 檔轉換成 .m3u8 索引檔和多支 .ts 檔
const ffmpeg = require('fluent-ffmpeg');

module.exports = {
  convertToHls: async (file) => {
    return new Promise((resolve) => {
      ffmpeg(file, { timeout: 432000 })
        .addOptions([
          '-profile:v baseline', // for H264 video codec
          '-level 3.0',
          '-s 640x360', // 640px width, 360px height
          '-start_number 0', // start the first .ts segment at index 0
          '-hls_time 10', // 10 second segment duration
          '-hls_list_size 0', // Maximum number of playlist entries
          '-f hls', // HLS format
        ])
        .output('./source-m3u8/output.m3u8')
        .on('end', () => {
          console.log('finish');
          resolve();
        })
        .run();
    });
  },
};
並且建立一支 convert.js 來執行:
// ./convert.js
const ffmpegHelper = require('./ffmpeg-helper');

(async () => {
   await ffmpegHelper.convertToHls('./source.mp4');
})();
轉換好的檔案會被放置到 source-m3u8 的資料夾內:
imgur

建立支援 HLS 的 Server

要建立 HLS 的 Node 伺服器可以使用套件 hls-server
// index.js
const HLSServer = require('hls-server');
const http = require('http');

const hls = new HLSServer(server, {
  path: '/streams', // Base URI to output HLS streams
  dir: 'source-m3u8', // Directory that input files are stored
});

server.listen(8000);

使用 ffplay 播放串流檔

$ node index.js    # 於專案資料夾下執行
$ ffplay http://localhost:8000/streams/output.m3u8
如此即可開始播放:
imgur

在瀏覽器進行播放

若要在瀏覽器進行播放,一樣需要額外的套件,這裡用的是 hls.js
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta http-equiv="X-UA-Compatible" content="ie=edge" />
  <title>HLS Client</title>
  <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>

<body>
  <div id="app">
    <video controls id="video"></video>

    <input type="text" />
    <button type="button" id="load">Load</button>
  </div>

  <script>
  const video = document.getElementById('video');
  const button = document.querySelector("#load");

  if (Hls.isSupported()) {
    var hls = new Hls();
    hls.attachMedia(video);
    hls.on(Hls.Events.MANIFEST_PARSED, function () {
      video.play();
    });
    button.addEventListener("click", function () {
      hls.loadSource(document.querySelector("input").value);
    })
  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.addEventListener('canplay', function () {
      video.play();
    });
    button.addEventListener("click", function () {
      video.src = document.querySelector("input").value;
    })
  }

  </script>
</body>

</html>
Server 的部分做一些修改,讓它可以 serve 這支 index.html
// index.js
const HLSServer = require('hls-server');
const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/html');
  const html = fs.readFileSync('index.html', 'utf8');
  res.write(html);
  res.end();
});

const hls = new HLSServer(server, {
  path: '/streams', // Base URI to output HLS streams
  dir: 'source-m3u8', // Directory that input files are stored
});

server.listen(8000);
完成後,順利的話只要在表單中輸入 http://localhost:8000/streams/output.m3u8 就可以在網頁上載到影片:
imgur

建立直播工具

直播的部分會分成「推流」和「拉流」兩個部分:
imgur
  • 在 server 的部分,這裡會使用到 Node-Media-Server 這個套件來接收從直撥主送過來的推流,並提供 .flv 檔給 Client。
  • 在 client 的部分,會使用 bilibili 提供的 flv.js 來拉流。
要建立直播,首先要把 media server 給 on 起來:
$ node app.js
接著透過 ffmpeg 假裝是直播主透過 RTMP 協定進行推流:
$ ffmpeg -re -i source.mp4 -c copy -f flv rtmp://localhost/live/mark
這時候 server 就可以收到直播主的推流,client 的部分則可以使用 flv.js 來拉流:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Live Streaming</title>
    <script src="https://cdn.bootcss.com/flv.js/1.4.2/flv.min.js"></script>
  </head>

  <body>
    <video controls id="video" width="100%"></video>

    <input type="text" />
    <button type="button" id="load">Load</button>
    <script>
      const button = document.querySelector('#load');
      if (flvjs.isSupported()) {
        button.addEventListener('click', function() {
          const video = document.getElementById('video');
          const target = document.querySelector('input').value;
          const flvPlayer = flvjs.createPlayer({
            type: 'flv',
            url: `http://localhost:8000/live/${target}.flv`,
          });
          flvPlayer.attachMediaElement(video);
          flvPlayer.load();
          flvPlayer.play();
        });
      }
    </script>
  </body>
</html>

影音串流伺服器常見問題

作為一個影音串流伺服器,常見的問題包含「伺服器連線數限制」、「流量頻寬問題」、「效能消耗問題」、「遠距離封包遺失問題」。
透過 Load Balance 可以解決「伺服器連線數限制」和「效能消耗問題」;而「流量頻寬問題」和「遠距離封包遺失問題」則可以透過 CDN 解決。
沒有 CDN 的協助基本上是很難完成影音串流伺服器的。

Content Delivery Network (CDN)

透過內容交付網路(CDN, Content Delivery Network),即使伺服器架設在美國,而使用者人在台灣,依然可以用更短的時間取得這些資源。
CDN 主要可以分成三個部分:
  • 智能 DNS(Intelligent DNS):告訴 client 最近的 Edge CDN 位址在哪
  • 邊緣 CDN(Edge CDN):散佈在世界各地的 CDN 節點
  • 來源伺服器(Origin Server):原始資料存放的位址
imgur
關於如何在 AWS 上設定 CDN 可以參考 30-23之 CDN 的說話島 ( AWS CloudFront CDN 實作 ) by 我是小馬克 @ iThome

WebRTC 與 SDP

透過 WebRTC 可以讓瀏覽器不需要任何外掛的情況下直接進行 P2P 的溝通,因此這裡示範如何用 WebRTC 建立 P2P 的影音連線。
雖然 WebRTC 可以讓使用者直接透過瀏覽器進行 P2P 連線,但在雙方建立連線前仍需要有一個伺服器讓他們知道雙方的位址,才能進行溝通,這個伺服器稱作 Signaling Server,這並沒有規範在 WebRTC 內,可以使用 HTTP 輪詢(一直打 request 詢問)或 websocket 的方式實作。這裡會使用到 peer.js 這個套件來實作。
當雙方還不認識彼此時,需要透過傳送自己的 SDP( Session Description Protocol,會話描述協議) 到 Signaling Server 來認識彼此,SDP 中會包含有發送者的地址、媒體類型、傳輸協議、媒體格式。
WebRTC 加上 SDP 後的流程如下:
  1. 用戶 A 向 Server 發送進行會話,內容包含 A 的 SDP。
  2. Server 將會話 SDP A 請求發送給用戶 B。
  3. 用戶 B 向 Server 進行應答,並回應 B 的 SDP。
  4. Server 向用戶 A 發送 用戶 B 的 SDP。
  5. A、B 雙方使用 SDP 開始建立連線。
imgur

影音串流在 P2P 上的困難與解法

當透過 P2P 的方式進行影音傳輸,因此當使用者有使用「網路位址轉換(NAT, Network Address Translation)」或防火牆時,會沒辦法直接找到目標電腦的位址。
在 WebRTC 則使用了 ICE (Interactive Connectivity Establishment) 這個框架來解決這個問題,ICE 整合了 STUN (Session Traversal Utilities for NAT)TURN (Traversal Using Relay NAT ) 兩個協定,讓 NAT 內的用戶一樣可以找到它的位置。

參考資源