profilBlog Server 测试服

cover

Next.js 避免暗黑模式闪烁

未分类
编辑

不知道大家有没有遇到过这样的问题,就是在 Next.js 中使用暗黑模式时,页面会闪烁一下,这是因为 Next.js 会在服务端渲染时并不知道用户是否选择了黑暗模式(比如我一般将这个变量存在 window.localStorage 里),所以所以在用户接收到页面之前,页面会先显示一下默认的样式,然后再根据用户的选择来渲染暗黑模式的样式,这样就会导致页面闪烁。

在使用 CSS in JS 的时候这种闪烁尤为明显,例如 MUI 的官网。一般网站都会默认使用亮色主题,但我又倾向于使用暗黑模式,所以经常看到这种闪烁。我认为这真的很影响用户体验,至少我自己看得很难受。

当然,解决办法是有的,下面我将分两种情况来介绍如何解决:CSS in JS 和普通 CSS (包括零运行时 CSS in JS 和 SASS/LESS 、 tailwindcss 等)。

CSS in JS

这里以 emotion 为例子吧,其他的 CSS in JS 库基本都有类似的 API。

解决这个问题的关键点是:浏览器在解析 HTML 的时候, style 标签和 script 标签都会堵塞渲染,我们可以在 body 之前就确定好用户的选择,然后在 body 之前就将对应的样式插入到 style 标签中,这样就可以避免闪烁了。也就是说,我们需要把响应的样式和脚本内联到 head 标签中。

例子

例如下面的 emotion + MUI 的具体例子(使用 App Router):

最新版的 MUI 已经内置 Next.js 的支持了,参考 Next.js Integration - Material UI。这里的例子的实现其实和官网是类似的。我自己实现一边是为了让大家更好地理解。

export default function RootLayout({children}: RootLayoutProps) {
    return (
        <html lang="zh-CN">
            <body>
                <ThemeRegistry>
                    {children}
                </ThemeRegistry>
            </body>
        </html>
    );
}

也就是说,我们需要在 ThemeRegistry 中将对应的样式和脚本内联到 head 标签中。下面看看它的实现:

"use client";

import CssBaseline from "@mui/material/CssBaseline";
import {createTheme, ThemeProvider as MuiThemeProvider} from "@mui/material/styles";
import {Roboto} from "next/font/google";
import Link, {LinkProps} from "next/link";
import {createContext, forwardRef, ReactNode, useContext, useEffect, useState} from "react";
import NextAppDirEmotionCacheProvider from "./EmotionCache";

const LinkBehavior = forwardRef<
    HTMLAnchorElement,
    LinkProps
>((props, ref) => {
    const { href, ...other } = props;
    // 使用 next.js 的 Link 替代原生的 a 标签, 防止路由时页面重载
    return <Link ref={ref} href={href} {...other} />;
});

export type ColorMode = "light" | "dark" | "system";
export type SystemColorMode = "light" | "dark"
type ColorModeContextType = {
    colorMode: ColorMode;
    currentColorMode: "light" | "dark";
    setColorMode: (colorMode: ColorMode) => void;
    toggleColorMode: () => void
};


const roboto = Roboto({
    weight: ["300", "400", "500", "700"],
    subsets: ["latin"],
    display: "swap",
});

interface ColorModeProviderProps {
    children: ReactNode;
}

const ColorModeContext = createContext<ColorModeContextType>(null!);

export function ColorModeProvider({children}: ColorModeProviderProps) {
    const [colorMode, _setColorMode] = useState<ColorMode>("system");
    const [systemColorMode, setSystemColorMode] = useState<SystemColorMode>("dark");
    useEffect(() => {
        const local = localStorage.getItem("theme.palette.mode") || "system";
        const defaultMode = local === "light" || local === "dark" ? local : "system";
        _setColorMode(defaultMode);

        const listener = (e: MediaQueryListEvent) => {
            setSystemColorMode(e.matches ? "dark" : "light");
        };
        window.matchMedia("(prefers-color-scheme: dark)")
            .addEventListener("change", listener);
        return () => {
            window.matchMedia("(prefers-color-scheme: dark)")
                .removeEventListener("change", listener);
        };
    }, []);
    const setColorMode = (mode: ColorMode) => {
        _setColorMode(mode);
        localStorage.setItem("theme.palette.mode", mode);
    };
    const toggleColorMode = () => {
        if (colorMode === "light") {
            setColorMode("dark");
        } else if (colorMode === "dark") {
            setColorMode("system");
        } else {
            setColorMode("light");
        }
    };
    const currentColorMode = colorMode === "system" ? systemColorMode : colorMode;

    return (
        <ColorModeContext.Provider value={{colorMode, setColorMode, currentColorMode, toggleColorMode}}>
            {children}
        </ColorModeContext.Provider>
    );
}

export const useColorMode = () => useContext(ColorModeContext);

interface ThemeProviderProps {
    children: ReactNode;
}

