主题切换闪烁问题

现象与解决方案

现象:把主题保存在 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 搜索 darklight 可以模拟对应主题色(搜索不到浏览器当前的主题色)

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 代码进行微调

相关推荐
张拭心2 小时前
Cursor 又偷偷更新,这个功能太实用:Visual Editor for Cursor Browser
前端·人工智能
I'm Jie2 小时前
深入了解 Vue 3 组件间通信机制
前端·javascript·vue.js
用户90443816324603 小时前
90%前端都踩过的JS内存黑洞:从《你不知道的JavaScript》解锁底层逻辑与避坑指南
前端·javascript·面试
CodeCraft Studio4 小时前
文档开发组件Aspose 25.12全新发布:多模块更新,继续强化文档、图像与演示处理能力
前端·.net·ppt·aspose·文档转换·word文档开发·文档开发api
PPPPickup4 小时前
easychat项目复盘---获取联系人列表,联系人详细,删除拉黑联系人
java·前端·javascript
老前端的功夫4 小时前
前端高可靠架构:医疗级Web应用的实时通信设计与实践
前端·javascript·vue.js·ubuntu·架构·前端框架
前端大卫5 小时前
【重磅福利】学生认证可免费领取 Gemini 3 Pro 一年
前端·人工智能
孜燃5 小时前
Flutter APP跳转Flutter APP 携带参数
前端·flutter
脾气有点小暴5 小时前
前端页面跳转的核心区别与实战指南
开发语言·前端·javascript
lxh01135 小时前
最长递增子序列
前端·数据结构·算法