【翻译】能否用本地存储替代Context-Redux-Zustand?

23 阅读18分钟

原文链接:www.developerway.com/posts/local…

作者:Nadia Makarevich

为什么我们在 React 中需要 Context/Redux/Zustand?本地存储的用途是什么?它有哪些局限性?何时应该使用它?

最近有人问我:"何时该用Redux/Zustand/Context API?它们在React中究竟有何必要?为什么不能直接用本地存储?"我特别喜欢这类问题。表面上看答案很简单:因为它们服务于截然不同的目的。

这只是表象。但事实果真如此吗?它们的用途为何如此不同?说到底不都是存储数据吗?是因为React做了些奇怪的事情,而在Svelte或Angular中使用就没问题?还是本地存储本身存在问题?又或者根本不存在问题?或许很久以前有问题,但现在已经解决了?

毕竟,若能抛弃所有状态管理库,直接利用浏览器原生API和语言API,该有多好?

是时候验证这种设想是否可行了。

为何需要 Context/Redux/Zustand

首先让我们探讨 Context/Redux/Zustand 等工具的意义——我们究竟为何需要它们?

在 React 中,一切围绕状态展开。我们通过 useStateuseReducer 等钩子将数据放入状态,将其渲染到屏幕上,并在需要更新屏幕信息时触发状态更新。通常发生在用户与界面交互之后。

对于简单的状态需求,"局部"状态(即通过 useState 钩子控制且不泄露到组件外部的状态)就足够了。例如下拉组件的 isOpen 状态——只有下拉组件本身能访问它,只要下拉功能正常,其他组件无需关心。

const Dropdown = ({ children, trigger }) => {
	// That's local state, only the Dropdown component knows about it
  const [isOpen, setIsOpen] = useState();


  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>{trigger}</button>
      {isOpen && children}
    </>
  );
};

然而,某些场景下确实需要跨组件共享状态。例如一套复杂的过滤器,它会影响页面不同区域的渲染内容。甚至像"深色模式"主题这样简单的功能——应用角落仅需一个按钮即可切换开关,但isDarkMode值却需要传递给页面上半数组件。

此时便会遇到难题。React严格遵循层级结构:组件只能通过 props/回调与子组件/父组件共享数据,绝不能与兄弟组件共享。因此在主题切换场景中,我无法让 <ToggleTheme /> 按钮将其当前主题值共享给除父组件/子组件之外的任何对象。

const App = () => {
  return (
    <>
      {/* This one has a local state with the isDarkMode state value */}
      <ToggleTheme />
      {/* This one can't have access to the local state of ToggleTheme
      and has no idea whether it's a light or dark theme. */}
      <SomeBeautifulContentComponent />
    </>
  );
};

为了解决这个问题,我们采用了一种称为"提升状态"的技术。该技术将状态提升到需要它的组件的最近公共父组件中,然后通过 props 进行分发。在我们的案例中,App 组件:

const App = () => {
  const [isDarkMode, setIsDarkMode] = useState(false);


  return (
    <>
      {/* Now ToggleTheme doesn't have state, it receives only props */}
      <ToggleTheme
        isDarkMode={isDarkMode}
        onClick={() => setIsDarkMode(!isDarkMode)}
      />
      {/* This one now has access to the theme value */}
      <SomeBeautifulContentComponent isDarkMode={isDarkMode} />
    </>
  );
};

然而这种模式本身也会引发问题。首要问题是无谓的重复渲染,这本身就是个庞大议题

其次是层级结构中每个组件的API都变得臃肿。即便如此简单的改动,也让原本简单的代码复杂度激增。但如果需要在状态中维护复杂对象,并通过多种方式更新状态的独立片段呢?还要将不同片段传递给层级树中更深处的组件?

代码很快就会变得难以阅读和管理。而且一半的组件只会传递数据而不会使用它。这个问题被称为"属性钻取"。

