完美的黑暗模式

2025-05-27

完美的黑暗模式

如果你想亲眼看看它的实际效果,并阅读我最初的意图(相信我,额外点击是值得的😄)你可以在这里查看我的完整帖子:

sreetamdas.com/blog/the-perfect-dark-mode


我是Josh W Comeau网站的忠实粉丝,也非常喜欢他发布的内容。他写过一些非常非常有趣的文章,但迄今为止最有趣的一篇是关于他探索完美暗黑模式的文章

这是一本完美的读物,既技术性十足又趣味十足,坦白说,它启发了技术博客文章的写作思路。我已经完整地读了三遍以上,读到第三遍结尾时,我就知道我必须尝试一下。

不过,这里有一个小问题:Josh 用Gatsby实现了它。而我的博客是用Next.js搭建的。(这两个网站都用 React 实现了类似静态的网站,我在之前的博客文章中对此进行了更详细的介绍。)

如果您还没有阅读过 Josh 的帖子,请先阅读他的文章,以便能够跟进。

好吧,我们进入未知领域!

问题

那么这有什么大不了的?什么才是完美的黑暗模式?

如果您查看支持暗模式的网站(例如mdxjs.com),在启用暗模式后尝试刷新页面时您会注意到一些事情。

完美暗黑模式-mdxjs-闪烁

可怕的灯光闪烁模式。

那么为什么会发生这种情况呢?

这个问题不仅限于静态/混合网站,几乎所有使用 JavaScript 来“水化”其组件的网站都会遇到。这是因为当我们的页面加载时,会发生以下情况:

  • HTML 首先加载,然后加载 JS 和 CSS
  • 默认情况下,网页具有transparent背景颜色,这意味着除非您使用某些扩展程序,否则您将获得白色背景
  • HTML 可以包含内联 CSS 来设置背景颜色,这样我们就不会看到“闪烁”,但目前内联 CSS 不支持媒体查询,所以我们无法确定用户是否更喜欢暗模式
  • 首先加载的 JS 需要先进行解析,然后才能开始“填充”页面。如果已存储任何暗黑模式的偏好设置(通常使用本地存储),JS 也会加载它。这意味着,在所有这些操作完成之前,我们的用户仍然只能看到 HTML 所描述的内容:透明背景。

解决方案

那么我们该怎么办呢?我们需要找到一种方法,能够在整个页面加载之前background-color运行一些代码并应用适当的内容(以及主题) 。

以下是我们需要实施的内容的粗略列表:

  • 如果用户之前访问过我们的网站,那么我们会使用他们保存的偏好设置
  • 如果用户之前没有访问过我们的网站或没有保存偏好设置,那么我们会检查他们的操作系统是否有偏好设置,并使用相同的
  • 如果上述两种方法仍未返回偏好设置,则我们默认使用浅色主题
  • 所有上述检查都需要在我们的页面呈现/显示给用户之前运行
  • 允许用户切换暗模式,并保存他们的偏好以供将来参考

让我们首先组合一个简单的 Next.js 页面和一个非常基本的暗模式切换:

// pages/index.js
import { useState } from "react";

const IndexPage = () => {
    const [isDarkTheme, setIsDarkTheme] = useState(false);

    const handleToggle = (event) => {
        setIsDarkTheme(ev.target.checked);
    };
    return (
        <div>
            <label>
                <input
                    type="checkbox"
                    checked={isDarkTheme}
                    onChange={handleToggle}
                />
                Dark
            </label>
            <h1>Hello there</h1>
            <p>General Kenobi!</p>
        </div>
    );
};

export default IndexPage;
Enter fullscreen mode Exit fullscreen mode

存储(和检索)用户偏好

首先,让我们添加一个功能,当用户之前访问过我们的网站时,可以存储和检索用户的偏好设置。localStorage一种非常简单的实现方式,即使用户刷新页面或完全关闭浏览器并稍后再次打开,也能保持访问。虽然在 localStorage 中存储敏感数据和/或大数据量数据存在一些问题,但它非常适合存储用户的暗黑模式偏好设置。

以下是我们如何theme使用来保存和加载我们的偏好设置localStorage

window.localStorage.setItem("theme", "dark"); // or "light"

const userPreference = window.localStorage.getItem("theme"); // "dark"
Enter fullscreen mode Exit fullscreen mode

系统范围的偏好

