发布于 2026-01-06 1 阅读
0

DejaVu:缓存与记忆化 1. DejaVu 与时间撕裂 事件传播 状态同步 推送还是拉取? 结论

DejaVu:缓存与记忆化

1. 似曾相识和时间撕裂

事件传播

状态同步

推还是拉?

结论

🥳 TL;DR React 状态撕裂、最终一致性和时间旅行 🤓

曾经,我还是个初级开发人员,遇到很多问题却束手无策。我常常被各种莫名其妙的事情搞得晕头转向,找不到任何解释。
后来,我学会了如何克服挑战和障碍。我明白了如何提前解决问题并规避风险。我不断学习算法和模式,以确保程序运行流畅,并使我的工作成果逐年变得更加稳定可预测。

过了很久,我才真正开始学习 React,它简化一切的方式让我惊叹不已:bug 消失了,所有功能都运行良好!如何让它更简单易用呢?这成了我唯一的问题。

这些日子已经过去了。
我刚刚意识到,仅仅一周的时间,我利用钩子程序创建并解决的问题就比之前一整年加起来还要多。
我又一次沦为初级开发人员。我又一次遇到了无法解释的问题。我必须也将会探索新的模式来应对未来的挑战。
欢迎加入我的旅程。

一部分为三部分的侦探悲剧

1. 似曾相识和时间撕裂

似曾相识的感觉是指觉得自己以前经历过当前的情境。这个词组的字面意思是“已经见过”。

有一天,几个不同的人在一期杂志上相遇。他们就未来的并发渲染进行了精彩的讨论,这为后来 React-Redux v6 的开发奠定了基础。

主要问题是“撕裂”——不同的时间片在一次渲染(输出)中共存。某些组件可能看到一个时间片New State,而其他组件可能仍然看到另一个时间片。作为渲染器Old将看到两者User

这只是一个理论问题,React 团队在 React-redux v6失败后也证实了它的“无关紧要性” 。不过,这里有一个例子或许可以证明这一点。

总之,关键在于,一年前这只是一个理论问题,可能要等到 React 真正面世后才会面临。 异步同时发生。

虽然 React 仍然是同步的,但我们遇到了一个问题,这个问题不是由异步性引起的,而是由 hooks 和闭包引起的——我们喜欢 JavaScript 的函数式作用域。

现在,国家总是部分地沉浸在过去,这已经很常见了

基于类的组件不存在“过去”的概念——只有一个this别无其他。它this始终代表“现在”。而
使用 Hooks 时,情况就不同了……

  • 当你执行操作时,它会看到来自局部函数作用域的onClick变量。而来自“过去”作用域的变量则只代表当前状态。refs

  • 当你声明时,不存在“过去”的概念 effect——只有“现在”。因此,你无法预知某个效应何时会触发。“过去”和“现在”的依赖关系会在 React 内部进行比较。

  • 当你跑步时effect——它已经time tick过去了。有些事情可能已经改变,但不会改变effect——它被冻结在时间里。

  • 运行时multiple effects,它们可能会相互影响,导致级联式和重复更新。在所有更新完成之前,情况并非完全past一致present——只要每个钩子都能独立运行,它们就会混合在一起。

在 RxJS 世界中,这被称为glitchesObservables发出的临时不一致性,它们并不被视为问题

GlitchesReact 中的 bug 也更多地与功能有关,而非 bug。然而,它们至少是一个严重的性能问题。

我们来举几个例子。

事件传播

阿喀琉斯和乌龟

阿喀琉斯与乌龟悖论

首先,我们选择一个简单的问题来解决——event propagation speed这个问题很容易复现,而且如果你有不止一个状态管理系统,你可能已经遇到过这个问题了。

  1. 每个活动交付系统都有其自身的运作方式。
  2. 或许,你至少有两个。

让我们设想一个相当标准的例子——React、React-Router、React-Router-Redux 和 Redux。

假设你要改变地点,会发生什么?

  • location变化
  • history更新
  • react-router-redux向 Redux 发送更新store
  • dispatch事件发生在 React 循环之外,因此 State 会同步更新,所有connected组件都会被触发。
  • 部分组件已更新。但是,withRouter/正在尚未更新的useRouter读取数据Context
  • 🤷‍♂️(你的申请既属于过去,也属于未来)
  • history更新调用下一个监听器,我们继续。
  • Router已更新
  • Context已更新
  • withRouter组件由上下文更新触发
  • 部分组件已更新,最终会使用正确的值。

