1

初识一致性:从一次React hooks的踩坑经历说起

让我们用具体例子来说明。对于以下的useAsynchook,你能看出哪些问题?

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<string>((resolve) => {
    setTimeout(() => {
      resolve(`data for ${id}`);
    }, 1000);
  });
}

查看完整示例:
hooks-inconsistency codesandbox

为了简化例子,我们省略了错误处理

揭晓答案:在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。说明某次副作用的执行用到了这两个不一致的数据,可能会造成无法挽回的影响。

可以看到,currentIddata被同时用于一个副作用操作中。当这两个数据出现不一致的时候,可能会造成无法挽回的影响。

在实际应用中,这两个不一致的数据会被传递给其他hooks以及子组件,你将你很难追踪定位这个问题。

最终的解决方式就是,在render阶段,就检查state与props是否不一致(props是否已经发生变化),如果是,就返回loading状态。最终代码:
hooks-inconsistency-fixed codesandbox

从这个例子,我们要吸取的教训是:

  • React hooks应该立即反映props的变化,而不应等待useEffect/useLayoutEffect来纠正数据不一致
  • 错误的渲染,虽然保持的时间很短(会立即在useEffect/useLayoutEffect中被纠正),但是当错误渲染被提交以后,仍然会触发意料之外的副作用,后果可能是无法挽回的。这些副作用包括:

    • useEffectuseLayoutEffect定义的副作用,使用了不一致的数据
    • 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的状态管理库并不多。由此可见,设计一致的数据系统并不是一件容易的事情。

计算的本质是通信

数据系统的本质是通信

上面已经提到,在数据系统中,解决数据不一致的通常原则是,【过时的结果】总比【错误的结果】要好。这是为什么呢?

因为【过时】只不过是信息传播时延的体现。哪怕是电磁波通信都有时延呢,对吧?信息传播的时延是注定存在的(并且大于等于光速时延),因此,数据系统【具有时延地】输出计算结果,是再正常不过的自然现象。

因此,当数据系统将【过时但正确的计算结果】传播给下游消费者,其实本质上就是一种有时延的通信而已。时延是注定存在的,下游消费者自然会欣然接受。

用这个视角仔细观察生活中的日常现象,你会发现通信无处不在,通信时延也无处不在。

计算的本质也是通信

既然数据系统的本质是通信,那么进一步思考,组成数据系统的各个环节(存储、计算、网络)也都应该是通信。对于存储和网络,相信大家都可以理解。但是为什么计算也可以理解为一种通信?

首先,我们要理解通信是什么。运动和通信是一体两面。从微观上看,通信必定对应着一种运动。(要么是粒子的运动,要么是波的运动)

人类通信发展的历史,从声波通信、视觉通信,发展到快马送信,再到电报通信,最后到无线通信,都没有脱离运动的微观本质。它们依次是声波、电磁波、物质分子、电子、电磁波的运动。

而计算,从微观上讲,就是信号从一条【信息通路】的输入端传播到了输出端。计算的结果就是粒子运动的结果。
比如,逻辑电路的工作原理归结于电子的运动。当我们在使用逻辑电路进行计算的时候,只不过是将电子注入输入端,然后从输出端观察电子运动的结果而已。

这不就是一种通信吗?计算的本质是信息的传播(即通信),而它们的本质都是运动。

让我们用通信的视角来进一步思考计算的本质:

  • 设计计算机器,本质上就是将我们的知识,设计成一条【信息通路】。在使用的时候,就将输入信号注入输入端,让信息传播,然后在输出端观察到信息传播的结果。
  • 设计逻辑电路、甚至芯片的过程,本质上就是在将我们的知识设计成复杂的【信息通路】,控制粒子的运动(信息的传播)。
  • 编程,其实本质就是在设计一种机器。程序就是对一种机器的描述(参考sicp 4.1.5节)。而通用计算机,就根据我们的程序,将自己模拟成程序所描述的那台机器。因此,归根结底,我们的每一行代码,其实都在决定电子的【信息通路】。
  • 算盘,其实也是人类设计的一种计算机器,它的工作原理就是算珠在【信息通路】上的运动。
其他类型的运动,如果足够可控,也可以用来实现计算。比如,光子计算机,就是利用了光子的运动来实现逻辑电路。

任何一个数据系统,包括其中的存储、计算、网络,本质上都是信息的传播(通信)。【输出过时的结果】只不过是通信时延的体现,是理所当然的事情,更无法避免。

因此,我们要避免的不是通信时延(当然我们应该尽力降低它),而是:

  • 从计算的角度来说,我们要避免的是混用不同版本的数据,导致数据不一致的结果(参考上面的“如何判断是否存在数据不一致”小节)。
  • 从通信的角度来说,我们要避免的是在通信过程中新旧的信息相互干涉,形成噪声。

希望这篇文章能给你提供一个全新的视角。


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.