prefers-color-scheme是一种 CSS 媒体功能,它允许我们检测用户是否设置了任何系统范围的暗模式偏好设置,如果用户尚未设置偏好设置,我们可以使用它。

我们需要做的就是运行 CSS 媒体查询,浏览器为我们提供了matchMedia()执行此操作的功能!

以下是检查用户是否设置了任何偏好的媒体查询:

const mql = window.matchMedia("(prefers-color-scheme: dark)");
Enter fullscreen mode Exit fullscreen mode

输出(当用户设置了暗模式偏好时):

{
    "matches": true,
    "media": "(prefers-color-scheme: dark)"
}
Enter fullscreen mode Exit fullscreen mode

让我们将这些添加到我们的应用程序中

import { useState } from "react";

const IndexPage = () => {
    const [isDarkTheme, setIsDarkTheme] = useState(false);

    const handleToggle = (event) => {
        setIsDarkTheme(ev.target.checked);
    };

    const getMediaQueryPreference = () => {
        const mediaQuery = "(prefers-color-scheme: dark)";
        const mql = window.matchMedia(mediaQuery);
        const hasPreference = typeof mql.matches === "boolean";

        if (hasPreference) {
            return mql.matches ? "dark" : "light";
        }
    };

    const storeUserSetPreference = (pref) => {
        localStorage.setItem("theme", pref);
    };
    const getUserSetPreference = () => {
        return localStorage.getItem("theme");
    };

    useEffect(() => {
        const userSetPreference = getUserSetPreference();
        if (userSetPreference !== null) {
            setIsDarkTheme(userSetPreference === "dark");
        } else {
            const mediaQueryPreference = getMediaQueryPreference();
            setIsDarkTheme(mediaQueryPreference === "dark");
        }
    }, []);
    useEffect(() => {
        if (isDarkTheme !== undefined) {
            if (isDarkTheme) {
                storeUserSetPreference("dark");
            } else {
                storeUserSetPreference("light");
            }
        }
    }, [isDarkTheme]);

    return (
        <div>
            <label>
                <input
                    type="checkbox"
                    checked={isDarkTheme}
                    onChange={handleToggle}
                />
                Dark
            </label>
            <h1>Hello there</h1>
            <p>General Kenobi!</p>
        </div>
    );
};

export default IndexPage;
Enter fullscreen mode Exit fullscreen mode
  • 当我们的页面加载完毕,并且IndexPage组件已安装时,如果用户在之前的访问中已经设置了偏好设置,我们将检索用户的偏好设置
  • 如果他们没有设置,则调用localStorage.getItem()返回null,然后我们继续检查他们的系统范围首选项是否为暗模式
  • 我们默认使用灯光模式
  • 每当用户切换复选框以打开或关闭暗模式时,我们都会保存他们的偏好以供localStorage将来使用

太棒了!我们的切换按钮已经正常工作了,而且我们也能够在页面中存储和检索正确的状态。

回归本源

最大的挑战(令人惊讶)是在向用户显示任何内容之前运行所有这些检查。由于我们使用的是 Next.js 及其静态生成功能,因此我们无法在代码/构建时知道用户的偏好设置🤷‍♂️

除非...有一种方法可以在我们的所有页面加载并呈现给用户之前运行一些代码!

看一下下面的代码:

<body>
    <script>
        alert("No UI for you!");
    </script>
    <h1>Page Title</h1>
</body>
Enter fullscreen mode Exit fullscreen mode

它看起来是这样的:

完美暗黑模式阻止脚本

如果你想亲自尝试一下,请查看这个
沙盒

<script>当我们在 body 内容前添加 时<h1>,实际内容的渲染会被脚本阻止。这意味着我们可以运行一些代码,这些代码保证在任何内容显示给用户之前运行,这正是我们想要的!

Next.js 的文档

从上面的例子,我们现在知道我们需要<script><body>页面的实际内容之前添加一个。

Next.js 提供了一种超级便捷的方法,只需添加一个(或)文件即可修改应用中的<html>和标签仅在服务器端渲染,因此我们的脚本会按照我们在客户端浏览器上描述的方式加载。<body>_document.tsx_document.jsDocument

使用这个,我们可以这样添加我们的脚本:

import Document, { Html, Head, Main, NextScript } from "next/document";

