file

截至目前,BPS 一共內建了六種語言(英文、西班牙文、日文、越南文、簡體中文、正體中文)。考慮到「一直到現在 BPS 都是由我一個人獨自開發」這一點,能夠做到這種程度、我想應該是可以稍微感到自豪一下的。不過當然那也是因為現在這個年代的工具太發達太方便的緣故,使得我根本不用花太多力氣就能輕鬆獨自維護多國語系。本篇中我就來分享一下這部份的作法。

Vue I18n

任何多國語系的作法都是大同小異的:首先必須要將所有出現在應用程式當中的字句都個別賦予一個代碼,然後對於每一種打算支援的語言建立一個對應表、去描述每一個代碼各自對應於什麼樣的翻譯文字。在應用程式執行時,採用的多國語系框架每當看到畫面上出現了代碼,就會根據當前使用者選用的語系、來把對應表中的對應字句顯示在畫面之上。

由於 BPS 的 UI 採用的是 Vue.js 框架寫成的,所以要做到多國語系很理所當然地就會採用 Vue I18n 這個外掛。它的對應表是採用 JSON 格式寫成的,所以可以很方便地以物件導向的方式去管理所有的字句代碼;底下的是 BPS 的英文語系檔案的一部分,可以看得出來這種格式在分門別類之上相當好用:

{
    "name": "English",
    "flag": "usa",
    "since": 0,
    "welcome": {
        "title": "Welcome to Box Pleating Studio!",
        "intro": [
            "This app is made to help origami designers to make complex, crazy designs using box pleating and GOPS gadgets.",
            "To begin, click on the upper-left menu button to create an empty new project, or read our brief user manual on {0} (in English only)."
        ],
        "install": {
            "hint": "You can also install BP Studio as a standalone app to your device, and use it offline!",
            "ios": "How to install on iOS: Open this website in Safari, tap the \"Share\" icon at the bottom of\tyour screen, and then tap \"Add to home screen\".",
            "bt": "Install Box Pleating Studio App",
            "ing": "Installing app, please wait...",
            "ed": "BP Studio is already installed on your device.",
            "open": "Open Box Pleating Studio App"
        },
        "copyright": "Copyright © 2020{0} by Mu-Tsun Tsai"
    },
    ...
}

Vue I18n 的功能也相當完整,考慮到了各種實務上的需求(例如單數複數形翻譯、帶有參數的字句、在參數位置插入巢狀 HTML 元件等等),而且既然它是 Vue 的外掛,當然也可以讓使用者即時切換語系而不用重新整理畫面。它提供了兩種語法可以方便地插入翻譯字句;第一種方法是使用 v-t 指令:

<div v-t="'welcome.intro[0]'"></div>

注意到 v-t 指令的內容還要再加上一層引號,因為我們要傳遞的是代碼的字串給該指令。使用 v-t 指令的效能是比較好的,但缺點是它只能最簡單地做到把字句內容完全塞到標籤之中。如果需要比較大的自訂空間或者需要使用有參數的字句,那就需要用它替 Vue 實體注入的 $t 方法了:

<div>
    {{$t('welcome.copyright', ['-' + new Date().getFullYear()])}}
</div>

這樣寫就會把後面的陣列參數依序地丟到字句中 {0} 等等的位置上。字句參數也可以是物件的格式,此時 {} 裡面填入物件的屬性名稱即可,使用方式相當靈活。

再來最值得一提的就是可以在參數的位置塞入巢狀的 Vue 語法;辦法就是透過 Vue I18n 提供的 <i18n> 元件:

<i18n path="welcome.intro[1]" tag="p">
    <a target="_blank" rel="noopener" href="https://github.com/MuTsunTsai/box-pleating-studio">GitHub</a>
</i18n>

這樣寫的話,<i18n> 元件內部的 HTML 語法(當然也可以繼續使用 Vue 功能)就會被塞入 welcome.intro[1] 字句的 {0} 位置之中;若有更多參數則可以使用 slot 語法,這部份可以參考官方文件的說明。

i18n Ally

接下來絕對要介紹 BPS 多國語系背後的最大功臣:VS Code 的 i18n Ally 延伸模組,沒有這個模組的話我根本不可能有辦法自己維護那麼多語系的。這個模組支援了包括 Vue I18n 在內的許多多國語系框架,讓你可以直接在編輯器中預覽字句,並且提供自動的機器翻譯等許多好用功能。

file

如上圖所示,所有在原始碼當中有使用到字句代碼的地方都可以直接預覽各種語言的內容。

file

