《互联网产品的客户端架构·规模化与架构演进》9.2 RTL、排版与本地内容

阿拉伯语用户打开同一张订单页,返回箭头仍指向左边,订单号在双向文本里跳到句子另一端。本节把方向传播、逻辑属性映射、双向文本隔离与图标镜像规则写成可测试的 TypeScript 模块,并说明哪一层负责决定方向、哪些元素该镜像、哪些必须保持原样。

利雅得的订单页,箭头指向了右边

一位沙特阿拉伯用户打开订单详情页。页面整体已经从右向左排列,标题在右边,返回按钮在右上角。但返回按钮上的箭头仍然指向左边,用户下意识地点了它,结果进入了下一页。

同一屏还有第二个问题。订单号 A-1024 夹在一句阿拉伯语里,渲染后 A-1024 跑到了句子的左端,和前面的词断开。第三个问题是客服头像:团队为了「统一 RTL 体验」,给整个卡片加了 transform: scaleX(-1),结果头像、品牌 logo 和数字全部被镜像,用户看到的是反过来的文字。

这三个问题分别属于方向传播、逻辑属性、双向文本边界。它们和翻译无关:文案已经翻成阿拉伯语,布局也切到了 RTL,但代码里仍然有 margin-left、text-align: left 和没有隔离的动态文本。

本节讨论的是排版层,不是语言层。方向由 locale 决定,但具体到每个组件,方向应该继承、显式覆盖还是自动判断,需要一条明确的规则。下面的区域、locale 与文本都是构造示例。

方向不是布尔值,而是可继承的上下文

最外层的 dir 通常由 locale 决定:ar、he、fa、ur 等语言默认 RTL,zh、en、ja 默认 LTR。但语言和文字方向不是一一对应的:阿塞拜疆语可以用拉丁字母,也可以用阿拉伯字母;库尔德语同样有拉丁与阿拉伯两种写法。因此 locale 里的 script 子标签应该优先于语言默认值。

方向在组件树里应该像字体颜色一样继承:

层级取值规则
根节点ltr / rtl由 locale 与 script 决定
普通组件auto继承父级,不重复声明
边界组件显式 ltr / rtl只用于嵌入相反方向的第三方内容
用户生成文本auto 或隔离符按首个强字符判断,不改变周围布局

「普通组件继承」这一条最容易被破坏。很多团队在每个页面都写一次 dir="rtl",看起来没问题,直到某个页面嵌入了 LTR 的支付表单。支付表单内部又写了一次 dir="ltr",但它的子组件仍然从根节点继承了 RTL,最终出现「表单是 LTR,输入框是 RTL」的混合状态。

逻辑属性:不要写死左右

CSS 早就提供了逻辑属性。把 margin-left 换成 margin-inline-start,把 padding-right 换成 padding-inline-end,把 text-align: left 换成 text-align: start,浏览器会根据 dir 自动映射到正确的一侧。

物理属性逻辑属性LTR 映射RTL 映射
margin-leftmargin-inline-start左右
margin-rightmargin-inline-end右左
padding-leftpadding-inline-start左右
border-leftborder-inline-start左右
text-align: lefttext-align: start左右
left: 0inset-inline-start: 0左右
top: 0inset-block-start: 0上上

块级方向(上下)不随书写方向变化,所以 margin-top 通常不需要改成逻辑属性。真正需要映射的是行内方向(左右)。

逻辑属性不能解决所有问题。画布、地图覆盖物、绝对定位的装饰元素有时必须使用物理坐标,因为它们的参照系不是文本流。这时应该在组件边界显式声明方向,而不是让物理坐标悄悄泄漏到业务组件里。

双向文本:边界比方向更重要

一段文本里可能同时出现 RTL 的阿拉伯语和 LTR 的订单号、URL、邮箱、代码。Unicode 双向算法会按字符的类别重新排序,结果是:没有隔离的动态文本可能跑到句子的另一端。

A-1024 本身是弱方向文本。它夹在阿拉伯语里时,浏览器会根据周围的强字符决定它的位置。如果前面是阿拉伯语、后面是标点,它可能被推到左边;如果前后都是数字,它又可能保持原位。这类问题不能靠「把字符串反转」解决,反转会让订单号本身变成 4201-A。

