React 与用户偏好:尊重用户已经在 OS 里设过的那些选项

每一个现代操作系统都会在某个时刻问用户:你想要什么样的 UI。深色还是浅色。高对比还是普通。动画开还是关。从左到右还是从右到左。首选语言。用户在系统设置里选一次,从那一刻起,这台机器上每一个好好做出来的原生 App 都会尊重这个选择。而你上线的 Web App 通常不会------它自己搞一个深色模式开关,自己用一个动画库,自己默认是英文,OS 偏好变成某个 issue 跟踪器里五行字的备注。

修起来不难,API 表面也很窄。浏览器通过 window.matchMedianavigator.language 暴露这些 OS 偏好,任何一个现代 React 应用一个下午就能接好。问题不是能不能,而是接线代码住在跟所有 Web 特性一样的 useEffect/useState/SSR 不一致的沼泽里,所以永远被搁置。ReactUse 为此提供了 7 个聚焦的 hook,它们一起覆盖了真正重要的 4 个用户偏好维度:主题、动效、对比度、语言区域。

这篇文章逐个走一遍------它返回什么、它藏了什么 bug、最终的组件长什么样。最后把它们组合进一个 useAppearance() hook,一次性读取这 4 个信号。

1. usePreferredDark------开启主题系统的那个布尔值

最简单的一个。usePreferredDark() 在用户 OS 设为深色模式时返回 true,否则 false。它是对 window.matchMedia('(prefers-color-scheme: dark)').matches 的轻量封装,帮你处理两件你本来要自己处理的事:SSR(没有 window)和实时更新(用户可以在你的标签页打开的同时切 OS 开关,你应该响应)。

手写版

tsx 复制代码
import { useEffect, useState } from "react";

