多次调用 onClick 的 useDarkMode 钩子

useDarkMode hook called multiple times onClick

我正在尝试使用自定义挂钩构建 SSR 兼容(无闪烁)暗模式。我想从多个组件调用它,这些组件应该使用事件总线保持同步(即发出自定义事件并在 useEffect 中注册相应的侦听器)。

我遇到的问题是每次触发 onClick={() => setColorMode(nextMode)} 时都会调用多次。在下面的屏幕截图中,只有单击 DarkToggle 时出现在红色框内的九行中的第一行是预期的。 (红框上方的日志发生在初始页面加载期间。)

是什么导致了这些额外的调用,我该如何避免它们?

An MVP of what I'm trying to build is on GitHub。下面是钩子的样子:

useDarkMode

import { useEffect } from 'react'
import {
  COLORS,
  COLOR_MODE_KEY,
  INITIAL_COLOR_MODE_CSS_PROP,
} from '../constants'
import { useLocalStorage } from './useLocalStorage'

export const useDarkMode = () => {
  const [colorMode, rawSetColorMode] = useLocalStorage()

  // Place useDarkMode initialization in useEffect to exclude it from SSR.
  // The code inside will run on the client after React rehydration.
  // Because colors matter a lot for the initial page view, we're not
  // setting them here but in gatsby-ssr. That way it happens before
  // the React component tree mounts.
  useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode)
  }, [rawSetColorMode])

  function setColorMode(newValue) {
    localStorage.setItem(COLOR_MODE_KEY, newValue)
    rawSetColorMode(newValue)

    if (newValue === `osPref`) {
      const mql = window.matchMedia(`(prefers-color-scheme: dark)`)
      const prefersDarkFromMQ = mql.matches
      newValue = prefersDarkFromMQ ? `dark` : `light`
    }

    for (const [name, colorByTheme] of Object.entries(COLORS))
      document.body.style.setProperty(`--color-${name}`, colorByTheme[newValue])
  }

  return [colorMode, setColorMode]
}

useLocalStorage

import { useEffect, useState } from 'react'

export const useLocalStorage = (key, initialValue, options = {}) => {
  const { deleteKeyIfValueIs = null } = options

  const [value, setValue] = useState(initialValue)

  // Register global event listener on initial state creation. This
  // allows us to react to change events emitted by setValue below.
  // That way we can keep value in sync between multiple call
  // sites to useLocalStorage with the same key. Whenever the value of
  // key in localStorage is changed anywhere in the application, all
  // storedValues with that key will reflect the change.
  useEffect(() => {
    let value = localStorage[key]
    // If a value isn't already present in local storage, set it to the
    // provided initial value.
    if (value === undefined) {
      value = initialValue
      if (typeof newValue !== `string`)
        localStorage[key] = JSON.stringify(value)
      localStorage[key] = value
    }
    // If value came from local storage it might need parsing.
    try {
      value = JSON.parse(value)
      // eslint-disable-next-line no-empty
    } catch (error) {}
    setValue(value)

    // The CustomEvent triggered by a call to useLocalStorage somewhere
    // else in the app carries the new value as the event.detail.
    const cb = (event) => setValue(event.detail)
    document.addEventListener(`localStorage:${key}Change`, cb)
    return () => document.removeEventListener(`localStorage:${key}Change`, cb)
  }, [initialValue, key])

  const setStoredValue = (newValue) => {
    if (newValue === value) return

    // Conform to useState API by allowing newValue to be a function
    // which takes the current value.
    if (newValue instanceof Function) newValue = newValue(value)

    const event = new CustomEvent(`localStorage:${key}Change`, {
      detail: newValue,
    })
    document.dispatchEvent(event)

    setValue(newValue)

    if (newValue === deleteKeyIfValueIs) delete localStorage[key]
    if (typeof newValue === `string`) localStorage[key] = newValue
    else localStorage[key] = JSON.stringify(newValue)
  }

  return [value, setStoredValue]
}

你有以下useEffect

useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode)
  }, [rawSetColorMode])

由于此 useEffect 依赖于 rawSetColorMode,因此只要 rawSetColorMode 发生变化,useEffect 就会运行。

现在 rawSetColorMode 内部调用 setValue 直到 rawSetColorMode 中的某些条件导致不调用 setValue

现在阅读变量名,似乎您只需要在初始渲染时使用上述所有 useEffect,因此您可以简单地将其写为

useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode)
  }, []) // empty dependency to make it run on initial render only

这应该可以解决您的问题

现在您可能会收到 ESLint 空依赖警告,您可以选择禁用它,例如

useEffect(() => {
    const initialColorMode = document.body.style.getPropertyValue(
      INITIAL_COLOR_MODE_CSS_PROP
    )
    rawSetColorMode(initialColorMode);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

或者使用 useCallback 记忆 rawSetColorMode 方法,这样它只创建一次,这在你的情况下可能很难做到,因为它内部有多个依赖项