我coding博客主题的时候遇上了这样的问题:添加过渡动画后,点击导航栏切换到其他页面,如果当前页面是dark模式,那么切换后就会变成light模式
DarkModeToggle代码分析
我的ModeToggle按钮的代码是从这里〰改的
JS:
<script is:inline>
// 从localstorage|系统获取主题
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
// 是dark模式就在dom的class中添加dark
const isDark = getThemePreference() === 'dark';
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
// 监听class,当class变化时触发回调
if (typeof localStorage !== 'undefined') {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
</script>
ModeToggle:
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
// 定义state
const [theme, setThemeState] = React.useState<
"theme-light" | "dark" | "system"
>("theme-light")
// 定义useEffect,组件渲染时执行
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark")
setThemeState(isDarkMode ? "dark" : "theme-light")
}, [])
// 定义useEffect,状态theme变化时执行
React.useEffect(() => {
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
document.documentElement.classList[isDark ? "add" : "remove"]("dark")
}, [theme])
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setThemeState("theme-light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
添加过渡后代码为何失效
在未加入
ViewTransition
时,网站是全页面导航,即切换页面后,页面重新加载 加入ViewTransition
后,网站变成了SPA模式,页面将不会全部刷新,而是通过JS将页面元素一个个替换 具体可参考:全页面导航 vs 客户端路由〰
Astro 的视图过渡动画支持由新的 View Transitions〰 浏览器 API 提供
如果是加入ViewTransition
之前,切换页面全部刷新,那么JS就会执行,但是添加了过渡动画之后页面部分切换,JS就不一定执行了,所以需要侦听生命周期事件〰,当页面刷新时就执行代码
且ViewTransition
之后,ModeToggle
组件也会重新渲染,造成组件的状态丢失,(const [theme, setThemeState] = React.useState<"theme-light" | "dark" | "system">("theme-light")
)被重新设置为theme-light
如何解决
ModeToggle
组件状态丢失
Astro中提供了transition:persist
指令,用于在切换页面时组件保留状态
<Navigation client:idle transition:persist />
这样组件就可以在切换页面后保留状态了
addEventListener
监听生命周期事件〰
视图切换生命周期共五种:
astro:before-preparation
〰astro:after-preparation
〰astro:before-swap
〰astro:after-swap
〰astro:page-load
〰
astro:before-swap
〰是能看得到切换前页面的最后一个周期,而astro:after-swap
〰是切换完成页面后的第一个周期
上文创建了observer
用来监听全局的class是否变化,变化了就设置localStorage
,一经创建,只要不刷新页面就一直存在
当页面改变后会默认变成light
模式,所以,此时就会触发observer
的回调函数,localStorage
也就变成了light
,所以,由于astro:before-swap
是页面变化前最后的一个周期,只有在astro:before-swap
以及之前我们检查到的localStorage
才是上个页面保存的localStorage
所以,结果出来了!!!我们应该在astro:before-swap
或之前的周期检查localStorage
,然后在astro:after-swap
或者之后的周期添加dark
类
那么问题又来了,如何在
astro:before-swap
周期记录isDark
值,然后在astro:after-swap
获取它呢,答案是用状态变量管理,Astro推荐使用Nano Stores〰来进行客户端的存储共享,所以后续使用Nano Stores〰
<script>
import { isDark } from "@/store.ts";
document.addEventListener("astro:before-swap", () => {
// 从localstorage|系统获取主题
const getThemePreference = () => {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
};
// 是dark模式就在dom中添加dark
isDark.set(getThemePreference() === "dark");
console.log("isDark:" + isDark.get());
});
document.addEventListener("astro:after-swap", () => {
document.documentElement.classList[
isDark.get() ? "add" : "remove"
]("dark");
});
</script>
同时,如果刷新整个页面,页面就会失常,因为我们把页面刷新时的代码都加进生命周期了。当页面整体刷新时,不经过生命周期,所以需要重新加上原来的代码,才能正常执行
代码
前提:Astro已经安装Nano Stores〰,具体参考本文〰
除此之外,我还用了TailwindCSS和Shadcn
store.ts
// 设置变量isDark
import { atom } from 'nanostores';
export const isDark = atom<boolean>(false);
ModeToggle.tsx
import * as React from "react"
import { Moon, Sun, SunMoon } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ModeToggle() {
const [theme, setThemeState] = React.useState<"theme-light" | "dark" | "system">("theme-light")
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark")
setThemeState(isDarkMode ? "dark" : "theme-light")
}, [])
const toggleTheme = () => {
setThemeState(prevTheme => {
if (prevTheme === "theme-light") {
return "dark"
} else if (prevTheme === "dark") {
return "system"
} else {
return "theme-light"
}
})
}
React.useEffect(() => {
const isDark =
theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches)
console.log(theme);
document.documentElement.classList[isDark ? "add" : "remove"]("dark")
}, [theme])
return (
<Button variant="outline" size="icon" onClick={toggleTheme} className="bg-transparent hover:bg-transparent animate-pulse">
{theme === "theme-light" && (
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 hover:stroke-ring" />
)}
{theme === "dark" && (
<Moon className="h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 hover:stroke-ring" />
)}
{theme === "system" && (
<SunMoon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:rotate-0 dark:scale-100 hover:stroke-ring" />
)}
</Button>
)
}
NavigationMenu.tsx
省略部分代码
import React, { useState } from 'react';
import { ModeToggle } from "@/components/ModeToggle";
return (
// ...
<ModeToggle />
// ...
);
RootLayout.astro
省略部分代码
// ...
<Navigation client:idle transition:persist />
// ...
// 页面整体刷新时
<script>
import { isDark } from "@/store.ts";
// 从localstorage|系统获取主题
const getThemePreference = () => {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
// 是dark模式就在dom中添加dark
isDark.set(getThemePreference() === "dark");
console.log("isDark1:" + isDark.get());
document.documentElement.classList[isDark.get() ? "add" : "remove"](
"dark",
);
// localStorage存在就添加observer,当class变化时触发回调
if (typeof localStorage !== "undefined") {
const observer = new MutationObserver(() => {
const isDark =
document.documentElement.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
}
</script>
// 页面过渡切换时
<script>
import { isDark } from "@/store.ts";
document.addEventListener("astro:before-swap", () => {
// 从localstorage|系统获取主题
const getThemePreference = () => {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
};
// 是dark模式就在dom中添加dark
isDark.set(getThemePreference() === "dark");
console.log("isDark:" + isDark.get());
});
document.addEventListener("astro:after-swap", () => {
document.documentElement.classList[
isDark.get() ? "add" : "remove"
]("dark");
});
</script>
参考: