🎋 竹子日誌

i18n 難的從來不是翻譯,是沒有人擁有那份語系檔

每次講到多語系,大家的第一反應都是「就裝個 i18n 套件,把字串抽出來包成 key,然後丟給翻譯就好了嘛」。

技術上確實是這樣沒錯。

但你只要把團隊規模乘上五倍、語系乘上十倍、專案跑上三到五年,你就會發現這件事從來就不是技術問題。

語系檔最後會變成公司走廊上那張公佈欄:每個人都往上貼,沒有人負責撕。

我大概兩年前寫過一篇最誇張的多語系,那個專案 28 種語系,最要命的是還分成 24 個子 Repo,我當時只覺得噁心。現在回頭看,可怕的其實不是 28 這個數字,是那 28 份檔案背後沒有任何人負責。在 AI Agent 還不那麼好用的年代,改一個詞就是一個下午。

——

多語系的痛,是一層一層往下挖的

先給一張全景,後面五節就照這個順序走。

多語系問題的五層結構,從命名、擁有權、流程、在地化到組織政治,越往下工程能自己解決的比例越低 淺 深 ① ② ③ ④ ⑤ 命名:key 怎麼取 擁有權:這個字是誰的 流程:翻譯跟不上 sprint 在地化:複數、長度、語序 政治:誰決定、誰執行、誰買單 開會、上個 lint 就壓得下來 共用要保守,key 要有 owner 功能上線與語系上線解耦 每個語系要有當地 reviewer 要有人被指定,而不是順便
第一層開個會、上個 lint 就壓得下來,第五層要動到四個部門的工作習慣。越往下挖,工程能自己解決的比例越低。

——

第一層:key 怎麼取,其實是誰來取

最淺的問題,是 key 要怎麼取。

有人寫 login.button.submit,有人寫 btn_login,有人直接 t('登入') 拿中文當 key,也有人 t('Login') 拿英文當 key。一個人一種流派,幾個人就幾種,全部進到同一個 zh-TW.json。

然後某天你要找「登入」這兩個字在哪裡,全域搜尋出來十四個 key,你不知道哪一個是活的。

直接拿一個語系的值當 key 這件事特別值得講。它一開始爽到不行,不用命名、不用查表、看 code 就知道畫面長怎樣,PM 也看得懂。

但它有兩個必爆的地雷。

第一,文案一改,key 就要跟著改,所有語系檔全部要跟著搬。你只是把「登入」改成「立即登入」,結果 28 份檔案一起動。

第二,同一個詞在不同語境會翻成不同的字。中文的「確定」,在英文可能是 OK、Confirm、Submit、Done。你拿中文當 key,等於在原文層就把語境壓扁了,後面救不回來。

那 nested 還是 flat?其實都可以活,重點不在深度,在切法。

我看過最多人犯的錯,是照「畫面」切 namespace。checkout.step2.dialog.title 這種,看起來很整齊,但畫面會改版,改版之後這棵樹就整個歪掉,然後沒有人願意去搬。

也不少人照前端的檔案結構命名。可是要溝通的時候,PM 看不懂、不是負責那個模組的前端也看不懂,最後只好把原本開發的人拖回來問。檔案一搬遷,名字又要大汰換,動一下改一堆。

比較能撐的切法,是照「誰會改它」切。namespace 的邊界應該對齊團隊邊界、對齊功能的擁有者,UI 結構只拿來輔助命名。

因為語系檔最後就是一個共同編輯的檔案,而共同編輯的檔案,唯一能救它的就是邊界清楚。

這個階段的痛還算便宜,開個會、寫個規範、上個 lint,大概就壓得下來。

——

第二層:多語系的共用,比程式碼的共用更危險

再往下一層,問題變成「這個詞是誰的」。

A 團隊改了 common.confirm,因為他們那頁的語境要更禮貌一點。結果 B 團隊的刪除確認框跟著一起變得很客氣,使用者按下去就把資料刪光了。

common 這個 namespace,是多語系裡面最危險的地方。它看起來很 DRY,實際上是一個沒有主人的公共廁所。

它還會自己長大。一開始只放「確定」「取消」,半年後裡面出現 common.dialog.button.confirm.1,你就知道完蛋了。

我的看法是,i18n 的共用要比程式碼的共用保守很多。

