React Hooks - effect 范式下的组件状态和依赖

1,018 阅读9分钟

Hooks 是 React 16 带来的新特性,它创建了一种和以往截然不同的编程范式。对习惯 class 范式的人来说,这套新玩意确实比较不好绕过来,这篇从头开始,看看 hooks 从哪来,要到哪去。 先不考虑多个组件是如何组织的,也就是 diff 之类的东西,我们先假设 react 知道何时 render 一个组件。专注于对组件内部状态和依赖的讨论上。

参考

视图 = render(状态)

以前,一个 class component 是很「笨重」的,有丰富的生命周期。

但学习 hooks,就必须忘了这点。我们回到 react 设计的初心:视图 = render(状态),render 做的事情就是这么简单。

重要的是目的,而不是过程。 React会根据我们当前的props和state同步到DOM。“mount”和“update”之于渲染并没有什么区别。

视图 = render(状态),这里的状态包括:props、state,广义上还包括 render 函数内部自己定义的变量、函数。

useState 和 Capture Value

先从一个 useState 开始,顾名思义,当 render 调用 useState,就是去某个地方取一个 state。

const Comp = props => {
	const [ foo, setFoo ] = useState(false);
};

Capture Value

这里注意foo是 render 函数作用域下定义的变量,它把 useState 的返回取出来,「复制」到自己身上。

根据作用域原理,在本次 render 函数执行的上下文中,只有 render 函数及其子作用域能修改这个变量,useState 数据源中的任何改变都不会影响本轮的foo了。这就像你拍了张照,虽然时间还在流逝,但我从你那条朋友圈只能看到那个时刻的样子。

const Comp = props => {
	const foo = false;
};

这个特性叫 Capture Value。

当然 state 变化还会触发新 render,但那属于另外一次 render 上下文的事儿了。

闭包

既然变量被「捕获」了,根据闭包原理,闭包获取到的变量也都是「捕获」的。

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

const Comp = props => {
	const [ foo, setFoo ] = useState(0);
	const log = () => setTimeout(() => {
		console.log(foo);
	}, 3000);
	return (
    <div>
      <p>render {foo}</p>
      <button onClick={() => setFoo(foo + 1)}>add</button>
      <button onClick={log}>log</button>
    </div>
  );
};

当我先点 2 次 add,再点一次 log,3s 内再点 3 次 add。时间到了以后,虽然页面 render 出 5,但 log 出来的仍是 2。

因为 log 是在第 2 次 render 后执行的,那个上下文里,捕获的 foo 就是 2,2 被闭包在 log 中,进一步闭包在 setTimeout 回调中。

useEffect 回调也只是「闭包」的一种

每一次渲染都是不同的 useEffect 声明,捕获不同的变量值。

const Comp = props => {
	const [ count, setCount ] = useState(1);
	useEffect(() => {
		console.log(count);
	});
};

看起来很正常是不是,但如果我们想当然,就出问题了,比如下面这个场景(称为 Case1,后面还会提到它):

const Comp = props => {
	const [ count, setCount ] = useState(1);
	useEffect(() => {
		setInterval(() => setCount(count + 1), 1000);
	}, []);
};

因为 [] 依赖,useEffect 闭包只在第一次 render 执行,那个上下文下,count 永远是 1。所以 count 永远加到 2,不会递加上去。

简单的绕过 Capture Value - useRef

ref 以前是被用来引用真实节点的,但在 hooks 里的 useRef 更强大,可以理解为一个外部的寄存器。只要你改了一次 current,任何之后执行的 render 函数都会拿到最新的 current。

这样在Case1中用 useRef 处理一下就解掉了 bug:

const Comp = props => {
	const [ count, setCount ] = useState(1);
	const lastCount = useRef(1);
	lastCount.current = count;
	useEffect(() => {
		setInterval(() => setCount(lastCount.current + 1), 1000);
	}, []);
}

这像极了一个外层变量

let lastCount = 0;
const Comp = props => {
	const [ count, setCount ] = useState(1);
	lastCount = count;
	useEffect(() => {
		setInterval(() => setCount(lastCount + 1), 1000);
	}, []);
}

