利雅得的订单页,箭头指向了右边
一位沙特阿拉伯用户打开订单详情页。页面整体已经从右向左排列,标题在右边,返回按钮在右上角。但返回按钮上的箭头仍然指向左边,用户下意识地点了它,结果进入了下一页。
同一屏还有第二个问题。订单号 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-left | margin-inline-start | 左 | 右 |
margin-right | margin-inline-end | 右 | 左 |
padding-left | padding-inline-start | 左 | 右 |
border-left | border-inline-start | 左 | 右 |
text-align: left | text-align: start | 左 | 右 |
left: 0 | inset-inline-start: 0 | 左 | 右 |
top: 0 | inset-block-start: 0 | 上 | 上 |
块级方向(上下)不随书写方向变化,所以 margin-top 通常不需要改成逻辑属性。真正需要映射的是行内方向(左右)。
逻辑属性不能解决所有问题。画布、地图覆盖物、绝对定位的装饰元素有时必须使用物理坐标,因为它们的参照系不是文本流。这时应该在组件边界显式声明方向,而不是让物理坐标悄悄泄漏到业务组件里。
双向文本:边界比方向更重要
一段文本里可能同时出现 RTL 的阿拉伯语和 LTR 的订单号、URL、邮箱、代码。Unicode 双向算法会按字符的类别重新排序,结果是:没有隔离的动态文本可能跑到句子的另一端。
A-1024 本身是弱方向文本。它夹在阿拉伯语里时,浏览器会根据周围的强字符决定它的位置。如果前面是阿拉伯语、后面是标点,它可能被推到左边;如果前后都是数字,它又可能保持原位。这类问题不能靠「把字符串反转」解决,反转会让订单号本身变成 4201-A。
正确做法是用隔离符把动态文本包起来:
U+2066LRI:把内容声明为 LTR 隔离。U+2067RLI:把内容声明为 RTL 隔离。U+2068FSI:按首个强字符自动判断。U+2069PDI:结束隔离。
隔离符不占可见宽度,但会阻止内部文本影响外部排序,也阻止外部文本影响内部。对于订单号、邮箱、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 区域能力差异与降级 。
常见错误与边界
- 把 RTL 当成整页翻转。 页面容器加
scaleX(-1)会镜像文字、头像与品牌标识,应该只镜像方向性图标。 - 业务样式写死
margin-left或text-align: left。 这些属性不会跟随方向变化,应使用margin-inline-start与text-align: start。 - 每个组件都重复声明
dir。 重复声明会切断继承,嵌入相反方向内容时容易出现父子方向不一致。 - 只按语言判断方向。 同一语言可能有多种 script,
ku-Latn与ku-Arab的方向相反,必须让 script 子标签优先。 - 用字符反转处理双向文本。 反转会破坏阿拉伯语连写与数字顺序,正确做法是使用 Unicode 隔离符。
- 给订单号、电话号码或数字容器加 RTL。 数字的字符顺序不随书写方向改变,强加 RTL 只会改变行内位置。
- 镜像中性图标。 时钟、搜索、播放等图标翻转后含义不变或产生歧义,不应镜像。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。