2024 年 9 月,Cloudflare Radar 迎來了四週年。過去四年來,我們不斷擴大 Radar 的範圍,它作為全球網際網路資源所提供的價值也與日俱增,隨著 Radar 資料和圖表經常出現在世界各地的出版物和社交媒體中,我們知道,我們需要提供英文以外的其他語言版本。
當地語系化很重要,因為大多數網際網路使用者的第一語言不是英文。根據 W3Techs 的資料,自 2023 年 1 月以來,網際網路上的英語使用率下降了 8.3 個百分點(從 57.7% 下降到 49.4%),而西班牙文、德文、日文、義大利文、葡萄牙文和荷蘭文等其他語言的使用率正在穩步上升。此外,CSA Research 的一項研究表明,65% 的網際網路使用者更喜歡以其母語表達的內容。
要成功(且輕鬆)地實現任何產品的當地語系化,必須先實現國際化。國際化是將產品準備好,以便進行翻譯並使其適應多種語言和文化的過程。它為產品以後以更快的速度(以及以更低的成本,包括時間和預算)進行當地語系化奠定了基礎。下面,我們回顧一下 Cloudflare 的 Radar 和全球化團隊如何合作,提供涵蓋 12 種語言的 Radar 體驗。
什麼是當地語系化?
當地語系化 (l10n) 是針對某個地區調整內容的過程,包括翻譯、相關影像以及影響內容感知方式的文化元素。理想情況下,當地語系化的目標是使內容聽起來就像最初就是針對該地區而編寫的,融入相關的文化細微差別,而不是僅僅用翻譯的文字替換英文。
當地語系化包括但不限於:
語言:即翻譯,但這只是開始。
語氣和訊息:當地語系化考慮的是什麼會引起目標受眾的共鳴,而不僅僅是什麼才是準確的。
影像:在一個國家/地區合適的內容在另一個國家/地區可能會有問題(例如,地圖往往包含爭議領土)。
日期、時間、度量和數字格式:格式因地點而異,即使使用相同的語言,格式也可能不同。在美國,日期遵循以下格式:「December 15, 2018」。但在英國,同一個日期的寫法是這樣的:「15 December 2018」。更不用說經常讓人混淆的格式:月/日/年與日/月/年的區別。
影像:XKCD,https://xkcd.com/2562/
皮克斯電影是當地語系化的一個很好的範例。皮克斯非常注重將其電影製作進行國際化的過程,因此他們可能會替換或插入一些場景,讓全世界的觀眾產生共鳴,而不僅僅是觸動美國觀眾。讓我們以《腦筋急轉彎》(Inside Out)(2015 年)為例。在電影中,Riley 回憶起在明尼蘇達州打冰球的經歷。世界上大多數人並不像美國人那樣熟悉冰球,所以皮克斯明智地決定在其他地方使用足球,以便與這些觀眾建立更直接的情感聯繫。
影像:《腦筋急轉彎》(2015 年)中的場景,由皮克斯動畫工作室和華特迪士尼影業製作。版權歸皮克斯動畫工作室和華特迪士尼影業所有。影像使用符合合理使用規定。
也不是必須得看電腦動畫電影。比如在電影《鬼店》(The Shining)(1980 年)中,著名的「只工作不玩耍,聰明的孩子也變傻」打字機場景被以不同的方式當地語系化為所有語言。製作人拍攝當地語系化場景並剪輯成電影的本地版本,這是前資訊技術時代的國際化範例。
影像:史丹利·庫柏力克 (Stanley Kubrick) 導演的《鬼店》(1980 年)中的場景。版權歸華納兄弟影業所有。影像使用符合合理使用規定。
國際化
當地語系化是一件很困難的事情,業內所有人都認同這一點。幸運的是,有一個策略:當地語系化的第一步是國際化 (i18n)。國際化是將產品準備好,以便進行翻譯並使其適應多種語言和文化的過程。這是一個有助於翻譯和當地語系化的準備步驟。程式碼的國際化程度越高,並且越多地考慮到語言和文化的細微差別,當地語系化就越容易。
硬編碼和外部化
Radar 國際化的第一步是評估有多少可當地語系化的字串是硬編碼的。硬編碼是將資料直接嵌入程式原始程式碼的做法。雖然這是一種方便快捷的程式碼編寫方式,但它會使以後變更或當地語系化程式碼變得更加困難。
組成 Radar 頁面的大部分字串以前都是硬編碼的,因此在開始翻譯之前,必須進行外部化,即從程式碼中提取需要當地語系化的文字並將其移動到單獨的檔案中的過程。
硬編碼字串:
import Card from “~/components/Card”;
import Chart from “~/components/Chart”;
export default function TrafficChart() {
return (
<Card
title="Traffic"
description="Share of HTTP requests"
>
<Chart />
</Card>
);
}
外部化鍵預留位置:
import { useTranslation } from "react-i18next";
import Card from “~/components/Card”;
import Chart from “~/components/Chart”;
export default function TrafficChart() {
const { t } = useTranslation();
return (
<Card
title={t("traffic.chart.title")}
description={t("traffic.chart.description")}
>
<Chart />
</Card>
);
}
外部化字串有幾個好處:
讓翻譯人員能夠處理僅包含可當地語系化字串的單獨、隔離的檔案
可以防止意外變更程式碼
允許開發人員部署更新、變更和修復,而不必每次都針對每種語言重新編譯或重新部署程式碼
看一下下面的範例,當編譯或部署程式碼時,到達第 10 行(左側)時,它會找到一個名為 traffic.chart.title
的鍵。然後,它將繼續在右側的 JSON 檔案中匹配該鍵,在第 1090 行找到它,並將其解析為「Traffic
」(英文)、「Tráfego
」(葡萄牙文)和「トラフィック
」(日文),對程式碼中存在的每個當地語系化 JSON 檔案都執行此操作。
偽翻譯
並非所有的字串都能輕鬆找到,有些字串深埋在程式碼中,有時是隱藏在繼承的舊程式碼或 API 中。幸運的是,有一些策略可以協助偵測硬編碼的字串。這就是偽翻譯發揮作用的地方了。
偽翻譯是一種將字串中的所有字元替換為外觀相似的字元的過程;偽翻譯的字串括在 [ ] 字元中,並在其中新增一些額外字元以模擬文字擴展(稍後會詳細介紹)。它是一種非常有用的工具,可以協助我們找到任何硬編碼的字串,並對 UI 進行壓力測試,以瞭解語言準備情況和長度變化性,同時仍保持內容的可讀性。例如,此字串:
路由資訊
經過偽翻譯後,看起來像這樣:
[R~óútíñg Í~ñfó~rmát~íóñ]
完成偽翻譯後,任何保持不變的英文字串很可能就是硬編碼的或者來自其他來源。在下面的螢幕截取畫面中,您可以看到 ASN
、Country
、Name
和 Prefix Count
沒有進行偽翻譯,必須由 Radar 開發人員外部化。全球化團隊與 Radar 團隊合作,報告並修復了硬編碼文字問題,以及下文提到的問題。
文字擴展
當從一種語言到另一種語言的翻譯內容比原文佔用更多空間時,就會發生文字擴展。有時這種擴展是水平的,例如從英文翻譯成德文平均擴展 35%,翻譯成西班牙文擴展 30%,翻譯成法文擴展 20%。從英文翻譯成亞洲語言可能在水平上會縮短,但在垂直上會擴展。有趣的是,英文的字元越少,當地語系化語言的擴展程度越大。
資料來源:IBM
UI 設計師和開發人員在建立應用程式時需要牢記這一點。因此,一個重要的考慮因素是使用較大的文字來測試設計模型,並規劃 UI 以適應文字擴展。如果某些英文內容幾乎無法容納在其容器內,則很可能無法容納其他語言,並可能破壞版面配置。
以下是 Radar 固定寬度側邊欄中,同一按鈕的不同語言版本的範例。由於是主導覽,截斷文字並不合適,唯一可行的選擇是換行,這意味著當地語系化的按鈕最終可能會有不同的高度。有時候,需要犧牲視覺一致性來換取可用性。
字串連接
在英文中,您可以輕鬆將單詞連接起來,因為大多數單詞沒有詞形變化。幾乎所有的程式設計語言都是使用英文設計的。一個語言學家的老笑話是這樣的:英文老師:是教英文的老師還是來自英國的老師?舉個例子,翻譯下面這句話將是一場噩夢:
A lovely little old rectangular green French silver whittling knife
大多數西方語言需要透過一些粘合劑來連接單詞:介詞、冠詞或詞形變化。這就是為什麼字串連接(透過組合兩個或多個字串將句子或句子部分組合在一起)對於當地語系化來說通常是一種糟糕的做法,儘管從開發的角度來看它似乎很有效。您不能假設所有語言都遵循與英文相同的句子結構。大多數語言都不是這樣的。
在其他語言中,句子可能需要完全反轉才能聽起來符合語法。當字串不包含預留位置時,這會成為一個特別嚴重的問題,因為它被假定為在字串的開頭或結尾處連接,例如:
"is currently categorized as:"
開發人員需要確保在字串本身中包含所有預留位置,以便翻譯人員可以根據需要輕鬆移動它們,例如:
"Distribution of {{botClass}} traffic by IP version"
簡體中文如下所示(注意 {{botClass}
預留位置被移動了)
"{{botClass}} 流量分布(按 IP 版本)"
字串再用
與字串連接一樣,如果您是開發人員,字串再用(在多個地方使用相同的字串,僅替換掉預留位置的內容)似乎很有效。當將其翻譯成帶性別語言(例如大多數歐洲語言)時,就會出現問題。在西班牙文中,根據其位置和上下文,像「open」這樣一個簡單的單詞單獨出現時,可能具有以下不同的翻譯:
其他例子包括 Custom
、Detected
或 Disabled
,根據它們在句子中的位置、在 UI 中的位置,根據它們是與單數、複數、陽性還是陰性名詞搭配,可以有不同的翻譯,因此可能需要在語言檔案中為這些建立額外的項目。
翻譯人員還需要知道將會用什麼內容來替換下面這種字串中的預留位置,因為預留位置前後的措辭可能涉及到陽性、陰性或中性術語(對於具有這些術語的語言,例如德文)。如果預留位置可以是其中的多種情況(既可以是陽性名詞,也可以是陰性名詞),則至少在某些情況下,翻譯將變得語法不正確。在以下範例中,翻譯人員需要知道 {link1}
和 {link2}
將被替換成什麼,才能知道在它們前後使用怎樣的措辭才能語法上正確。
Your use of the URL Scanner is subject to our {{link1}}. Any personal data in a submitted URL will be handled in accordance with our {{link2}}
.
更好的方法是使用元件預留位置,並包含要翻譯的上下文文字:
Your use of the URL Scanner is subject to our <link1>Online Service Terms of Use</link1>. Any personal data in a submitted URL will be handled in accordance with our <link2>Privacy Policy</link2>
.
地區考慮因素
日期格式
不同國家/地區的日期格式差別很大。您不能假設所有國家都使用月/日/年格式,甚至一週的開始日期也可能因國家/地區或文化而異。 下面是美式英文、歐洲西班牙文(其週從週一開始而不是週日)和簡體中文(使用完全不同的日期格式)中的 Radar 日期選擇器對比情況。
值得慶幸的是,開發人員不需要知道所有特定於國家/地區的詳細資料,因為他們可以使用 Intl.DateTimeFormat 或 Date.toLocaleString() 來做到這一點。
Intl.DateTimeFormat 接收的地區設定和格式選項與 Day.js 或 Moment.js 等日期庫中常見的字串標記不同。除非您專門在這些庫中使用當地語系化的字串標記,否則標記的順序以及您可能新增到格式中的任何字元或分隔符號都是固定的,這會帶來問題,因為日期格式部分的順序應該根據地區設定而改變。 Intl.DateTimeFormat 會處理所有這些問題,並為您省去了向專案新增日期格式相依性和載入特定於庫的地區設定資源的麻煩。
以下是使用 Intl.DateTimeFormat 和 react-i18next 的通用 React 元件的範例。以下程式碼將針對美式英文 (en-US) 將日期轉譯為「Tue, Oct 1, 2024」,而針對日文 (ja-JP) 將日期轉譯為「2024年10月1日(火)」。
import { useTranslation } from "react-i18next";
export default function SomeComponent() {
const { i18n } = useTranslation();
const date = new Date("2024-10-01");
return (
<time dateTime={date.toISOString()}>
{new Intl.DateTimeFormat(i18n.language, {
weekday: "short",
month: "short",
year: "numeric",
day: "numeric",
}).format(date)}
</time>
);
}
記數法
與此類似,不同的地區使用不同的記數法。在美國和英國,句號用作小數分隔符號,逗號用作千位分隔符號。而另一些國家/地區則使用逗號作為小數分隔符號,句號(或空格)用作千位分隔符號。同樣,開發人員也沒有必要瞭解這方面的所有細節,因為他們可以使用 Intl.NumberFormat。
以下是使用 Intl.NumberFormat 和 react-i18next 的通用 React 元件的範例。以下程式碼將針對美式英文 (en-US) 將數字轉譯為「12,345,678.90」,而針對葡萄牙文 (pt-PT) 將數字轉譯為「12 345 678,90」。可以傳遞 Intl.NumberFormat 選項以將數字格式化為小數、百分比、貨幣等,並指定小數位數和舍入策略等內容。
import { useTranslation } from "react-i18next";
export default function SomeComponent() {
const { i18n } = useTranslation();
return (
<span>{new Intl.NumberFormat(i18n.language, {
style: "decimal",
minimumFractionDigits: 2,
}).format(12345678.9)}</span>);
}
截至 12 月中旬,區域化數字格式尚未在 Radar 上完全實作。我們預計這項工作將於 2025 年第一季末完成。
清單排序
如果您有一個按順序顯示的專案清單(例如下拉清單中的國家/地區清單),那麼僅翻譯這些項目是不夠的。例如,當翻譯成葡萄牙文時,「South Africa
」變成「África do Sul
」,這意味著它應該排在靠近清單頂部。除此之外,每種語言都有不同的排序要求,這些要求遠遠超出了 A-Z 字母表的範圍。例如,幾種亞洲語言根本不使用拉丁字元,而可能會按筆劃或部首順序排序。
以下是使用 String.localeCompare 和 react-i18next 的通用 React 國家/地區選擇器元件的範例。以下程式碼匯入了具有名稱和 alpha-2 程式碼的國家/地區清單,並根據作用中地區設定的翻譯版國家/地區名稱對選項進行排序。可以將 Intl.Collator 選項傳遞給 localeCompare() 以滿足特定的排序需求。
import { useTranslation } from "react-i18next";
import Select from "~/components/Select";
import COUNTRIES from "~/constants/geo";
export default function CountrySelector() {
const { t, i18n } = useTranslation();
const options = COUNTRIES.map(({ name, code }) => ({
label: t(name, { ns: "countries" }),
value: code,
})).sort((a, b) => a.label.localeCompare(b.label, i18n.language));
return <Select options={options} onChange={(option) => { /* do something */ }} />;
}
API 當地語系化
許多 Radar 畫面和報告都包含來自 Cloudflare 或第三方 API 的輸出。遺憾的是,這些 API 中的絕大多數僅輸出英文內容。當將其與網站的翻譯部分結合起來時,可能會給人留下網站當地語系化程度很差的印象。
為了解決這個問題,我們獲取 API 輸出並將所有內容對應到單獨的檔案中,翻譯所有可能的訊息,然後顯示該訊息而不是原始輸出。但隨著時間的推移,API 不斷發展,新訊息不斷增加,或現有訊息發生變化,保持最新翻譯就變成了一場永無止境的「打地鼠」遊戲。
"Address unreachable error when attempting to load page": "Error de dirección inaccesible al intentar cargar la página",
"Authentication failed": "Autenticación fallida",
"Browser did not fully start before timeout": "El navegador no se ha iniciado por completo antes de agotar el tiempo de espera.",
"Certificate and/or SSL error when attempting to load page": "Error de certificado y/o SSL al intentar cargar la página",
"Crawl took too long to finish": "El rastreo ha tardado demasiado en completarse.",
"DNS resolution failed": "Error de resolución de DNS",
"Network connection aborted.": "Conexión de red cancelada"
最佳做法應該是,讓 API 接受地區設定參數或標頭,工程師在構建這些 API 時就考慮到多種語言,即使是錯誤訊息也是如此。這可以為可能擁有的任何數量的用戶端節省時間和資源。
專案設定
Radar 是一個在 Cloudflare Pages 上執行的 Remix 專案。在研究實施國際化的方法時,我們偶然發現了這篇 Remix 部落格文章,經過一些試驗後,我們決定使用 Sergio Xalambrí 的 remix-i18next。我們主要遵循資源庫中的安裝說明,但做了一些變更。
每個地區設定資料夾都有多個翻譯檔案,每個資料來源一個翻譯檔案,以幫助我們維持來自 API 的字串。每個檔案都可用于為翻譯建立命名空間,以避免鍵衝突,並可根據需要為每個路由或元件單獨載入。
在 remix-i18next 的說明中,您可以找到用於載入這些檔案的後端外掛程式的概念,但您無法將 i18next-fs-backend 與 Cloudflare Pages 一起使用,因為無法存取檔案系統。為了解決這個問題,我們使用了資源方法,類似於此範例 remix-i18next 和 vite 設定中使用的方法,但我們不想每次新增新的命名空間時都必須維護資源字典,因此 vite 的 Glob 匯入派上了用場:
import { serverOnly$ } from "vite-env-only/macros";
export const resources = serverOnly$(
Object.entries(import.meta.glob("./*/*.json", { import: "default" })).reduce(
(acc, [path, module]) => {
const parts = path.split("/").slice(1);
const locale = parts[0];
const namespace = parts[1].split(".")[0];
if (!acc[locale]) acc[locale] = {};
if (!acc[locale][namespace]) acc[locale][namespace] = {};
module().then((value) => (acc[locale][namespace] = value));
return acc;
},
{},
),
)!;
這將透過匯入地區設定資料夾中的所有 JSON 檔案來建立僅限伺服器端的資源字典,並將其作為 Remix 的 entry.server.tsx 中 i18next 設定的資源屬性傳遞。
為了在用戶端側載入命名空間,我們建立了一個 Remix 資源路由,該路由使用資源字典並使用所要求地區設定的命名空間物件進行回應:
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { resources } from "~/i18n";
export async function loader({ params }: LoaderFunctionArgs) {
const { locale, namespace } = params;
return resources[locale]?.[namespace] || {};
}
然後,您可以將 i18next-http-backend 或 i18next-fetch-backend 後端外掛程式用於 Remix 的 entry.client.tsx 中的 i18next 設定:
import i18next from "i18next";
import i18nextHttpBackend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import { getInitialNamespaces } from "remix-i18next/client";
import { options } from “~/i18n”;
await i18next
.use(initReactI18next)
.use(i18nextHttpBackend)
.init({
...options,
ns: getInitialNamespaces(),
backend: { loadPath: "/api/i18n/{{lng}}/{{ns}}" },
});
預設命名空間使用 defaultNS 設定屬性定義:
export const defaultNS = ["main", "countries"];
用於每個路由的其他命名空間可以透過 Remix 的 handle export 定義:
export const handle = {
i18n: ["url-scanner", "domain-categories"],
};
伺服器端的命名空間由 entry.server.tsx 上的 getRouteNamespaces 函數選擇:
const ns = i18n.getRouteNamespaces(remixContext);
在用戶端側,範例建議在每個 useTranslation() 鉤子執行個體上聲明命名空間,但我們在 Remix 的 root.tsx 檔案中解決了這個問題:
import { useLocation, useMatches } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { defaultNS } from “~/i18n”;
export function Layout({ children }) {
const location = useLocation();
const matches = useMatches();
const handle = matches?.find((m) => m.pathname === location.pathname)?.handle || {};
useTranslation([...new Set([...defaultNS, ...(handle.i18n || [])])]);
...
}
這會導致用戶端外掛程式呼叫資源路由並載入所需的命名空間。
我們還希望在 URL 路徑名稱中包含地區設定,但在預設語言中不包含,Remix 的可選分段讓我們能夠做到這一點。remix-i18next 預設沒有 URL 地區設定偵測,但您可以提供自己的 findLocale 函數,該函數將接收要求作為參數,然後您可以剖析要求 URL 以擷取地區設定。
搜尋引擎最佳化
為專案設定國際化後,您可以告知搜尋引擎您網頁的當地語系化版本。這樣,搜尋引擎就可以使用與搜尋詞彙相同的語言顯示您網站的當地語系化結果。
<head>
...
<link rel="alternate" href="https://radar.cloudflare.com/" hreflang="en-US">
<link rel="alternate" href="https://radar.cloudflare.com/de-de" hreflang="de-DE">
<link rel="alternate" href="https://radar.cloudflare.com/es-es" hreflang="es-ES">
<link rel="alternate" href="https://radar.cloudflare.com/es-la" hreflang="es-LA">
<link rel="alternate" href="https://radar.cloudflare.com/fr-fr" hreflang="fr-FR">
<link rel="alternate" href="https://radar.cloudflare.com/it-it" hreflang="it-IT">
<link rel="alternate" href="https://radar.cloudflare.com/ja-jp" hreflang="ja-JP">
<link rel="alternate" href="https://radar.cloudflare.com/ko-kr" hreflang="ko-KR">
<link rel="alternate" href="https://radar.cloudflare.com/pt-br" hreflang="pt-BR">
<link rel="alternate" href="https://radar.cloudflare.com/pt-pt" hreflang="pt-PT">
<link rel="alternate" href="https://radar.cloudflare.com/zh-cn" hreflang="zh-CN">
<link rel="alternate" href="https://radar.cloudflare.com/zh-tw" hreflang="zh-TW">
<link rel="alternate" href="https://radar.cloudflare.com/" hreflang="x-default">
<link rel="canonical" href="https://radar.cloudflare.com/">
...
</head>
您還應該當地語系化頁面標題、說明和相關的 Open Graph 中繼資料。要使用 Remix 和 remix-i18next 實現此目的,您可以在路由載入器中使用 getFixedT 方法來解析翻譯並傳回 meta 匯出的資料:
import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/server-runtime";
import i18n from "~/i18next.server";
export async function loader({ request }: LoaderFunctionArgs) {
const t = await i18n.getFixedT(request);
return {
meta: {
title: t("meta.about.title"),
description: t("meta.about.description"),
url: request.url,
},
};
}
export const meta = ({ data }: MetaArgs<typeof loader>) => data.meta;
如果您在父路由中定義預設中繼標籤,則可能還需要合併中繼物件。
結論
在語言方面,幾乎沒有絕對的東西。即使在相同的地方長大,您的西班牙文、法文、日文也會與別人不同。家庭、教育、環境、人際關係都會給您的語言增添色彩。它就像一份家傳食譜——獨一無二,讓人感覺像家一樣,而且沒有商量的餘地。這不會讓它變得更好或更壞,只是讓它成為您的語言。您的語言總是與其他語言不同。
當地語系化並非易事。我們已經看到,有很多事情可能會出錯,而在這個過程中也會浮現出許多未知因素。這也可能使產品變得更好,因為它會對產品的程式碼和設計進行壓力測試。全球化團隊和 Radar 團隊之間的緊密關係使我們的工作更加順利。此外,我們的翻譯人員接受挑戰,不斷熟悉 Radar 平台,分析英文內容,找到不僅能引起觀眾共鳴而且適合所配置空間的正確翻譯,不斷檢查上下文、以前的翻譯、一致性、產業標準,基於風格指南、語氣和傳訊目的進行調整,在完成所有這些之後,最終不得不承認,總有人會(在不同程度上)不同意他們的用詞選擇。
如果您還沒有探索 Cloudflare Radar 的當地語系化版本,我們鼓勵您這樣做。按一下 Radar 介面右上角的語言下拉式功能表並選擇您想要的語言——Radar 將以該語言顯示,直到做出新的選擇。對翻譯有意見或建議?請透過 radar_localization@cloudflare.com 告訴我們。