现象与解决方案
现象:把主题保存在 localStorage 中,下次打开页面会自动打开原来的主题,但是刚打开时会有一个闪烁
原因:因为把读取 localStorage 中的主题值的过程放在 useEffect 中了
在 useEffect 中执行的,其执行顺序为
- HTML 加载 此时是默认主题色或者浏览器默认颜色
- React 挂载,渲染页面
- 执行 useEffect 重新渲染页面,此时应用保存在 localStorage 中的主题变量和主题色
正确做法是,应该放在 createRoot 之前同步执行,避免闪烁
js
(function(){
const savedTheme = localStorage.getItem('theme');
const browserTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', savedTheme || browserTheme || 'light');
})();
实现过程
先介绍一下切换主题要用到的知识点
:root 和 html
:root 这个伪类匹配文档树的根元素,在 HTML 中代表的就是 <html> 标签,但是优先级比 html元素选择器 高
css
// demo 中 html 选择器写在 :root 后面,如果同优先级应该是后者优先的,实际上确实红色,说明 :root 优先
:root {
background: red; // 此时页面为红色,注释掉这行时页面呈黄色
}
html {
background: yellow;
}
系统主题色
后面提到的主题都可以理解为模式(参考 Chrome - 设置 - 外观 - 模式 只能选择深色浅色和自动,而主题可以自定义) DevTools 中 cmd + shift + p 搜索 dark 或 light 可以模拟对应主题色(搜索不到浏览器当前的主题色)
color-scheme
允许元素按照哪些颜色方案呈现,简单来说就是 按照浏览器模式显示页面,常见选择为 亮色 light 和 暗色 dark,页面的默认颜色会按照系统模式显示
css
注意:
1.是按照浏览器的模式,而非主题
2.是按照模式显示,而不是给 html 元素添加颜色,打开一个空白 html 页面可以看到,背景颜色、文字颜色、滚动条颜色都会受到影响(可以视为,只对没有通过 css 设置的样式生效,优先级最低)
css
:root {
color-scheme: ligth dark;
}
CSS 媒体查询
CSS 媒体查询检测系统浏览器主题色是否为深色,prefers-color-scheme
html 文件中只写这两个样式,切换浏览器模式,发现不管是 dark 还是 light 模式,都是显示蓝色,说明 @media (prefers-color-scheme: dark) 比 [data-theme='dark'] 优先级更低
css
:root[data-theme='dark'] {
body {
background: blue;
}
}
@media (prefers-color-scheme: dark) {
:root {
body {
background: red;
}
}
}
一般情况下都会使用 JS 来获取 localStorage 或 浏览器主题,不需要使用 @media (prefers-color-scheme: dark) 来实现主题切换, 如果需要纯 CSS 实现颜色变量切换就可以使用了(比如禁用 js 时?)
css
:root {
--color-background: #FFF;
--color-text: #000;
--size-sm: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: #000;
--color-background: #FFF;
}
}
JS 媒体查询
JS 查询 window.matchMedia()
js
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkModeMediaQuery.addEventListener('change', (e) => {
const darkModeOn = e.matches
console.log('Dark Mode is', darkModeOn);
})
完整的 CSS 变量声明
css
:root {
--color-background: #FFF;
--color-text: #000;
--size-sm: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: #000;
--color-background: #FFF;
}
}
:root {
--color-background: #000;
--color-background: #FFF;
}
next.js 项目中保存主题
为了避免闪烁问题,在 React 项目中应该在 createRoot 之前获取 localStorage 保存的主题,而在 Next.js 项目中,情况又会复杂一些 方案一:在 layout 中使用内联脚本执行 方案二:使用 next-themes 实现,会自动给 html 添加 color-scheme 属性(原理其实一样)
两种方法都很不难,但是按照传统的代码实现中间容易遇到两个问题
传统代码一般是 const [theme, setTheme] = useState('light') 初始化主题变量,useEffect 中读取 localStorage 值并 setTheme,最新版 react 的 eslint 配置会报错 react/set-state-in-effect 可以直接关掉这条规则,但是仔细考虑一下这条规则出现的原因,确实应该尽量遵守,不应该为了读取一个值重复渲染一次,应该在初始化 state 的时候就进行赋值
改成 const [theme, setTheme] = useState(() => localStorage.getItem('theme')),eslint 报错没了,但是 next.js 又报错了,大致意思就是 水合过程中发现服务端预渲染的结果和客户端渲染结果不一致 ,按照 官方文档 的解决方案,使用 supressHydrationWarning={true} 或者 dynamic 官方推荐尽量不使用 suppressHydrationWarning,而且实际情况中可能很多地方使用(上一个简单 demo 则只需要在 button 属性上增加即可),所以我们采用 dynamic 方案,官方推荐的是在引入组件时包裹一层,但是考虑到我们是通用组件的话,可以在导出的时候包裹一层即可
解决 react/set-state-in-effect 并保存到 localStorage 代码如下
ts
type Theme = 'dark' | 'light';
const getBrowserTheme = (): Theme => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return isDark ? 'dark' : 'light';
}
export default function ToggleTheme {
// 初始化页面主题是通过前面提到过的 createRoot 之前获取,这里只是初始化切换主题的按钮文案
const [theme, setTheme] = useState<Theme>('light');
const handleToggleTheme = (t: Theme) => {
setTheme(t);
document.documentElement.setAttribute('theme', t);
localStorage.setItem('theme', t);
}
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== 'theme') return;
if (!e.newValue) {
// localStorage 值不存在则获取浏览器模式
setTheme(() => getBrowserTheme());
} else {
// 存在则改变 theme
setTheme(e.newValue as Theme);
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
})
return (
<button onClick={() => handleToggleTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</button>
)
}
浏览器监听 storage 事件用于跨标签/窗口同步数据,当前窗口修改 localStorage 时不会触发当前窗口的监听
解决 next.js Hydrate 过程中服务端预渲染和客户端渲染结果不一致方案
js
// 官方推荐
export default function Toggle() {};
// 引入的地方动态引入包裹一层,然后使用 DynamicToggle
const DynamicToggle = dyanmic(() => import('../componnents/Toggle'), {ssr: false});
// 我的代码
import dynamic from 'next/dynamic';
// 简化的组件 demo,真是情况应该参考上面的 demo
const Toggle = () => {
const [theme, setTheme] = useTheme();
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</button>
};
const DynamicToggle = dynamic(Toggle, {ssr: false});
export default DynamicToggle;
document.startViewTransition
document.startViewTransition(cb) 开始一个新的视图过渡,可以理解为在 DOM 发生变化的过程中展示一层过渡动效,常见的例子
- 图片预览页切换图片
- 切换主题按钮点击后整个页面发生一个区域覆盖过程
js
const toggleTheme = (t) => {
// ...
}
const toggleWithTransition = (t) => {
if (document.startViewTransition) {
document.startViewTransition(() => {
toggleTheme(t);
})
} else {
toggleTheme(t);
}
}
具体动效由 ::viewtransition-new 和 ::viewtransition-old 的样式决定
css
::view-transition-new(root) {
mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="white"/></svg>')
center / 0 no-repeat;
animation: scale 1s;
animation-fill-mode: both;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
animation-fill-mode: both;
z-index: -1;
}
.dark::view-transition-new(root) {
animation: scale 1s;
animation-fill-mode: both;
}
@keyframes scale {
to {
mask-size: 200vmax;
}
}
可以 参考开源 的动效,直接 copy 代码进行微调