能用 Local Storage 替代 Context-Redux-Zustand 吗?

能用 Local Storage 替代 Context-Redux-Zustand 吗?

最近收到一个问题:“在 React 中何时使用 Redux/Zustand/Context API?为什么不能用 local storage 代替?”。这是个好问题。表面上,答案很简单:因为它们服务于非常不同的目的。这只是表面。但这是真的吗?是什么让它们的目的如此不同?它们不都是存储数据吗?是因为 React 做了什么奇怪的事情,而在像 Svelte 或 Angular 这样的框架中这样做就没问题?还是 local storage 本身有问题?或者其实也没问题?也许它在过去有问题,但现在不是了?毕竟,能摆脱所有那些状态管理库,只利用原生的浏览器和语言 API,那该多好啊?是时候弄清楚我们能否做到这一点了。

为什么我们需要 Context/Redux/Zustand

首先谈谈 Context/Redux/Zustand 等的目的。我们为什么需要它们?

在 React 中,一切都围绕着状态(state)。用 useState 或 useReducer 这样的钩子(hooks)将数据放入状态,在屏幕上渲染这些数据,并在需要时(通常是用户与 UI 交互后)触发状态更新,以便用新信息更新屏幕。

对于简单的状态需求,“局部”状态(用 useState 钩子控制、不会泄露到其组件之外的状态)就足够了。比如一个下拉组件的 isOpen 状态。只有下拉组件本身能访问它。只要下拉菜单正常工作,其他组件都不会关心。

const Dropdown = ({ children, trigger }) => {
  // 这是局部状态,只有 Dropdown 组件知道它
  const [isOpen, setIsOpen] = useState();
  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>{trigger}</button>
      {isOpen && children}
    </>

  );
};

然而,有些情况下状态需要在不同的组件之间共享。比如一组复杂的过滤器,它会影响页面上不同地方的渲染内容。或者甚至像“深色模式”主题这样简单的东西,在应用角落有一个按钮可以开关它,但 isDarkMode 值需要分发给页面上的一半组件。

React 是严格层次化的:组件只能通过 props/回调与其子组件/父组件共享数据,永远不能与同级组件共享。因此,在主题化的情况下,不能让一个 <ToggleTheme /> 按钮与除其父/子组件之外的任何人共享当前主题值。

const App = () => {
  return (
    <>
      {/* 这个组件有一个包含 isDarkMode 状态的局部状态 */}
      <ToggleTheme />
      {/* 这个组件无法访问 ToggleTheme 的局部状态,也不知道是亮色还是暗色主题 */}
      <SomeBeautifulContentComponent />
    </>

  );
};

为了解决这个问题,有一种叫做“状态提升(lifting state up)”的技术。状态被移动到需要它的组件的最接近的共同父组件中,然后通过 props 分发下去。

App 组件:

const App = () => {
  const [isDarkMode, setIsDarkMode] = useState(false);
  return (
    <>
      {/* 现在 ToggleTheme 没有状态了,它只接收 props */}
      <ToggleTheme
        isDarkMode={isDarkMode}
        onClick={() =>
 setIsDarkMode(!isDarkMode)}
      />
      {/* 这个组件现在可以访问主题值了 */}
      <SomeBeautifulContentComponent isDarkMode={isDarkMode} />
    </>

  );
};

然而,这种模式会带来它自己的问题。第一个问题是不必要的重新渲染(re-renders),这本身就是一个巨大的话题。第二个问题是层级中每个组件的 API 变得臃肿。即使是这个简单的改动也让之前简单的代码复杂性激增。但如果需要一个复杂的对象状态以及更新该状态中独立部分的多种方法呢?并且需要将不同的部分传递给层次树中更深处的不同组件呢?代码很快就会变得难以阅读和管理。而且一半的组件将只是传递数据而不使用它们。这个问题被称为“属性钻取(prop drilling)”。

为了解决这个问题,需要像 Context 这样的解决方案。有了 Context,我们可以将所有与状态相关的东西提取到它自己的组件中,然后直接在需要的地方访问值和回调函数。这就像试图把一架钢琴从 16 楼搬到地面:你可以走楼梯,缓慢但坚定地将它一层一层地拖下去。或者,你可以直接把它从阳台扔下去...乘坐电梯,跳过中间的所有楼层。App 的 API 将恢复到之前的样子,只是多了一个现在持有 isDarkMode 状态和 Context 的组件:

const App = () => {
  return (
    {/* 这个组件控制 isDarkMode 状态并通过 Context 分发它 */}
    <ThemeProvider>
      {/* 这个组件直接从 Context 中使用 isDarkMode */}
      <ToggleTheme />
      {/* 这个组件也通过 Context 访问 isDarkMode */}
      <SomeBeautifulContentComponent />
    </ThemeProvider>
  );
};

像 Redux、Zustand 等其他状态管理库解决的正是相同的问题。它们只是在优缺点和实现上略有不同。

为什么需要 Local Storage

到目前为止,我们一直在 React 本身和 JavaScript(也就是浏览器的 JavaScript 运行时)内部操作。在任何地方创建的变量,无论是状态还是其他,都会在用户关闭浏览器标签页甚至刷新网页时消失。除非我们想防止这种情况发生,并采取一些额外的步骤将其持久化到更长期的地方。这就是我们需要一些外部数据存储解决方案的时候,从完整的数据库到 JSON 文件。或者 Local Storage

Local Storage 是一种比页面短暂生命周期更持久地存储和访问数据的简单方法。放入 Local Storage 的所有内容都会在那里存在,只要用户的浏览器本身存在,而不仅仅是一个加载的标签页。如果不小心关闭了标签页然后返回页面,数据仍然会在那里。数据是按域名划分作用域的。也就是说,网站可以控制它自己的数据,但其他什么都不能控制。别人也不能控制你的数据。只要能被转换成字符串,你可以在 local storage 中存储任何东西。

下次访问你喜欢的网站时,打开 Chrome 的开发者工具(DevTools),导航到“应用程序(Application)”选项卡,打开“本地存储(Local storage)”选项卡,看看里面的数据。你会发现各种各样的东西:分析数据、指标、主题、各种令牌(tokens)、跟踪同意书(tracking consents),谁知道还有什么。

还是以主题化作为例子。这通常不是你想引入后端和登录的东西,特别是对于一个简单的网站。但同样,你也不希望用户每次加载你的网站时都重新选择。解决方案:将用户的偏好存储在 local storage 中,并在每次页面加载时从那里检索,而不是重置为默认值。

Local Storage 的 API 可能是 JavaScript 和 React 领域中最简单的东西。甚至没什么可说的,它只不过是“保存项目(save item)”、“获取项目(get item)”、“删除项目(delete item)”、“全部清除(clear allthethings)”。就这样。

// 在 local storage 中保存主题
localStorage.setItem("theme""dark");
// 从 local storage 中提取主题
const theme = localStorage.getItem("theme");
// 从 local storage 中删除主题
localStorage.removeItem("theme");
// 清空整个存储
localStorage.clear();

在 React 这边,我们通常在应用的最开始就从 Local Storage 提取这个值:

const theme = localStorage.getItem("theme");

把它放入 Context/Redux/Zustand,以便主题可以在任何地方被访问:

// 创建 Theme Context Provider, Zustand 存储, Redux 存储等
const ThemeContext = createContext('light');
const ThemeProvider = ({ children }) => {
  // 从 Local Storage 提取并放入内存,供 React 后续访问
  const theme = localStorage.getItem("theme");
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>

  );
};
// 在 App 中使用
const App = () => {
  return <ThemeProvider>{/*... // 应用的其余部分*/}</ThemeProvider>;
};

创建一个 useTheme 钩子:

const useTheme = () => useContext(ThemeContext);

然后像使用任何其他共享状态值一样在任何地方使用它:

// 使用来自 Context/Redux/Zustand 的 "theme" 值的各种组件
const Button = () => {
  const theme = useTheme();
  return ... // 按钮的实现
}
const Navigation = () => {
  const theme = useTheme();
  return ... // 导航的实现
}

Context 和 Local Storage 有完全不同的目的

为什么我不能直接在 useTheme 钩子内部从 Local Storage 读取? 在这里引入 Context 的意义是什么?为什么我不能这样做来简化实现?

const Button = () => {
  const theme = localStorage.getItem("theme");
  return ... // 按钮的实现
}

或者,如果我们想更花哨点,为什么我们不能重写 useTheme 钩子呢?这样 Button 甚至不需要知道 Local Storage:

// 我们为什么不这样做?为什么要引入 Context?
const useTheme = () => {
  return localStorage.getItem("theme");
};
// Button 甚至不需要知道
const Button = () => {
  const theme = useTheme();
  return ... // 按钮的实现
}

没有 Context 及其相关的复杂性,没有 Redux/Zustand,也不需要学习另一个库,API 很简单。是什么阻止了我们?

有很多原因!

“不”用 Local Storage:产品原因

有时,我们根本不需要 Local Storage 的“持久化”效果,就这么简单。是的,主题应该在页面刷新时保留。但像展开的抽屉、打开的模态对话框或选中的复选框这样的东西可能就不应该了。事实上,我们可能会期望页面刷新会清除一切,给我们一个“默认”的页面体验。其他任何情况都会让人觉得是个错误。在这种情况下,我们大多会为大多数状态问题使用 Redux/Context/Zustand。而 Local Storage 可以负责那些我们明确需要持久化的东西,比如主题。否则,我们将不得不设法在每次页面加载时重新初始化 Local Storage 中的所有内容,这只会增加而不是减少复杂性。

“不”用 Local Storage:与 React 同步

但让我们假设从产品角度看,我们确实想要持久化通常放入 Redux/Context/Zustand 的大部分状态。在这种情况下,我们仍然有一个问题需要解决:如何将 Local Storage 连接到 React。因为当我上面实现“主题化”时,我稍微骗了你一下。按目前呈现的方式,它永远不会正常工作。或者更准确地说,它会工作得很奇怪。那里缺少了一件事:点击按钮应该切换深色模式的开关。

const ToggleThemeButton = () => {
  return (
    <button onClick={() => {
      // 我们需要在这里切换主题
    }}>
      Dark mode on/off
    </button>

  );
};

如果我调用 localStorage.setItem("theme", ...),它不会起作用。

// 这不会工作!
const ToggleThemeButton = () => {
  const theme = localStorage.getItem("theme");
  return (
    <button onClick={() => {
      // 只改变了 local storage 的值
      // React 无法感知到它
      localStorage.setItem("theme", theme === "dark" ? "light" : "dark");
    }}>
      Dark mode on/off
    </button>

  );
};

这样做会更新 Local Storage 的值,没错。所以,在页面刷新时,主题值将从存储中读取,深色/浅色模式会切换。但当我们点击按钮时它不会切换。为了使其具有适当的交互性,我们需要通知 React 某些东西发生了变化,它需要更新 UI,需要触发一次重新渲染(re-render)

无论是通过 useState & useReducer 钩子,像 Redux/Zustand 这样的外部库,甚至 useSyncExternalStore。基本上,我们需要将外部系统(即 Local Storage)连接到 React 的生命周期中,才能看到任何变化。

最简单、“天真”的方法是在 useTheme 钩子中包含状态:

const useTheme = () => {
  // 提取初始值
  const initialTheme = localStorage.getItem("theme") || "light";
  // 将其保存到状态中
  const [theme, setTheme] = useState(initialTheme);

  const toggleTheme = () => {
    const newTheme = theme === "dark" ? "light" : "dark";
    // 当 toggleTheme 被调用时,在状态中和在 local storage 中同时设置新值
    setTheme(newTheme);
    localStorage.setItem("theme", newTheme);
  };

  return { theme, toggleTheme };
};

我们会有一个从 Local Storage 提取的初始值并放入状态。还有一个 toggleTheme 回调函数,在里面我们改变局部状态,然后将该值“镜像”回 Local Storage。

然而,这里面有一个问题。这里的“唯一真实来源(source of truth)”是局部状态,Local Storage 只在应用初始化时使用。局部状态是,嗯,局部的。它没有以任何方式在不同的组件之间共享。如果我在两个不同的地方使用那个 useTheme,我会得到两个独立的状态副本。因此,一旦我从其中任何一个触发 toggleTheme,它们就会彼此不同步。看看这个例子,尝试按下按钮,然后重新加载页面。

我们又回到了需要在不同的 React 组件之间共享状态的需求。 也就是说,我们又回到了 Context/Redux/Zustand。在这种情况下,实现将移动到 ThemeProvider(或 Zustand/Redux 等价物):

// 整个实现只是移到了 provider 中
const ThemeProvider = ({ children }) => {
  const initialTheme = localStorage.getItem("theme") || "light";
  const [theme, setTheme] = useState(initialTheme);

  const toggleTheme = () => {
    const newTheme = theme === "dark" ? "light" : "dark";
    setTheme(newTheme);
    localStorage.setItem("theme", newTheme);
  };

  return (
    // 在真实项目中,别忘了在这里 memoize 这个值!
    <ThemeContext.Provider value={{ themetoggleTheme }}>
      {children}
    </ThemeContext.Provider>

  );
};

useTheme 钩子变回从 React(而不是 Local Storage)中提取它需要的东西:

// 或者 Redux/Zustand 等价物
const useTheme = () => useContext(ThemeContext);

这是可工作的实现。这是否意味着只是 React 不争气,而 Local Storage 是一个完美的状态管理解决方案,如果不是因为 React 的话? 🤔🤔

实际上,不是 😉😉 这次不是。Local Storage 在 React 之外还有许多缺点,使得它用于任何状态管理相关的目的都有问题。

“不”用 Local Storage:监听变更事件

首先,如果应用的其他部分(无论是 React 还是非 React)在 React 生命周期之外手动更新了 Local Storage 中的 "theme" 值,会发生什么?如果应用正在从另一个框架迁移到 React(或反之),或者仅仅是由于疏忽,这很容易发生。UI 中渲染的值和保存在 Local Storage 中的值将再次变得不同步,就像我们上面看到的那样。看看这个例子。

对于一个“正确”的解决方案,我们需要监听 Local Storage 本身的变化,并在它们发生时将它们推送回 React。这不是 React 领域,而是原生 JavaScript 领域:我们需要找到一个要监听的事件,然后用 addEventListener 为该事件添加一个监听器。

快速搜索一下就会发现,当 Local Storage 被更新时,会触发一个 "storage" 事件。所以理论上,这应该很简单:

const ThemeProvider = ({ children }) => {
  // 其他一切保持不变
  useEffect(() => {
    // 监听所有 "storage" 事件
    window.addEventListener("storage", (event) => {
      // 确保是 "theme" 被更新了
      if (event.key === "theme") {
        // 同时更新 React 部分
        setTheme(event.newValue);
      }
    });
    return ... // 别忘了清理事件监听器
  }, []);
};

我们需要做的就是在 ThemeProvider 内部添加一个 useEffect,用 addEventListener 监听 storage 事件,并通过 setTheme 更新状态。React 会从这里接管并用正确的值更新 UI。

除了它不起作用。 自己看吧 😬😬。如果你以前从未使用过 Local Storage,调试这个可能会让你抓狂。因为语法和用法绝对正确。如果你像我一样倾向于浏览文档的文字部分而只阅读代码示例,你可能需要一段时间才能弄清楚为什么会这样。因为答案其实在文档中,就在第一段 😅😅。只是很容易错过它。

“该事件不会在引发更改的窗口(window)上触发。” 这意味着上面的代码示例有效,但前提是你并行打开两个标签页。如果你在一个标签页中点击 "change storage value",你会看到值在另一个标签页中发生了变化。只是在触发更新的标签页中没有变化。

这为我们提供了一些在标签页间同步数据的绝佳机会,但留下了如何处理当前标签页的难题。当然,如果有真正的需要,有办法可以解决。最简单的方法就是说这种行为不受支持并忽略它 😅😅。毕竟这是一个相当罕见的边缘情况。

我们也可以在更新 local storage 时手动在当前标签页中分派(dispatch)一个事件。像这样:

const e = new StorageEvent('storage', {
  key"theme",
  newValue: value,
  ... // 其他必要的属性
});
window.dispatchEvent(e);

或者如果有非常强烈的需求要支持非原生行为,甚至可以修补原生实现。对于我们的主题化例子,我选择了手动分派作为最简单的选项。在这里试试看。

如果你想真正用 Local Storage 替换 Redux/Context/Zustand,这种行为可能有点扫兴。但我们假设这不是问题:我们确信没有应用的其他部分能在未经我们同意的情况下更改存储值。

还有其他需要考虑的问题,这些问题同样与 React 本身无关。例如服务器端支持(或者说是缺乏)。

“不”用 Local Storage:SSR 和服务器组件 (Server Components)