正确做法是用隔离符把动态文本包起来:

  • U+2066 LRI:把内容声明为 LTR 隔离。
  • U+2067 RLI:把内容声明为 RTL 隔离。
  • U+2068 FSI:按首个强字符自动判断。
  • U+2069 PDI:结束隔离。

隔离符不占可见宽度,但会阻止内部文本影响外部排序,也阻止外部文本影响内部。对于订单号、邮箱、URL、代码片段,最稳妥的做法是逐段隔离,而不是整段拼接后交给浏览器猜。

还有一个常见误区:给数字或订单号加 dir="rtl"。数字的阅读顺序不会因为书写方向改变,123 在 RTL 里仍然是 123,不是 321。把数字容器设成 RTL 只会改变它在行内的位置,不会改变数字本身。

图标镜像:只镜像方向性图标

图标分三类,镜像规则不同:

类别例子RTL 处理理由
方向性返回、前进、箭头、缩进、撤销、重做镜像图标表达的是「往阅读方向的前或后」
中性时钟、搜索、播放、暂停、设置不镜像图形含义与方向无关
品牌GitHub、微信、支付品牌不镜像品牌标识必须保持原样

判断一个图标是否应该镜像,可以问一句:把它左右翻转后,含义是否仍然是同一个动作? 返回箭头翻转后仍然表示「返回」,所以镜像;播放按钮翻转后仍然是播放,但翻转会让三角形指向另一个方向,反而制造歧义,所以不镜像;品牌 logo 翻转后不再是那个品牌,所以不镜像。

不要对整个容器做 scaleX(-1)。那会把文字、头像、阴影和滚动条一起镜像。镜像应该发生在图标组件内部,并且由方向上下文决定。

把方向规则写成模块

下面的模块把 locale 方向推断、方向继承、逻辑边映射、图标分类、双向文本隔离都变成纯函数。它不依赖 DOM,因此可以在 Node 里直接测试。

// file: src/i18n/rtl-layout.ts
// tier: L1
// verify: node --test test/i18n/rtl-layout.test.ts

export type Direction = "ltr" | "rtl";

export type DirectionInput = Direction | "auto";

export type LogicalEdge = "inline-start" | "inline-end" | "block-start" | "block-end";

export type PhysicalEdge = "left" | "right" | "top" | "bottom";

export type BidiClass = Direction | "neutral" | "mixed";

export type IconKind = "directional" | "neutral" | "brand";

export type MirrorAction = "mirror" | "keep";

export type BidiPart = {
  text: string;
  direction: Direction;
};

export type DirectionAttributes = {
  dir: Direction;
  align: "start" | "end";
};

const RTL_LANGUAGES = new Set<string>([
  "ar",
  "he",
  "fa",
  "ur",
  "ps",
  "sd",
  "ug",
  "yi",
  "dv",
  "ku",
  "ckb",
  "nqo",
]);

const RTL_SCRIPTS = new Set<string>([
  "arab",
  "hebr",
  "thaa",
  "nkoo",
  "adlm",
  "syrc",
  "samr",
  "mand",
]);

const LTR_SCRIPTS = new Set<string>([
  "latn",
  "cyrl",
  "grek",
  "hans",
  "hant",
  "jpan",
  "kore",
  "deva",
  "beng",
  "guru",
  "gujr",
  "orya",
  "taml",
  "telu",
  "knda",
  "mlym",
  "sinh",
  "thai",
  "lao",
  "tibt",
  "mymr",
  "geor",
  "armn",
  "ethi",
]);

const DIRECTIONAL_ICONS = new Set<string>([
  "arrow-left",
  "arrow-right",
  "chevron-left",
  "chevron-right",
  "back",
  "forward",
  "next",
  "previous",
  "undo",
  "redo",
  "indent-increase",
  "indent-decrease",
  "reply",
  "send",
  "list-ordered",
  "text-align-left",
  "text-align-right",
]);

const BRAND_ICONS = new Set<string>([
  "github",
  "wechat",
  "alipay",
  "google",
  "apple",
  "x",
]);

const LETTER = /\p{L}/u;