export default class MyDocument extends Document {
    render() {
        return (
            <Html>
                <Head />
                <body>
                    <script
                        dangerouslySetInnerHTML={{
                            __html: customScript,
                        }}
                    ></script>
                    <Main />
                    <NextScript />
                </body>
            </Html>
        );
    }
}

const customScript = `
        console.log("Our custom script runs!");
`;
Enter fullscreen mode Exit fullscreen mode

<Html><Head /> 页面正确呈现所必需的。
<Main />
<NextScript />

危险设置哇?

浏览器 DOM 为我们提供了innerHTML获取或设置元素内 HTML 内容的方法。通常,通过代码设置 HTML 内容是有风险的,因为很容易无意中将用户暴露于跨站脚本 (XSS)攻击。React 默认会在渲染内容之前对其进行过滤,从而保护我们免受此类攻击。

如果用户尝试将其名称设置为<script>I'm dangerous!</script>,React 会将类似 的字符编码<&lt;。这样,脚本就不会产生任何效果。

React 还提供了一种使用 覆盖此行为的方法dangerouslySetInnerHTML,提醒我们这是危险的。好吧,在我们的用例中,我们实际上确实想要注入并运行一个脚本。

请注意它如何要求我们将innerHTML脚本
作为string

我们快到了!

我们现在知道如何确保我们的脚本在页面的其余部分之前加载(并且在 Next.js 的帮助下Document,在任何页面之前加载),但我们仍然需要这个难题的更多部分:

  • 一旦加载,就运行我们的脚本。
  • background-color根据我们将添加的所有逻辑更改和其他 CSS 属性!

IIFE

我们的下一个难题是如何尽快运行我们的自定义脚本。
提醒一下,我们这样做是为了确定暗黑模式的正确状态(激活/停用,或者更简单地说,true/ false),以避免用户加载网页时出现任何不正常的切换“闪烁”。

输入立即调用函数表达式!(或简称IIFE )

IIFE 只是一个 JavaScript 函数,定义后立即执行。除了定义后立即运行的优点之外,IIFE 还非常适合用来避免污染全局命名空间——我们绝对可以使用它,因为一旦运行并设置了 apt 模式,我们的逻辑就不再有用了。

IIFE 如下所示:

(function () {
    var name = "Sreetam Das";
    console.log(name);
    // "Sreetam Das"
})();

// Variable name is not accessible from the outside scope

console.log(name);
// throws "Uncaught ReferenceError: name is not defined"
Enter fullscreen mode Exit fullscreen mode

让我们把它添加到我们的_document.js

import Document, { Html, Head, Main, NextScript } from "next/document";

function setInitialColorMode() {
    function getInitialColorMode() {
        const preference = window.localStorage.getItem("theme");
        const hasPreference = typeof preference === "string";

        /**
         * If the user has explicitly chosen light or dark,
         * use it. Otherwise, this value will be null.
         */
        if (hasPreference) {
            return preference;
        }

        // If there is no saved preference, use a media query
        const mediaQuery = "(prefers-color-scheme: dark)";
        const mql = window.matchMedia(mediaQuery);

        const hasPreference = typeof mql.matches === "boolean";
        if (hasPreference) {
            return mql.matches ? "dark" : "light";
        }

        // default to 'light'.
        return "light";
    }

    const colorMode = getInitialColorMode();
}

// our function needs to be a string
const blockingSetInitialColorMode = `(function() {
        ${setInitialColorMode.toString()}
        setInitialColorMode();
})()
`;

