helux 2 发布,聊聊react18里副作用的双调用机制

770 阅读8分钟

react 18 新增了启发式的并发渲染机制,副作用函数会因为组件重渲染可能调用多次,为了帮助用户理清正确的副作用使用方式,在开发模式启用StrictMode时,会刻意的故意调用两次副作用函数,来达到走查用户逻辑的效果,但此举也给部分升级用户带来了困扰,本文将讨论helux如何规避此问题。

helux 简介

helux是一个主打轻量、高性能、0成本接入的react状态库,你的应用仅需替换useStateuseShared,然后就可以在其他代码一行都不用修改的情况下达到提升react局部状态为全局共享状态的效果,可访问此在线示例了解更多。

import React from 'react';
+ import { createShared, useShared } from 'helux';
+ const { state: sharedObj } = createShared({a:100, b:2});

function HelloHelux(props: any) {
-  const [state, setState] = React.useState({ a: 100, b: 2 });
+  const [state, setState] = useShared(sharedObj);

   // 当前组件仅依赖a变更才触发重渲染
   // helux会动态收集当前组件每一轮渲染的最新依赖,以确保做到精确更新
   return <div>{state.a}</div>;
}

默认共享对象是非响应的,期望用户按照react的方式去变更状态,如用户设置enableReactive为true后,则可创建响应式对象

const { state, setState } = createShared({ a: 100, b: 2 }, true);
// or
const { state, setState } = createShared({ a: 100, b: 2 }, { enableReactive: true });

// 将更新所有使用 `sharedObj.a` 值的组件实例
sharedObj.a++; 
setState({a: 1000});

2.0 带来了什么

2.0版本做了以下三个调整

精简api命名

原来的 useSharedObject api重新导出为更精简的useShared,配合createShared以便提高用户的书写效率和阅读体验。

新增信号记录(实验中)

内部新增了信号相关的记录数据,为将来要发布的helux-signal(一个基于helux封装的react signal模式实现库)做好相关基础建设,helux-signal还在原型阶段,在合适的时机会发布beta版本体验。

不使用信号时,需要createShareduseShared 来两者一起搭配,createShared创建共享状态,useShared负责消费共享状态,它返回具体的可读状态值和更新函数。

const { state: sharedObj } = createShared({a:100, b:2}); // 创建
function HelloHelux(props: any) {
  const [state, setState] = useShared(sharedObj); // 使用
  // render logic ...
}  

使用信号时,仅需要调用helux-signal一个接口createSignal既可以完成状态的创建,然后组件可跳过useShared钩子函数直接读取共享状态。

需注意,目前helux 2仅是内部完成了相关基础建设,上层负责具体实现的helux-signal还在实验阶段,现阶段社区已有preact提供的signals-react库来支持signal模式开发react组件。

一个预想的完整的基于helux-signal开发react组件示例如下:

import { createSignal, comp } from 'helux-signal';

const { state, setState } = createSignal({a:100, b:2}); // 创建信号
// 以下两种方式都将触发组件重渲染
state.a++;
setState({a: 1000});

// <HelloSignal ref={ref} id="some-id" />
const HelloSignal = comp((props, ref)=>{ // 创建可读取信号的react组件
	return <div>{state.a}</div>; // 当前组件的依赖是 state.a	
})

新增 useEffect、useLayoutEffect

v2版本新增了useEffectuseLayoutEffect两个接口,这也是本文要重点讨论的两个接口,为何helux提供这两个接口来替代原生接口呢?且看下面的内容一一详解。

react18 的副作用

react 18 新增了启发式的并发渲染机制,副作用函数会因为组件重渲染可能调用多次,为了帮助用户发现未正确使用副作用带来的可能问题(例如忘了做清理行为),在开发模式启用StrictMode时,会刻意的故意调用两次副作用函数,来达到走查用户逻辑的效果,期望用户正确的理解副作用函数。

实际情况什么情况会产生多次挂载行为呢?新文档特意提到了一个例子,由于在18里react会分离组件的状态与卸载行为(非用户代码控制的卸载),即组件卸载了状态依然保持,再次挂载时会由react内部还原回来,例如离屏渲染场景需要此特性。

双调用的困扰

但此举也给部分升级用户带来了困扰,以下面例子为例:

function UI(props: any) {
  useEffect(() => {
    console.log("mount");
    return () => {
      console.log("clean up");
    };
  }, []);
}

在 strcit 模式打印如下

mount
clean up
mount

用户真正卸载组件后还有一次clean up打印,由此让很多用户误以为是bug,去react仓库提issue描述升级18后useEffect产生了两次调用,后来react官方专门解释了此问题是正常现象,为辅助用户存在不合理的副作用函数刻意做的双调用机制。

但有些场景用户的确期望开发时也只产生一次调用(例如组件的数据初始化),于是就有了以下各种花式对抗双调用的方式。

移除 StrcitMode

最简单粗暴的方式,就是移除根组件处的StrcitMode包裹,彻底屏蔽此双调用行为。

root.render(
-  <React.StrictMode>
    <App />
-  </React.StrictMode>
);

用户可能只需要某些地方无双调用,其他地方需要双调用检查副作用的正确性的话,但此举属于一杆子打死所有场景行为,不太通用。

