Astro添加过渡后DarkModeToggle按钮失效
2024-2-27
·
hexer

我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-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>
    )
}

省略部分代码

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>

参考: