初识一致性:从一次React hooks的踩坑经历说起
让我们用具体例子来说明。对于以下的useAsync
hook,你能看出哪些问题?
function useAsync(id: number) {
const [state, setState] = useState({
loading: true,
data: null as null | string
});
useEffect(() => {
let discarded = false;
setState({
loading: true,
data: null
});
fakeAPI(id).then((data) => {
if (discarded) return;
setState({
loading: false,
data
});
});
return () => {
discarded = true;
};
}, [id]);
return state;
}
async function fakeAPI(id: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`data for ${id}`);
}, 1000);
});
}
为了简化例子,我们省略了错误处理
揭晓答案:在id变化以后的第一次渲染,useAsync
仍会返回上一个id对应的data。这一次渲染产生了两个不一致的数据:新的id和旧的data。
很多人认为这不是一个问题,因为在这次渲染以后,useEffect
会立即更新状态为loading(纠正不一致),然后下一次渲染就会返回loading的状态。
但是,这次错误渲染虽然保持的时间很短,但是仍然会产生无法预料的副作用。
在我们的完整示例中,useAsync
是这样被使用的:
const [currentId, setCurrentId] = useState(1);
const { data, loading } = useAsync(currentId);
useEffect(() => {
if (!loading && data) {
// will trigger side effect with inconsistent id and data
console.log("Trigger some side effect!! id:", currentId, ", data:", data);
}
}, [currentId, loading, data]);
其中一个打印输出就是Trigger some side effect!! id: 2 , data: data for 1
。说明某次副作用的执行用到了这两个不一致的数据,可能会造成无法挽回的影响。
可以看到,currentId
和data
被同时用于一个副作用操作中。当这两个数据出现不一致的时候,可能会造成无法挽回的影响。
在实际应用中,这两个不一致的数据会被传递给其他hooks以及子组件,你将你很难追踪定位这个问题。
React hooks踩坑总结
从这个例子,我们要吸取的教训是:
- React hooks应该立即反映props的变化,而不应等待useEffect/useLayoutEffect来纠正数据不一致
虽然错误的渲染,保持的时间很短(会立即在useEffect/useLayoutEffect中被纠正),但是当错误渲染被提交以后,仍然会触发意料之外的副作用,后果可能是无法挽回的。这些副作用包括:
useEffect
、useLayoutEffect
定义的副作用,使用了不一致的数据- DOM更新,将不一致的数据渲染到视图上。(当然,如果你立即在useLayoutEffect中纠正了数据不一致,那么用户不会看到)
- 在错误的渲染中,初始化了一个状态,使用了不一致的数据,比如
useState(() => currentId + data)
。这个状态被错误地初始化以后,如果没有及时手动纠正,会造成后续更多的错误 - 在错误的渲染中,你修改了一个ref对象,使用了不一致的数据。比如
ref.current = currentId + data
。和上一条类似的道理,这样的数据如果没有及时手动纠正,会造成后续更多的错误
延申:数据一致性
实际上,数据一致性,对于React维护者、状态管理库作者,乃至任何领域的开发者而言,都是一个经常为之困扰的问题。下面我们会从比较抽象、普适的视角来讨论数据一致性。
何为数据不一致
概括来说,就是当【源数据】更新时,如果【旧的源数据、及其衍生数据】没有被及时更新,就会出现新旧数据混用的情况,导致错误的结果和副作用。
数据不一致的本质原因
使用【源数据】来计算结果,需要一定的时间(存储、计算、网络都是耗时的),在这段时间中,【源数据】可能会发生变化。此时在数据系统中就会存在多份不同版本的数据(及其衍生数据)。
至于【源数据】发生变化的原因,通常可以总结为【外部系统的信息传播到了本数据系统】。
后面我们会谈到,整个数据系统,包括其中的存储、计算、网络,本质上都是通信。
比如,当你的程序监听到文件的变更时,本质上是文件系统的数据更新传播到了本数据系统。如果此时你对旧的文件的计算还没完成,那么旧要谨慎地将新旧数据区分开来,否则会产生数据不一致。
比如,React应用收到用户输入事件时,本质上是现实世界的事件传播到了本数据系统。如果此时你对过往的状态和事件还没处理完成(即上一次渲染还没完成),那么旧不能盲目地让新的事件影响上一次渲染,否则会产生数据不一致。事实上,React在Concurrent Mode中,就会在不同地渲染中,严格隔离事件的影响,保障数据一致性。
如何判断是否存在数据不一致的问题?
如果你的计算结果,与下述任何一个都不同,就说明存在数据不一致:
- 完全使用【旧的源数据】计算的结果。如果你的情况与它相同,说明你的结果是【正确的】,只不过【过时了】(曾经正确)
- 完全使用【新的源数据】计算的结果。如果你的情况与它相同,那么说明你的结果就是最新的、正确的
如何解决数据不一致
一般的解决方式是:
- 丢弃旧的源数据和衍生数据(计算结果),使用新的源数据重新计算
- 使用旧的数据完成计算(不考虑新的数据),输出结果。这个结果虽然是【过时的】,但是起码是【正确的】。你先可以将这个结果输出给下游,让下游不至于【饥饿】。于此同时,你使用新的【源数据】重新计算(丢弃所有旧数据)。这样,就能确保新旧数据不被混用(说起来简单,要做到其实很难)
- 容忍一定程度的不一致。因为在某些问题中,数据不一致是可以接受的
计算的本质是通信
数据系统的本质是通信
上面已经提到,在数据系统中,解决数据不一致的通常原则是,【过时的数据】总比【错误的数据】要好。这是为什么呢?
因为【过时】只不过是信息传播时延的体现。哪怕是电磁波通信都有时延呢,对吧?既然信息传播的时延是注定存在的(并且大于等于光速时延),那么,数据系统【具有时延地】输出计算结果,也是可以接受的。
因此,当数据系统将【过时但正确的数据】传播给下游消费者,其实本质上就是一种有时延的通信而已。下游消费者自然会欣然接受。
用这个视角仔细观察生活中的日常现象,你会发现通信无处不在,通信时延也无处不在。
计算的本质是通信
既然数据系统的本质是通信,那么进一步思考,组成数据系统的各个环节(存储、计算、网络)也都应该是通信。存储和网络,相信大家都可以理解。但是为什么计算也可以理解为一种通信?
首先,我们要理解通信是什么。从微观上讲,通信都对应于一种运动。运动和通信是一体两面,运动将信息从一个地方带到了一个地方。
人类通信发展的历史中,从声波通信、视觉通信,到快马送信,再到电报通信,最后到无线通信,都没有脱离运动的本质。
从微观上讲,它们的运动要么是以粒子的形式,要么是以波的形式
而计算,从微观上讲,就是信号从一条【信息通路】的输入端传播到了输出端。这不就是一种通信吗?计算的本质是信息的传播,而它们的本质都是运动。
比如,逻辑电路的工作原理归结于电子的运动。
而设计计算机器,本质上就是将我们的知识,设计成一条【信息通路】。在使用的时候,就将输入信号注入输入端,让信息传播,然后在输出端观察到输出信号。
比如,设计逻辑电路、甚至芯片的过程,本质上就是在将我们的知识设计成复杂的【信息通路】。如果你仔细思考会发现,算盘,其实也是人类设计的一种计算机器,它的工作原理就是算珠在【信息通路】上的运动。
其他类型的运动,如果足够可控,也可以用来实现计算。比如, 光子计算机,就是利用了光子的运动来实现逻辑电路。
任何一个数据系统,包括其中的存储、计算、网络,本质上都是信息的传播(通信)。【输出过时的结果】只不过是通信时延的体现,是理所当然的事情,更无法避免。
我们真正要避免的,是在通信过程中新旧的信息相互干涉,产生噪声。从计算的角度来说,就是混用不一致的数据。
React中的数据一致性
React Context的核心保障之一,就是数据一致性:就是在同一次渲染中,从任何组件读取某个context的结果是相同的。如果渲染在某个地方被中断(suspend或时间片用完),后面回来继续渲染的时候,仍然读取到一样的context数据,即使中间这段时间已经有新的渲染更新了context的数据。React Concurrent Mode的核心工作就是将新的信息隔离在旧的渲染之外,将旧的渲染。
对于React数据管理库的作者来说,要支持Concurrent Mode,就意味着不能打破React的这种信息隔离。当一个外部事件发生时(比如用户点击),这个信息只能传播给新启动的渲染,而不能传播到其他已经再进行的渲染。也就是说,必须只有新启动的渲染能感知到对应的新状态,而已有渲染必须只能感知到旧的状态。
如果打破了这种信息隔离,旧意味着,旧的渲染同时混用了新旧的数据,这个现象被称为"tearing":
dai-shi/will-this-react-global-state-work-in-concurrent-mode: Checking tearing in React concurrent mode 项目就测试了多个状态管理库的tearing情况。能完美避免tearing的状态管理库并不多。由此可见,设计一致的数据系统并不是一件容易的事情。