function ThemeProvider({children}: ThemeProviderProps) {
    const {currentColorMode} = useColorMode();
    const theme = createTheme({
        typography: {
            fontFamily: roboto.style.fontFamily,
        },
        components: {
            MuiLink: {
                defaultProps: {
                    component: LinkBehavior,
                },
            },
            MuiButtonBase: {
                defaultProps: {
                    LinkComponent: LinkBehavior,
                },
            },
        },
        palette: {
            mode: currentColorMode,
        },
    });

    return (
        <MuiThemeProvider theme={theme}>
            {children}
        </MuiThemeProvider>
    );
}

interface ThemeRegistryProps {
    children: ReactNode;
}

export default function ThemeRegistry({children}: ThemeRegistryProps) {
    return (
        <NextAppDirEmotionCacheProvider options={{key: "mui"}}>
            <ColorModeProvider>
                <ThemeProvider>
                    <CssBaseline/>
                    {children}
                </ThemeProvider>
            </ColorModeProvider>
        </NextAppDirEmotionCacheProvider>
    );
}

这是一个 use client 的客户端组件,这里主要实现了从 localStorage 中读取用户的选择,然后设置对应的 palette.mode

可以看到,使用了一个名为 NextAppDirEmotionCacheProvider 的组件,这个组件的作用是将 emotion 的 cache 保存 head 标签中,这样就可以避免闪烁了。下面看看它的实现:

"use client";

import type {EmotionCache, Options as OptionsOfCreateCache} from "@emotion/cache";
import createCache from "@emotion/cache";
import {CacheProvider as DefaultCacheProvider} from "@emotion/react";
import {useServerInsertedHTML} from "next/navigation";
import {ReactNode, useState} from "react";

export type NextAppDirEmotionCacheProviderProps = {
    /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
    options: Omit<OptionsOfCreateCache, "insertionPoint">;
    /** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */
    CacheProvider?: (props: {
        value: EmotionCache;
        children: ReactNode;
    }) => ReactNode;
    children: ReactNode;
};

// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps) {
    const {options, CacheProvider = DefaultCacheProvider, children} = props;

    const [registry] = useState(() => {
        const cache = createCache(options);
        cache.compat = true;
        const prevInsert = cache.insert;
        let inserted: { name: string; isGlobal: boolean }[] = [];
        cache.insert = (...args) => {
            const [selector, serialized] = args;
            if (cache.inserted[serialized.name] === undefined) {
                inserted.push({
                    name: serialized.name,
                    isGlobal: !selector,
                });
            }
            return prevInsert(...args);
        };
        const flush = () => {
            const prevInserted = inserted;
            inserted = [];
            return prevInserted;
        };
        return {cache, flush};
    });

    useServerInsertedHTML(() => {
        const inserted = registry.flush();
        if (inserted.length === 0) {
            return null;
        }
        let styles = "";
        let dataEmotionAttribute = registry.cache.key;

        const globals: {
            name: string;
            style: string;
        }[] = [];

        inserted.forEach(({name, isGlobal}) => {
            const style = registry.cache.inserted[name];

            if (typeof style !== "boolean") {
                if (isGlobal) {
                    globals.push({name, style});
                } else {
                    styles += style;
                    dataEmotionAttribute += ` ${name}`;
                }
            }
        });

        return (
            <>
                {globals.map(({name, style}) => (
                    <style
                        key={name}
                        data-emotion={`${registry.cache.key}-global ${name}`}
                        dangerouslySetInnerHTML={{__html: style}}
                    />
                ))}
                {styles && (
                    <style
                        data-emotion={dataEmotionAttribute}
                        dangerouslySetInnerHTML={{__html: styles}}
                    />
                )}
            </>
        );
    });

    return <CacheProvider value={registry.cache}>{children}</CacheProvider>;
}

任然是一个客户端组件,但是不要被它的名字骗了,客户端组件并不是指完全在浏览器中渲染的组件,而是由服务端渲染成 HTML,再由浏览器进行 “水和” 作用。

所谓的水和作用,其实就是将 DOM 事件监听加载到服务器产生的 HTML 字符串上,毕竟服务器只能返回字符串,不能传递 JS 函数。其次就是运行 useEffect 里的代码,这里就是调用 emotion 提供的相关 API。

useServerInsertedHTML 则是由 next/navigation 提供的一个 hook,它的作用是在服务器渲染的 HTML 字符串上运行一些代码,这里就是将 emotion 的 cache 保存到 head 标签中。注意:这个钩子实在服务器上运行的!甚至可以说这根本就不是标准的 React 钩子。 useServerInsertedHTML 的参数是一个函数,这个函数的返回值会被插入到 head 标签中,这个行是在服务器上发生的,这就实现了将 emotion 的 cache 保存到 head 标签中。

普通 CSS

这里的 "普通CSS" 指的是:

  • 纯 CSS 文本
  • 零运行时 CSS in JS (也就是编译期转换为纯 CSS)
  • SASS/LESS 等 CSS 预处理器
  • tailwindcss (同样是编译期转换为纯 CSS)

相对于 CSS in JS 来说,普通的 CSS 就简单多了,因为我们可以直接在 head 标签中插入对应的样式,这样就可以避免闪烁了。而且一般来说,普通的 CSS 都使用 color-scheme 或某个放在根节点的 class 实现的。

例子