function isRtlCodePoint(code: number): boolean {
  return (
    (code >= 0x0590 && code <= 0x08ff) ||
    (code >= 0xfb1d && code <= 0xfdfd) ||
    (code >= 0xfe70 && code <= 0xfefc)
  );
}

export function directionForLocale(locale: string): Direction {
  const tags = locale.trim().toLowerCase().split(/[-_]/).filter((tag) => tag.length > 0);
  if (tags.length === 0) return "ltr";
  if (tags.some((tag) => LTR_SCRIPTS.has(tag))) return "ltr";
  if (tags.some((tag) => RTL_SCRIPTS.has(tag))) return "rtl";
  if (RTL_LANGUAGES.has(tags[0])) return "rtl";
  return "ltr";
}

export function resolveDirection(locale: string, explicit: DirectionInput = "auto"): Direction {
  return explicit === "auto" ? directionForLocale(locale) : explicit;
}

export function propagateDirection(parent: Direction, child: DirectionInput = "auto"): Direction {
  return child === "auto" ? parent : child;
}

export function logicalToPhysical(edge: LogicalEdge, direction: Direction): PhysicalEdge {
  switch (edge) {
    case "inline-start":
      return direction === "rtl" ? "right" : "left";
    case "inline-end":
      return direction === "rtl" ? "left" : "right";
    case "block-start":
      return "top";
    case "block-end":
      return "bottom";
  }
}

export function physicalToLogical(edge: PhysicalEdge, direction: Direction): LogicalEdge {
  switch (edge) {
    case "left":
      return direction === "rtl" ? "inline-end" : "inline-start";
    case "right":
      return direction === "rtl" ? "inline-start" : "inline-end";
    case "top":
      return "block-start";
    case "bottom":
      return "block-end";
  }
}

export function logicalBoxStyle(side: "margin" | "padding", start: string, end: string): string {
  return `${side}-inline-start:${start};${side}-inline-end:${end};`;
}

export function iconKind(name: string): IconKind {
  const key = name.trim().toLowerCase();
  if (BRAND_ICONS.has(key)) return "brand";
  if (DIRECTIONAL_ICONS.has(key)) return "directional";
  return "neutral";
}

export function mirrorIcon(name: string, direction: Direction): MirrorAction {
  if (direction === "ltr") return "keep";
  return iconKind(name) === "directional" ? "mirror" : "keep";
}

export function mirrorScaleX(direction: Direction, action: MirrorAction): string {
  return direction === "rtl" && action === "mirror" ? "scaleX(-1)" : "none";
}

function charDirection(char: string): Direction | "neutral" {
  if (!LETTER.test(char)) return "neutral";
  const code = char.codePointAt(0) ?? 0;
  return isRtlCodePoint(code) ? "rtl" : "ltr";
}

export function firstStrongDirection(text: string): Direction | "neutral" {
  for (const char of text) {
    const direction = charDirection(char);
    if (direction !== "neutral") return direction;
  }
  return "neutral";
}

export function lastStrongDirection(text: string): Direction | "neutral" {
  const chars = [...text];
  for (let index = chars.length - 1; index >= 0; index -= 1) {
    const direction = charDirection(chars[index]);
    if (direction !== "neutral") return direction;
  }
  return "neutral";
}

export function classifyBidi(text: string): BidiClass {
  let hasLtr = false;
  let hasRtl = false;
  for (const char of text) {
    const direction = charDirection(char);
    if (direction === "ltr") hasLtr = true;
    if (direction === "rtl") hasRtl = true;
  }
  if (hasLtr && hasRtl) return "mixed";
  if (hasRtl) return "rtl";
  if (hasLtr) return "ltr";
  return "neutral";
}

export function isolateBidi(text: string, direction: Direction): string {
  const open = String.fromCharCode(direction === "rtl" ? 0x2067 : 0x2066);
  const close = String.fromCharCode(0x2069);
  return `${open}${text}${close}`;
}

export function isolateAuto(text: string): string {
  return `${String.fromCharCode(0x2068)}${text}${String.fromCharCode(0x2069)}`;
}

export function safeConcat(parts: BidiPart[]): string {
  return parts.map((part) => isolateBidi(part.text, part.direction)).join("");
}

