订阅以接收新文章的通知:

国际化与本地化:将 Cloudflare Radar 推向全球受众

2024-12-16

13 分钟阅读时间
这篇博文也有 English繁體中文版本。

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~íóñ]

完成伪翻译后,任何保持不变的英文字符串很可能就是硬编码的或者来自其他来源。在下面的截图中,您可以看到 ASNCountryNamePrefix 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”这样一个简单的单词单独出现时,可能具有以下不同的翻译:

其他例子包括 CustomDetectedDisabled,根据它们在句子中的位置、在 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.DateTimeFormatDate.toLocaleString() 来实现。

Intl.DateTimeFormat 接收的区域设置和格式选项Day.jsMoment.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-backendi18next-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 告诉我们。

我们保护整个企业网络,帮助客户高效构建互联网规模的应用程序,加速任何网站或互联网应用程序抵御 DDoS 攻击,防止黑客入侵,并能协助您实现 Zero Trust 的过程

从任何设备访问 1.1.1.1,以开始使用我们的免费应用程序,帮助您更快、更安全地访问互联网。要进一步了解我们帮助构建更美好互联网的使命,请从这里开始。如果您正在寻找新的职业方向,请查看我们的空缺职位
RadarJavaScriptRadar MapsLocalization

在 X 上关注

Cloudflare|@cloudflare

相关帖子