React之useState、useEffect原理解析
- 一. useState的实现
- 1.1 惰性初始化state
- 1.2 Object.is算法
- 二. useEffect的实现
- 2.1 变量冲突问题
- 2.2 变量冲突解决方案
- 三. 拓展小知识
参考文章:React Hook的实现原理和最佳实践
一. useState的实现
首先,我们来看一个简单的useState()
的使用案例:
import './App.css';
import { useEffect, useState } from 'react'
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
console.log(`update---${count}`)
}, [count])
return (
<div>
<button onClick={() => setCount(count + 1)}>
{`当前点击次数:${count}`}
</button>
</div>
);
}
export default App;
分析:来看下面这行代码:
const [count, setCount] = useState(0)
可以发现:
- 调用
useState()
函数,会返回一个变量(count
)以及一个函数(setCount
)。 -
useState()
函数中可以传入一个参数,也就是该变量的初始值。
那么根据上述发现的2点,我们来自定义一个函数(创建个react脚手架,在index.js
文件中修改):
import React from "react";
import ReactDOM from "react-dom";
function useState(initVal) {
let val = initVal;
function setVal(newVal) {
val = newVal;
// 修改变量后,调用render函数,重新渲染页面
render();
}
return [val, setVal];
}
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => { console.log(count); setCount(count + 1); }}>
{`当前点击次数:${count}`}
</button>
</div>
)
}
// 初次渲染用
render();
function render() {
ReactDOM.render(<App />, document.getElementById("root"));
}
但是效果如下:
出现这种情况的原因分析:
- 首先,可以看到控制台上有所输出,说明:自定义的
setState
函数执行了。 - 但由于
let val = initVal;
这行代码是在函数内部被声明的,也因此每次调用useState
函数的时候,都会重新声明val
变量,从而导致其状态无法被保存。
因此我们对上述代码进行一个修改,将val
变量放到全局作用域中:
// 全局作用域
let val;
function useState(initVal) {
// 判断val是否存在 存在就使用
val = val|| initVal;
function setVal(newVal) {
val = newVal;
// 修改变量后,调用render函数,重新渲染页面
render();
}
return [val, setVal];
}
此时,代码修改后的页面效果才是正常的:
1.1 惰性初始化state
我们可以注意到,在使用useState
的时候,允许我们传入一个参数作为该状态变量的默认值。我们将这个参数先命名为initValue
,该参数只会在组件初次渲染的时候起生效,在后续渲染的时候则会被忽略。
同时,倘若这个初始值需要经过计算获得,那么这种情况我们大致分为这么2种方式:
-
useEffect()
函数中去调用一个伪代码getUserInfo()
,然后通过返回值去调用setState
函数赋值。 - 第一种不在本环节讨论范围内,那么第二种就是用惰性初始化
state
的方式。useState
中不仅可以传入一个参数作为默认值,还可以传入一个函数,在函数中计算并返回初始的state
即可。
例如(伪代码):
const [state, setState] = useState(() => {
const userName= getUserInfo();
return userName;
});
1.2 Object.is算法
React中,通过Object.is
算法来比较状态变量的不同,其判别的标准如下(摘自官网):Object.is(A,B)
只要满足下列条件任意一条,就代表这两个值相同。
- 两个都
undefined
. - 两个都是
null
。 - 两个都是
true
/false
。 - 两个长度相同且字符相同且顺序相同的字符串。
- 两者都是同一个对象。
- 两个数字和:
两个都 +0。
两个都 -0。
两个都 NaN。
或两者都不为零且两者均不NaN相同,且两者的值相同。
Object.is(25, 25); // true
Object.is('foo', 'foo'); // true
Object.is('foo', 'bar'); // false
Object.is(null, null); // true
Object.is(undefined, undefined); // true
Object.is(window, window); // true
Object.is([], []); // false
var foo = { a: 1 };
var bar = { a: 1 };
Object.is(foo, foo); // true
Object.is(foo, bar); // false
// Case 2: Signed zero
Object.is(0, -0); // false
Object.is(+0, -0); // false
Object.is(-0, -0); // true
Object.is(0n, -0n); // true
// Case 3: NaN
Object.is(NaN, 0/0); // true
Object.is(NaN, Number.NaN) // true
二. useEffect的实现
我们知道useEffect()
函数会在第一次渲染之前调用,并且有两个参数:
- 参数1:执行的函数。
- 参数2:数组(可选参数),
useEffect()
函数根据第二个参数中是否有变化,来判断是否执行第一个参数的函数。
也因此,我们在开发过程中,对于只希望其执行一次的useEffect()
函数,我们往往写入第二个参数为一个空数组(否则可能引起无限渲染的BUG)。
实现1:useEffect
传入一个函数参数,里面调用即可。
import React from "react";
import ReactDOM from "react-dom";
let val;
function useState(initVal) {
val = val || initVal;
function setVal(newVal) {
val = newVal;
render(); // 重新render页面
}
return [val, setVal];
}
// 自定义的useEffect
function useEffect(fn) {
fn();
}
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`自定义useEffect调用--count:${count}`);
});
return (
<div>
<button onClick={() => { console.log(count); setCount(count + 1); }}>
{`当前点击次数:${count}`}
</button>
</div>
)
}
// 初次渲染用
render();
function render() {
ReactDOM.render(<App />, document.getElementById("root"));
}
但是页面效果如下,可以发现每点击一次按钮,就重新渲染一次,而每次渲染则调用一次useEffect()
函数。
那么再来看看有两个参数版本的自定义useEffect()
函数,这个函数需要做到:
- 根据第二个参数来判断是否执行
useEffect()
函数。
let watchArr;
function useEffect(fn, watch) {
const hasWactchChange = watchArr
? !watch.every((val, i) => val === watchArr[i])
: true;
if (hasWactchChange) {
fn();
watchArr = watch;
}
}
完整案例如下(index.js
文件):
import React from "react";
import ReactDOM from "react-dom";
let val;
function useState(initVal) {
val = val || initVal;
function setVal(newVal) {
val = newVal;
render(); // 重新render页面
}
return [val, setVal];
}
// 自定义的useEffect
let watchArr;
function useEffect(fn, watch) {
const hasWactchChange = watchArr
? !watch.every((val, i) => val === watchArr[i])
: true;
if (hasWactchChange) {
fn();
watchArr = watch;
}
}
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`自定义useEffect调用--count:${count}`);
}, []);
return (
<div>
<button onClick={() => { setCount(count + 1); }}>
{`当前点击次数:${count}`}
</button>
</div>
)
}
// 初次渲染用
render();
function render() {
ReactDOM.render(<App />, document.getElementById("root"));
}
页面效果如下:可以见到,useEffect()
函数就执行了一次,因为第二个参数中我们传入了一个空数组。
2.1 变量冲突问题
在上面,我们初步实现了useState和useEffect函数,并成功调用,但是上述案例都是在一个变量的情况下发生的,那么倘若有两个变量的情况下,依旧采用上述的自定义代码,会发生什么?
案例如下:
import React from "react";
import ReactDOM from "react-dom";
let val;
function useState(initVal) {
val = val || initVal;
function setVal(newVal) {
val = newVal;
render(); // 重新render页面
}
return [val, setVal];
}
// 自定义的useEffect
let watchArr;
function useEffect(fn, watch) {
const hasWactchChange = watchArr
? !watch.every((val, i) => val === watchArr[i])
: true;
if (hasWactchChange) {
fn();
watchArr = watch;
}
}
function App() {
const [count, setCount] = useState(0);
const [data, setData] = useState(0);
useEffect(() => {
console.log(`自定义useEffect调用--count:${count}`);
}, [count]);
useEffect(() => {
console.log(`自定义useEffect调用--data:${data}`);
}, [data]);
return (
<div>
<button onClick={() => { setCount(count + 1); }}>
{`按钮1:当前点击次数:${count}`}
</button>
<hr />
<button onClick={() => { setData(data + 1); }}>
{`按钮2:当前点击次数:${data}`}
</button>
</div>
)
}
// 初次渲染用
render();
function render() {
ReactDOM.render(<App />, document.getElementById("root"));
}
页面效果如下:
原因分析:以useState
为例:
- 所有调用
useState
方法的地方,都会共享一个全局变量val
。 - 因此在组件中多次调用,就会引起变量冲突问题。
2.2 变量冲突解决方案
代码改进:
- 通过一个全局的数组来维护变量。
- 通过一个全局的下标用来定位对应的状态变量存储的位置。
代码如下:
import React from "react";
import ReactDOM from "react-dom";
let memoizedState = [];
let currentIndex = 0;
function useState(initVal) {
memoizedState[currentIndex] = memoizedState[currentIndex] || initVal;
const cursor = currentIndex;
function setVal(newVal) {
memoizedState[cursor] = newVal;
render();
}
// 返回state 然后 currentIndex+1
return [memoizedState[currentIndex++], setVal];
}
// 自定义的useEffect
function useEffect(fn, watch) {
const hasWatchChange = memoizedState[currentIndex]
? !watch.every((val, i) => val === memoizedState[currentIndex][i])
: true;
if (hasWatchChange) {
fn();
memoizedState[currentIndex] = watch;
currentIndex++; // 累加 currentIndex
}
}
function App() {
const [count, setCount] = useState(0);
const [data, setData] = useState(0);
useEffect(() => {
console.log(`自定义useEffect调用--count:${count}`);
}, [count]);
useEffect(() => {
console.log(`自定义useEffect调用--data:${data}`);
}, [data]);
return (
<div>
<button onClick={() => { setCount(count + 1); }}>
{`按钮1:当前点击次数:${count}`}
</button>
<hr />
<button onClick={() => { setData(data + 1); }}>
{`按钮2:当前点击次数:${data}`}
</button>
</div>
)
}
// 初次渲染用
render();
function render() {
console.log(memoizedState); // 执行hook后 数组的变化
currentIndex = 0; // 重新render时需要设置为 0
ReactDOM.render(<App />, document.getElementById("root"));
}
页面效果如下:
从上述代码中,我们可以发现每次调用render()
函数,都要将对应的全局下标重置为0,这个操作我刚开始看到就觉得匪夷所思,想了半天我才明白是为什么:
- 因为我们是根据Hook的调用顺序,来依次将变量存储在数组中的。
- 而我们
useEffect
函数的第二个参数是数组的原因,也是因为我们变量的存储也是以数组形式来存在。
备注:
我们这里的代码是个简化版的,官方的
useState
和useEffect
函数肯定是要更完善的。希望大家引以区分。
这里在对上述案例中的输出做一个解释,我们以第一次输出为例:(此时点击按钮1)
结果分析:
那么此时的数组值也就对应了控制台中输出的内容:
我们也可以注意到代码中:
- 每次调用
setXXX
函数的时候,都会从数组memoizedState
中对应的位置去取值,并重新赋值,从而获得一个全新的数组memoizedState
(之所以能够把索引位置对得上,是因为调用render
函数的时候把全局下包也重置为0了) - 那么对于
useEffect()
函数,则通过第二个参数是否发生变化来决定其是否调用。
三. 拓展小知识
在日常开发当中,我们往往会给一个按钮添加一个onChange
事件或者onClick
事件,那么就以onClick
事件为例:
- 假设按钮A绑定了
onClick
事件,里面肯定是调用我们自定义的onClick
函数。 - 由于其他组件的原因我们触发了组件的渲染,那么每次render的时候,都会重新产生新的
onClick
函数。 - 这会造成不必要的渲染而引起性能浪费。
伪代码如下:
class Demo extends Component{
render() {
return
<div>
<Button onClick={ () => { console.log('Hello World!!'); }} />
</div>;
}
}
因此我们在类式组件开发过程中,往往会这么改写代码,来避免性能浪费问题:
class Demo extends Component{
constructor(){
super();
this.buttonClick = this.buttonClick.bind(this);
}
render() {
return
<div>
<Button onClick={ this.buttonClick } />
</div>;
}
}
那如果在函数式组件中开发,如何写呢?
回答:采用ReactHook中的useCallback()
函数:
function Demo(){
const buttonClick = useCallback(() => {
console.log('Hello World!!')
},[])
return(
<div>
<Button onClick={ buttonClick } />
</div>
)
}
作用:useCallback
函数会生成一个记忆函数,这样更新时就能保证这个函数不会发生渲染。 从而达到规避性能浪费的目标。