题外话。既然「像极了外层变量」,为什么不直接用外层变量呢?这就是 useRef 的价值,当你依赖 lastCount 是个变量,这个依赖总会变,但 useRef 的返回,无论 current 怎么变都会保持依赖不变。

useEffect 依赖比较和 effect 回收

如果Case1不用 useRef 怎么做好呢?在接下去之前,先聊下 useEffect 的依赖比较和回收。

::useEffect 总是在渲染完成后执行::

也就是直到本次 UI 变化落地后才执行,这就保证了确实是在出现「副作用」以后处理。

useEffect 比较了最近两次的依赖变量静态值

实际上 useEffect 的第二个参数做了什么呢,在 useEffect 的内部::记录了上一次依赖的「值」,和这一次比较,并在不同的时候执行回调::。相当于:

// 第一次
const Comp = props => {
	useEffect(() => {
		console.log(foo);
	}, [1, 'a']);
};
// 第二次
const Comp = props => {
	useEffect(() => {
		console.log(foo);
	}, [2, 'a']);
};

当 useEffect 观察到 1 !== 2 ,就执行了「第二次」声明的 useEffect 回调。

effect 回收

当回调中返回另一个回调,这个回调会在当前 effect 结束后执行。用来清除事件绑定之类的。

const Comp = props => {
	useEffect(() => {
		console.log('effect start');
		return () => console.log('effect end');
	}, [2, 'a']);
};

比如一个 useEffect 被 1、2、3 三个状态触发了三次。

  • 当 1 渲染完,此时 UI 属于 1,触发 1 的 effect,暂存 1 的 callback;
  • 当 2 渲染完,此时 UI 属于 2,触发 2 的 effect,暂存 2 的 callback;但 UI 不属于 1 了,触发 1 的 callback
  • 当 3 渲染完,此时 UI 属于 3,触发 3 的 effect,暂存 3 的 callback;但 UI 不属于 2 了,触发 2 的 callback

回收可以用在请求竞态处理上

在状态进入下个阶段后,基于当前状态的请求应该取消掉。

const [ data, setData ] = useState(null);
useEffect(() => {
	let cancelled = false;
	fetch('/xxx?id=' + id).then(res => {
		if (!cancelled) {
			setData(res);
		}
	});
	return () => {
		cancelled = true;
	};
}, [id])

useEffect 依赖管理:诚实第一

回到Case1,把代码再贴一次:

const Comp = props => {
	const [ count, setCount ] = useState(1);
	useEffect(() => {
		setInterval(() => setCount(count + 1), 1000);
	}, []);
};

我们犯的最大错误是什么?不诚实。明明 useEffect 里的操作依赖了 count,却没有告诉 useEffect。count 变的时候 useEffect 都不知道!

诚实的方法一:覆盖所有依赖

effect中用到的所有组件内的值都要包含在依赖中

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

诚实地告诉 useEffect 依赖了 count,使它能感知 count 变化并重新执行拿到最新 count。(interval 要处理好回收,不然重复声明了)

诚实的方法二:让 useEffect 自给自足

我们可以使用setState的 函数形式

const Comp = props => {
	const [ count, setCount ] = useState(1);
	useEffect(() => {
		const int = setInterval(() => setCount(
			lastCount => lastCount + 1
		), 1000);
	}, []);
};

setCount 改传函数,这个函数传入最新的 count,返回要 set 的。

这样 useEffect 依赖区又「清净」了。

useReducer - useState 的替代者?

但 setState 的函数形式只能处理单状态场景,比如Case1中增量(step)是变的,那么增量变了 useEffect 里面就要拿到最新增量。有一种方式是把增量作为依赖:

const Comp = props => {
	const [ count, setCount ] = useState(1);
	const [ step, setStep ] = useState(1);
	useEffect(() => {
		const int = setInterval(() => setCount(
			lastCount => lastCount + step
		), 1000);
		return () => clearInterval(int);
	}, [ step ]);
};

但这里有问题,step 变化时 int 会被清掉重做,那时间就不准了,而且这种做法太不优雅。

「要是能让 setCount 同时接收最新的 count 和 step 就好了。」该 useReducer 出马了。

