跳到主内容

从 300 处方向判断到省 70%:CSS 逻辑属性 RTL 改造实录 ​

问题背景:RTL 不是把文本右对齐那么简单 ​

一个面向阿语、希伯来语市场的 Vue3 + VitePress 文档站,最初只在 <html dir="rtl"> 后补了一套覆盖样式。短期看似可用,但随着组件增加,问题迅速放大:卡片图标间距要镜像、弹窗关闭按钮位置要镜像、代码块圆角和分隔线要镜像,甚至部分 padding-left、right: 0、margin-left: auto 在不同语言包之间互相覆盖。

传统写法把“左、右、上、下”写死为物理方向,而 RTL 的核心是书写方向与流方向变化。继续维护 [dir="rtl"] 覆盖层,会产生大量重复代码。统计一次业务组件后,约 300 处方向相关声明需要人工判断;改造为逻辑属性并配合 aspect-ratio 后,方向相关样式减少约 70%,媒体容器也不再依赖 JS 测量。

方案对比:物理属性、覆盖层与逻辑属性 ​

方案写法特征优点缺点适用场景
物理属性left/right、margin-left直观RTL 需覆盖,容易漏改纯 LTR 页面
[dir=rtl] 覆盖为 RTL 重写一套样式上手快维护成本高、优先级混乱临时兼容旧项目
CSS 逻辑属性inset-inline-start、padding-inline自动随书写方向变化需建立团队命名习惯多语言、长期项目
JS 判断方向根据 dir 计算 class 或样式灵活渲染成本高、状态易不同步极少数复杂交互

逻辑属性不是新命名游戏,而是把样式绑定到“行向(inline)”和“块向(block)”。在英文页面中,行向起点是左;在阿语页面中,行向起点是右。浏览器自动完成映射,业务代码无需反复判断。

改造流程:先定方向模型,再替换组件样式 ​

扫描物理方向left/right/padding建立映射表inline 与 block组件批量替换布局与间距优先双方向回归视觉与截图aspect-ratio 固化媒体比例减少方向无关的尺寸 JS

第一步不是全局替换,而是区分“方向相关”和“方向无关”。例如阅读箭头、步骤序号通常要镜像;但播放按钮、公司 Logo、勾选图标未必应该镜像。建议先在设计规范中标注 mirror、keep、text 三类资源。

常用映射关系 ​

css
/* 物理属性到逻辑属性的典型映射 */
.card {
  /* 旧:左内边距固定,RTL 下需写 padding-right 覆盖 */
  padding-inline-start: 20px;
  /* 旧:margin-left: auto,在 RTL 中应把元素推到另一侧 */
  margin-inline-start: auto;
  /* 旧:left: 16px; top: 16px; right: 16px */
  inset-inline: 16px;
  inset-block-start: 16px;
  /* 边框、圆角、定位、尺寸都可使用逻辑方向 */
  border-inline-start-width: 4px;
  border-start-start-radius: 12px;
}

常见对应关系如下:left 对应 inset-inline-start,right 对应 inset-inline-end;margin-left 对应 margin-inline-start;padding-right 对应 padding-inline-end;width 对应 inline-size;height 对应 block-size。

Vue3 组件实战:卡片、图标与分隔线自动镜像 ​

下面示例可直接放入 Vue3 项目。组件不判断当前语言,只表达“图标位于文本起点、说明位于尾部”的布局语义。

vue
<template>
  <article class="doc-card">
    <div class="doc-card__icon" aria-hidden="true">
      <slot name="icon">📘</slot>
    </div>

    <div class="doc-card__body">
      <h3>{{ title }}</h3>
      <p>{{ description }}</p>
      <a class="doc-card__link" :href="href">
        {{ linkText }}
      </a>
    </div>
  </article>
</template>

<script setup lang="ts">
defineProps<{
  title: string
  description: string
  href: string
  linkText: string
}>()
</script>