所以,你并没有做错什么,只是由于混合了事件传播速度不同的状态而导致了双重渲染。

好消息——React-Redux v7 已经解决了这个问题。它使用了与 Redux-Router 相同的 Context,因此事件传播速度也相同。然而,任何其他状态管理方式,特别是使用自定义订阅模型的方式,可能(目前)还无法解决这个问题。

这里有个问题。如果另一次渲染又触发了一些事件,再次改变了状态,会发生什么情况?

好吧,《阿喀琉斯,乌龟》,你只会得到更多浪费的渲染图。

阿喀琉斯和乌龟

然而,你可能认为这不是你的问题。我不同意这种说法。让我们从另一个角度来看待同一个问题。

状态同步

克隆体

你听说过CAP定理吗?最简单的描述是:不存在能够实现理想状态管理的方法。
The Ideal State它包含以下内容:

  • Consistency每个人都read读取“真”值
  • Availability每个read或每个write都能完成这项工作
  • Partition tolerance即使各个部分都停止运作,它作为一个整体也能继续运转。

我们在客户端状态管理方面没有任何问题Availability。但是,我们在 ` Consistencyand`方面确实存在问题Partition tolerance。无论您要写入什么,或者刚刚写入什么,只要 `and`write将在 `and` 中执行,future就没有“读取”命令。您只能获取本地闭包中已有的内容,也就是“过去”的状态。

我这里有一个很好的例子:

  • 假设你有一些搜索结果
  • 传入的道具是一个search term
  • 你把它储存current page在里面。local state
  • 如果之前未加载,则加载search-term+current page

代码永远是解释问题的最佳方式。

const SearchResults = ({searchTerm}) => {
  const [page, setPage] = useState(0);

  useEffect(
     // load data
     () => loadIfNotLoaded(searchTerm, page), 
     // It depends on these variables 
     [page, searchTerm]
  );

  return "some render";
}
Enter fullscreen mode Exit fullscreen mode

一切都好吗?确实很好,除了一个地方。更新page后可能需要重置一下term。对于“全新”搜索来说,应该就是这样——从头开始。

const SearchResults = ({searchTerm}) => {
  const [page, setPage] = useState(0);

  useEffect(
     // load data
     () => loadIfNotLoaded(searchTerm, page), 
     // It depends on these variables 
     [page, searchTerm]
  );

+  // reset page on `term` update
+  useEffect(
+     () => setPage(0), 
+     [searchTerm]
+  );

  return "some render";
}
Enter fullscreen mode Exit fullscreen mode

那么,更新后会发生什么呢searchTerm

  • 🖼 组件正在渲染
  • 🧠第一个效果将被设置为触发,只要searchTerm它发生了变化
  • 🧠第二个效果将被设置为触发,只要searchTerm它发生了变化
  • 🎬第一个效果会触发新旧 searchTerm文件 page加载——创建此效果时,文件是旧的。
  • 🎬第二个效果触发setPage(0)
  • 🖼 组件渲染
  • 🧠第一个效果将被设置为触发,只要page它发生了变化
  • 🖼 组件渲染时状态正确
  • 🎬 第一个效果再次触发,加载新的 searchTerm新的 page
  • 🖼 组件会在搜索结果加载完毕后渲染并显示正确的搜索结果。

所以——修改一个 props,组件渲染 3 到 4 次,数据获取 2 次,其中一次数据有误——新旧 searchTerm数据都有 page。翻桌子!

玩玩看:

Achilles and the Tortoise当一个更新(页面)试图访问另一个更新(搜索词)时,另一个更新也在移动,这就是同样的情况。

一切都崩溃了。我们仿佛回到了几年前。

一点也不好笑,使用 Redux 肯定有其合理之处。而且,只要 Redux 符合规范,能帮助我们“正确地”完成任务,我们就都被要求使用它。
今天我们却被告知不要再用了,但原因却不一样,比如它太全局化了。

简而言之——解决我们的问题有两种方法。

1. 用火烧死它

或者设置key重新挂载组件,并将其重置为“正确”的值

<SearchResults searchTerm={value} key={value} />
Enter fullscreen mode Exit fullscreen mode

我会说——这是最糟糕的建议,因为你会丢失一切——本地状态、渲染后的 DOM,所有的一切。不过,理论上可以用相同的key原理来改进它。