function ManualDark() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    setDark(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setDark(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  return dark ? "dark" : "light";
}

这是对的,但初始 useState(false) 是猜的------对于 SSR 渲染的页面,深色模式用户第一次打开你网站时会产生 hydration 不一致。同样的修复在真实代码库里要写 5 次,而且默认值经常不一致。

ReactUse 版

tsx 复制代码
import { usePreferredDark } from "@reactuses/core";

function Component() {
  const isDark = usePreferredDark();
  return <Theme name={isDark ? "dark" : "light"} />;
}

usePreferredDark 是布尔进、布尔出------丢哪都行,零配置。首次渲染返回 SSR 安全的默认值;客户端挂载后,真实的 matchMedia 值流进来,并随着用户切换保持同步。

2. usePreferredColorScheme------当"深色"不够用时

prefers-color-scheme 有 3 个值,不是 2 个:'light''dark''no-preference'。大多数应用把第三个塌缩到前两个里的一个------这没问题,直到你上线"跟随系统"模式,然后发现有用户显式设了"无偏好",而你的应用现在选错了默认值。usePreferredColorScheme 返回完整的字符串。

tsx 复制代码
import { usePreferredColorScheme } from "@reactuses/core";

function ThemeBadge() {
  const scheme = usePreferredColorScheme();
  // scheme: "light" | "dark" | "no-preference"
  return <span>System theme: {scheme}</span>;
}

三值形式最有用的地方是带"系统"选项的主题选择器:

tsx 复制代码
type Choice = "light" | "dark" | "system";

function ThemePicker({ choice, onChange }: { choice: Choice; onChange: (c: Choice) => void }) {
  const scheme = usePreferredColorScheme();
  const effective =
    choice === "system"
      ? scheme === "dark"
        ? "dark"
        : "light"
      : choice;

  return (
    <fieldset>
      <legend>主题</legend>
      {(["light", "dark", "system"] as const).map((c) => (
        <label key={c}>
          <input
            type="radio"
            checked={choice === c}
            onChange={() => onChange(c)}
          />
          {c}
          {c === "system" && ` (当前 ${effective})`}
        </label>
      ))}
    </fieldset>
  );
}

可见标签告诉用户"系统"现在实际意味着什么------一个很小的细节,能挡住最常见的深色模式困惑("系统选项坏了;它给了我浅色")。

3. useColorMode------带持久化的主题状态

usePreferredDark 报告 OS 偏好。useColorMode 更进一步:它持有应用实际应用的 主题。它把 OS 偏好作为默认值,允许用户覆盖,把覆盖持久化到 localStorage,并把选中的模式写到 <html> 的 class 或属性上,这样你的 CSS 就能切换。

useColorMode 才是你要的真实主题切换器:

tsx 复制代码
import { useColorMode } from "@reactuses/core";

function ThemeToggle() {
  const [mode, setMode] = useColorMode();
  // mode: "light" | "dark" | "auto"

  return (
    <button onClick={() => setMode(mode === "dark" ? "light" : "dark")}>
      切到 {mode === "dark" ? "light" : "dark"}
    </button>
  );
}

一个 hook 你就拿到:

  • 初始值:如果用户之前设过,从 localStorage 读;否则从 prefers-color-scheme
  • 'auto' 模式下实时跟踪 OS 变化
  • <html> 上 class 切换(html.dark vs html.light),CSS 里不用任何 JS 条件
  • SSR 安全:服务端和首次客户端绘制渲染同样的模式

自己实现主题系统的常见坑:首次绘制会闪一下错误模式,因为 OS 偏好是在 hydration 之后读到的。useColorMode 在渲染时同步写入解析后的模式,并在 React 接管树之前从 localStorage 读取持久化选择,从而避开这个问题。配合 <head> 里一段微小的内联 <script> 在更早的时刻就把 class 设好,闪现就彻底没了。

4. useReducedMotion------Web 上最便宜的可访问性胜利

prefers-reduced-motion 是 OS 级信号,表示用户希望屏幕上动得少一点。被视差弄晕的人、对大幅过渡有身体疼痛的前庭功能障碍用户、屏幕阅读器用户(那本身就够"动")------他们都会打开这个。尊重它对你没成本,赢得巨大善意。无视它是上线一个排斥用户的 App 最快的方式之一。

tsx 复制代码
import { useReducedMotion } from "@reactuses/core";
import { motion } from "framer-motion";

function FadeIn({ children }: { children: React.ReactNode }) {
  const reduced = useReducedMotion();

  return (
    <motion.div
      initial={reduced ? { opacity: 1 } : { opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: reduced ? 0 : 0.4 }}
    >
      {children}
    </motion.div>
  );
}

减弱动效开启时,组件跳过 y 轴位移并用 0ms 过渡------内容仍然出现,只是没了动画。这是正确的模式:不要移除视觉变化,移除运动本身。一个不带动画的 toast 仍然有用;一个根本不出现的 toast 是 bug。

useReducedMotion 返回布尔且响应 OS 设置,用户中途切换偏好时动画会立刻停下。

常见接入位置:

  • 页面过渡
  • Modal/抽屉的进入退出
  • 数字递增动画
  • 视差/滚动驱动效果
  • 自动播放轮播(减弱动效时也要停掉 autoplay)

5. usePreferredContrast------被请求时增强边界

prefers-contrast 是较新的媒体特性,报告用户是否要求 OS 给更高或更低的对比度。值有 'more''less''no-preference''custom'。和减弱动效一样,这是一个小群体但收益巨大------高对比模式对低视力用户至关重要。

tsx 复制代码
import { usePreferredContrast } from "@reactuses/core";

function Card({ children }: { children: React.ReactNode }) {
  const contrast = usePreferredContrast();
  const cls =
    contrast === "more"
      ? "card card--high-contrast"
      : "card";
  return <div className={cls}>{children}</div>;
}

高对比变体通常做 3 件事:更粗的边框、更强的颜色值(没有粉彩/灰蒙背景)、更清晰的焦点环。你不需要并行另一套主题------几个针对性覆盖就够:

css 复制代码
.card--high-contrast {
  border: 2px solid currentColor;
  background: var(--surface);
  color: var(--text-strong);
}
.card--high-contrast :focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

usePreferredContrast 返回原始字符串,所以如果你对低对比用户有事可做,可以独立分支 'more''less'(大多数应用只匹配 'more',忽略其余)。

浏览器暴露 navigator.languages------用户首选区域的有序数组,例如 ["en-US", "zh-CN", "ja-JP"]。大多数应用只读 navigator.language(第一项),丢掉了信号:一个设了 ["zh-CN", "en-US"] 的用户想要中文优先、英文兜底,而不是你猜的随便什么。

usePreferredLanguages 返回完整数组,并在用户改浏览器语言偏好时保持同步:

tsx 复制代码
import { usePreferredLanguages } from "@reactuses/core";

const SUPPORTED = ["en", "zh-Hans", "zh-Hant", "ja", "es"] as const;

function pickLocale(preferred: readonly string[]): string {
  for (const lang of preferred) {
    const base = lang.toLowerCase();
    if (SUPPORTED.includes(base as (typeof SUPPORTED)[number])) return base;
    const region = base.split("-")[0];
    const match = SUPPORTED.find((s) => s.toLowerCase().startsWith(region));
    if (match) return match;
  }
  return "en";
}

function LocaleAuto() {
  const preferred = usePreferredLanguages();
  const locale = pickLocale(preferred);
  return <App locale={locale} />;
}

协商逻辑做的事就是服务端 Accept-Language 内容协商干了几十年的事:挑出应用支持的优先级最高的语言,优雅 fallback,最后兜底英文。相对 navigator.language 的胜利是实打实的:一个首选 "de-CH"、次选 "en" 的用户如果你不支持德语,就会落到英文版,而不是看到一个翻译了一半的 UI。

7. useTextDirection------RTL 不只是 CSS

从右到左的语言(阿拉伯语、希伯来语、波斯语)把整个页面的阅读方向翻转。CSS 通过逻辑属性(margin-inline-start 而不是 margin-left)处理大部分,但真正的 RTL 实现还需要 JS 驱动的行为翻转:键盘方向键、轮播的滚动吸附、动画方向、拖拽消除方向。

useTextDirection 读取(也可写入)目标元素的 dir 属性:

tsx 复制代码
import { useEffect } from "react";
import { useTextDirection } from "@reactuses/core";

function App({ locale }: { locale: string }) {
  const [dir, setDir] = useTextDirection();

  useEffect(() => {
    setDir(isRtl(locale) ? "rtl" : "ltr");
  }, [locale, setDir]);

  return (
    <main>
      <Carousel direction={dir === "rtl" ? "leftward" : "rightward"} />
      <KeyboardHandler arrowsFlipped={dir === "rtl"} />
    </main>
  );
}

function isRtl(locale: string): boolean {
  return ["ar", "he", "fa", "ur"].some((p) => locale.startsWith(p));
}

默认 hook 读 <html dir="...">,但它可以指向任意元素------对那些需要独立于外围页面感知 RTL 的嵌入式 widget 很有用。

拼起来:useAppearance

大多数应用想在根节点一次性读这 4 个信号------颜色、动效、对比度、方向------然后通过 context 往下传。单一派生 hook 比每个组件里调 4 次更干净:

tsx 复制代码
import {
  usePreferredDark,
  usePreferredContrast,
  useReducedMotion,
  usePreferredLanguages,
  useTextDirection,
} from "@reactuses/core";

export type Appearance = {
  isDark: boolean;
  highContrast: boolean;
  reducedMotion: boolean;
  locale: string;
  dir: "ltr" | "rtl";
};

export function useAppearance(): Appearance {
  const isDark = usePreferredDark();
  const contrast = usePreferredContrast();
  const reducedMotion = useReducedMotion();
  const preferred = usePreferredLanguages();
  const [dir] = useTextDirection();

  const locale = pickLocale(preferred);

  return {
    isDark,
    highContrast: contrast === "more",
    reducedMotion,
    locale,
    dir: dir === "rtl" ? "rtl" : "ltr",
  };
}

在根节点用一次:

tsx 复制代码
function App() {
  const appearance = useAppearance();

  return (
    <AppearanceContext.Provider value={appearance}>
      <html
        className={`${appearance.isDark ? "dark" : "light"} ${
          appearance.highContrast ? "contrast-more" : ""
        } ${appearance.reducedMotion ? "motion-reduce" : ""}`}
        dir={appearance.dir}
        lang={appearance.locale}
      >
        <Routes />
      </html>
    </AppearanceContext.Provider>
  );
}

<html> 元素现在反映用户设过的每一个偏好:class 给主题/对比度/动效变体,dir 给方向,lang 给区域。任何想根据偏好分支的 CSS 规则用一个属性选择器就行,任何需要原始信号的组件可以从 AppearanceContext 拿,不必再次订阅 matchMedia

能用 CSS 就用 CSS,JS 留给真需要时

合理的疑问:这些东西一半是不是根本不需要 JS?prefers-color-schemeprefers-reduced-motionprefers-contrast 都是 CSS 媒体特性,可以在样式表里处理:

css 复制代码
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

纯视觉变化,CSS 赢。这些 JS hook 真正赚到饭的场景:

  • 偏好驱动行为而不仅是外观(轮播自动播放、传给动画库的时长数值)
  • 偏好决定挂载哪个组件 (<Parallax /> vs <StaticImage />)
  • 偏好影响一个住在 React state 里的派生值(区域协商、主题持久化)
  • 你想要一个用户开关来覆盖 OS 偏好(useColorMode'auto' vs 'light' vs 'dark')

经验法则:静态的交给 CSS,JS 真需要知道时再拿这些 hook。

总结

Hook 信号 什么时候用......
usePreferredDark OS 深色模式偏好 选主题要一个布尔值
usePreferredColorScheme 完整 light/dark/no-preference "跟随系统"模式 UX 需要第三个值
useColorMode 带持久化的实际应用主题 你在搭主题系统本身
useReducedMotion prefers-reduced-motion 你把时长传给动画库,或要拦下动效繁重的组件
usePreferredContrast prefers-contrast 你要出一个高对比变体
usePreferredLanguages 完整 navigator.languages 你要做区域协商,不只是检测首选语言
useTextDirection dir 属性 你支持 RTL 语言并需要 JS 驱动翻转

尊重用户已经在 OS 里设过的偏好,是你能上线的最便宜的可访问性升级。门槛低------返回布尔、切 className、传时长------收益高。更多 hook 在 reactuse.com,如果你明天打开 prefers-reduced-motion,你的 App 不再把卡片到处甩,那今天键盘没白敲。

相关推荐
RPGMZ41 分钟前
RPGMZ 游戏场景全局提示框 带三秒隐藏插件
前端·javascript·游戏·rpgmz
宠..42 分钟前
VS Code 修改 C++ 标准同时修改错误检测标准
java·linux·开发语言·javascript·c++·python·qt
JarvanMo1 小时前
2026年最佳Flutter图标包
前端
Mahir081 小时前
Redis 三大缓存问题:穿透、击穿、雪崩的原理与完整解决方案
数据库·redis·缓存·面试·大厂面试题
Arthur14726122865471 小时前
Vue Query 缓存机制实战:别再让 gcTime 和 staleTime 背锅了
前端
Rkgua1 小时前
React中的赋值操作为什么不是=?
前端·javascript
heyCHEEMS1 小时前
记录一个 React 表单的小坑:缓存节流导致页面刷新
前端·javascript
老杨聊大模型1 小时前
分块(Chunking)分块没做好,耶稣来了也救不了你!!!
人工智能·面试
Languorous.1 小时前
C++数据结构高阶|B+树深度解析:从底层原理到数据库应用,面试高频考点全覆盖
数据结构·b树·面试