<style scoped>
.doc-card {
  display: flex;
  /* gap 不区分左右,天然适合双向布局 */
  gap: 14px;
  padding: 20px;
  border: 1px solid #e2e8f0;
  border-radius: 16px;
  background: #fff;
}

.doc-card__icon {
  /* 关键:不要写 margin-right,交给行向起点和终点 */
  flex: 0 0 auto;
  inline-size: 44px;
  block-size: 44px;
  display: grid;
  place-items: center;
  border-radius: 12px;
  background: #eef2ff;
}

.doc-card__body {
  min-inline-size: 0;
}

.doc-card__link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: #4f46e5;
  font-weight: 600;
  text-decoration: none;
}

.doc-card__link::after {
  content: "→";
  /* 箭头属于方向性图标,RTL 下自动翻转 */
  transform: scaleX(1);
}

:global([dir="rtl"]) .doc-card__link::after {
  transform: scaleX(-1);
}
</style>

这里唯一保留的 RTL 规则是箭头镜像,因为字符 → 本身携带物理方向。卡片间距、定位、文本流均由逻辑属性处理。若图标库支持 direction 风格变量,也可以进一步把这类规则沉淀到基础样式中。

VitePress 配置:按语言切换 dir 与 lang ​

VitePress 的多语言配置应在路由元信息中声明语言方向,避免运行时手动操作 DOM。

ts
// .vitepress/config.ts
import { defineConfig } from 'vitepress'\nexport default defineConfig({
  locales: {
    root: {
      label: 'English',
      lang: 'en',
      // 英文默认从左到右
    },
    ar: {
      label: 'العربية',
      lang: 'ar',
      // VitePress 会把该字段输出到 html 元素
      dir: 'rtl',
      link: '/ar/',
      themeConfig: {
        nav: [
          { text: 'دليل', link: '/ar/guide/' },
          { text: 'واجهة برمجية', link: '/ar/api/' }
        ]
      }
    }
  },
  themeConfig: {
    socialLinks: [{ icon: 'github', link: 'https://github.com/example/docs' }]
  }
})

自定义主题中还可以封装一个组合式函数,用于极少数必须感知方向的场景:

ts
// composables/useDirection.ts
import { computed } from 'vue'
import { useData } from 'vitepress'

export function useDirection() {
  const { lang } = useData()

  const rtlLanguages = new Set(['ar', 'he', 'fa', 'ur'])
  const dir = computed<'ltr' | 'rtl'>(() =>
    rtlLanguages.has(lang.value) ? 'rtl' : 'ltr'
  )

  // 返回语义化值,组件不要直接拼 .rtl / .ltr class
  const isRTL = computed(() => dir.value === 'rtl')

  return { dir, isRTL }
}

原则仍然是:CSS 能解决的不要交给 JS。这个组合式函数只给画布拖拽、第三方播放器、地图覆盖物等无法使用逻辑属性的场景使用。

aspect-ratio:让图片、视频封面在双向布局中稳定占位 ​

RTL 改造常顺带暴露媒体容器问题:旧代码用 padding-top: 56.25% 做 16:9 占位,或在图片 onload 后计算高度。前者需要额外定位层,后者会引入布局抖动。现代 CSS 的 aspect-ratio 可以直接声明比例。

css
.media-box {
  /* 宽度跟随容器,高度按 16:9 自动计算 */
  aspect-ratio: 16 / 9;
  inline-size: 100%;
  border-radius: 16px;
  overflow: hidden;
  background: #f1f5f9;
}

.media-box img,
.media-box video {
  inline-size: 100%;
  block-size: 100%;
  /* 保持内容裁剪,不拉伸;RTL/LTR 表现一致 */
  object-fit: cover;
  display: block;
}

.avatar {
  /* 正方形头像:用逻辑尺寸避免宽高写死后再被覆盖 */
  inline-size: 48px;
  aspect-ratio: 1;
  border-radius: 50%;
}

在 Vue 列表中,还可以通过 CSS 变量传入不同比例,但要做白名单校验,避免任意样式注入:

vue
<template>
  <figure class="ratio-card">
    <div class="ratio-card__media" :style="mediaStyle">
      <img :src="src" :alt="alt" loading="lazy" />
    </div>
    <figcaption v-if="caption">{{ caption }}</figcaption>
  </figure>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  src: string
  alt: string
  caption?: string
  ratio?: '16/9' | '4/3' | '1'
}>()

// 只允许设计系统中存在的比例,避免用户输入进入 style
const ratioMap = {
  '16/9': '16 / 9',
  '4/3': '4 / 3',
  '1': '1'
} as const

const mediaStyle = computed(() => ({
  '--media-ratio': ratioMap[props.ratio ?? '16/9']
}))
</script>

<style scoped>
.ratio-card__media {
  inline-size: 100%;
  aspect-ratio: var(--media-ratio);
  overflow: hidden;
  border-radius: 12px;
  background: #f8fafc;
}

.ratio-card__media img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}
</style>

工程化检查:用 Stylelint 阻止物理方向回流 ​

一次性迁移并不难,难的是后续不再写回 margin-left、right: 0。可以在 Stylelint 中增加约束,对业务样式给出警告或错误。

js
// stylelint.config.js
export default {
  extends: ['stylelint-config-standard', 'stylelint-config-recommended-vue'],
  rules: {
    // 业务组件默认使用逻辑属性;特殊场景在行内说明原因
    'declaration-property-value-disallowed-list': [
      {
        '/^margin/': ['left'],
        '/^padding/': ['right']
      },
      {
        message: 'RTL 项目请使用 margin-inline-* 或 padding-inline-*'
      }
    ],
    'property-disallowed-list': [
      ['left', 'right'],
      {
        message: '定位请使用 inset-inline-start/end,并确认是否真的需要物理方向'
      }
    ]
  }
}

如果旧项目规模较大,不建议第一天就开启 error。可先在 CI 中输出报告,按页面或组件目录渐进迁移;新组件目录先强制,旧代码通过 .stylelintrc 局部放宽。

避坑总结:RTL 改造中最容易踩的 5 个坑 ​

  1. 把所有 left/right 机械替换成逻辑属性。 常见现象是返回按钮、步骤连线方向正确了,但产品 Logo、勾选图标也被翻转。修正方法是先区分方向性资源与非方向性资源:阅读箭头使用镜像或逻辑定位,品牌标识、检查状态、播放控件通常保持原样。

  2. 只改 margin 和 padding,遗漏定位、圆角与边框。 页面在窄屏下出现“圆角跑到错误一侧”或激活竖线位置不一致。应同步检查 left/right/top/bottom、border-radius、border-left、text-align、box-shadow 等属性,分别替换为 inset-*、border-*-radius、border-inline-*。

  3. 在 JS 中根据 isRTL 拼大量 class。 典型错误是维护 .rtl-mt-12、.ltr-pl-4 这类类名,导致样式和语言状态耦合。优先使用 CSS 逻辑属性;只有第三方画布、拖拽坐标、定位算法才读取方向,并在计算层统一转换坐标。

  4. aspect-ratio 与固定宽高同时使用导致比例失效。 当元素同时声明 width、height 和 aspect-ratio,在常规块级布局中宽高会优先生效,比例被覆盖。建议只固定行向尺寸,例如 inline-size: 100%,让块向尺寸由比例推导;若内容可能溢出,再配合 min-block-size 或 max-block-size 验证。

  5. 忽略数字、代码和表格的双向算法。 阿语文本中夹杂英文 API 名称时,标点可能跑到错误位置;复制出来的代码顺序也可能异常。涉及代码、路径、数字编号时使用 dir="ltr" 与 direction: ltr 隔离,但容器位置仍交给逻辑属性,避免整个组件脱离 RTL 文档流。

完成改造后,建议把 LTR 和 RTL 截图纳入视觉回归,对导航、卡片、弹窗、表格、代码块、媒体卡片六类高频组件建立固定用例。这样 RTL 不再是上线前的一次性翻译检查,而会成为组件设计和工程规范的一部分。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。