如何为Gatsby.js构建完美的黑暗模式

351 阅读12分钟

简介

也许建立这个博客最难/最复杂的部分是加入黑暗模式。

不是实时嵌入的代码片段,不是管理和聚集所有内容和数据的统一GraphQL层,不是自定义的分析系统,不是无数的奇思妙想。该死的黑暗模式。

这是一个很好的提醒,当涉及到软件开发时,一个简单的功能可以有一个复杂的实现。

An XKCD comic. A project manager asks for a feature that checks whether a photo was in a natural park — an easy request — and then if it can check if the photo contains a bird — an astronomically difficult challenge.

这个问题如此卑鄙的原因与Gatsby/Next.js等框架的工作方式有关;HTML是提前生成的。如果你不小心,你就会出现那种明显的闪烁,即用户在短暂的时间内看到错误的颜色。

今天我们将学习如何为Gatsby.js构建完美的黑暗模式。这个基本策略也可以用于Next.js或任何SSR应用,😄

在我们深入学习之前,有几件事情要简单说一下。

  • 本教程假定有相当多的React知识。如果你对React还不是很熟悉,你可能希望把它收藏起来备用。

  • 有一些工具可以解决这个问题,比如Theme UIgatsby-plugin-use-dark-mode。它们不会对每一个用例都有效,但它们可能对你的用例有效。

这篇文章建立在前两篇文章的知识基础上。如果你还不熟悉Gatsby的编译时构建系统或CSS变量,你可以先看看这些文章。

补水的危险

Gatsby和其他 "静态 "帖子通过预先生成HTML来工作。我们看看这到底是如何工作的,以及其影响是什么。

面向React开发者的CSS变量

CSS 变量真的很酷,并且开启了很多令人兴奋的可能性。我们将学习如何在React和CSS-in-JS中使用它们。

我们的要求

这里是我们对这个功能的一套标准。

  • 用户应该能够点击一个切换按钮,在浅色和深色模式之间切换。

  • 用户的偏好应该被保存下来,以便将来访问时使用正确的颜色主题。

  • 它应该根据用户的操作系统设置,默认为用户的 "首选 "颜色方案。如果没有设置,它应该默认为浅色。

  • 即使用户选择了一个非默认的颜色主题,网站在第一次加载时也应该闪烁。

  • 该网站应显示错误的切换状态。

正如我们将看到的,最后两个是很多龙的休息的地方🐉。

制图

鉴于这些要求,最初的颜色主题应该是什么?这里有一个流程图。

A flow chart showing how the requirements above work out: First we look at the localStorage value. If it's not set, we look at prefers-color-scheme. If that's not set, we default to "light".

第一道关卡

让我们写一个小函数来帮助我们更新我们的颜色主题,基于我们之前写的条件。

function getInitialColorMode() {
  const persistedColorPreference = window.localStorage.getItem('color-mode');
  const hasPersistedPreference = typeof persistedColorPreference === 'string';
  // If the user has explicitly chosen light or dark,
  // let's use it. Otherwise, this value will be null.
  if (hasPersistedPreference) {
    return persistedColorPreference;
  }
  // If they haven't been explicit, let's check the media
  // query
  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const hasMediaQueryPreference = typeof mql.matches === 'boolean';
  if (hasMediaQueryPreference) {
    return mql.matches ? 'dark' : 'light';
  }
  // If they are using a browser/OS that doesn't support
  // color themes, let's default to 'light'.
  return 'light';
}

我们将需要一些状态!这取决于你如何管理状态,但在这个例子中,我们将使用React上下文。

function getInitialColorMode() {
  /* Same as above. Omitted for brevity */
}
export const ThemeContext = React.createContext();
export const ThemeProvider = ({ children }) => {
  const [colorMode, rawSetColorMode] = React.useState(getInitialColorMode);
  const setColorMode = (value) => {
    rawSetColorMode(value);
    // Persist it on update
    window.localStorage.setItem('color-mode', value);
  };
  return (
    <ThemeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ThemeContext.Provider>
  );
};

我们将我们的getInitialColorMode 函数传递给useState ,以得出我们的初始值。然后,我们创建我们自己的辅助函数,setColorMode ,更新React状态,但也在localStorage中持久化该值,以便下次加载。最后,我们把所有的东西都传递给一个上下文提供者。

如果你不熟悉React上下文,它是一个工具,你可以用来使数据全局可用。我们正在使它成为我们应用程序中的任何组件都可以访问和改变颜色模式。

我们的第一道关卡

如果你尝试按原样构建这段代码,你会得到一个错误。

问题是,我们的getInitialColorMode 函数在挂载时立即运行,以确定初始值应该是什么。但在Gatsby中,第一次渲染并不发生在用户的设备上。

这就是我们问题的关键所在。当React第一次渲染时,我们没有办法知道用户的颜色偏好是什么,因为渲染是在云端进行的,可能是在用户访问前几个小时或几天。

我们可以假设用户想要一个浅色的主题,然后在React在客户端上重新水化后把它换掉......但如果我们这样做,我们就会出现可怕的闪烁现象

但如果我们这样做,就会出现可怕的闪烁:页面加载时出现 "光模式的闪光"。

理想情况下,我们发送给用户的HTML应该已经有了正确的颜色,甚至在它被画的第一帧上就有了。但是,我们不知道用户想要什么颜色,直到它出现在他们的设备上。有可能解决这个问题吗?

一个可行的解决方案

以下是我们的解决方案,在一个较高的水平上。

  • 使用CSS变量来处理我们所有的造型

  • 当我们在编译时生成HTML时,我们所有的内容(页面本身)之前注入一个<script> 标签

  • 在该脚本标签中,找出用户的颜色偏好

  • 使用JavaScript更新CSS变量

为了实现我们的目标,我们利用了一些小技巧。下面是最初的HTML的样子(在我们的JS包被执行之前)。

