跳到主要内容

Next.js 避免暗黑模式闪烁

· 阅读需 13 分钟
Skyone

不知道大家有没有遇到过这样的问题,就是在 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。这里的例子的实现其实和官网是类似的。我自己实现一边是为了让大家更好地理解。

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

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

components/ThemeRegistry.tsx
"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 标签中,这样就可以避免闪烁了。下面看看它的实现:

components/EmotionCache.tsx
"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>

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

下面是完整的例子:

app/global.css
@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 的颜色:

tailwind.config.ts
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 类名。下面是实现:

app/layout.tsx
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 字符串,使得这个操作在服务端渲染时完成。

变量 bootloader 的值
!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 渲染之前读取用户的选择。

【完】