任何存储在 Local Storage 中的东西在服务器端都不可用。它毕竟是一个浏览器 API。如果你试图在服务器环境中直接访问 localStorage,你会得到一个 "localStorage is not defined" 错误。你必须选择要么对使用 Local Storage 的应用部分禁用 SSR,要么使用一些合理的默认值渲染这些部分,然后用 Local Storage 中的值覆盖它们。如果 SSR 对你很重要,这是需要记住的一点。

“不”用 Local Storage:键值对和字符串

另一件需要记住的事情是,Local Storage 是一个非常简单的键值对存储,而且它对你的整个域名(domain)是全局的,并且是永久性的。每一个页面,每一个你安装的外部库,只要浏览器存在(可能是几年!),都将共享相同的全局空间。在这种条件下,你需要非常小心地命名事物。准备好发明你自己的命名空间系统吧。否则,某个东西意外覆盖了另一个东西,你的整个应用就会崩溃和出现故障。

此外,这对键值中的“值(value)”部分只能是字符串。没有布尔值,没有数组,没有对象。告别你默认的类型安全,准备好来回转换一切吧。Zod 将成为你最好的朋友(尽管它已经是了)。

“不”用 Local Storage:错误处理

使用 Local Storage 意味着你需要非常注意你的错误处理和监控。也就是说,你需要有它们 😅😅。因为 Local Storage 会抛出错误并破坏你的整个应用。

首先,你会经常在 Local Storage 中使用 JSON.parse(...)(或 Zod 中的等价物)。记得你只能在那里存储字符串吗?如果你想存储一些复杂的状态对象,它们必须先被字符串化(stringify),然后再解析回来(parse)。而 JSON.parse(...) 非常挑剔,只接受语法正确的 JSON。否则,它会抛出错误。

// 如果存储中的值不是有效的 JSON,这会破坏你的整个应用
const myState = JSON.parse(localStorage.getItem("my-state"));

错误地解析我们的主题值(例如 JSON.parse("dark"))会毁掉你的应用。字符串不是有效的 JSON!

其次,如果用户配置了某些安全策略,它可能抛出 SecurityError。别问是哪些,我从来没做过,但理论上有可能。

最后,你知道 Local Storage 有限制吗? 😉😉 最多只允许 5 MB。超出这个限制,就会抛出 QuotaExceededError

诚然,要超过 5 MB 的数据相当困难。除非,当然,你把它用于存储整个应用的状态加备份之类的东西。或者意外引入了某种“内存”泄漏,即你经常存储唯一的数据且从不清理(想想带时间戳的分析值)。虽然罕见,但也可能发生。当你在拼命修复问题时,祝你好运地向你的非技术用户解释他们需要清除本地存储。

“是”用 Local Storage

总结一下:实际上可以用 Local Storage 作为状态管理并放弃显式使用 Redux/Zustand/Context。然而,解决方案将更复杂、更脆弱、如果实现不正确容易抛出错误,并且最终在底层仍然需要使用 Redux/Zustand/Context 的机制。😅😅 所以真的没有意义,除非你有迫切的“产品”需求需要数据持久化。

那么 Local Storage 有什么好处呢?例如,表单数据备份。如果你有一个用户需要填写的复杂表单,定期将数据保存在 Local Storage 中是个好主意。这样,如果用户不小心关闭了页面,你可以立即恢复它。

作为一个微型后端(mini-backend),如果你不想麻烦地设置真实的后端。主题化就是一个完美的例子。还有各种无需登录的、仅限浏览器的游戏。

UI 中的一些“锦上添花”的东西,比如记住哪个标签是打开的,或者侧边导航是展开还是折叠的。

实现与不同标签页之间通信相关的真正酷炫的东西。 比如实时编辑、通知,或者其他完全不同且令人印象深刻的玩意儿。还记得几年前那个令人难以置信的“合并气体行星(merging gas giants)”演示吗?由 Local Storage 驱动!

最后

还没有使用过我们刷题网站(https://fe.ecool.fun/)或者刷题小程序的同学,如果近期准备或者正在找工作,千万不要错过,题库已经更新1600多道面试题,除了八股文,还有现在面试官青睐的场景题,甚至最热的AI与前端相关的面试题已经更新,努力做全网最全最新的前端刷题网站。


有会员购买、辅导咨询的小伙伴,可以通过下面的二维码,联系我们的小助手。

图片