关于React中useEffect以及Hooks的思考

人类发展的进程中淘汰的永远都是不思进取的守旧派。React中亦是如此思想,或许激进,但大多数人们总期待“新桃换旧符”,不是吗?

0x00 React中的useEffect

在React中有非常多的Hooks,其中useEffect使用非常频繁,针对一些具有副作用的函数进行包裹处理,使用Hook的收益有:增强可复用性、使函数组件有状态

数据获取、订阅或手动修改DOM都属于副作用(side effects)。

effect会在React的每次render之后执行,如果是有一些需要同步的副作用代码,则可以借助useLayoutEffect来包裹,它的用法和useEffect类似

useEffect有两个参数,第一个传递一个函数,第二个参数是作为effect是否执行第一个参数中的函数是否执行的标准,换句话说,第二个参数数组中的变量是否变化来决定函数是否执行,函数是否执行依赖于第二个参数的值是否变化。在React中的比较是一个shallow equal(浅比较),对于深层次的对象嵌套,无法准确判断是否发生变化。

useEffect借助了JS的闭包机制,可以说第一个参数就是一个闭包函数,它处在函数组件的作用域中,同时可以访问其中的局部变量和函数。

多个useEffect串联,根据是否执行函数(依赖项值是否变化),依次挂载到执行链上

在类组件中,有生命周期的概念,在一些讲react hooks的文章中常常会看到如何借助useEffect来模拟 componentDidmountcomponentUnmount的例子,其第二个参数是一个空数组[],这样effect在组件挂载时候执行一次,卸载的时候执行一下return的函数。也同样是闭包的关系,通过return一个函数,来实现闭包以及在React下次运行effect之前执行该return的函数,用于清除副作用。

0x01 构建React Hooks的心智模型

个人在一开始接触react hooks的时候,觉得代码的执行有点违背常识,在对react构建合理的心智模型花了不少时间。函数组件(Functional Component)没有生命周期的概念,React控制更新,频繁的更新但是值有的会变,有的不变,反而使得程序的可理解性变差了。

不过在后来不断地学习以及运用之后,我个人觉得hooks其实是一种非常轻量的方式,在项目构建中,开发自定义的hooks,然后在应用程序中任意地方调用hook,类似于插件化(可插拔)开发,降低了代码的耦合度。但随之也带来了一些麻烦的事情,有的同学在一个hook里写了大量的代码,分离的effect也冗杂在一起,再加上多维度的变量控制,使得其他同学难以理解这个hook到底在干嘛。

针对hook的内部代码冗杂的问题,首先得明确当前hook的工作,是否可拆分工作,在hook里可以调用其他的hook,所以是否可以进行多个hook拆分?或者组织(梳理)好代码的运行逻辑?

React中每次渲染都有自己的effects

React中的hooks更新,可以把其看作是一个“快照”,每一次更新都是一次“快照”,这个快照里的变量值是不变的,每个快照会因为react的更新而产生串行(可推导的)差异,而effect中的函数每一次都是一个新的函数。

我对于hooks的心智模型,简单来讲,就是一种插件式、有状态、有序的工具函数。


0x02 useEffect

针对useEffect,React每一次更新都会根据useEffect的第二个参数中依赖项去判断是否决定执行包裹的函数。

React会记住我们编写的effect function,effect function每次更新都会在作用于DOM,并且让浏览器在绘制屏幕,之后还会调用effect function。

整个执行过程可以简单总结如下:

  1. 组件被点击,触发更新count为1,通知react,“count值更新为1了”
  2. react响应,向组件索要count为1的UI
  3. 组件:
    1. 给count为1时候的虚拟dom
    2. 告知react完成渲染时,记得调用一下effect中的函数() => {document.title = 'you click' + 1 + 'times!'}
  4. React通知浏览器绘制DOM,更新UI
  5. 浏览器告知ReactUI已经更新到屏幕
  6. React收到屏幕绘制完成的消息后,执行effect中的函数,使得网页标题变成了“you click 1 times!”。

0x03 useRef

假如已经对上面的思想和流程已经烂熟于心,对于“快照”的概念也十分认同。

有时候,我们想在effect中拿到最新的值,而不是通过事件捕获,官方提供了useRef的hook,useRef在“生命周期”阶段是一个“同步”的变量,我们可以将值存放到其current里,以保证其值是最新的。

对于上面描述,为什么说其值是捕获而不是最新的,可以通过 setState(x => x + 1),来理解。传入的x是前一个值,x+1是新的值,在一些setTimeout异步代码里,我们想获取到最新的值,以便于同步最新的状态,所以用ref来帮助存储最新更新的值。

这种打破范式的做法,让程序有一丝丝的dirty,但确实解决了很多问题,这样做的好处,也可以表明哪些代码是脆弱的,是需要依赖时间次序的。

而在类组件中,通过 this.setState() 的做法每次拿到的也是最新的值


0x04 effect的清理

在前面的描述中或多或少涉及到对于effect的清理,只是为了便于一个理解,但描述并不完全准确。

例如下面的例子:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

假设第一次渲染的时候props是{id: 10},第二次渲染的时候是{id: 20}。你可能会认为发生了下面的这些事:

  • React 清除了 {id: 10}的effect。
  • React 渲染{id: 20}的UI。
  • React 运行{id: 20}的effect。

但是实际情况并非如此,如果按照这种心智模型来理解,那么在清除时候,获取的值是之前的旧值,因为清除是在渲染新UI之前完成的。这和之前说到的React只会在浏览器绘制之后执行effects矛盾。

