必须将 data-theme 设在 document.documentElement 上,因为 CSS 变量、伪元素(如 ::-webkit-scrollbar)、color-scheme 等仅响应根元素属性,不继承 body 的 class 或 dataset;初始化脚本需内联且置于所有样式表前,按 localStorage → 系统偏好 → 默认值顺序设置,并用 matchMedia 监听系统变更同步更新 data-theme 和 localStorage。

必须把 data-theme 设在 document.documentElement 上,否则 CSS 变量、伪元素(如 input::-webkit-inner-spin-button)、滚动条颜色全都不生效。
为什么 document.body.classList.toggle('dark') 切了没反应
常见错误是只操作 body 的 class,但你的 CSS 变量写在 :root 里,而 :root 就是 html 元素——它不继承 body 的 class。所以即使 body 有 dark 类,:root { --bg: #1a1a1a } 这类规则根本不会触发。
- 所有主题变量必须用
html[data-theme="dark"]或html.dark这类选择器显式覆盖,不能只靠.dark .text - 伪元素(
::-webkit-scrollbar、::placeholder)和部分浏览器 UI 控制(如color-scheme)只响应根元素属性,不看body类 - 如果你用了
document.body.setAttribute('data-theme', 'dark'),那更糟:CSS 选择器匹配不到,变量也不更新
页面闪白/闪黑的根本原因和修复时机
闪屏不是 JS 慢,而是样式应用顺序错了:浏览器先按默认主题渲染完,再等 JS 执行才改 data-theme,造成视觉跳变。
- 初始化脚本必须是
<script></script>内联,且严格放在所有<link rel="stylesheet">之前 - 读取逻辑要三者优先级明确:
localStorage.getItem('theme')>window.matchMedia('(prefers-color-scheme: dark)').matches> 默认'light' - 示例写法:
<script> const saved = localStorage.getItem('theme'); const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.dataset.theme = saved ?? (systemPrefersDark ? 'dark' : 'light'); </script> <link rel="stylesheet" href="style.css">
window.matchMedia 监听系统变更时的坑
只调一次 .matches 不够,用户在系统设置里切了深色模式,网页毫无反应——因为 matchMedia 不自动重执行。
立即学习“前端免费学习笔记(深入)”;
- 监听必须在初始化后立刻注册:
const mql = window.matchMedia('(prefers-color-scheme: dark)'); mql.addEventListener('change', e => { ... }) - 回调里要同步更新
document.documentElement.dataset.theme和localStorage.setItem('theme', ...),否则下次刷新还是旧值 - 编辑器或热重载场景下,重复注册会导致多次触发,务必保存
mql引用,销毁前调mql.removeEventListener - 别让
matchMedia覆盖用户手动选择:如果localStorage有值,就忽略系统变更
真正麻烦的不是切换本身,而是那些被忽略的边界:伪元素样式、Shadow DOM 内部组件、meta[name="color-scheme"] 同步、按钮 aria-pressed 状态——它们不跟着 dataset.theme 自动更新,得单独处理。



















