正常情况下,使用 React 提供的事件处理能解决大部分需求,但难免会有使用自定义事件订阅的情况。
现在有如下需求:页面中有一个按钮,每次点击按钮之后,count
会加1,按下键盘的【Enter】按键之后,会在控制台打印当前的 count
。
我们使用 class 组件和函数组件可以写出如下两种代码:
// class component
const event = "keydown";
class App extends Component {
state = {
count: 0,
};
handlekKeydown = (e) => {
if (e.code === "Enter") {
console.log("current count: ", this.state.count);
}
};
componentDidMount() {
window.addEventListener(event, this.handlekKeydown);
}
componentWillUnmount() {
window.removeEventListener(event, this.handlekKeydown);
}
render() {
return (
<div>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Increase
</button>
</div>
);
}
}
// function component
const event = "keydown";
function App() {
const [count, setCount] = useState(0);
const handlekKeydown = (e) => {
if (e.code === "Enter") {
console.log("current count: ", count);
}
};
useEffect(() => {
window.addEventListener(event, handlekKeydown);
return () => {
window.removeEventListener(event, handlekKeydown);
};
}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
class 组件能够按照预期执行,点击 n 次按钮之后按下【Enter】按键,打印出的 count
会是 n。
但是函数组件不管点击多少次按钮,控制台打印的结果都是0,执行的结果与 class 组件不符,是什么原因导致这两种写法差异如此巨大?
很多武侠小说中,主角想要练绝世武功之前得先忘掉之前所学的东西,使用 Hook
也是如此。
useEffect
虽然看起来是将componentDidMount
、componentWillUnmount
结合了起来,但它们并不完全相等,我在《浅谈 React 常用的 Hooks》这篇文章提到过:
只要在
JavaScript/TypeScirpt
中执行或调用函数,会创建一个新的执行上下文并将其放在执行堆栈中。这样就创建了执行上下文,就像全局上下文一样。它将拥有自己的变量和函数空间。它将经历创建阶段,然后它将逐行执行函数中的代码。函数执行完毕之后,会弹出执行栈,如果发现没有变量再指向函数空间,也会在堆中销毁该函数空间。
函数组件就是通过反复执行一定的逻辑渲染出 JSX 的一个函数。如果useEffect
里面的依赖为[]
,在其回调函数中捕获的是初始的函数上下文,也就是说 props 和 state 永远都是初始值。在任意一次渲染中,props 和 state 是始终保持不变的。 如果 props 和 state 在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。组件内的每一个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获定义它们的那次渲染中的 props 和 state。
此时我们改一下代码:
const event = "keydown";
function App() {
const [count, setCount] = useState(0);
const handlekKeydown = useCallback(
(e: any) => {
if (e.code === "Enter") {
console.log("current count: ", count);
}
},
[count]
);
useEffect(() => {
window.addEventListener(event, handlekKeydown);
return () => {
window.removeEventListener(event, handlekKeydown);
};
}, [handlekKeydown]);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
使用用 useCallback
包装函数,并且将 count
放入其依赖中,随后将 handlekKeydown
放入useEffect
的依赖,当 count
改变之后,useCallback
会返回一个新的引用,useEffect
会感知到这个依赖项的变化,所以其回调里面可以拿到最新的 props 和 state。具体关于 useCallback
的用法可以看我之前写的这篇文章《浅谈 React 常用的 Hooks》。
但这种写法会引来一个新的问题:每点击一次按钮之后,useEffect 会产生一个新的回调,会重新创建一个绑定函数,如此反复创建、移除,对性能上会产生影响。
我们可以回过头开开之前 class 组件的写法,让我们来仔细看看我们class组件中的 handlekKeydown
方法:
handlekKeydown = (e) => {
if (e.code === "Enter") {
console.log("current count: ", this.state.count);
}
};
这个类方法从 this.state.count
中读取数据,此处的this
指向了当前组件的实例。React 本身会随着时间的推移而改变,以便你可以在渲染方法以及生命周期方法中得到最新的实例。
所在点击多次【Increase】按下【Enter】之后,我们的组件进行了重新渲染,this.state
将会改变。handlekKeydown
方法从一个最新的的state
中得到了count
。
在函数式组件中,你也可以拥有一个在所有的组件渲染帧中共享的可变变量,它被称为“ref”。在 class 组件中,Refs 通常被用于拿到子组件的 DOM:
Refs 提供了一种方式,允许我们访问 DOM 节点或在 render 方法中创建的 React 元素。
函数组件中,Refs 可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。
const ref = useRef(initialValue);
我们使用useRef
改造一下代码:
const event = "keydown";
function App() {
const [count, setCount] = useState(0);
const ref = useRef(count);
const handlekKeydown = (e) => {
if (e.code === "Enter") {
console.log("current count: ", ref.current);
}
};
useEffect(() => {
ref.current = count;
});
useEffect(() => {
window.addEventListener(event, handlekKeydown);
return () => {
window.removeEventListener(event, handlekKeydown);
};
}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
在这里,你可以把 ref 理解为函数组件中的实例字段。我们通过一个useEffect
实现了自动赋值操作,这样handlekKeydown
中就能永远拿到最新的 count。
useEffect(() => {
ref.current = count;
});
但是这样写有一个问题,如果页面中涉及多个状态呢?难道每个状态都要重新定义一个 ref 用于存取值?,这样的话代码会又臭又长。
我们可以换种思路,可以用 ref 来储存方法:
const event = "keydown";
function App() {
const [count, setCount] = useState(0);
const handlekKeydown = (e) => {
if (e.code === "Enter") {
console.log("current count: ", count);
}
};
const ref = useRef(handlekKeydown);
useEffect(() => {
ref.current = handlekKeydown;
});
useEffect(() => {
const cb = (e) => ref.current(e); // 注意这里通过回调拿到最新值
window.addEventListener(event, cb);
return () => {
window.removeEventListener(event, cb);
};
}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
这样完美的解决了上面提到的问题。
实际上 React 官方也注意到了这个问题,提出了一个新的 RFC (useEvent)用于解决类似的问题:
function Chat() {
const [text, setText] = useState('');
// 🟡 Always a different function
const onClick = () => {
sendMessage(text);
};
return <SendButton onClick={onClick} />;
}
但是害怕开发者将当前使用 useCallback
包裹的函数都替换,因为 useEvent
会”擦除“流入其值的响应性,所以暂时关闭了这个 RFC,发布了一个不同的、范围更小的 RFC 来取代这个 RFC,新的 RFC 为 useEffectEvent。
总结:
函数组件和 class 组件是有所不同的,函数式组件捕获了渲染当前帧所使用的值 。由于 class 组件有this
, React 组件在随着时间推移而改变的过程中,你总能从this
上获取最新的状态,因为 this
就指向组件的实例。而useRef
在函数组件中可以充当一个容器,返回的 ref 对象在组件的整个生命周期内持续存在,我们可以借助它在函数组件的每一帧传递数据。