同样的,下面举个例子。这个例子使用 tailwindcss,包含三种模式:亮色、暗黑和跟随系统。实现方式就是:

  • html 标签上有 dark 类,则使用暗黑模式
  • html 标签上有 light 类,则使用亮色模式
  • html 标签上没有 darklight 类,则使用跟随系统模式

tailwind 官网的例子是对于每个元素都写明对于的 dark mode 的表示方式来实现的,例如:

<p class="bg-white text-black dark:bg-white dark:text-black">233</p>

但我习惯用 CSS 变量来实现,例如:

<p class="bg-bg-main text-text-main">233</p>

能省去很多笔墨,还能防止漏掉某个暗色样式的情况。

下面是完整的例子:

@tailwind base;
@tailwind components;
@tailwind utilities;

img, video {
    max-width: unset;
}

@layer base {
    :root {
        color-scheme: light;
        --bg-d: #f2f5f8;
        --bg-l: #ffffff;
        --bg-hover: #eceef2;
        --text-main: #475c6e;
        --text-content: #37475b;
        --text-subnote: #64778b;
    }

    .light:root {
        color-scheme: light;
        --bg-d: #f2f5f8;
        --bg-l: #ffffff;
        --bg-hover: #eceef2;
        --text-main: #475c6e;
        --text-content: #37475b;
        --text-subnote: #64778b;
    }

    .dark:root {
        color-scheme: dark;
        --bg-d: #181c27;
        --bg-l: #252d38;
        --bg-hover: #3e4b5e;
        --text-main: hsla(0, 0%, 100%, .92);
        --text-content: hsla(0, 0%, 100%, .86);
        --text-subnote: hsla(0, 0%, 100%, .66);
    }

    @media (prefers-color-scheme: dark) {
        :root {
            color-scheme: dark;
            --bg-d: #181c27;
            --bg-l: #252d38;
            --bg-hover: #3e4b5e;
            --text-main: hsla(0, 0%, 100%, .92);
            --text-content: hsla(0, 0%, 100%, .86);
            --text-subnote: hsla(0, 0%, 100%, .66);
        }
    }
}

这里写了 4 遍 CSS 变量,但是利用优先级,只有一个会生效。这样就可以实现上述的三种模式了。下面自定义 tailwind 的颜色:

import type {Config} from "tailwindcss";

const config: Config = {
    content: [
        "./pages/**/*.{js,ts,jsx,tsx,mdx}",
        "./components/**/*.{js,ts,jsx,tsx,mdx}",
        "./app/**/*.{js,ts,jsx,tsx,mdx}",
    ],
    theme: {
        extend: {
            colors: {
                bg: {
                    dark: "var(--bg-d)",
                    light: "var(--bg-l)",
                    hover: "var(--bg-hover)",
                },
                text: {
                    main: "var(--text-main)",
                    content: "var(--text-content)",
                    subnote: "var(--text-subnote)",
                },
            },
        },
    },
    plugins: [],
};
export default config;

这里的 content 是指 tailwind 会扫描这些文件,然后将其中的类名加入到 CSS 中,这样就可以使用 tailwind 的类名了。这里的 theme 是指自定义的 tailwind 的主题,这里主要是自定义颜色。这里的 extend 是指扩展 tailwind 的主题,这里主要是扩展颜色,让 tailwind 可以使用 bg-bg-main 这样的类名,并且对应的颜色使用了上面定义的 CSS 变量。

最后,我们需要给 Next.js 的 head 打个补丁,以实现在 body 渲染之前读取用户的选择,然后设置对应的 html 类名。下面是实现:

import {ReactNode} from "react";
import "./globals.css";

const bootloader = `!function(){var t=localStorage.getItem("pattern.mode"),a=document.documentElement.classList;"light"===t?a.add("light"):"dark"===t&&a.add("dark")}();`;

interface RootLayoutProps {
    children: ReactNode;
}

function RootLayout({children}: RootLayoutProps) {
    return (
        <html lang="zh-CN">
            <head>
                <script dangerouslySetInnerHTML={{__html: bootloader}}/>
            </head>
            <body>
                {children}
            </body>
        </html>
    );
}

export default RootLayout;

重点就是 bootloader 这个字符串,它会在 head 标签中插入一个 script 标签,这个标签的内容就是读取用户的选择,然后设置对应的 html 类名。这样就可以实现在 body 渲染之前读取用户的选择,然后设置对应的 html 类名了。

使用 React 提供的 dangerouslySetInnerHTML 来插入 HTML 字符串,使得这个操作在服务端渲染时完成。

!function(){
    var t = localStorage.getItem("pattern.mode");
    var a = document.documentElement.classList;
    if ("light" === t) {
        a.add("light");
    } else if ("dark" === t) {
        a.add("dark");
    }
}();

总结

这里介绍了两种解决办法,一种是针对 CSS in JS 的,一种是针对普通 CSS 的。这两种方法都是在服务端渲染时完成的,所以不会出现闪烁的情况。 究其本质,其实两种方法的基本思想是一样的:在服务端将 CSS 和 一小串 JS 代码插入到 head 标签中,在 body 渲染之前读取用户的选择。

【完】