局部包裹StrcitMode

StrcitMode除了包裹根组件,也支持包裹任意子组件,用户可以在需要的地方包裹

 <React.StrictMode><YourComponent /></React.StrictMode>

相比全局移除,此方法较为温和,但包裹StrictMode是一个强迫性的行为,需要代码处导出安排哪里需要包裹那里不需要包裹,较为麻烦,有没有既能在根组件包裹StrcitMode又能局部屏蔽双调用机制的方式呢?用户们开始从代码层面入手,准确的说是useEffect回调里入手

使用useRef标记执行状态

大体思路是使用useRef记录一个副作用函数是否已执行的状态,让第二次调用被忽略。

function Demo(props: any) {
  const isCalled = React.useRef(false);

  React.useEffect(() => {
    if (isCalled.current === false) {
      await somApi.fetchData();
      isCalled.current = true;
    }
  }, []);
}

此举有一定的局限性,就是如果加上依赖后,isCalled无法控制,按思维会副作用清理函数里置isCalled.current为false,这样在组件的存在期过程中变更id值时,尽管有双调用行为也不会打印两次mock api fetch

 React.useEffect(() => {
    if (isCalled.current === false) {
	    isCalled.current = true;
      console.log('mock api fetch');
	    return ()=>{
		 isCalled.current = false;
		 console.log('clean up');
	    };
    }
  }, [id]); // id 变更时,发起新的请求

但如上写法,在组件首次挂载时还是发生两次调用,打印顺序为

mock api fetch
clean up
mock api fetch

有没有真正的完美方案,让基于根组件包裹StricMode时,子组件初次挂载和存在期始终副作用只发生一次调用呢?接下来让helux 提供的useEffect来彻底解决此问题吧

使用helux的useEffect

我们只要核心理解react双调用的原由:让组件卸载和状态分离,即组件再次挂载时存在期的已有状态会被还原,既然有一个还原的过程,那么入手点就很容易了,主要就是观察在组件还原那一刻的运行日志来查找规律。

先标记一个序列自增id当做组件示例id,观察挂载行为针对是哪一个实例

let insKey = 0;
function getInsKey() {
  insKey++;
  return insKey;
}

function TestDoubleMount() {
  const [insKey] = useState(() => getInsKey());
  console.log(`TestDoubleMount ${insKey}`);
  React.useEffect(() => {
    console.log(`mount ${insKey}`);
    return () => console.log(`clean up ${insKey}`);
  }, [insKey]);
  return <div>TestDoubleMount</div>;
}

可观察到日志如下图,可发现灰色的打印 TestDoubleMount 是react故意发起的第二次调用,副作用都是针对2号示例,1号作为一次冗余的调用被react丢弃掉。

image.png

由于id是自增的,react会刻意的对同一个组件发起两次调用,丢弃第一个并针对第二个调用重复执行副作用(mount-->clean-->mount ---> 组件卸载后 clean),那么我们在第二个副作用执行时只要检查前一个示例是否存在副作用记录,同时记录第二个副作用的执行次数,就很容易做到屏蔽第二次模式出的副作用了,即(mount-->clean-->mount ---> 组件卸载后 clean)被修改为(mount ---> 组件卸载后 clean),在组件真正执行卸载时执行设定的clean。

伪代码如下

function mayExecuteCb(insKey: number, cb: EffectCb) {
  markKeyMount(insKey); // 记录当前实例id 挂载次数
  const curKeyMount = getKeyMount(insKey); // 获取当前实例挂载信息
  const pervKeyMount = getKeyMount(insKey - 1); // 获取前一个实例的挂载信息
  if (!pervKeyMount) { // 前一个示例无挂载信息则是双调用行为
    if (curKeyMount && curKeyMount.count > 1) { // 当前实例第二次挂载才正在执行用户的副作用函数
      const cleanUp = cb();
      return () => {
        clearKeyMount(insKey); // 清理当前实例挂载信息
        cleanUp && cleanUp(); // 返回清理函数
      };
    }
  }
}

在此基础上封装一个useEffect给用户即可达到我们上面说的目的:让基于根组件包裹StricMode时,子组件初次挂载和存在期始终副作用只发生一次调用。

function useEffect(cb: EffectCb, deps?: any[]) {
  const [insKey] = useState(() => getInsKey()); // 写为函数避免key自增开销
  React.useEffect(() => {
    return mayExecuteCb(insKey, cb);
  }, deps);
}

如果感兴趣useEffect的具体实现可查看仓库代码

现在你可以像使用原生的useEffect那样使用helux导出的useEffect,同时享受到某些场景不需要双调用检测的好处了。

import { useEffect } from 'helux';

useEffect(() => {
  console.log('mock api fetch', id);
  return () => {
    console.log('mock clean up');
  };
}, [id]); // id 变更时,发起新的请求

结语

了解双调用的设计初衷与流程有助于帮助我们更清晰的理解副作用函数如何治理,同时也可以帮助我们为避免双调用机制做出更好的决策。

helux属于模块联邦sdk hel-micro子包,初衷是为 react 提供一种更灵活、更低廉成本的状态共享方式,如果你对helux或hel-micro感兴趣,欢迎关注并给予我们更多的改进反馈意见。