React这样做的好处是不会阻塞浏览器的一个渲染(屏幕更新)。当然,按照这个规则,effect的清除也被延迟到了浏览器绘制UI之后。那么正确的执行顺序应该是:

  • React渲染了id 20 的UI
  • React清除了id 10的effect
  • React运行id 20的effect

那么为啥effect里清除的是旧的呐?

组件内的每一个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获定义它们的那次渲染中的props和state。

那么,effect的清除并不会读区到“最新”的props,它只能读取到定义它那次渲染中props的值

人类发展的进程中淘汰的永远都是不思进取的守旧派。React中亦是如此思想,或许激进,但大多数人们总期待“新桃换旧符”,不是吗?


0x05 effect的更新依赖

useEffect中的第二个参数,可以是一个参数数组(依赖数组)。React更新DOM的思想,不管过程怎样,只将结果展示给世人。

React在更新组件的时候,会对比props,通过AST等方式比较,然后仅需更新变化了的DOM。

第二个参数相当于告诉了useEffect,只要我给你的这些参数任中之一发生了改变,你就执行effect就好了。如此,便可以减少每次render之后调用effect的情况,减少了无意义的性能浪费。

那么在开发过程中,我们会尝试在组件载入时候,通过api获取远程数据,并运用于组件的数据渲染,所以我们使用了如下的一个简单例子:

useEffect(() => {
  featchData();
}, []);

由于是空数组,所以只有在组件挂载(mount)时获取一遍远程数据,之后将不再执行。如果effect中有涉及到局部变量,那么都会根据当前的状态发生改变,函数是每次都会创建(每次都是创建的新的匿名函数)。

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

你可能会认为上面的例子,会在组件加载后,每秒UI上count+1,但实际情况是只会执行一次。为什么呐?是不是觉得有些违反直觉了?

因为,并没有给effect的依赖项加入count,effect只会在第一次渲染时候,创建了一个匿名函数,尽管通过了setInterval包裹,每秒去执行count + 1,但是count的值始终是为0,所以在UI表现上永远渲染的是1。

当然,通过一些规则,我们可以通过加上count来改变其值,或者通过useRef,或者通过setState(x => x+1),模式来实现获取最新的值。例如下面的黑科技操作:

// useRef
function Example() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count; // 假如这一行代码放到effect函数中会怎么样呐?可以思考下!
  // answer: 在effect中count是effect匿名函数声明时就有了,值就是0,那么拿到的count值自然也是渲染前(本次props中的值)的count(值为0,再次复盘理解下快照的概念),但由于依赖数组中并不存在任何依赖,所以该匿名函数不会二次执行。
  // 但,由于setInterval的原因,函数会不停地setCount,关键是其中的参数了,countRef.current = count;取到的值是第一次快照时候的值0,所以其更新的值永远为0+1 = 1。这样的结果是符合预期规则的。
  // 那为什么放在外面就好了呐?因为countRef.current同步了count的最新值,每次render前就拿到了新的count值,并且赋值给countRef.current,由于ref的同步特性(及时性、统一性),所以循环中获取的countRef.current也是最新的值,故而能实现计数效果

  useEffect(() => {
    const id = setInterval(() => {
      setCount(countRef.current + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

// setState传入函数
function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(x => x + 1);     // 传递参数为一个函数时候,默认传递的第一个参数是之前的值,这是useState的hook在处理
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

// 使用useReducer
function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

上面的做法其实有些自欺欺人了,可以看到如下图中的log,在setInterval匿名函数中count并没有发生改变,这可能会给我们的业务带来一定的风险。

不过一般情况下,如果不是对业务或程序有充分的了解,我并不建议大家这样做。

对于依赖,首先得诚实地写入相关联的参数,其次,可以优化effect,考虑是否真的需要某参数,是否可以替换?

依赖项中dispatch、setState、useRef包裹的值都是不变的,这些参数都可以在依赖项中去除。

依赖项是函数

可以把函数定义到useEffect中,这样添加的依赖变成了函数的参数,这样子,useEffect就无需添加xxx函数名作为依赖项了。

另外如果单纯把函数名放到依赖项中,如果该函数在多个effects中复用,那么在每一次render时,函数都是重新声明(新的函数),那么effects就会因新的函数而频繁执行,这与不添加依赖数组一样,并没有起到任何的优化效果,那么该如何改善呐?

方法一:

如果该函数没有使用组件内的任何值,那么就把该函数放到组件外去定义,该函数就不在渲染范围内,不受数据流影响,所以其永远不变

方法二:

用useCallback hook来包装函数,与useEffect类似,其第二个参数也是作为函数是否更新的依赖项


0x06 竞态

常见于异步请求数据,先发后到,后发先到的问题,这就叫做竞态,如果该异步函数支持取消,则直接取消即可

那么更简单的做法,给异步加上一个boolea类型的标记值,就可以实现取消异步请求

function Article({ id }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    let didCancel = false;

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [id]);

  // ...
}

按照之前的规则,例如id=19,并且获取数据的时间为30s,变成了id=20,其获取数据的时间仅需5s,那么执行顺序应该如下:

  1. id=19组件卸载,didCancle=true,当id=19异步请求收到数据时30s后,由于!didCancle === false,则不执行数据更新
  2. id=20,因id改变,首先设置了didCancle=false,请求获取数据,5s后拿到了数据,然后更新数据,最后将更新后数据渲染到屏幕

0x07 总结

hooks的思想非常值得学习,结果导向,以思想为指引,对于React的运用也将更加得心应手!

最后如果您喜欢这篇文章的话,不妨关注下小东的博客嗷:https://blog.dyboy.cn

感谢以下文章给予的帮助和指导:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343