正在进行中,请查看测试结果
本仓库旨在描述和规范状态管理的极端情况。
🥳 TL;DR React 状态撕裂、最终一致性和时间旅行 🤓
曾经,我还是个初级开发人员,遇到很多问题却束手无策。我常常被各种莫名其妙的事情搞得晕头转向,找不到任何解释。
后来,我学会了如何克服挑战和障碍。我明白了如何提前解决问题并规避风险。我不断学习算法和模式,以确保程序运行流畅,并使我的工作成果逐年变得更加稳定可预测。
过了很久,我才真正开始学习 React,它简化一切的方式让我惊叹不已:bug 消失了,所有功能都运行良好!如何让它更简单易用呢?这成了我唯一的问题。
这些日子已经过去了。
我刚刚意识到,仅仅一周的时间,我利用钩子程序创建并解决的问题就比之前一整年加起来还要多。
我又一次沦为初级开发人员。我又一次遇到了无法解释的问题。我必须也将会探索新的模式来应对未来的挑战。
欢迎加入我的旅程。
一部分为三部分的侦探悲剧
似曾相识的感觉是指觉得自己以前经历过当前的情境。这个词组的字面意思是“已经见过”。
有一天,几个不同的人在一期杂志上相遇。他们就未来的并发渲染进行了精彩的讨论,这为后来 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这个问题很容易复现,而且如果你有不止一个状态管理系统,你可能已经遇到过这个问题了。
让我们设想一个相当标准的例子——React、React-Router、React-Router-Redux 和 Redux。
假设你要改变地点,会发生什么?
location变化history更新react-router-redux向 Redux 发送更新storedispatch事件发生在 React 循环之外,因此 State 会同步更新,所有connected组件都会被触发。withRouter/正在从尚未更新的useRouter读取数据。Contexthistory更新调用下一个监听器,我们继续。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 termcurrent page在里面。local statesearch-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";
}
一切都好吗?确实很好,除了一个地方。更新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";
}
那么,更新后会发生什么呢searchTerm?
searchTerm它发生了变化searchTerm它发生了变化searchTerm文件的 page加载——创建此效果时,文件是旧的。setPage(0)page它发生了变化searchTerm和新的 page所以——修改一个 props,组件渲染 3 到 4 次,数据获取 2 次,其中一次数据有误——新旧 searchTerm数据都有 page。翻桌子!
玩玩看:
Achilles and the Tortoise当一个更新(页面)试图访问另一个更新(搜索词)时,另一个更新也在移动,这就是同样的情况。
一切都崩溃了。我们仿佛回到了几年前。
一点也不好笑,使用 Redux 肯定有其合理之处。而且,只要 Redux 符合规范,能帮助我们“正确地”完成任务,我们就都被要求使用它。
今天我们却被告知不要再用了,但原因却不一样,比如它太全局化了。
简而言之——解决我们的问题有两种方法。
或者设置key重新挂载组件,并将其重置为“正确”的值
<SearchResults searchTerm={value} key={value} />
我会说——这是最糟糕的建议,因为你会丢失一切——本地状态、渲染后的 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]);
这一次,即使提供了“正确”的值,我们的loadingsideEffect 也只会调用一次。
searchTerm或page更新page为 0从某种角度来看——我们只是在时间上转移影响……
接受游戏规则,让他们站在你这边玩就行了。
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";
}
searchTerm首次更新page和usedSearchTermusedSearchTerm并page加载数据。现在这些变量是同时更新的。问题解决了吗?不,如果变量很多,这种模式就不适用了。我们来试着找出问题的根源:
这个问题的另一个名称是 a Diamond Problem,它也与状态更新传播的 Push 或 Pull 变体有关。
PUSH更新都会“通知”consumers用户更改的内容。因此,一旦某些内容发生更改,用户consumer就会收到关于具体更改的通知。这就是钩子的工作原理。PULL每个用户收到consumer“变更”通知后,都需要pull从数据存储中进行更新。这就是Redux 的工作原理。问题在于PULL没有“准确找零”通知,每个顾客都必须pull自行找零。这就是为什么需要使用记忆化和像 reselect 这样的库的原因。
问题在于PUSH——如果存在多个更改——consumer可能会被多次调用,从而导致暂时的不一致以及似曾相识感。
以下是来自状态管理专家™(也是reatom的创建者)@artalar 的一张很棒的图表。
这是一个cost caclulator由PUSH 模式引起的级联更新。让我们用 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}`;
}
cost更新完成,我们就进行更新tax。pricetax更新完成,我们就进行更新。priceprice该组件更新了两次,其下方的一些组件可能也更新了。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}`;
}
你绝对想不到,⬆️这就是⬆️这篇文章的核心。
缓存与记忆化——我们以同步的方式(即拉取模式)从彼此中导出数据,因此不会出现上述问题。
然而,这里有个问题——这个例子确实解决了计算器示例中的问题,但并没有解决我们的问题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]
);
这是“已修复”的代码沙箱 - https://codesandbox.io/s/hook-state-tearing-dh0us
它并非固定不变,而是有所调整——我们已经规范化了状态更新的传播速度——所有值都在同一时间内更新。
解决这个问题还有另一种方法——改变我们分发“副作用”的方式。
以redux-saga为例,如果“状态”分发了多个事件,你可以takeLatest忽略第一个事件,或者takeLeading忽略后面的事件。
你可能也知道这叫做防抖(debounce)。我更喜欢称之为Event Horizons事件传播边界。
这里的任何(任何!)例子都可以通过延迟执行来“修复” loading effect,实际上只执行最后一个,即“稳定”的、正确的例子。
这是一个非常重要的概念,它允许批量处理请求并进行各种优化——但要明白,任何异步调用都可能需要一些时间,尤其是网络请求。如果将它们延迟几毫秒,甚至几个 CPU 周期(或 Node.js 进程周期),一切都会有所改善。
但是请记住——在代码中添加任何超时和其他异步操作都会增加难度。
useState状态仅在首次渲染期间从 props 派生而来。useMemo其他值来源于状态和属性。useEffect属性和状态的一些变化会反映回状态中。任何操作都
useEffect可能导致故障。
glitches由于不同的钩子独立更新,您可能会在单个组件中遇到暂时的不一致,从而导致(暂时的)未定义行为,甚至(暂时的)损坏状态。
问题必然与 hooks 有关,因为你必须将组件渲染到最后,而如果某些操作useEffect需要同步状态,就无法“中止”。
问题在于Caching和Memoization,它们受的影响CAP Theorem不同——只有记忆化才不会导致撕裂。
glitches导致重新渲染,并降低应用运行速度。
(惊喜!)ClassComponentscomponentDidUpdate也getDerivedStateFromProps让复杂的状态更新变得更加便捷。您只需更新它们,无需额外的重新渲染。
请不要忘记上课——它们会很有帮助!
Redux 是 PULL 模式,它会对单个 dispatch 进行多次小的状态更新,Redux 可以将多个状态更新批量处理,从而实现单个 React 渲染,这是broken states不可能的。
Redux 太棒了。而且,我要明确一点,它更不容易崩溃。
千万不要“信任”任何单一的解决方案。我之前尝试用钩子函数解决一些状态问题,结果却惨不忍睹,直到我接受了“世上没有完美的工具”这个事实。
钩子并非理想之选
是的,这几乎不是什么问题。你可能永远不会遇到我上面提到的那些可怕的事情。
钩子太棒了!
但是,我们必须面对现实——国家管理过去是、现在是、将来也永远是一个非常复杂的问题……
无论你是否同意这一点,这里都尝试“记录”不同状态管理系统的所有极端情况:
文章来源:https://dev.to/thekashey/dejavu-caching-versus-memoization-298n