要避免属性钻取,我们需要像Context这样的解决方案。借助Context,我们可以将所有与状态相关的部分提取到独立组件中,然后在需要的地方直接访问值和回调函数。这就像要把钢琴从16楼搬到地面:你可以选择走楼梯,一层层缓慢而稳妥地拖拽;或者干脆从阳台扔下去,直接乘电梯跳过中间所有楼层。

App的API将恢复到原有状态,唯一新增的是一个同时持有isDarkMode状态和Context的组件:

const App = () => {
  return (
    {/* This one controls the isDarkMode state and distributes it via Context */}
    <ThemeProvider>
      {/* This one uses isDarkMode from Context directly */}
      <ToggleTheme />
      {/* This one also has access to isDarkMode via Context */}
      <SomeBeautifulContentComponent />
    </ThemeProvider>
  );
};

尝试这个示例——它包含两个应用,一个采用属性传递(props drilling),另一个使用上下文(Context)。通过对比感受两种方案的差异。

其他状态管理库(如Redux、Zustand等)解决的正是相同问题。它们在优缺点和实现方式上略有不同,但核心目标都是避免属性传递并构建更连贯的状态组织体系。

它们之间的差异及如何选择,本身就是一个值得单独撰文探讨的话题。或许改日再论。眼下,让我们聚焦于本地存储。

为何需要本地存储

在此之前,我们始终在React框架内部及JavaScript环境中运行——更准确地说,是在浏览器的JavaScript运行时环境中操作。无论何处创建的变量(无论是否属于状态变量),只要用户关闭浏览器标签页或刷新网页,这些变量都会立即消失。

当然,除非我们采取额外措施防止数据丢失,将其持久化存储在更长效的载体中。此时就需要借助外部数据存储方案——从完整的数据库到JSON文件,或是本地存储。

本地存储是比网页短暂生命周期更持久的数据存储方式。只要用户浏览器本身存在(而非仅限于打开的标签页),所有存入本地存储的数据都会长期保留。即使意外关闭标签页后返回页面,数据依然存在。

该存储空间具有域名作用域限制。即:您的网站可管理自身数据,但无法触及其他数据,同样也无法被其他网站控制您的数据。

只要能转换为字符串的数据均可存储于本地存储。下次访问常用网站时,请在Chrome中打开开发者工具,切换至"应用程序"选项卡,进入"本地存储"标签页查看数据详情。

在那里你会看到各种各样的内容:分析数据、指标、主题设置、各类令牌、跟踪许可,还有天知道什么其他东西。

我们再以主题设置为例。通常你不会想为此引入后端和登录机制,尤其是对于简单的网站。但同样,你也不希望用户每次加载网站时都重新选择主题。解决方案:将用户偏好存储在本地存储中,每次页面加载时从那里获取,而不是将其重置为默认值。

本地存储 API 可能是 JavaScript 和 React 领域中最简单的东西。甚至没什么好说的,它无非就是"保存项"、"获取项"、"删除项"、"清除所有内容"。仅此而已。

// Save theme in local storage
localStorage.setItem("theme", "dark");


// Extract theme from local storage
const theme = localStorage.getItem("theme");


// Delete theme from local storage
localStorage.removeItem("theme");


// Wipe the entire storage
localStorage.clear();

在 React 端,我们通常会在应用程序启动时从本地存储中提取该值:

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

将其放入上下文/Redux/Zustand中,以便主题可在任何地方访问:

// Create Theme Context Provider, Zustand storage, Redux storage, etc
const ThemeContext = createContext('light');


const ThemeProvider = ({ children }) => {
	// Extract from Local Storage and put it into memory for further React access
  const theme = localStorage.getItem("theme");


  return (
    <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
  );
};


// Use it in App
const App = () => {
  return <ThemeProvider>{/*... // the rest of the app*/}</ThemeProvider>;
};

创建useTheme 钩子:

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

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

// Various components that use the "theme" value from Context/Redux/Zustand
const Button = () => {
  const theme = useTheme();


  return ... // button's implementation
}


const Navigation = () => {
  const theme = useTheme();


  return ... // navigation's implementation
}

你看?Context和本地存储的用途完全不同!

🤔

🤨

🤔

还是说其实有联系?为什么我不能在useTheme钩子内部直接读取本地存储?引入Context的意义何在?为什么不能像这样操作来简化实现?

const Button = () => {
  const theme = localStorage.getItem("theme");


  return ... // button's implementation
}

或者,如果我们想搞得更花哨些,为什么不能改写 useTheme 钩子呢?这样按钮甚至无需知道本地存储的存在:

// Why aren't we doing this? Why introduce Context?
const useTheme = () => {
  return localStorage.getItem("theme");
};


// Button wouldn't even have to know
const Button = () => {
  const theme = useTheme();


  return ... // button's implementation
}

没有上下文及其相关复杂性,没有Redux/Zustand,也无需学习新库,API本身很简单。还有什么阻碍我们?

理由多着呢!

拒绝本地存储:产品层面的考量

有时,我们纯粹不希望本地存储产生"持久化"效果,仅此而已。

是的,主题样式应该在页面刷新后保留。但展开的抽屉菜单、打开的模态对话框或"已选中"的复选框等元素则不应如此。事实上,我们通常期望页面刷新能清除所有状态,恢复"默认"页面体验。否则任何残留状态都会被视为错误。

在这种情况下,我们主要使用 Redux/Context/Zustand 处理大部分状态管理需求。而本地存储则负责需要显式持久化的内容,如主题样式。否则就必须设计方案在每次页面加载时重新初始化本地存储,这反而会增加复杂性而非简化流程。

拒绝本地存储:与React同步

但假设从产品角度出发,我们确实希望持久化存储通常存入Redux/Context/Zustand的大部分状态。此时仍存在一个待解难题:如何将本地存储与React连接起来。

因为在实现上述"主题切换"功能时,我稍稍隐瞒了实情。当前展示的方式永远无法正常工作——或者更准确地说,它会产生异常行为。其中缺失的关键点在于:点击按钮时应实现深色模式的切换功能。

const ToggleThemeButton = () => {
  return (
    <button
      onClick={() => {
        // We need to toggle the theme here
      }}
    >
      Dark mode on/off
    </button>
  );
};

如果我调用 localStorage.setItem("theme", ...),它将无法正常工作。

// This is not going to work!
const ToggleThemeButton = () => {
  const theme = localStorage.getItem("theme");


  return (
    <button
      onClick={() => {
	      // Just changes local storage value
	      // React can't pick it up
        localStorage.setItem("theme", theme === "dark" ? "light" : "dark");
      }}
    >
      Dark mode on/off
    </button>
  );
};

这样做确实会更新本地存储的值。因此,页面刷新时,主题值将从存储中读取,深色/浅色模式会随之切换。但点击按钮时并不会切换。

要实现真正的交互性,我们需要告知React发生了变化,需要更新UI。我们需要触发重新渲染。若您不清楚重新渲染的概念或其在 React 中的作用,我备有大量相关资源(包括一本专著),其中半数篇幅专门探讨重新渲染及其触发机制。

核心要点在于:更新 React 中的任何内容都需触发重新渲染,而唯一触发方式就是改变状态。无论是通过 useState & useReducer 钩子、Redux/Zustand 等外部库,还是使用 useSyncExternalStore,本质上都需要将外部系统(如本地存储)与 React 生命周期关联,才能观察到变化。

最简单直接的方法是将状态纳入 useTheme 钩子:

const useTheme = () => {
	// extract the initial value
  const initialTheme = localStorage.getItem("theme") || "light";
  // save it into state
  const [theme, setTheme] = useState(initialTheme);


  const toggleTheme = () => {
    const newTheme = theme === "dark" ? "light" : "dark";
    // when toggleTheme is called, set the new value both in state
    // and in local storage
    setTheme(newTheme);
    localStorage.setItem("theme", newTheme);
  };


  return {
    theme,
    toggleTheme,
  };
};

我们将初始值从本地存储中提取并放入状态中。同时设置一个toggleTheme回调函数,在其中修改本地状态后,再将该值"镜像"回本地存储。

但这里存在一个问题。此处的"真实数据源"是本地状态,本地存储仅用于应用初始化阶段。本地状态具有局限性——它完全独立于其他组件,不存在任何共享机制。若在两个不同位置使用useTheme,将产生两个独立的状态副本。一旦从任一组件触发toggleTheme,两者状态便会立即失调。

这种情况下的行为会相当诡异🤪。请查看此处示例,尝试点击按钮后刷新页面。

这再次凸显了不同React组件间共享状态的需求,即需要回归Context/Redux/Zustand等方案。

具体实现将转移至ThemeProvider(或Zustand/Redux的等效组件):

// The entire implementation just moved to the 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 (
	  // In real life, don't forget to memoize the value here!
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

通过useTheme挂钩,将所需内容从本地存储提取回React:

// Or Redux/Zustand equivalent
const useTheme = () => useContext(ThemeContext);

这里是可运行的实现方案

这是否意味着React本身很糟糕,而本地存储只要没有React的限制就是完美的状态管理方案?🤔

其实不然😉 这次情况不同。本地存储在React之外还存在诸多缺陷,使其在任何状态管理相关场景中都难以胜任。

拒绝本地存储:监听变更事件

首先,如果应用程序的其他部分(无论是否使用React)在React生命周期之外手动更新本地存储中的"theme"值会怎样?当应用程序正从某个框架迁移到React(或反之)时,这种情况很容易发生。或者仅仅是操作疏忽。

此时UI渲染值与本地存储值将再次失调,与前文示例完全相同。请参阅此处示例。要实现"正确"的解决方案,我们需要监听本地存储本身的变更,并在变更发生时将其推送回React。

这已超出 React 框架范畴,属于原生 JavaScript 领域:我们需要找到可监听的事件,并通过 addEventListener 为其添加监听器。快速搜索发现,当本地存储更新时会触发名为 "storage" 的事件。因此理论上实现应该很简单:

const ThemeProvider = ({ children }) => {
  // everything else stays the same


  useEffect(() => {
	  // listening for all "storage" events
    window.addEventListener("storage", (event) => {
	    // making sure that it's "theme" that was updated
      if (event.key === "theme") {
	      // updating the React part as well
        setTheme(event.newValue);
      }
    });


    return ... // don't forget to clean up the event listener here
  }, []);


};

我们只需在 ThemeProvider 中添加一个 useEffect,用 addEventListener 监听存储事件,再通过 setTheme 更新状态。React 会自动接管后续操作,用正确值更新 UI。

但实际并不奏效。亲眼看看吧 😬。

若你从未接触过本地存储,调试这个错误可能会让你抓狂。因为语法和用法完全正确。而且如果你像我一样习惯略过文档文字部分只看代码示例,可能需要花些时间才能弄明白问题所在。

答案其实就在文档里,而且就在第一段😅只是很容易被忽略。"事件不会在触发变更的窗口上触发"。这意味着上述代码示例仅在并行打开两个标签页时有效。若在某个标签页点击"修改存储值",你会在另一个标签页看到值已更新——唯独不会在触发更新的标签页上显示变化。

这为我们在标签页间同步数据提供了绝佳机会,但当前标签页的处理方式却令人有些困惑。当然,若确有必要,我们仍有办法应对。

最简单的做法就是直接声明不支持此行为并忽略它 😅 毕竟这属于相当罕见的边缘情况。

另一种方案是在触发本地存储更新时,手动向当前标签页分发事件。具体实现类似如下:

const e = new StorageEvent('storage', {
  key: "theme",
  newValue: value,
  ... // other necessary properties
});
window.dispatchEvent(e);

或者在确实需要支持非原生行为时,甚至可以直接修改原生实现。

在主题示例中,我选择了手动分发作为最简便的方案。您可在此处进行尝试。

若你真想用本地存储替代Redux/Context/Zustand,这种行为可能令人头疼。但假设这不成问题:我们确信应用中没有任何部分能在未经许可的情况下修改存储值。

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

拒绝本地存储:SSR与服务器端组件

存储在本地存储中的任何内容都无法在服务器端访问。毕竟这是浏览器API。若在服务器环境中直接访问localStorage,将报错"localStorage未定义"。

对于使用本地存储的应用模块,你必须选择:要么放弃服务器端渲染(SSR),要么采用合理默认值渲染这些模块,再通过本地存储值覆盖默认值。

若SSR对你至关重要,请务必谨记这一点。

拒绝本地存储:键值对与字符串

还需注意的是,本地存储本质上是极其简单的键值存储,且在整个域名范围内永久生效。只要浏览器运行,每个页面、每个安装的外部库都将共享同一全局空间——这种共享可能持续数年之久!

在此环境下命名时务必谨慎。请准备好创建专属命名空间体系。否则一旦发生意外覆盖,整个应用将崩溃或出现异常。

更棘手的是,该存储的"值"部分仅支持string。布尔值、数组、对象统统不行。告别默认类型安全机制,做好反复转换的准备。Zod将成你最得力的助手(虽然它本就该如此)。

拒绝本地存储:错误处理之道

使用本地存储意味着你必须高度关注错误处理与监控机制——换言之,你必须具备这些能力😅。因为本地存储可能引发崩溃,甚至摧毁整个应用。

首先,你将频繁使用 JSON.parse(...)(或 Zod 中的等效方法)操作本地存储。请谨记:本地存储仅支持string存储!若需存储复杂状态对象,必须先将其序列化为字符串,再反序列化解析。而 JSON.parse(...) 极其挑剔,只接受语法正确的 JSON 数据,否则就会抛出异常。

// This will destroy your entire app
// if the value in storage is not a valid JSON
const myState = JSON.parse(localStorage.getItem("my-state"));

误将主题值进行解析(例如 JSON.parse("dark"))会破坏你的应用。字符串并非有效的 JSON 格式!

其次,若用户配置了特定安全策略,可能会抛出 SecurityError 异常。具体哪些策略?恕我不知,我从未尝试过,但理论上存在这种可能性。

最后,你知道本地存储存在容量限制吗?😉 允许存储量不超过5MB。超出限额将抛出QuotaExceededError异常。

当然,实际使用中很难达到5MB上限——除非你将其用于存储全局应用状态及备份等非临时字符串数据。或者意外引入某种"内存泄漏"——频繁存储唯一数据却从不清理(比如带时间戳的分析值)。这种情况虽罕见,但确实可能发生。

祝你好运,当你拼命修复问题时,还要通过电话向非技术用户解释他们需要清理本地存储。

支持本地存储 简而言之:确实可以将本地存储用于状态管理,从而避免显式使用 Redux/Zustand/Context。但这种方案会更复杂、更脆弱,若实现不当容易抛出错误,且最终仍需在底层使用 Redux/Zustand/Context。😅 因此除非存在迫切的"产品级"数据持久化需求,否则这种做法毫无意义。

那么本地存储有何用处?

例如表单数据备份。当用户需要填写复杂表单时,定期将数据保存至本地存储是个好主意。这样即使用户意外关闭页面,也能瞬间恢复表单内容。

作为微型后端,当你不想折腾真实后端时。主题管理就是绝佳范例。各类无需登录的纯浏览器游戏也适用。还有些UI中的"锦上添花"功能,比如记住当前打开的标签页或侧边导航的展开状态。

实现不同标签页间超酷的交互功能。比如实时编辑、通知系统,或是其他颠覆想象的惊艳创意。还记得几年前那个超炫的"合并气态巨行星"演示吗?正是靠本地存储实现的

但切勿将其作为共享状态的替代方案:Redux/Zustand/Context正是为此而生。我们当前只需厘清它们的差异并学会正确选择工具。关于这点——且待下篇文章揭晓😉