<!DOCTYPE html>
<html>
  <head>
    <title>My Awesome Website</title>
  </head>
  <body>
    <script>
      /*
        - Check localStorage
        - Check the media query
        - Update our CSS variables depending
          on those values.
      /*
    </script>
    <div>
      <h1>My Awesome Website<h1>
      <p>Content here.</p>
    </div>
  </body>
</html>

封锁的HTML

注入的<script> 标签我们的主体内容之前。这一点很重要,因为脚本是阻塞的;在JS代码被评估之前,任何东西都不会被画到屏幕上。

例如:看看用户在运行以下HTML时看到了什么。

<body>
  <script>
    alert('No UI for you!');
  </script>
  <h1>Page Title</h1>
</body>

注意,<h1> ,直到<script> 运行完毕才显示。😮

性能!

你可能听说过,访问localStorage很慢,应该用可以异步使用的东西来代替,比如IndexedDB。

在这种特殊情况下,localStorage是同步的这一事实是一个特点,而不是一个bug;我们阻止渲染,直到我们知道我们应该用哪种颜色来渲染!但是,如果这需要花费太长时间呢?

但如果这需要太长时间呢?它不会降低用户体验吗?我决定对此进行测试。我节制了我的CPU,并尝试从localStorage检索短字符串。平均来说,它需要12微秒(0.012毫秒)。我们的用户不会介意这种等待。

IndexedDB对于存储大块的数据是很好的(我在Beatmapper中使用它来存储几兆字节的二进制文件!)。对于短字符串,localStorage是完美的💯。

反应性的CSS变量

CSS变量真的很酷。它们最好的技巧是它们是反应性的。当一个变量的值发生变化时,HTML会立即更新。

我们可以在我们所有的组件中使用CSS变量。例如,使用styled-components。

const Button = styled.button`
  background: var(--color-primary);
`;

这将在HTML中生成一个按钮,指向我们的CSS变量。当我们用JavaScript改变这个CSS变量时,我们的按钮就会对这个变化做出反应,变成正确的颜色,而不需要我们去针对和改变按钮本身。

这就是我们的策略的核心:依靠CSS变量进行造型,然后通过调整CSS变量的值来预先控制HTML。

在Gatsby中更新HTML

当Gatsby构建时,它会为我们网站的每个页面产生一个HTML文件。我们的目标是该内容上方注入一个<script> 标签,这样浏览器就会首先解析它。

Gatsby非常酷的一点是,它在构建过程中的每一步都暴露了逃生舱口。我们可以钩住它,并添加一些自定义的行为!

如果你还没有,在你的项目根目录下创建一个gatsby-ssr.js 文件。gatsby-ssr.js 是一个文件,当Gatsby编译你的网站时(在构建时间),它会运行。

我们将添加以下代码。

const MagicScriptTag = () => {
  const codeToRunOnClient = `
(function() {
  alert("Hi!");
})()
  `;
  // eslint-disable-next-line react/no-danger
  return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />;
};
export const onRenderBody = ({ setPreBodyComponents }) => {
  setPreBodyComponents(<MagicScriptTag />);
};

这里有很多事情要做,所以让我们把它分解一下。

  • onRenderBody 是Gatsby暴露的一个生命周期方法。它将在构建过程中生成我们的HTML时运行这个函数。

  • setPreBodyComponents 是一个函数,它将注入一个React元素在它构建的其他东西(我们的实际网站)之上,在 标签内。<body>

  • MagicScriptTag 是一个React组件,它渲染了一个 标签。我们传递给它一个字符串化的片段,并使用 ,将该脚本嵌入到返回的元素中。<script> dangerouslySetInnerHTML

  • 我们使用一个IIFE(摇摆不定的老派!)来避免污染全局命名空间。

但我不想太危险!

你可能已经注意到,这里有很多关于注入脚本标签的仪式和警告,这是有原因的

有一种类型的攻击被称为跨站脚本(XSS)。在这些攻击中,恶意的用户通过一个查询参数或文本字段滑入一点代码。他们希望我们的应用程序会无意中运行他们的代码,让他们访问内存中的东西,提出可信的网络请求,诸如此类的东西。

令人高兴的是,React在默认情况下会保护我们免受这种影响,在渲染文本之前对其进行消毒。如果用户试图将他们的名字设置为<script>Do stuff</script> ,React会将< 等字符编码为&lt; 。这样一来,脚本就没有效果了。

在我们的案例中,我们实际上想注入一个有效果的脚本我们正在尝试XSS我们自己的网站,以检查黑暗模式。

React为此提供了一个逃生舱口:dangerouslySetInnerHTML 。不过你可以在晚上睡个好觉了--我们是在编译时进行注入的,所以用户没有办法把他们的恶意代码塞进去。

越过鸿沟

你可能会想,为什么我们要把代码放在一个字符串中,然后进行渲染。我们就不能正常地调用那个函数吗?

我喜欢把这看作是空间和时间上的一个 "鸿沟"。有一个时刻,我们在我们的电脑上或云端建立我们的代码。然后是客户在他们的设备上运行我们的代码,在一个非常不同的地点和时间。

我们想写一些代码,在编译时被注入,但只在运行时执行。我们需要把这个函数当作一块数据来传递,让它在用户的设备上运行。

我们必须这样做,因为我们还没有正确的信息;我们不知道用户的localStorage里有什么,或者他们的操作系统是否在黑暗模式下运行。直到我们在用户的设备上运行代码,我们才会知道这些。

相反的情况也是如此!当这段代码最终运行时,它将无法访问我们捆绑的任何JS代码;它将在捆绑的代码被下载之前运行!这意味着它不会自动知道我们的代码。这意味着它不会自动知道我们的设计令牌是什么。

生成脚本

通过使用一些字符串插值,我们可以 "生成 "我们将需要的函数。

const MagicScriptTag = () => {
  let codeToRunOnClient = `
(function() {
  function getInitialColorMode() {
    /* Same code as earlier */
  }
  const colorMode = getInitialColorMode();
  const root = document.documentElement;
  root.style.setProperty(
    '--color-text',
    colorMode === 'light'
      ? '${COLORS.light.text}'
      : '${COLORS.dark.text}'
  );
  root.style.setProperty(
    '--color-background',
    colorMode === 'light'
      ? '${COLORS.light.background}'
      : '${COLORS.dark.background}'
  );
  root.style.setProperty(
    '--color-primary',
    colorMode === 'light'
      ? '${COLORS.light.primary}'
      : '${COLORS.dark.primary}'
  );
  root.style.setProperty('--initial-color-mode', colorMode);
})()`;
  // eslint-disable-next-line react/no-danger
  return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />;
};

我们将我们的getInitialColorMode 函数移到这个字符串中(我们不能简单地导入它并调用它!我们需要复制/粘贴它)。一旦我们知道初始颜色应该是什么,我们就可以开始设置我们的CSS变量。我们使用字符串插值,这样,实际被注入的脚本就会看起来像。

root.style.setProperty(
  '--color-text',
  colorMode === 'light'
    ? 'black' // resolves from ${COLORS.light.text}
    : 'white' // resolves from ${COLORS.dark.text}
);

为了使事情尽可能简单明了,我们为网站中的每种颜色手动调用root.style.setProperty 。正如你所想象的,当你有几十种颜色时,这就有点乏味了!我们将讨论潜在的优化。我们将在附录中讨论潜在的优化方法。

我们还设置了最后一个属性:--initial-color-mode 。这是我们从这个运行时脚本传递给我们的React应用的一个土豆;它将读取这个值,以便找出初始React状态应该是什么。

状态管理

如果我们不想给用户提供在明暗模式之间切换的选项,我们的工作就完成了!初始状态是完美的。

不过,没有切换的黑暗模式是不完整的。我们想让用户选择我们的网站应该是浅色还是深色的!我们可以根据有根据的猜测来决定。我们可以根据他们的操作系统的设置做一个有根据的猜测,但是用户喜欢深色的操作系统并不意味着他们希望我们的网站采用深色,反之亦然。

下面是我们如何在React中捕获这种状态。

export const ThemeContext = React.createContext();
export const ThemeProvider = ({ children }) => {
  const [colorMode, rawSetColorMode] = React.useState(undefined);
  React.useEffect(() => {
    const root = window.document.documentElement;
    const initialColorValue = root.style.getPropertyValue(
      '--initial-color-mode'
    );
    rawSetColorMode(initialColorValue);
  }, []);
  const setColorMode = (value) => {
    /* TODO */
  };
  return (
    <ThemeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ThemeContext.Provider>
  );
};

为了突出相关的位子。

  • 我们用undefined 来初始化这个状态。这是因为对于第一次渲染(在编译时),我们没有对window 对象的任何访问。

  • 在React应用重水后,我们立即抓取根元素,并检查其--initial-color-mode 的设置。

  • 我们将其设置到我们的状态中,所以现在我们的React状态已经继承了我们在onRenderBody 中设置的CSS变量的值。

操作的顺序

这一系列的事件可能很难可视化,所以我建立了一个小的交互式吉斯莫。点击或悬停在每个步骤上以查看更多的背景。

添加一个切换器

我们已经建立了我们的状态管理代码;让我们用它来建立一个切换器吧

当切换被触发时,我们需要做的事情如下。

  1. 更新跟踪当前颜色模式的React状态。

  2. 更新localStorage,这样我们就能记住他们的偏好,以便下次使用。

  3. 更新所有的CSS变量,这样它们就会指向不同的颜色。

下面是处理程序的样子。

function setColorMode(newValue) {
  const root = window.document.documentElement;
  // 1. Update React color-mode state
  rawSetColorMode(newValue);
  // 2. Update localStorage
  localStorage.setItem('color-mode', newValue);
  // 3. Update each color
  root.style.setProperty(
    '--color-text',
    newValue === 'light' ? COLORS.light.text : COLORS.dark.text
  );
  root.style.setProperty(
    '--color-background',
    newValue === 'light' ? COLORS.light.background : COLORS.dark.background
  );
  root.style.setProperty(
    '--color-primary',
    newValue === 'light' ? COLORS.light.primary : COLORS.dark.primary
  );
}

同样,为了使事情尽可能的简单,我们没有做任何花哨的迭代来生成setProperty 的调用。

在我的博客中,我建立了一个花哨的动画切换器,它在太阳和月亮之间变形。

构建这个切换器超出了本教程的范围,尽管我很快就写下它。我们将研究如何使用React Spring和SVG遮罩来构建像这样的很棒的UI装饰。

现在,为了专注于逻辑,我们将坚持使用一个比较低调的切换。

下面是它的工作原理。

import { ThemeContext } from './ThemeContext';
const DarkToggle = () => {
  const { colorMode, setColorMode } = React.useContext(ThemeContext);
  return (
    <label>
      <input
        type="checkbox"
        checked={colorMode === 'dark'}
        onChange={(ev) => {
          setColorMode(ev.target.checked ? 'dark' : 'light');
        }}
      />{' '}
      Dark
    </label>
  );
};
We have a checkbox, a

我们有一个复选框,我们把它的值设置为colorMode ,从上下文中提取。当用户切换复选框时,我们调用我们的setColorMode 函数,使用替代颜色模式。

但是有一个问题当我们为生产构建网站时,我们的复选框并不总是以正确的状态初始化。

请记住,最初的渲染是在编译时在云端进行的,所以colorMode 最初会是undefined 。每个用户都会得到相同的HTML,而这个HTML总是带有一个未选中的复选框。

我们最好的办法是将切换的渲染推迟到React应用知道colorMode 应该是什么之后。

const DarkToggle = () => {
  const { colorMode, setColorMode } = React.useContext(ThemeContext);
  if (!colorMode) {
    return null;
  }
  return <label>{/* Unchanged */}</label>;
};

通过在第一次编译时不渲染任何东西,我们留下了一个空白点,当React知道这些数据时,可以在客户端填上。

成功了!🌈

我们已经达成了一个解决方案,满足了我们所有的要求:我们的用户从第一帧开始就看到了正确的颜色方案,而且他们不会在错误的状态下看到一个切换按钮。

当涉及到优化和清理时,它有一些自由,在下面的附录中进行了探讨,但核心思想都是一样的。请自由挖掘它吧

实现一个没有妥协的黑暗模式是不容易的,但我认为这值得一试。小细节很重要,尤其是要避免在用户访问的前几秒钟出现UI故障!这一点很重要。

附录:调整

我对这个解决方案还做了一些小的调整和优化。让我们来谈谈它们吧!

迭代

在我们的例子中,我们最终在两个地方重复了setProperty 的代码。

  • gatsby-ssr.js ,创建初始变量时。

  • 在我们的ThemeProvider 组件内,当切换模式时。

这种重复令人厌烦的事情是,当你添加或改变一个设计标记时,你需要记住更新这两个地方。我们可以通过动态地生成它们来解决这个问题。

Object.entries(COLORS).forEach(([name, colorByTheme]) => {
  const cssVarName = `--color-${name}`;
  root.style.setProperty(cssVarName, colorByTheme[newValue]);
});

这是一个很好的小胜利,因为你可以调整颜色和尺寸,而根本不需要考虑这个过程。

你需要的确切代码取决于你的设计标记的结构。

不使用JavaScript

关于Gatsby的一个巧妙之处在于,许多Gatsby网站在禁用JS的情况下工作。我们目前的解决方案没有考虑到这一点;如果你在没有启用JS的情况下访问这个网站,一切都能正常渲染,但没有颜色 😱。

我们可以通过在gatsby-ssr.js 中的构建过程中,在文档的<head> 中注入一个<style> 标签来解决这个问题。就像我们在运行时注入一个脚本标签来调整颜色一样,我们可以注入一个样式标签来设置默认值,在JS被禁用的情况下使用。

这里有一个快速和肮脏的例子。

const FallbackStyles = () => {
  return (
    <style>
      {`
        html {
          --color-text: ${COLORS.text};
          --color-background: ${COLORS.background};
          --color-primary: ${COLORS.primary};
        }
      `}
    </style>
  );
};
export const onRenderBody = ({ setPreBodyComponents, setHeadComponents }) => {
  setHeadComponents(<FallbackStyles />);
  setPreBodyComponents(<MagicScriptTag />);
};
You could generate e

你可以动态地生成每个键/值对,以避免样式的重复。

这个修正被添加到了例子库中,所以可以在那里查看一个 "真实世界 "的例子。

最小化

很久以来,JS代码都经历了一个 "最小化 "或 "丑化 "的过程;我们将完全可读的代码进行乱码处理,使其占用尽可能少的空间。

这在使用Gatsby或Create React App等构建系统时自动发生,但对于我们注入的那个小脚本来说,它并没有发生!这些工作发生在模块构建系统之外。这项工作发生在模块构建系统之外;webpack不知道它。

我试着用一个叫Terser的依赖性。它接收一串源代码,并进行一些操作以使其变小。你像这样使用它。

const outputCode = Terser.minify(inputCode).code;

我在我的博客上试了一下,差别相当小(~200字节),可以忽略不计。你的里程可能会有所不同,这取决于你在那个注入的脚本中做了多少工作!

脚本生成

gatsby-ssr ,我们注入一个脚本标签,我们通过提供一个字符串来做到这一点,这个字符串以后将作为一个函数来执行。

不过在字符串中写一个函数并不好玩;我们没有任何静态检查,没有Prettier支持,当我们打错东西时也没有红色的下划线。

我们可以用传统的方法来写一个函数,然后把它串化。

function doStuff() {
  /* stuff */
}
String(doStuff);

但这也有一些问题。

  • 我们想把这个函数作为一个IIFE,以防止泄漏到全局状态中去。

  • 我们需要以某种方式将我们的设计令牌传递给它!与 "常规 "函数不同,在这种情况下,我们不能依赖父级作用域,因为它将在一个完全不同的上下文中执行。

我通过在字符串化之后做一些调整,解决了这两个困境。

function setColorsByTheme() {
  const colors = '🌈';
  // Do stuff with `colors`, as if it was an object
  // that held everything!
}
const MagicScriptTag = () => {
  // Replace that rainbow string with our COLORS object.
  // We need to stringify it as JSON so that it isn't
  // inserted as [object Object].
  const functionString = String(setColorsByTheme).replace(
    "'🌈'",
    JSON.stringify(COLORS)
  );
  // Wrap it in an IIFE
  let codeToRunOnClient = `(${functionString})()`;
  // Inject it
  return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />;
};

最后这个调整对我来说有点像洗牌;我们获得了IDE的支持,但我们的代码变得更加复杂/不直观了。我选择保留它是因为我喜欢🌈表情符号,但你可能喜欢做不同的取舍。

有很多潜在的优化和调整,但在一天结束时,最重要的是用户体验,而我们已经用这个解决方案实现了一个坚实的体验✨