export default class MyDocument extends Document {
    render() {
        return (
            <Html>
                <Head />
                <body>
                    <script
                        dangerouslySetInnerHTML={{
                            __html: blockingSetInitialColorMode,
                        }}
                    ></script>
                    <Main />
                    <NextScript />
                </body>
            </Html>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

现在,我们能够在页面完全加载之前正确获取暗黑模式的正确状态!我们最后的挑战是将其传递给页面组件,以便我们能够实际应用首选的暗黑模式状态。

这里的挑战是,我们需要能够从页面及其 React 组件完全加载之前运行的纯 JS 脚本中传输这条信息,并对其进行“水化”。

CSS 变量

最后一步是使用用户喜欢的主题更新我们的页面。

有多种方法可以解决此问题:

  • 我们可以使用 CSS 类来定义不同的主题,并通过编程来切换它们

  • 我们可以使用 Reactstate并传递一个.class模板字面量

  • 我们也可以使用 styled-components

虽然所有选项看起来都是可能的解决方案,但每个选项都需要添加更多的样板

或者我们可以用 CSS 变量!

CSS 自定义属性(也称为 CSS 变量)允许我们在整个文档中重复使用特定的值。这些值可以使用自定义属性符号来设置,并使用如下函数访问var()

:root {
    --color-primary-accent: #5b34da;
}
Enter fullscreen mode Exit fullscreen mode

一个常见的最佳实践是在

:root伪类,以便它可以在整个
HTML 文档中全局应用

CSS 变量的最大优点是它们具有响应性,在页面的整个生命周期内保持活动状态,更新它们会立即更新引用它们的 HTML 而且它们可以使用 JavaScript 进行更新!

// setting
const root = document.documentElement;
root.style.setProperty("--initial-color-mode", "dark");

// getting
const root = window.document.documentElement;
const initial = root.style.getPropertyValue("--initial-color-mode");
// "dark"
Enter fullscreen mode Exit fullscreen mode

当你想在 CSS 中重复使用某些值时,CSS 变量确实很有用;我的网站使用了一些你可以在这里看到的变量

还有更多!

我们可以使用HTML 属性,并且由于 CSS 也可以访问这些属性,因此我们可以根据data-theme设置的属性为 CSS 变量分配不同的值,如下所示:

:root {
    --color-primary-accent: #5b34da;
    --color-primary: #000;
    --color-background: #fff;
    --color-secondary-accent: #358ef1;
}

[data-theme="dark"] {
    --color-primary-accent: #9d86e9;
    --color-secondary-accent: #61dafb;
    --color-primary: #fff;
    --color-background: #000;
}

[data-theme="batman"] {
    --color-primary-accent: #ffff00;
}
Enter fullscreen mode Exit fullscreen mode

我们也可以很容易地设置和删除属性:

if (userPreference === "dark")
    document.documentElement.setAttribute("data-theme", "dark");

// and to remove, setting the "light" mode:
document.documentElement.removeAttribute("data-theme");
Enter fullscreen mode Exit fullscreen mode

最后,我们现在能够将计算出的暗模式状态从阻塞脚本传递到我们的 React 组件。

回顾

在我们将目前为止的所有内容汇总在一起之前,让我们回顾一下:

  • 网页加载完成后,我们使用Next.js 的 DocumentIIFE注入并运行阻塞脚本

  • 使用localStorage检查用户上次访问时保存的偏好设置

  • 使用CSS 媒体查询检查用户是否具有系统范围的暗模式偏好

  • 如果以上两项检查均无结果,我们将默认使用浅色主题

  • 将此偏好设置作为CSS 变量传递,我们可以在切换组件中读取它

  • 主题可以切换,切换后我们会保存偏好设置以供将来访问

  • 即使用户偏好非默认主题,我们也不应该在第一次加载时出现闪烁

  • 我们应该始终显示切换按钮的正确状态,如果正确状态未知,则推迟渲染切换按钮

最终结果如下:

import Document, { Html, Head, Main, NextScript } from "next/document";

function setInitialColorMode() {
    function getInitialColorMode() {
        const preference = window.localStorage.getItem("theme");
        const hasPreference = typeof preference === "string";

        /**
         * If the user has explicitly chosen light or dark,
         * use it. Otherwise, this value will be null.
         */
        if (hasPreference) {
            return preference;
        }

        // If there is no saved preference, use a media query
        const mediaQuery = "(prefers-color-scheme: dark)";
        const mql = window.matchMedia(mediaQuery);

        const hasPreference = typeof mql.matches === "boolean";
        if (hasPreference) {
            return mql.matches ? "dark" : "light";
        }

        // default to 'light'.
        return "light";
    }

    const colorMode = getInitialColorMode();
    const root = document.documentElement;
    root.style.setProperty("--initial-color-mode", colorMode);

    // add HTML attribute if dark mode
    if (colorMode === "dark")
        document.documentElement.setAttribute("data-theme", "dark");
}

// our function needs to be a string
const blockingSetInitialColorMode = `(function() {
        ${setInitialColorMode.toString()}
        setInitialColorMode();
})()
`;

export default class MyDocument extends Document {
    render() {
        return (
            <Html>
                <Head />
                <body>
                    <script
                        dangerouslySetInnerHTML={{
                            __html: blockingSetInitialColorMode,
                        }}
                    ></script>
                    <Main />
                    <NextScript />
                </body>
            </Html>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

注意我们如何使用style.setProperty()以及documentElement.setAttribute()传递数据

让我们添加 CSS,在应用暗模式时为 CSS 变量添加单独的值

:root {
    --color-primary-accent: #5b34da;
    --color-primary: #000;
    --color-background: #fff;
}

[data-theme="dark"] {
    --color-primary-accent: #9d86e9;
    --color-primary: #fff;
    --color-background: #000;
}

body {
    background-color: var(--color-background);
    color: var(--color-primary);
}
Enter fullscreen mode Exit fullscreen mode

太棒了!现在我们需要将这些样式导入到我们的应用程序中。

由于我们希望这些样式在整个网站中可用,因此我们需要使用Next.js 提供的App组件。这与我们之前看到的类似Document,它是一个特殊的组件,可用于控制Next.js 应用中的每个页面,因为它用于初始化我们的页面。

这也使其成为添加我们的全局 CSS 的正确位置!

import "../styles.css";

export default function MyApp({ Component, pageProps }) {
    return <Component {...pageProps} />;
}
Enter fullscreen mode Exit fullscreen mode

最后是我们的 React 组件页面:

import { useEffect, useState } from "react";

const IndexPage = () => {
    const [darkTheme, setDarkTheme] = useState(undefined);

    const handleToggle = (event) => {
        setDarkTheme(event.target.checked);
    };
    const storeUserSetPreference = (pref) => {
        localStorage.setItem("theme", pref);
    };

    const root = document.documentElement;
    useEffect(() => {
        const initialColorValue = root.style.getPropertyValue(
            "--initial-color-mode",
        );
        setDarkTheme(initialColorValue === "dark");
    }, []);
    useEffect(() => {
        if (darkTheme !== undefined) {
            if (darkTheme) {
                root.setAttribute("data-theme", "dark");
                storeUserSetPreference("dark");
            } else {
                root.removeAttribute("data-theme");
                storeUserSetPreference("light");
            }
        }
    }, [darkTheme]);

    return (
        <div>
            {darkTheme !== undefined && (
                <label>
                    <input
                        type="checkbox"
                        checked={darkTheme}
                        onChange={handleToggle}
                    />
                    Dark
                </label>
            )}
            <h1>Hello there</h1>
            <p style={{ color: "var(--color-primary-accent)" }}>
                General Kenobi!
            </p>
        </div>
    );
};

export default IndexPage;
Enter fullscreen mode Exit fullscreen mode

我们使用原始 CSS 的方法的一个警告是,在开发过程中,我们
仍然会遇到“闪烁”,但是一旦应用程序编译和
构建完成,就不会再出现闪烁,一切都会按预期工作。

如果您使用Styled-components,那么这不是
问题,因为我们可以使用ServerStyleSheet()
它来确保导入的 CSSApp被正确
收集并添加到Document自身中,从而防止
开发过程中出现闪烁






参考文档实现

将我们的状态初始化isDarkThemeundefined允许我们推迟渲染暗模式切换,从而防止向用户显示错误的切换状态。

就是这样!

我们终于拥有了完美的暗黑模式,没有任何闪烁。正如Josh 所说,这绝对不是一件容易的事;我绝对没想到会用到 CSS 变量和 IIFE 之类的东西,我相信你也一样!

这里有几个链接供您查看我们完成的应用程序:

实时应用:nextjs-perfect-dark-mode.netlify.app

存储库:github.com/sreetamdas/nextjs-perfect-dark-mode-example

沙盒:codesandbox.io/s/> dreamy-nightingale-ikwks

当然,有一些软件包可以为您处理所有这些问题,包括“flash”,它们的实现只有一点不同(Donavon 在这里使用该.class方法)

最终,越来越多的人在他们的网站上添加了暗模式,希望我在这里的经历也能够帮助你为你的网站上实现完美的模式。

发现任何拼写错误?有什么想说的或需要改进的地方吗?欢迎在 Twitter 上联系我,甚至可以点击下方按钮分享这篇文章 :)

文章来源:https://dev.to/sreetamdas/the-perfect-dark-mode-2d7g
PREV
为什么我们要放弃 CSS-in-JS
NEXT
你可能不知道的 Git stash 实用技巧