useReducer 就是组件级的 Redux。

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。

const Comp = props => {
	const [ state, dispatch ] = useReducer((state, action) => {
		if (action === 'add') {
			return {
				...state,
				count: state.count + state.step
			};
		}
	}, {
		count: 1,
		step: 1,
	});
	useEffect(() => {
		setInterval(() => dispatch('add'), 1000);
	}, []);
};

这里最大的进步就是 interval 不用清除了,非常优雅准确。

和 useState 的区别

同样是返回一个状态及其修改入口,useReducer 更重却合理。

  1. 能处理复杂的状态依赖,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。
  2. 不直接操作 state,而是把操作细节解耦到 state 管理者自己那里

dispatch 不变

当你把逻辑交还 state 管理者,就避免了很多 render 上下文的函数声明和下发。

并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为 你可以向子组件传递 dispatch 而不是回调函数

这好在哪呢?声明的函数是变的(除非你用 useRef 固定),但 dispatch 是不变的。

useCallback - 函数依赖化

函数也是数据流的一部分。

当我们正常声明一个函数,它是 render 下的变量,每次 render 它的引用都是新的。好像不太对?如果有什么依赖这个函数怎么办,比如传给子组件?

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

综合场景:避免 props 透传,优化子组件渲染

当父组件 id 变化,子组件需要更新数据(且子组件本身并不依赖父 id)

不用 useCallback:

const Parent = () => {
	const fetchData = () => {
		fetch('/xxx?id=' + id);
	};
	return <Child pid={id} fetchData={fetchData} />;
};
// 👇 fetchData,pid
const Child = props => {
	const { pid, fetchData } = props;
	useEffect(() => {
		fetchData();
	}, [pid])
};

用 useCallback:

const Parent = () => {
	const fetchData = useCallback(() => {
		fetch('/xxx?id=' + id);
	}, [id]);
	return <Child fetchData={fetchData} />;
};
// 👇 fetchData
const Child = props => {
	const { fetchData } = props;
	useEffect(() => {
		fetchData();
	}, [fetchData])
};

**使用useCallback,函数完全可以参与到数据流中。**我们可以说如果一个函数的输入改变了,这个函数就改变了。如果没有,函数也不会改变。

这样有两层好处:

  1. 避免不必要的 pid 透传,它的依赖已经携带在了 fetchData 上
  2. 避免子组件无脑渲染。因为 fetchData 不再像一个普通函数那样每次 render 都改变,而是只在 pid 改变时改变。

场景:id 变化后重新请求数据

可以这样写:

const { id } = props;
const fetchData = () => {
	fetch('/xxx?id=' + id);
};
useEffect(() => {
	fetchData();
}, [id]);

但「问题」在,理论上 id 只是 fetchData 的依赖,useEffect 本身用不到,提前到调用它的 useEffect 中不合理也不清晰

用 useCallback:fetchData 依赖 id,useEffect 依赖和调用一致为 fetchData。

const { id } = props;
const fetchData = useCallback(() => {
	fetch('/xxx?id=' + id);
}, [id]);
// useEffect 依赖和调用对应,非常清晰
useEffect(() => {
	fetchData();
}, [fetchData]);

useMemo

类似 useCallback,useMemo 能让一个对象「依赖化」。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。

const background = useMemo(() => {
	return {
		backgroundColor: color,
		backgroundImage: `url(${image})`,
		backgroundRepeat: 'no-repeat',
	};
}, [ color, image ]);

这不就是个 computed 么?只在依赖变化时计算。

有助于减少每次 render 都要进行的昂贵计算。

总结

  • effect 范式组件是 hooks 的产物,很好贯彻了 UI = render(state)
  • 每次 render 的一切(状态、effect、函数)都被捕获下来,叫做 Capture Value
  • useEffect 比较的是依赖的捕获值,一定要对依赖诚实
  • useState / useEffect 是驱动 render 触发和运行的基础
  • useRef 能用不变的盒子存储可变的值
  • useReducer 擅长组织复杂的 state
  • useCallback、useMemo 能让普通的函数和值依赖化