程式碼抽共用,錯了頂多重構;文案抽共用,錯了是使用者直接看到怪東西,而且不會有 compile error 提醒你。

字串重複三次,其實沒關係。真的沒關係。那點重複的成本,遠比一個共用詞誤傷三個團隊便宜。最不需要為了共用而共用的就是 i18n key,能不共用就不要共用。

再來是孤兒 key。

功能下架了,但沒人敢刪那幾行,因為不確定還有沒有人在用。三年後,en.json 裡面有四成是死的,但沒有人有膽子動它。

也許有人會說,這件事可以掃描啊,跑個 static analysis 把沒被引用的 key 撈出來就好了嘛。

問題是動態組 key。

t(`order.status.${statusCode}`)

只要專案裡有這種寫法,你的靜態掃描就直接失效,而且是全域失效。你永遠不知道 order.status.PENDING_REFUND 是不是還有人在用。

這是我心中多語系的天坑。它讓「刪除」這個動作永遠帶著風險,於是所有人選擇不刪,於是檔案只會長不會縮。

真的要治,只有兩條路:要嘛禁掉動態 key,要嘛強制動態的部分集中在一個地方、明確列出所有可能值。

還有一個很少人做、但我覺得很值得的東西:key 的生命週期。

新增要有人 review,廢棄要標記而不是直接砍,標記之後放一個 release 的寬限期,過了再刪。跟 API 的 deprecation 是同一套思路。

只是大家都覺得「不過就是文字嘛」,所以沒人這樣做。

——

第三層:工程的節奏跟翻譯的節奏對不上

這一層開始真的是團隊的苦痛。

工程的節奏是 sprint,兩週一次;翻譯社的節奏是報價、排程、交件,來回可能兩週。設計還在調文案,PM 還在跟法務確認用詞。

於是你會遇到經典場景:功能上線了,但 ja.json 裡面那三個 key 還是英文。

然後就開始有人在 code 裡面寫 fallback,寫著寫著整個專案就變成「中文一定對、英文大概對、其他語系隨緣」。

這裡有一個很多團隊沒想清楚的決策:翻譯缺漏的時候,到底要顯示什麼?

顯示 key?使用者會看到 order.status.pending,很醜,但至少大家一眼就知道壞了。

fallback 到英文?畫面看起來正常,但問題會被藏起來,藏到某天客戶截圖來罵人才被發現。

我自己傾向 production fallback 到英文,但 CI 要擋、staging 要顯示成醒目的樣子。讓問題在使用者看到之前就吵一次。

再往上一步,是 string freeze。

這在遊戲業和大型軟體公司是標準動作:某個時間點之後文案不准改,送翻譯,翻完回來驗,然後才發版。

但現在大家都在跑持續交付,你要一個團隊「這週開始文案凍結」,會被當成瘋子。

所以更實際的做法,是把「功能上線」跟「語系上線」解耦。功能先用 feature flag 藏著,語系到齊了才對那個地區開。

這樣工程節奏跟翻譯節奏就不用硬綁在一起,各自跑各自的。

還有一個成本超低但幾乎沒人做的事:pseudo-localization。

就是在開發階段把所有字串自動變成 [!!! Ĺöĝĩñ !!!] 這種——加長三成、換成帶重音的字元、前後加標記。

你會在第一天就發現哪個按鈕的寬度是寫死的、哪段文字沒有被 t() 包到、哪個版面德文一定爆。

不用等到翻譯回來,不用等到當地同事回報。

真的很便宜,而且真的很有效。

最後是 source of truth。

到底是 Figma 上的文案?是 Google Sheet?是 repo 裡的 JSON?還是翻譯平台?

我看過一個團隊,這四個地方全部都有一份,而且四份都不一樣。🙄

這種狀況沒有工具能救,因為問題不是同步,是根本沒有人被授權說「以我這份為準」。

只能有一個地方是真的,其他都只能是它的投影。這句話講起來很簡單,做起來要動到四個部門的工作習慣。

——

第四層:在地化不等於翻譯

到這一層就不是工程能自己解決的了。

日期格式、貨幣、數字的千分位、姓名的順序、敬語階級、複數規則、文字長度、RTL 的版面、法規要求的揭露文字⋯⋯

先講複數。英文兩種形態(one / other),你以為包一下就好,結果阿拉伯文有六種,俄文有四種,日文一種。

這不是「加個 if」能解的,這是 CLDR 定義的語言學規則。你的 i18n 套件有沒有正確支援,決定了你要不要在半年後大改一次。

再講長度。同一句話,德文大概比英文長 30%,芬蘭文還會再長,中日韓則短很多。

同一顆按鈕在中文、英文、德文下的文字寬度對照,德文超出照中文刻死的固定寬度 中文 English Deutsch 登入 Log in Anmelden 虛線是照中文刻死的寬度,德文就從這裡溢出去
按鈕寬度如果是照中文刻的,德文上線那天,你會在同一頁看到好幾顆按鈕的字被切掉。

再講語序。{name} 已將 {count} 個項目移到 {folder} 這種句子,在有些語言裡面順序會完全不一樣,而且會因為 count 是 1 還是 5 而改變動詞形態。

所以字串串接絕對不行。你只要在程式碼裡面寫 t('moved') + count + t('items') 這類組合,想靠重複利用來少管幾個 key,就等於保證某些語言永遠翻不好。

然後是那些工程完全無權決定的部分。

行銷會說「這句話在馬來西亞不能這樣講」,業務會說「這個客戶要求把這個詞改掉」,法務會說「這段免責聲明一個字都不能動」。

你以為在做 i18n,其實你在做 l10n,而 l10n 背後站著的是法務、行銷、當地業務。

這時候你才發現,那個 JSON 檔不是設定檔,它是一份對外的法律文件。

那怎麼辦?只能建 reviewer 制度。

每個語系要有一個名字,一個真的會用那個語言的人,最好是當地的同事或代理商。他不用懂 code,但他要有權說「這句不行」。

沒有這個人,你的翻譯就是 Google 翻譯等級,而且沒有人知道到底翻得對還是錯。

——

第五層:說白了,是政治

最深的一層,是沒有人想扛這件事。

多語系很像基礎建設,做得好沒人稱讚,做不好全公司都罵你。它不會出現在任何一份 OKR 上,也沒有哪個 PM 的績效是「語系品質」。

所以它永遠是「順便做一下」。

但你想想,一個 28 種語系的產品,每加一個詞彙就要動 28 個檔案、跑 28 次翻譯、驗 28 次版面。這種量級的事情,你要一個人「順便」?

我覺得要讓這件事被正視,唯一的辦法是把成本算給老闆看。

一個新詞彙,從設計定稿、開 key、送翻譯、回稿、驗版面、修爆版,乘上 28 個語系,你把工時攤出來,老闆才會知道那個「順便」到底是幾個人天。

成本沒有被算出來之前,它就是免費的。免費的東西不會有人管,也不會有人給資源。

然後是衡量。

治理沒有指標就是空話。至少要有三個數字:各語系的翻譯覆蓋率、死 key 的比例、從文案定稿到翻譯上線的 lead time。

這三個數字如果沒有人看,那就代表沒有人在治理,只是在救火。

治理的本質其實只有三件事:誰決定、誰執行、誰買單。

誰決定文案的最終版本,誰負責把它送進去,出錯的時候誰要處理。這三個問題只要有一個沒答案,你的多語系就會慢慢腐爛,而且腐爛得非常安靜。

至於要不要成立一個專門的團隊——如果你有五個以上的語系、三個以上的產品團隊,我覺得該有。不一定是一整個團隊,但至少要有一個明確的 owner,他的工作內容裡面白紙黑字寫著這件事。

不然它就會一直是那個「大家都覺得很重要,但誰都不想碰」的東西。

——

所以我後來的想法變了。

以前我會想「怎麼設計一個好的 i18n 架構」,現在我會先問「這家公司有沒有人真的擁有這件事」。

多語系破版這件事,我在關於前端架構裡面就列過,那時候我還當它是個架構關注點。前陣子看一個一人開發的字幕產品,語系是寫死在程式碼裡的,一個人做反而沒這個問題——因為擁有者只有一個,就是他自己。

如果沒有那個人,架構寫得再漂亮,兩年後一樣是一坨。

工具永遠救不了流程,流程也救不了沒有人負責的組織。

那你們家的語系檔,現在是誰在維護的?

留言