const SearchResults = ({ searchTerm }) => {
  const [page, setPage] = useState(0);
  const [key, setKey] = useState(null/*null is an object*/);

  useEffect(    
    () => {
      if (key) {// to skip the first render
      console.log("loading", { page, searchTerm });
      }
    },
    [key] // depend only on the "key"
  );

  // reset page on `term` update
  useEffect(() => {    
    setPage(0);
    console.log("changing page to 0");
  }, [searchTerm]);

  useEffect(() => {
    setKey({}); 
   // we are just triggering other effect from this one
  }, [page, searchTerm]);
Enter fullscreen mode Exit fullscreen mode

这一次,即使提供了“正确”的值,我们的loadingsideEffect 也只会调用一次。

  • 页面和搜索词集
  • 首次使用 useEffect 时没有任何作用,键未设置
  • 第二个 useEffect 没有执行任何操作(页面为 0)
  • 第三次使用效果改变关键
  • 首先 useEffect 会加载数据
  • ...
  • searchTermpage更新
  • 首次使用效果未触发
  • 第二次使用效果可能会更新page为 0
  • 第三用途效果更新密钥
  • 👉 first useEffect 会在一切“稳定”时加载数据

从某种角度来看——我们只是在时间上转移影响……

2. 回到过去

接受游戏规则,让他们站在你这边玩就行了。

const SearchResults = ({searchTerm}) => {
  // ⬇️ mirror search term ⬇️
  const [usedSearchTerm, setSeachTerm ] = useState(searchTerm);
  const [page, setPage] = useState(0);

  // reset page on `term` update
  useEffect(
     () => setPage(0), 
     [searchTerm]
  );

  // propagare search term update
  useEffect(
     () => setSeachTerm(searchTerm), 
     [searchTerm]
  );

  useEffect(
     // load data
     () => loadIfNotLoaded(usedSearchTerm, page), 
     // It depends on these variables
     // and they are in sync now
     [page, usedSearchTerm]
  );  
  return "some render";
}
Enter fullscreen mode Exit fullscreen mode
  • 更改searchTerm首次更新pageusedSearchTerm
  • 更改usedSearchTermpage加载数据。现在这些变量是同时更新的

问题解决了吗?不,如果变量很多,这种模式就不适用了。我们来试着找出问题的根源:

推还是拉?

这个问题的另一个名称是 a Diamond Problem,它也与状态更新传播的 Push 或 Pull 变体有关。

  • 每次PUSH更新都会“通知”consumers用户更改的内容。因此,一旦某些内容发生更改,用户consumer就会收到关于具体更改的通知。这就是钩子的工作原理。
  • PULL每个用户收到consumer“变更”通知后,都需要pull从数据存储中进行更新。这就是Redux 的工作原理。

问题在于PULL没有“准确找零”通知,每个顾客都必须pull自行找零。这就是为什么需要使用记忆化和像 reselect 这样的库的原因。

问题在于PUSH——如果存在多个更改——consumer可能会被多次调用,从而导致暂时的不一致以及似曾相识感。

以下是来自状态管理专家™(也是reatom的创建者)@artalar 的一张很棒的图表。

现金周转

这是一个cost caclulatorPUSH 模式引起的级联更新。让我们用 hooks 重新实现它:

const PriceDisplay = ({cost}) => {
  const [tax, setTax] = useState(0);
  const [price, setPrice] = useState(0);

  // update tax on cost change
  useEffect(() => setTax(cost*0.1), [cost]); // 10% tax

  // update price - cost + tax
  useEffect(() => setPrice(tax + cost), [cost, tax]);

  return `total: ${price}`;
}
Enter fullscreen mode Exit fullscreen mode
  • 一旦cost更新完成,我们就进行更新taxprice
  • 一旦tax更新完成,我们就进行更新。price
  • price该组件更新了两次,其下方的一些组件可能也更新了。
  • 换句话说—— price“太快了”。

这是 PUSH,现在让我们用 PULL 来重写它。

const PriceDisplay = ({cost}) => {
  const tax = useMemo(() => cost * 0.1, [cost]); // 10% tax
  const price = useMemo(() => tax  + cost, [tax, cost]);

  return `total: ${price}`;
}
Enter fullscreen mode Exit fullscreen mode
  • 实际上,这不是拉动,这是真正的瀑布,但是……
  • 🤔...🥳!!

你绝对想不到,⬆️这就是⬆️这篇文章的核心。

缓存与记忆化——我们以同步的方式(即拉取模式)从彼此中导出数据,因此不会出现上述问题。

然而,这里有个问题——这个例子确实解决了计算器示例中的问题,但并没有解决我们的问题paginated search

但是……我们再试着解决这个问题吧。

const useSynchronizedState = (initialValue, deps) => {
  const [value, setValue] = useState(initialValue);
  const refKey = useRef({});

  // reset on deps change
  useEffect(() => {
    setValue(0);
  }, deps);

  // using `useMemo` to track deps update
  const key = useMemo(() => ({}), deps);
  // we are in the "right" state (deps not changed)
  if (refKey.current === key) {
    return [value, setValue];
  } else {
    refKey.current = key;
    // we are in the "temporary"(updating) state 
    // return an initial(old) value instead of a real
    return [initialValue, setValue];
  }
};

const SearchResults = ({ searchTerm }) => {
  const [page, setPage] = useSynchronizedState(0, [searchTerm]);

  useEffect(    
    () => {
      console.log("loading", { page, searchTerm });
    },
    [page, searchTerm]
  );
Enter fullscreen mode Exit fullscreen mode

这是“已修复”的代码沙箱 - https://codesandbox.io/s/hook-state-tearing-dh0us

它并非固定不变,而是有所调整——我们已经规范化了状态更新的传播速度——所有值都在同一时间内更新。

另一种方法

解决这个问题还有另一种方法——改变我们分发“副作用”的方式。
redux-saga为例,如果“状态”分发了多个事件,你可以takeLatest忽略第一个事件,或者takeLeading忽略后面的事件。
你可能也知道这叫做防抖(debounce)。我更喜欢称之为Event Horizons事件传播边界。

这里的任何(任何!)例子都可以通过延迟执行来“修复” loading effect,实际上只执行最后一个,即“稳定”的、正确的例子。

这是一个非常重要的概念,它允许批量处理请求并进行各种优化——但要明白,任何异步调用都可能需要一些时间,尤其是网络请求。如果将它们延迟几毫秒,甚至几个 CPU 周期(或 Node.js 进程周期),一切都会有所改善。

但是请记住——在代码中添加任何超时和其他异步操作都会增加难度。

结论

1. 那么,我们究竟有哪些诱饵呢?

  • useState状态仅在首次渲染期间从 props 派生而来。
  • useMemo其他值来源于状态属性。
  • useEffect属性和状态的一些变化会反映回状态中。

任何操作都 useEffect可能导致故障。

2. React 是一个主题glitches

由于不同的钩子独立更新,您可能会在单个组件中遇到暂时的不一致,从而导致(暂时的)未定义行为,甚至(暂时的)损坏状态。

问题必然与 hooks 有关,因为你必须将组件渲染到最后,而如果某些操作useEffect需要同步状态,就无法“中止”。

问题在于CachingMemoization,它们受影响CAP Theorem不同——只有记忆化才不会导致撕裂。

glitches导致重新渲染,并降低应用运行速度。

3. 使用类组件处理复杂的状态情况。

(惊喜!)ClassComponentscomponentDidUpdategetDerivedStateFromProps让复杂的状态更新变得更加便捷。您只需更新它们,无需额外的重新渲染。

请不要忘记上课——它们会很有帮助!

4. 使用外部状态(例如 Redux)

Redux 是 PULL 模式,它会对单个 dispatch 进行多次小的状态更新,Redux 可以将多个状态更新批量处理,从而实现单个 React 渲染,这是broken states不可能的。

Redux 太棒了。而且,我要明确一点,它更不容易崩溃。

5. 意识到问题所在

千万不要“信任”任何单一的解决方案。我之前尝试用钩子函数解决一些状态问题,结果却惨不忍睹,直到我接受了“世上没有完美的工具”这个事实。

钩子并非理想之选

6. 这可能根本不是个问题。

是的,这几乎不是什么问题。你可能永远不会遇到我上面提到的那些可怕的事情。

钩子太棒了!

但是,我们必须面对现实——国家管理过去是、现在是、将来也永远是一个非常复杂的问题……

无论你是否同意这一点,这里都尝试“记录”不同状态管理系统的所有极端情况:

正在进行中,请查看测试结果

本仓库旨在描述和规范状态管理的极端情况。

文章来源:https://dev.to/thekashey/dejavu-caching-versus-memoization-298n