useEvent RFC

官方文档:useEvent RFC

以下为官方文档摘要,附上部分翻译及理解,包括与第三方 hooks useMemoizedFn 的对比

Basic example

1
2
3
4
5
6
7
8
9
function Chat() {
const [text, setText] = useState('');

const onClick = useEvent(() => {
sendMessage(text);
});

return <SendButton onClick={onClick} />;
}

The code inside useEvent “sees” the props/state values at the time of the call. The returned function has a stable identity even if the props/state it references change. There is no dependency array.

这里官方举了一个例子,使用 useEvent 包裹事件,执行时,即使 props 或者 state 发生变化,onClick 函数都有稳定的引用(引用不变),且没有依赖数组。

Motivation

This onClick event handler needs to read the currently typed text:

1
2
3
4
5
6
7
8
9
10
function Chat() {
const [text, setText] = useState('');

// 🟡 Always a different function
const onClick = () => {
sendMessage(text);
};

return <SendButton onClick={onClick} />;
}

Let’s say you want to optimize SendButton by wrapping it in React.memo. For this to work, the props need to be shallowly equal between re-renders. The onClick function will have a different function identity on every re-render, so it will break memoization.

The usual way to approach a problem like this is to wrap the function into useCallback to preserve the function identity. However, it wouldn’t help in this case because onClick needs to read the latest text:

这个例子的目的是:在 onClick 事件中获取当前输入的 text。
上面这个例子,当每次重渲染时,onClick 函数都会被重新创建,每次创建都会有一个新的引用,显然,这导致了一定的性能损耗

1
2
3
4
5
6
7
8
9
10
function Chat() {
const [text, setText] = useState('');

// 🟡 A different function whenever `text` changes
const onClick = useCallback(() => {
sendMessage(text);
}, [text]);

return <SendButton onClick={onClick} />;
}

In the above example, the text changes with every keystroke, so onClick will still be a different function on every keystroke. (We can’t remove text from the useCallback dependencies because otherwise the onClick handler would always “see” the initial text.)

useCallback: useCallback 官方文档
为了使 onClick 的引用始终不变,把它包裹在 onCallback 里:
deps 设置为 text:芜湖~,那就变成和之前一样了,每次键入,onClick 都会被重新创建,失去了 onCallback 的缓存作用。
deps 设置为空数组:因为回调函数会被 onCallback 缓存,形成闭包,那么只能获取到 text 的初始值 —— 空字符串。

Internal implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// (!) Approximate behavior
function useEvent(handler) {
const handlerRef = useRef(null);

// In a real implementation, this would run before layout effects
useLayoutEffect(() => {
handlerRef.current = handler;
});

return useCallback((...args) => {
// In a real implementation, this would throw if called during render
const fn = handlerRef.current;
return fn(...args);
}, []);
}

In other words, it gives you a stable function that calls the latest version of the function you passed.

总的来讲,useEvent 解决了两个痛点:
1. 每次重渲染时,函数引用不会改变,优化了性能问题。
2. 当 props 或者 state 发生变化时,onClick 函数都能获取到最新的值。

The built-in useEvent would have a few differences from the userland implementation above.

Event handlers wrapped in useEvent will throw if called during render. (Calling it from an effect or at any other time is fine.) So it is enforced that during rendering these functions are treated as opaque and never called. This makes it safe to preserve their identity despite the changing props/state inside. Because they can’t be called during rendering, they can’t affect the rendering output — and so they don’t need to change when their inputs change (i.e. they’re not “reactive”).

这里只是 useEvent 的简单的实例,真正的实现方式与它还是有一些区别,过程大致是这样的:
1. 把最新的函数引用保存在一个 ref 中
官网文档:useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。
2. 然后在布局绘制之前,把 hander 赋值给 handlerRef.current,这样就可以在布局绘制之前获取到函数引用。
官网文档:useLayoutEffect 在所有的 DOM 变更之后同步调用 effect,可以使用它来读取 DOM 布局并同步触发重渲染,在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。
3. 在渲染时,为了不会改变函数引用,将 deps 配置为空数组,执行最新函数的引用(根据 1 得知,这个引用在 ref.current 中存储)。
官网文档:useCallback (fn, deps) 相当于 useMemo (() => fn, deps),传入 useMemo 的函数会在渲染期间执行。
这样就保证函数引用始终不变,且拿到的值永远是最新的。

注意几个 hooks 的执行时机:

  1. useLayoutEffect 是在 DOM 准备完成,但还未渲染到屏幕之前,同步执行。—— 关键词:render 之前,同步。
  2. useEffect 被异步调度,当页面渲染完成后再去执行,不会阻塞页面渲染。 —— 关键词:render 之后,异步。
  3. useCallback (fn, deps) 相当于 useMemo (() => fn, deps),传入 useMemo 的函数会在渲染期间执行。—— 关键词:render 期间。

Drawbacks

在这部分简述了命名问题,以及其他一些不足之处。例如:

回调函数获得的值并不是真正 “实时” 的,如果使用 async/await 构造一个异步函数,而且在 await 之后读取 props,你会发现该值与 await 之前相同,如果要获得真正最新的值,需要进入另一个事件。因此,事件通常不应该是异步的。像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Chat() {
const [count, setCount] = useState('');

const handleClick = useEvent(async () => {
setCount(count + 1);
alert("1-------------You clicked on: " + count);
setTimeout(() => {
alert("2-------------You clicked on: " + count); // 与上一个相同,打印调用时快照的值
}, 3000);
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={handleClick}>add count</button>
</div>
)
}

解释一个概念:

Capture Value :每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。

上面例子中 count 值只是调用时的快照,即使等待时更改 count 的值,当前的函数回调还是拿不到最新的值。这点与 useMemoizedFn 相同。

和 useMemoizedFn 的区别

useMemoizedFn 的部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var react_1 = require("react");

function useMemoizedFn(fn) {
var fnRef = react_1.useRef(fn); // why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728

fnRef.current = react_1.useMemo(function () {
return fn;
}, [fn]); // 注意!

var memoizedFn = react_1.useRef();

if (!memoizedFn.current) {
memoizedFn.current = function () {
var args = [];

for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}

return fnRef.current.apply(this, args);
};
}

return memoizedFn.current;
}
  1. useMemoizedFn 的实现方式与 useEvent 相同,只是 fnRef.current 的更新是在渲染期间执行(useMemo),而 useEvent 在渲染之前更新函数的引用。
  2. useEvent 定位于处理事件回调函数这一单一场景,而 useMemoizedFn 定位于缓存各种函数。