export function bidiBoundaryRisk(left: string, right: string): boolean {
  const before = lastStrongDirection(left);
  const after = firstStrongDirection(right);
  return before !== "neutral" && after !== "neutral" && before !== after;
}

export function directionAttributes(direction: Direction): DirectionAttributes {
  return { dir: direction, align: "start" };
}

模块里有三个关键决策。

第一,directionForLocale 先看 script 子标签,再看语言默认值。ku-Latn 是 LTR,ku-Arab 是 RTL,不能只按语言查表。

第二,logicalToPhysical 只做映射,不读全局状态。组件传入当前方向,函数返回物理边。这样同一个组件在 LTR 与 RTL 下可以用同一份代码测试。

第三,safeConcat 对每一段文本单独隔离,而不是对拼接后的整段文本隔离。整段隔离只能阻止内外互相影响,不能阻止段与段之间的弱方向文本漂移。

判据与反例

判断一套 RTL 实现是否可靠,看五条判据。

判据一:方向只在一个地方决定。 根节点根据 locale 决定方向,普通组件继承,只有嵌入相反方向内容时才显式覆盖。

判据二:业务样式里没有物理左右。 搜索 margin-left、padding-right、text-align: left、left:,如果它们出现在业务组件里,就应该被替换成逻辑属性或显式映射。

判据三:动态文本有隔离。 订单号、邮箱、URL、代码片段在 RTL 文案里不会跑到句子另一端,也不受周围文本影响。

判据四:镜像只发生在图标层。 页面容器、卡片、头像不做整体翻转;方向性图标镜像,中性图标与品牌图标保持原样。

判据五:两种方向都有测试。 同一个组件在 ltr 与 rtl 下各跑一遍,断言逻辑边映射、图标动作与文本隔离结果,而不是只靠人工切换语言看一眼。

反例一:用 margin-left: 16px 做列表缩进。RTL 下列表缩进仍然在左边,文字却从右边开始,视觉上像少了一块。

反例二:给整个卡片加 scaleX(-1)。文字、头像、品牌 logo 全部镜像,用户会怀疑页面坏了。

反例三:用 split("").reverse() 处理混合文本。字符级反转会破坏阿拉伯语的连写形态,也会把数字和标点顺序打乱。

反例四:给订单号加 dir="rtl"。订单号的位置可能改变,但字符顺序不会变成用户期望的样子,反而让复制粘贴的结果不稳定。

小结

RTL 不是把页面左右翻转,而是把方向当成一种可继承的上下文。

  • locale 与 script 共同决定根方向,普通组件继承,只有方向边界显式覆盖。
  • 业务样式使用逻辑属性,物理左右只保留在画布、地图等非文本参照系里。
  • 动态文本逐段隔离,不反转字符串,不给数字强加 RTL。
  • 图标按「翻转后含义是否不变」分类,只镜像方向性图标,不对容器做整体翻转。
  • 每个组件在 LTR 与 RTL 下都要有测试,而不是只靠人工切换语言检查。

方向处理对了,翻译才算真正落地。否则用户看到的是一门被排版破坏的语言。

阅读导航:上一节:9.1 文案、时区与货币 · 下一节:9.3 区域能力差异与降级 。

常见错误与边界

  1. 把 RTL 当成整页翻转。 页面容器加 scaleX(-1) 会镜像文字、头像与品牌标识,应该只镜像方向性图标。
  2. 业务样式写死 margin-left 或 text-align: left。 这些属性不会跟随方向变化,应使用 margin-inline-start 与 text-align: start。
  3. 每个组件都重复声明 dir。 重复声明会切断继承,嵌入相反方向内容时容易出现父子方向不一致。
  4. 只按语言判断方向。 同一语言可能有多种 script,ku-Latn 与 ku-Arab 的方向相反,必须让 script 子标签优先。
  5. 用字符反转处理双向文本。 反转会破坏阿拉伯语连写与数字顺序,正确做法是使用 Unicode 隔离符。
  6. 给订单号、电话号码或数字容器加 RTL。 数字的字符顺序不随书写方向改变,强加 RTL 只会改变行内位置。
  7. 镜像中性图标。 时钟、搜索、播放等图标翻转后含义不变或产生歧义,不应镜像。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查