本文先从几个代码常见问题来切入,同步 React Hooks 的常见问题及解决技巧,之后通过对这些技巧的拓展使用来阐明什么是 React Hooks 的调度控制,从而提高个人 React 的姿势水平,防止被社区大神们的新名词击穿心智。
业务代码中的常见问题
闭包陷阱
问题描述:在 FC 中,父组件把 function 作为 props 传给子组件时, 因为 function 本身没有包useCallback ,可能无关的父组件重执行也会 trigger 子组件的 重执行/ useEffect,不符合预期。
(下文称本问题为问题1)
codesandbox.io/s/expensive…
每次text 改变 刷新父组件,刷新了Form 父组件,则handleSubmit函数也被刷新,即使ExpensiveTree memo了 还是会被重新执行。
引申问题(常见于业务代码的错误写法):
useCallback 滥用
有时候会看到这种随便把各种内联函数都包了useCallback:
这样写可能有两种动机:
-
动机1:出于 类 的心智模型,希望套 useCallBack来减少 FC 中创建函数的次数,来进行性能优化。
- 答:这种想法是错误的。内联函数必然被创建,包了也无法减少创建次数。加useCallback反倒增加性能负担(一次浅比较)。
-
动机2:这个包 useCallBack 的函数需要绑给子组件当 props,为避免触发子组件的 Effect 而能包则包。(即为了解决
问题1)- 答:确实可以解决一些
问题1的case。但很多时候想真正解决问题1,我们需要故意给这个useCallBack 少写依赖,依赖如果写全了还是会被预期外的 trigger effect 。这引申到下一个问题的答案:任何时候少写依赖都是绝对错误 的,且一定要遵循 react-hooks 的规则检查。因此 动机2也不成立。
- 答:确实可以解决一些
因此,结论是:如果不知道是否要写useCallBack,就不要写,非必要不优化。为什么少写依赖一定错误,且一定要打开 react-hooks 的 eslint 检查并严格执行,由下文说明(举例部分照抄了zhuanlan.zhihu.com/p/98554943 这篇文章)。
useCallback/useEffect 等 hooks api 根据业务故意少写依赖
见过各种业务代码都有这种写法,少写依赖会造成许多神奇的bug,这也是一些水平不高的公司前端会认为 React 比 Vue 难维护的原因。
下面举例部分照抄了zhuanlan.zhihu.com/p/98554943 这篇文章,明确的结论是:少写依赖 一定 是buggy的。任何时候都应该打开 react hooks 的 lint 检查,并严格执行。这一点我们简单类比论证:
- FC 的闭包值
for (var i = 0, j = 1, k = 2; i < 10; i++, j++, k++) {
const handleConsole = () => {
setTimeout(((i, j, k) => console.log(i, j, k)).bind(null, i, j, k));
};
handleConsole();
}
这里i / j / k 看做 FC 的 props里的三个值,任意一个值变化,都会重新执行 FC,创建一个新的函数作用域,有闭包值。
handleConsole 相当于 FC 的一个内联函数,预期内它访问到的 props 值都是闭包值。
且每一个调用栈,即上面每一次 for 循环里,handleConsole都是一个 新 的函数。
如果 handleConsole 需要包 useCallback ,则依赖应写 [ i , j , k ] ,i / j / k 任一变化,包裹于useCallback的 handleConsole 都需要更新到最新的调用栈,才能访问到正确的 i / j / k 值,即当前闭包值。
如果故意少写依赖,如写 [ i , j ] ,则当 k 变化时,FC 刷新,进入了新的调用栈,但这个包裹的 handleConsole 函数没刷新,还属于旧的调用栈!因为少写了 k ,它访问到的i / j / k 值 ,则属于 i / j 上次变化时的函数闭包,错误非常离谱。
综上,我们可以证实结论:任何时候少写依赖都是绝对错误 的,且一定要遵循 react-hooks 规则检查。
如何正确解决useEventCallback 问题
现在我们的核心诉求是在不少写依赖的前提下,解决上文提到的 问题1 。
正确解法,可以直接解决你的业务问题:ahooks.js.org/zh-CN/hooks…
这个问题的根因是 React hooks 本身存在的一个反人类设计 case:
其实这个注意就很离谱。我们使用的各种 UI 组件,以及Class组件传承下来的习惯,都习以为常地把 独立的回调函数 当做 props (如 input 的 onChange ) ,您说不推荐岂不是搞笑。
以至于官方文档有专门一章节解释这个问题。 文档上给出的函数名叫 useEventCallback 所以拿来当问题的名字。简单来说,React 为FC 开了一个官方口子 useRef , 用它来绕过上述问题。
下面例子来自官网:zh-hans.reactjs.org/docs/hooks-…
官网依赖写的不太对,可以看演示codesandbox.io/s/test-usec…
目标:函数 handleSubmit() 依赖外部变量 text 希望把handleSubmit 包 useCallback后,不在依赖项里写 text 也能访问到 text 的最新值。
import { useState, useCallback } from "react";
function Form() {
const [text, updateText] = useState("");
const handleSubmit = useCallback(() => {
alert(text);
}, [text]);
// [text] 重新创建 handleSubmit,希望绕过,以避免trigger ExpensiveTree
return (
<>
<input value={text} onChange={(e) => updateText(e.target.value)} />
<ExpensiveTree onSubmit={handleSubmit} />
</>
);
}
useRef 设计上是让 FC 闭包内部访问到一个外部的实例,从而保存闭包的值,等同于:
const ref = {current: null}
for(var i=0;i<10;i++){
ref.current = i;
setTimeout(((val) => console.log('val:',ref.current)).bind(null,ref));
// 拿到的是最新值
}
//本例子来自于 useCallback hell问题总结
所有第三方库都有这样的工具 hooks 如:ahooks.js.org/zh-CN/hooks… 。 据观察,大致在 19 年,微软,阿里之类的第三方Hooks都有了这个工具函数。官方文档 19 年4月更新这一章。可见这个时间点之后掰扯这个问题都是不好好看文档的,或者说这个问题的确很反直觉。
2022/5/5更新
官方终于绷不住了,决定推出这个APi,RFC 提案就叫 useEvent
Hooks 实践问题:hooks 本质是 Rx
这篇文章写的更清楚:你可能不知道的流式 React Hooks 。说实话我更早也是在社区里舶来这个观点的,可见对于社区大佬而言这又早就是常识了。
问题描述:hooks 实际是一套全新的 函数式 风格的 框架,不应该使用 基于类 的代码写法。一切我们从传统的 基于类的 框架( Vue2 React <16.8 )继承来的心智模型和代码习惯都应该改变,藉此才能写出合格的Hooks 代码。
问题根因(例子代码来自你可能不知道的流式 React Hooks )
一个典型场景,搜索框输入字符,出现下拉建议:
基于类 的写法 (这种写法在祖传代码中很常见)
const Demo: React.FC = () => {
const [state, setState] = useState({
keyword: '',
});
const query = useCallback((queryState: typeof state) => {
// ...
}, []);
// handleKeywordChange 是挂dom的方法
const handleKeywordChange = useCallback((e: React.InputEvent) => {
const latestState = { ...state, keyword: e.target.value };
setState(latestState);
//注意 fetchData 和 setData行为 耦合了
query(latestState);
}, [state, query]);
return // view
}
基于类 不好 ! 基于副作用 好!
基于副作用(effect) 的写法,也可以说是函数式的写法,体现了 Rx 的特点
const Demo: React.FC = () => {
const [state, setState] = useState({
keyword: '',
});
// handleKeywordChange 挂dom
const handleKeywordChange = useCallback((e: React.InputEvent) =>
{
const nextKeyword = e.target.value;
//只做一件事,setData,引发data变更(流)
setState(prev => ({ ...prev, keyword: nextKeyword }))
}, []);
// 处理 state 的副作用(流)
useEffect(() => {
// query
}, [state]);
return // view
}
可以看到 ,基于类的写法是有点stupid的 ,具体对比在 你可能不知道的流式 React Hooks 中有详细写。
解决:简单来说,比如任何挂dom的事件(理解模型即 Rx 中的事件源,触发数值变化这种副作用的事件:下图中 fromEvent click),只做一件事就是修改数据 (setState ) ,对状态( state )的变化(流)做出的逻辑统一放进 副作用 (类似于Rx 中的操作符,下图中scan subscribe ) 中处理,且应该多多拆分副作用(类似于Rx 拆分流)。
同样代码上有另一个简单的原则:任何调接口的函数(异步操作),都应该在 useEffect 里。
其实这一点对Vue 也是一样 ,函数事件应该只修改 data的值,用watch 处理data的值来处理副作用。 Vue3 直接有 watchEffect 这个api 处理副作用。
简而言之,只有把 副作用 的处理抽离成单独的逻辑(即把副作用 的逻辑抽离出来,符合 调度 的流程,例如 Rx ),这样的前端业务代码才能被称为编程。如果你使用 SWR 或 react-query ,就能感受到这两个库就是上述逻辑的直接实现。
总结
经过上文对常见业务代码问题的解答,我们清楚了两个点:
- 使用hooks时少写依赖是错的,但能通过 useRef 这个口子可以绕过这一点,从而来根据业务逻辑控制依赖有哪些(控制副作用的依赖链条 => 控制调度)
- Hooks 是 React 对 Rx 模型的实现,因此理论上我们能对整个调度逻辑完全受控( 因为用hooks写出的逻辑能够全等于用 RxJS 代码做出的实现 )。
则我们仅通过 React 提供的 api 来控制整个数据变化的调度过程实际上是可能的。这里的调度过程指的是整个业务逻辑,数据从产生到消耗的整个流 完全受控(摆脱任何框架默认行为)。这一点通过下文来展开。
React Hooks 调度控制
本章的目的是抛砖一种对 Hooks 的理解方式,以求水平的精进。这里的调度不是指框架自己的调度模型像 reconcile 逻辑或浏览器行为,指的仅仅是写Hooks 代码时的心智模型。
经过上文,我们已经明确了 React FC Hooks 这一套心智模型的细节,和它为什么是一种 Rx 模型抽象。
作为对上文知识的应用和拓展,通过做一道极易理解的题来切入:
题目
下面解法全照抄 zhihu 提问下的回答。
当父组件count更新的时候,需要将子组件的number和父组件的count保持同步,但是使用useEffect和useMemo都会让子组件render两次。这里希望子组件只执行一次。
在线编辑,点点更明白: codesandbox.io/s/zhihu-que…
import React, { useEffect, useState } from "react";
function A() {
const [count, setCount] = useState(0);
return (
<div>
<p>我是父组件</p>
<p>父组件的count是{count}</p>
<button onClick={() => setCount(count + 1)}>click</button>
<B count={count} />
</div>
);
}
const B = React.memo(({ count }) => {
const [number, setNumber] = useState(0);
useEffect(() => {
setNumber(count);
}, [count]);
console.log("子组件render");
return (
<div>
<p>我是子组件</p>
<p>子组件的number是{number}</p>
<button onClick={() => setNumber(number + 1)}>click</button>
</div>
);
});
export default A;
子组件的state要和父组件同步。一个正常的思路都会导致子组件多执行一次:即 count 变化导致 B 执行一次,B 内部的同步又执行了自己一次。下面解法来自知乎回答。
标准解法
标准正确答案,来自 antd 的实践: 子组件把内部变量 number的 setter 通过 ref 传给父组件, 由父组件同步 count 和 number ,子组件不再管同步的逻辑。
在线编辑:codesandbox.io/s/zhihu-ans…
真标准解法
本问题本质上其实是一个封装 React 组件库时的一个常见场景。
比如 Input 组件,需要同时支持受控和不受控:即 props 传了 value 则直接用传入的 value (受控);如props 没传 value 则用自己的一个 state (不受控)。
如何实现? Input 组件肯定要维护自己的一个 state 并保持和 props 里可能传入的 value 同步。我们希望在保持同步的同时,Input 组件自己不会多执行一次。这是本问题的本质 问题描述 , 即不 hack 的,合乎设计的 api 使用会导致子组件多执行一次。
针对这种场景其实有现成的解决方案,见:ahooks.js.org/zh-CN/hooks…
可以去观察这个 hooks 的代码,似乎在这直接贴 github 地址会审核不通过。
即不用state ,用一个ref 同步父组件值呢?ref 正好不会触发自己的执行,然后用一另一个state 专门控制子组件自己是否更新。 一个 ref 和 一个空 useState 分离值的储存和更新。
简单使用这种思路,我们先试试在 useEffect 里更新 ref 值:codesandbox.io/s/zhihu-ans…
render次数符合预期了,但我们发现UI 不符合预期。这里复习到的知识点是:
所以这里不符合预期。但上面例子把useEffect改成useMemo后居然能符合预期!
这里其实不包在 useMemo , 直接放在 render 里修改 ref 也行。这里的一个复习点在于 useMemo 里回调和 render 纯同步执行。
引申问题
- 直接在 render 里修改 ref 值是不推荐的(虽然问题不大,毕竟大家都这么干),可以关联出另一个新问题,即 juejin.cn/post/710310…
- 这种 ref 的使用方式或多或少有点 hack ,对于这个问题, React 标准 API 是否真的无解?
调度控制解法
这里我们归纳一下题目写法的逻辑和运行效果:
组件A 修改count值,通过props传给B ,B 通过 count 的副作用同步到自己的 number
运行效果:
这个逻辑其实是没问题的,无需像标准写法那样拆开这个逻辑。(其实甚至子组件执行两次也符合逻辑)
但我们有没有方法在不修改上述逻辑的情况下,强行控制调度,即控制 FC 的 UI 部分 ,使用 useCallback 这种 API 的合乎它语义化的功能,让父组件render一次,子组件也只render一次?其实是可以的。
下面的解法证明了即使不常用,我们仅使用 React 的 api 也【能够】做到完全控制调度。
改造组件 B
原版写法:
const B = React.memo(({ count }) => {
const [number, setNumber] = useState(0);
useEffect(() => {
setNumber(count);
}, [count]);
console.log("子组件render");
return (
<div>
<p>我是子组件</p>
<p>子组件的number是{number}</p>
<button onClick={() => setNumber(number + 1)}>click</button>
</div>
);
});
count 改变,执行一次,effect修改number,又执行一次。
UI 只依赖 number ,能否做到count 修改时不执行,待number修改时才执行一次?
把 FC 变成自定义 Hooks话可以做到,下面的写法让人大开眼界。改造后写法:
function useServiceB(count: number) {
const [number, setNumber] = useState(0);
useEffect(() => {
setNumber(count);
}, [count]);
//只有number改变时,才返回新的 FC 组件
const B = useCallback(() => {
console.log("子组件render");
return (
<div>
<p>我是子组件</p>
<p>子组件的number是{number}</p>
<button onClick={() => setNumber(number + 1)}>click</button>
</div>
);
}, [number]);
return B;
}
嗯,的确十分巧妙,这里相当于把逻辑和视图隔离开了。是不是这样就直接完事了?
中间版本在线编辑:codesandbox.io/s/zhihu-ans…
运行结果:
呃,不仅执行了两次,父组件A 也跟着执行两次了,开了倒车,尴尬。这里有个脑筋急转弯,把组件B变成 useServiceB 的代价是自定义 Hooks 必须要在父组件内调用,相当于把 useEffect 转到父 FC 里了。
所以这里的确变成了父组件Arender一次子组件B也render一次,但因为副作用转给A,导致A自己会执行两次。
因此,逻辑和UI分离也要对组件A做。
改造组件 A
最终版本在线编辑:codesandbox.io/s/zhihu-que…
OK 大功告成。这证实了我们借助上文归纳的ref绕依赖等方式,仅通过 React 的 API 在不 hack 的前提下确实能任意强行地控制调度。这个解法很好的提高了我们React 的姿势水平:
- 对 UI 块包 useCallBack 同时Ref 消依赖的方式能实现 render 的调度控制
- 彻底拆分 UI 和 逻辑,拆分的逻辑层使前端写单测成为可能。
- 从 React api 的设计的完备程度而言,并非有【必须】 hack 才能实现的功能。即便不得不像上面这样拐弯抹角,但完全不 hack 的使用 api 也是完全可能的。