而最好用的當然就是自動翻譯功能了。一開始我必須先設定某一個語系為預設語系(在此為英文),而如果我有某個語系(例如日文)有一些對應的字句還沒有翻譯,我在側欄當中就會看到如上圖這樣顯示出未翻譯的項目,然後我只要按下右邊的「翻譯文案」按鈕就可以自動將那些項目填入 Google 的機器翻譯。等到翻譯完成之後,我只要找一位以該語言為母語的朋友幫我校稿一下、小修正一些機器翻譯不完美之處或專有名詞等等就行了;要找到願意幫我校閱機器翻譯的人,遠比要找一個願意幫我從頭開始翻譯的人要容易得太多了。當然,這也是需要平常就先建立一些人脈才行,不過總之這就是我有辦法維護六種語言的最大秘訣所在;不這麼做的話,別說六種了,即便是要翻成簡體中文我都沒有十成把握。

file

重構也是 i18n Ally 的一個好用功能;只要在項目上按右鍵並點選「重命名路徑」,就可以同時統一重新命名所有語系的對應項目之代碼、以及在專案原始碼當中的所有實際用例。當然,為了要讓這個功能能夠正確發揮作用,所有原始碼中的用例都應該要使用明文的方式去呈現代碼,而不宜讓代碼是作為運算出來的結果,例如像這樣的寫法就是不好的:

<div v-t="'welcome.intro[' + i + ']'"></div>

因為如此一來 i18n Ally 就無法把這個運算式跟字句代碼正確對應在一起,於是重構的時候就會遺漏這一個用例。另外一個也會受到這種運算式寫法影響的是它的使用情況報告功能:

file

如圖所示,產生出來的報告可以讓我們很清楚地知道語系檔案中的每一個項目被用在專案中的什麼地方,以及哪些項目沒有被使用到(隨著 UI 的改版,這種情況很可能會發生)、以便我們可以清理那些沒有用到的項目。這個報告功能要能正確運作的前提也是一樣,所有的代碼都必須明文呈現才行。

當然,為了要完全做到這一點,有的時候程式碼會出現一些不是很理想的耦合情況。舉例來說,假設我有一個不同於 UI 的核心 script 會在執行發生錯誤的時候丟出訊息字句的代碼,然後 UI 的部份接收到代碼了之後再去透過 $t 方法來將它的內容呈現在畫面上。然而,雖然我允許該 script 相依於字句代碼本身、但我卻不希望它相依於 Vue I18n 套件(這部份的相依性我希望只有 UI 相依於它就好)。此時我要怎麼做才能使得 i18n Ally 認得在該 script 裡面第一次出現字句代碼的時候就已經等於是一則該字句的用例呢?

我發現,當我指定 i18n Ally 對此專案的框架選項為 Vue 的時候,其中一種它辨識字句代碼的規則是任何形如 t('...') 這樣的字串(注意到前面沒有 $ 也無所謂),於是我可以在我的 script 當中宣告一個啞函數:

function t(code) { return code; }

然後每當我需要指定字句代碼的時候,我就可以寫 t('code') 這樣的形式,而 i18n Ally 就會正確地認得這是一則用例了。如果有更加自訂的需求,i18n Ally 也允許你完全自訂新的代碼識別規則。

初次使用的語言自動偵測

一個據我所知 Vue I18n 並沒有內建的功能就是「當使用者第一次造訪網站的時候、根據使用者的系統語言來自動切換語系」的功能,這個部份必須自己刻。這雖然好像有點不理想,不過其實也有其一定程度的道理在:畢竟要知道這是「第一次造訪」就必須藉由 cookie 或 localStorage 來記錄使用者行為,這部份的實作方式會隨著網站政策而有很大的差異,所以給人自己刻好像也比較合適。

以 BPS 來說,使用者的語系設定就是寫在 localStorage 裡面的的一個叫作 'locale' 的項目之上。如果啟動的時候發現 localStorage 裡面沒有這一項資料,那就當作這是使用者第一次使用。此時會根據 naviagtor.languages 來找出理論上最適合當前使用者的語系。如果發現 naviagtor.languages 裡面有超過一種語言是在支援的六種語系之中,那麼會順便跳出一個一次性的對話方塊請使用者確認要使用哪一種語言;而相對地如果使用者的偏好語言都不被支援,那就自動使用預設的英文語系。決定好了之後就將設定值第一次寫入到 localStorage 之中,並且在之後每次使用者變更設定的時候同樣儲存之。

另外,考慮到一個情境是「可能使用者慣用的語言從某個版本的 BPS 開始才加入、使用者可能會想要在應用程式更新的時候被告知」,在語系檔案裡面我有一個項目叫做 since,其值不是字句、而是一個代表建置版本的數值。應用程式啟動的時候會去比較看看、語系檔案裡面是否有任何一個的 since 值比上次更新時的建置版本更新,如果有而且那正好是使用者的系統所偏好的語言之一,那麼也會再次跳出彈跳方塊提示使用者看看是否要改選擇自己慣用的語系。

多國語系的 RWD

「RWD」是「Responsive Web Design」(反應式網頁設計)的縮寫,這邊的「反應式」通常只是指「對於不同的螢幕解析度做出反應」而言,但是在這邊,我想也可以同時順便指「對不同的語系作出反應」。這裡面主要的問題在於不同的語言的文字長度各有所不同,而可能會影響到部份 UI 的設計。舉例來說,BPS 的其中一個 UI 對正體中文來說會是長這樣:

file

可是對於西班牙文來說,因為對應的文字長度太長了,使得表單的第二個功能必須變成兩行才行:

file

仔細觀察這兩者的差異,我們會發現這看似簡單、但是裡面確有很多複雜的玄機。該功能一共有三個元件:一個「新增葉邊」的按鈕,一個「長度為」的文字標籤,以及一個數字輸入方塊。我希望做到的 RWD 如下:

  1. 如果三個元件可以同時塞下一排,那麼盡量讓數字輸入方塊的寬度填滿該列剩餘的寬度。
  2. 如果數字方塊已經縮小到了指定的最小寬度(此處為 90px)之後、仍然無法把三個元件塞在同一列,那麼就在按鈕跟文字標籤之間換列,並且讓數字方塊繼續維持最小寬度就好,同時在文字標籤的前面自動加上「…」以提示使用者這兩列是在講同一件事情。
  3. 我需要完全只用 CSS 做到這樣的變化,而且不能夠用到「當前的語系是什麼」的資訊(這個要求是考慮到維護性),只能根據文字本身的寬度來自動達到這種變化。

各位不妨思考看看如何才能做到?已知一列的總寬度固定為 230px。

好,底下是我的解法(部份採用了 BootStrap 5 的語法),給各位參考:

<!-- D1 -->
<div class="mt-3 d-flex" style="flex-wrap: wrap;">
    <button class="btn btn-primary flex-shrink-0" @click="..." v-t="'panel.vertex.addLeaf'"></button>
    <!-- D2 -->
    <div class="flex-grow-1 d-flex">
        <!-- L1 -->
        <label class="col-form-label ms-2 text-end"
            style="width: 0; flex-grow: 1000; max-width: calc((100% - 230px * 0.98) * 50); overflow: hidden;">
            ...&‎nbsp;
        </label>
        <!-- D3 -->
        <div class="flex-grow-1 d-flex">
            <!-- L2 -->
            <label class="col-form-label me-2 flex-shrink-0" v-t="'panel.vertex.ofLength'"></label>
            <!-- D4 -->
            <div class="flex-grow-1" style="width: 90px;">
                <number v-model="newLength" :min="1"></number>
            </div>
        </div>
    </div>
</div>

這當然有點複雜,所以我稍微來解釋其運作原理。最內層的 <number> 元件是我自訂的數字輸入方塊元件,各位只要知道它永遠是 width: 100% 即可,所以真正決定它大小的是它的上層 <div>(即標註為 D4 的那一個)。我的 <label> 元件有兩個,一個是負責產生「...」文字的 L1,另外一個則是原本的文字標籤 L2。

注意到一開始我指定了 L1 以及 D4 分別設定了寬度為 090px,這是為了讓排版引擎有一個基準可以去判斷 D2 是否能夠塞入 D1 扣除掉按鈕之後剩下的空間,如果不行的話,因為 D1 設置了 flex-wrap: wrap,D2 就會換到下一列去;而無論是否可以,D2 都會佔據滿可用的寬度(因為它有 flex-grow-1)。

接著關鍵就在於 L1 的 style 設定。它設定了 flex-grow: 1000,這遠大於 D3 的 flex-grow-1,所以它會比起 D3 要絕對優先地佔據掉可用的空間。但是同時,它設定了 max-width: calc((100% - 230px * 0.98) * 50),而仔細閱讀這一個算式,會發現它的意思基本上就是說「如果 D2 的寬度達到 230px,那麼 L1 的最大寬度就是 230px,否則自身的最大寬度就是 0」1,再搭配上 overflow: hidden,這就等於是說「如果 D2 的寬度達到全寬、那 L1 就盡可能佔據可用寬度,否則就把 L1 隱藏起來」的意思。

如果 L1 被隱藏了,那麼 D3 的 flex-grow-1 就會發揮作用,使得 D3 佔據 D2 的全部空間,並且因為 D4 相對於 L2 也是 flex-grow-1,所以 D3 內部的空間會優先給 D4 使用。

我個人覺得這算是滿好的一道 CSS 習題,所以分享給大家,希望或許能幫上一些有類似需求的人。當然,這個解法有一個弱點在於它有個前提是 D1 的總寬度是固定的(230px);如果 D1 的寬度是不固定的、即便仍然可能採用類似的解法,肯定也會變得更加複雜。這部份如果各位有更好的解答,非常歡迎指教。


  1. 當然精確來說還有大約 2% 的機動範圍存在,但是由於按鈕的寬度怎樣也會超過 2%,所以 D2 的寬度不可能介於 98% 到 100% 之間,一定要嘛小於 98%,要嘛就是等於 100%。 


分享此頁至:
最後修改日期: 2021/07/03

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。