0 概述
React Hook 经验汇总,Hook已经出来一段时间了,看大众的评论,基本上是毁誉参半。这个机制的确能更容易地复用逻辑,但是也很容易写错误的代码。
我觉得最好的Hook文档还是在官网
1 useState
代码在这里
1.1 基础
import { useState } from 'react';
//Counter的使用,将setCounter传入新值
export default function CounterPage() {
let [counter, setCounter] = useState(0);
let incCounter = function () {
setCounter(counter + 1);
};
let decCounter = function () {
setCounter(counter - 1);
};
return (
<div>
<div>当前的counter为:{counter}</div>
<button onClick={incCounter}>加</button>
<button onClick={decCounter}>减</button>
</div>
);
}
使用useState创建一个状态,和一个设置状态的方法,这段简单
1.2 useState的修改参数为闭包
import { useState } from 'react';
//setCounter可以传入一个闭包,获取旧值,然后返回新值
export default function Counter2Page() {
let [counter, setCounter] = useState(0);
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
setCounter((prevCount) => prevCount + 1);
}}
>
加
</button>
<button
onClick={() => {
setCounter((prevCount) => prevCount - 1);
}}
>
减
</button>
</div>
);
}
setCounter不仅可以传递一个最新值,还可以传递一个闭包,用来获取最新值以后返回一个修改值。
1.3 useState的初始参数为闭包
import { useState } from 'react';
//useState的初始值可以是一个闭包,用来计算初始值
export default function Couter3Page() {
let [counter, setCounter] = useState(() => {
return 1 + 1;
});
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
setCounter((prevCount) => prevCount + 1);
}}
>
加
</button>
<button
onClick={() => {
setCounter((prevCount) => prevCount - 1);
}}
>
减
</button>
</div>
);
}
useState的初始化参数可以是一个闭包,用来初始化复杂的初始化操作。一般是依赖于props数据时的初始化过程。
1.4 useReducer
import { useReducer } from 'react';
import { useState } from 'react';
type CounterAction = {
type: 'inc' | 'dec';
};
export default function CounterPage() {
let [counter, dispatch] = useReducer(function (
state: number,
action: CounterAction,
) {
if (action.type == 'inc') {
return state + 1;
} else if (action.type == 'dec') {
return state - 1;
}
return state;
},
0);
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
dispatch({ type: 'inc' });
}}
>
加
</button>
<button
onClick={() => {
dispatch({ type: 'dec' });
}}
>
减
</button>
</div>
);
}
useReducer,就是useState的进阶版了,这样能让修改数据的地方更加聚合在一起,也没啥好说的。用过的redux的都觉得简单
2 useEffect
2.1 基础
代码在这里
import { useEffect } from 'react';
import { useState } from 'react';
export default function EffectPage() {
let [open, setOpen] = useState(false);
let [counter, setCounter] = useState(0);
//useEffect没有参数的时候,代表每次都会在render后重新触发
//effect的语义,render是一个UI=render(state)的纯函数,那么effect就是纯函数以外的副作用
//即使触发的状态按钮,也会使得useEffect的运行
useEffect(() => {
console.log('reset document title!');
document.title = '计算器' + counter;
});
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
setCounter((prevCount) => prevCount + 1);
}}
>
加
</button>
<button
onClick={() => {
setCounter((prevCount) => prevCount - 1);
}}
>
减
</button>
<button
onClick={() => {
setOpen((prevOpen) => !prevOpen);
}}
>
状态:{open ? '打开' : '关闭'}
</button>
</div>
);
}
useEffect的语义就是每一次render以后,都会触发一次的副作用。代码中,就实现了,每次render以后,就重新设置一下title,这个想法还是相当直观的。
2.2 cleanup
import { useEffect } from 'react';
import { useState } from 'react';
export default function EffectPage() {
let [counter, setCounter] = useState<number>(0);
//因为effect每次在render都会重新触发
useEffect(() => {
console.log('add interval ');
var i = 0;
document.title = '计算器' + counter;
let interval = setInterval(() => {
document.title = '计算器' + counter + '!'.repeat(i);
i++;
}, 500);
//每次新的effect替换旧的时候,就会调用旧effect的清理函数来清理
return () => {
console.log('clean interval');
clearInterval(interval);
};
});
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
setCounter((prevCount) => prevCount + 1);
}}
>
加
</button>
<button
onClick={() => {
setCounter((prevCount) => prevCount - 1);
}}
>
减
</button>
</div>
);
}
每次render都会触发一次effect的方法,显然,如果我们在effect放入定时器的话,每次render都会产生很多定时器。所以,effect的第一个参数是闭包,这个参数的返回值可以是一个闭包。每次新的副作用执行时,先执行旧的副作用的清理函数,这个清理函数称为cleanup。
2.3 依赖
import { useEffect } from 'react';
import { useState } from 'react';
export default function EffectPage() {
let [open, setOpen] = useState(false);
let [counter, setCounter] = useState(0);
//effect可以带有一个dependence参数,只有参数里面的引用没变时,才会触发副作用
//这个时候,触发状态按钮,不会再次触发effect了
//只有在触发加减按钮的时候,才会触发effect
useEffect(() => {
console.log('reset document title!');
document.title = '计算器' + counter;
}, [counter]);
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
setCounter((prevCount) => prevCount + 1);
}}
>
加
</button>
<button
onClick={() => {
setCounter((prevCount) => prevCount - 1);
}}
>
减
</button>
<button
onClick={() => {
setOpen((prevOpen) => !prevOpen);
}}
>
状态:{open ? '打开' : '关闭'}
</button>
</div>
);
}
effect作为副作用的另外一个问题是,如果effect的闭包需要的内容没变,还需要每次执行一次副作用,似乎就太浪费了。例如,在代码中,副作用就是每次counter变化的时候,更新一下document的标题。但是,如果我们设置open按钮,都需要更新一下document的标题,就会造成浪费了(当副作用是ajax操作的时候就会更加明显)。
注意,React仅仅是对数据进行浅比较而已,如果依赖数据的深层发生变化,但是引用不变的话,副作用依然不会执行
因此,useEffect有第二个参数,手动填写effect执行副作用时,必须是在哪些数据已经变动的情况下。例如,我们填写的依赖是counter,那么就是只有首次render,以及counter变化的时候,才会执行副作用。因此,我们能看到,当我们更改open状态的时候,不会用reset document title的输出。
2.4 空依赖
import { useEffect } from 'react';
import { useState } from 'react';
export default function EffectPage() {
let [open, setOpen] = useState(false);
let [counter, setCounter] = useState(0);
//effect的dependence为空的时候,整个组件只会触发一次
useEffect(() => {
console.log('reset document title!');
document.title = '计算器' + counter;
}, []);
return (
<div>
<div>当前的counter为:{counter}</div>
<button
onClick={() => {
setCounter((prevCount) => prevCount + 1);
}}
>
加
</button>
<button
onClick={() => {
setCounter((prevCount) => prevCount - 1);
}}
>
减
</button>
<button
onClick={() => {
setOpen((prevOpen) => !prevOpen);
}}
>
状态:{open ? '打开' : '关闭'}
</button>
</div>
);
}
一种特殊的情况是,useEffect的第二个参数是一个空数组,代表依赖的数据为空。那么副作用仅会在首次render的时候触发一次,之后都不会触发。
2.5 useLayoutEffect
useLayoutEffect,与useEffect是相似的,不过它是与render操作是同步执行而已,这点在官网写得很清楚
3 useCallback
代码在这里
3.1 没有callback缓存
import { memo, useState } from 'react';
type Props = {
name: string;
onClick: () => void;
};
//即使用了memo,但是依然是每次两个Button都重绘,因为每次onClick的实例都不同
let ChildButton = memo((props: Props) => {
console.log('Child Button Render');
return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
let [counter, setCounter] = useState(0);
let [counter2, setCounter2] = useState(0);
let inc = function () {
setCounter((prevState) => {
return prevState + 1;
});
};
let inc2 = function () {
setCounter2((prevState) => {
return prevState + 1;
});
};
console.log('Top Render');
return (
<div>
<div>当前的counter为:{counter}</div>
<ChildButton onClick={inc} name="计数器1" />
<div>当前的counter2为:{counter2}</div>
<ChildButton onClick={inc2} name="计数器2" />
</div>
);
}
我们做了一个实验,对子组件加入memo的包装,那么当props不变的时候,组件就不会重新render。显然,这是一种class组件中的componentShouldUpdate的机制而已。
但是,在实验中可以看到,每次看起来inc与inc2都没变,但是ChildButton依然会触发重新的Render。这是不对的,因为inc与inc2是在闭包中创建的,所以它们在每次render都会产生新的引用,它们是新的实例,因此会导致memo失效。
3.2 有callback缓存
import { memo, useCallback, useState } from 'react';
type Props = {
name: string;
onClick: () => void;
};
let ChildButton = memo((props: Props) => {
console.log('Child Button Render');
return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
let [counter, setCounter] = useState(0);
let [counter2, setCounter2] = useState(0);
//使用了useCallback以后,仅在首次的时候使用闭包,而后都会缓存这个闭包,从而避免不必要的渲染
//但是每次render,闭包依然生成,只是不用它而已
//依赖参数像useEffect一样的用法
let inc = useCallback(function () {
setCounter((prevState) => {
return prevState + 1;
});
}, []);
let inc2 = useCallback(function () {
setCounter2((prevState) => {
return prevState + 1;
});
}, []);
console.log('Top Render');
return (
<div>
<div>当前的counter为:{counter}</div>
<ChildButton onClick={inc} name="计数器1" />
<div>当前的counter2为:{counter2}</div>
<ChildButton onClick={inc2} name="计数器2" />
</div>
);
}
因此,对于Hook中的函数组件,每次创建闭包都是新实例的问题,Hook提供了useCallback组件来解决这个问题。它就像redux里面的selector的做法,每次先检查一下useCallback的第二个参数,第二个参数依赖没变的时候,才去获取新闭包,否则一直沿用上次的旧闭包。
在每次render的时候依然会创建新闭包,只是这个新闭包没有被useCallback返回出来而已。另外一方面,对于依赖的比较依然是浅拷贝的方式。
4 useMemo
4.1 没有数据缓存
import { memo, useState, useCallback } from 'react';
type Props = {
name: string;
onClick: () => void;
};
let ChildButton = memo((props: Props) => {
console.log('Child Button Render');
return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
let [mode, setMode] = useState('fish');
let [counter, setCounter] = useState(0);
let [counter2, setCounter2] = useState(0);
let inc = useCallback(function () {
setCounter((prevState) => {
return prevState + 1;
});
}, []);
let inc2 = useCallback(function () {
setCounter2((prevState) => {
return prevState + 1;
});
}, []);
let total = (function () {
console.log('expensive sum');
let result = 0;
for (var i = 0; i != 10; i++) {
result += counter + counter2;
}
return result;
})();
console.log('Top Render');
return (
<div>
<div>当前的mode为:{mode}</div>
<button onClick={() => setMode(mode + '!')}>更新mode</button>
<div>当前的counter为:{counter}</div>
<ChildButton onClick={inc} name="计数器1" />
<div>当前的counter2为:{counter2}</div>
<ChildButton onClick={inc2} name="计数器2" />
<div>{'总数为:' + total}</div>
</div>
);
}
我们加入一个新功能,每次将所有的Counter值加起来,为了增大消耗,我们让循环执行10次。可以看到,每次render的时候,这个统计操作都会执行,这会造成额外的资源消耗。
4.2 有数据缓存
import { memo, useState, useCallback, useMemo } from 'react';
type Props = {
name: string;
onClick: () => void;
};
let ChildButton = memo((props: Props) => {
console.log('Child Button Render');
return <button onClick={props.onClick}>{props.name}</button>;
});
export default function CounterPage() {
let [mode, setMode] = useState('fish');
let [counter, setCounter] = useState(0);
let [counter2, setCounter2] = useState(0);
let inc = useCallback(function () {
setCounter((prevState) => {
return prevState + 1;
});
}, []);
let inc2 = useCallback(function () {
setCounter2((prevState) => {
return prevState + 1;
});
}, []);
let total = useMemo(
function () {
console.log('expensive sum');
let result = 0;
for (var i = 0; i != 10; i++) {
result += counter + counter2;
}
return result;
},
[counter, counter2],
);
console.log('Top Render');
return (
<div>
<div>当前的mode为:{mode}</div>
<button onClick={() => setMode(mode + '!')}>更新mode</button>
<div>当前的counter为:{counter}</div>
<ChildButton onClick={inc} name="计数器1" />
<div>当前的counter2为:{counter2}</div>
<ChildButton onClick={inc2} name="计数器2" />
<div>{'总数为:' + total}</div>
</div>
);
}
Hook提供了useMemo来解决这个问题,它就像redux中的selector这样操作。
5 useContext
代码在这里
import { createContext, useContext, useState, memo } from 'react';
const ModeContext = createContext({ mode: 'fish' });
let GrandSon = memo(function () {
console.log('grand son render');
let data = useContext(ModeContext);
return (
<div>
<div>我是孙组件</div>
<div>mode为:{data.mode}</div>
</div>
);
});
let Son = memo(function () {
console.log('son render');
return (
<div>
<div>我是子组件</div>
<GrandSon />
</div>
);
});
export default function () {
console.log('top render');
let [mode, setMode] = useState('fish');
return (
<div>
<h3>当前mode为:{mode}</h3>
<button
onClick={() => {
setMode((mode) => {
if (mode == 'fish') {
return 'cat';
} else {
return 'fish';
}
});
}}
>
切换mode
</button>
<ModeContext.Provider value={{ mode: mode }}>
<Son />
</ModeContext.Provider>
</div>
);
}
Hook提供了更轻松地使用Context的方式,而且当context变化时,会自动通知context的消费者自动render。同时,中间的Son组件不会进行render,性能有更好的提升。
6 useRef
代码在这里
6.1 组件的引用
import { useEffect } from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback } from 'react';
export default function CounterPage() {
let myRef = useRef<HTMLDivElement>(null);
useEffect(function () {
myRef.current?.setAttribute(
'style',
'color:red; border: 1px solid blue;',
);
}, []);
return (
<div ref={myRef}>
<div>你好</div>
</div>
);
}
Hook中提供了useRef来获取组件的引用,从而调用它的DOM操作,这点还是很简单的。要注意的是,引用实例在每次render都是不变的,变化的是引用的current属性而已
6.2 数据的引用
import { useEffect } from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback } from 'react';
export default function CounterPage() {
//refresh组件用来强行刷新的
const [refresh, setRefresh] = useState(false);
let myRef = useRef({ counter: 0 });
let inc = useCallback(() => {
myRef.current.counter++;
setRefresh((prevState) => !prevState);
}, []);
let dec = useCallback(() => {
myRef.current.counter--;
setRefresh((prevState) => !prevState);
}, []);
return (
<div>
<div>计数器为:{myRef.current.counter}</div>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
</div>
);
}
我们可以利用useRef在每次render的不变性,来创建一个数据的最新值的存放点,而不是数据的快照值。
7 最佳实践
代码在这里
useEffect与useState,我们可以组合创建自己的Hook。这种方法,给与了我们更好地复用业务代码逻辑的方式。另外一方面,我们也能看到性能的提升。
受限于原来的store对class组件的绑定方法,Redux对Flex的改进之一是,全局只有一个Store。这样view对store的绑定只需要用一个@connect就能实现了,没有繁琐的代码。但是,带来的问题是,每次刷新都会将所有@connect的地方都通知一遍,然后用shouldComponentUpdate来过滤是否需要更新。而且,每次更新都是从父组件一直传递到子组件的更新,而不能实现仅仅的两个没有@connect的子组件的更新。
Hook在这一点上给与我们新的想法。
7.1 不变key的store引用
type Emiter<T> = (data: T) => void;
class EventEmiter<T> {
private globalEmiterId = 0;
private handlersMap: Map<number, Emiter<T>> = new Map();
private data: T;
constructor(data: T) {
this.data = data;
}
subscribe(handler: Emiter<T>): number {
let emiterId = this.globalEmiterId;
this.globalEmiterId++;
this.handlersMap.set(emiterId, handler);
return emiterId;
}
unsubscribe(emiterId: number) {
this.handlersMap.delete(emiterId);
}
set(data: T) {
this.data = data;
this.handlersMap.forEach((e) => {
e(data);
});
}
get(): T {
return this.data;
}
}
export default EventEmiter;
我们首先创建一个EventEmiter,所有store都需要通知view的方式,这个类还是相当简单的
import { useEffect } from 'react';
import { useState } from 'react';
import EventEmiter from './EventEmiter';
class CounterStore extends EventEmiter<number> {
constructor() {
super(0);
}
public inc = () => {
this.set(this.get() + 1);
};
public dec = () => {
this.set(this.get() - 1);
};
}
let store = new CounterStore();
export default function useCounter() {
let [counter, setCounter] = useState(store.get());
//加入[]依赖符,仅在首次render的时候进行subscribe操作
useEffect(function () {
let emiterId = store.subscribe((data) => {
//setCounter是稳定的,每次render返回的都是同一个setCounter
//如果setCounter传入的参数不变,那么就不会触发render,注意这种不变仅仅是浅比较的不变
setCounter(data);
});
return () => {
store.unsubscribe(emiterId);
};
}, []);
return [counter, store.inc, store.dec] as const;
}
然后我们利用EventEmiter创建了一个自己的CounterStore,然后创建一个自己的Hook组件,它在组件刚实例化时,绑定store。在store变化的时候,更新view的状态。
import { memo } from 'react';
import useCounter from './useCounter';
type Props = {
name: string;
};
// memo可以使得只有props发生变化的时候才重新render,注意不包括state
export default memo(function (props: Props) {
let [counter, inc, dec] = useCounter();
console.log('Child Render');
return (
<div>
<h2>{props.name}</h2>
<div>{'当前值为:' + counter}</div>
<button onClick={inc}>加1</button>
<button onClick={dec}>减1</button>
</div>
);
});
然后我们创建一个Button组件
import { useState } from 'react';
import ChildButton from './Button';
export default function Parent() {
let [open, setOpen] = useState(false);
let [buttons, setButtons] = useState<number[]>([]);
console.log('Parent Render');
return (
<div>
<div>
<button
key="add"
onClick={() => {
setButtons((prevState) => [
...prevState,
prevState.length + 1,
]);
}}
>
添加一个
</button>
<button
key="clear"
onClick={() => {
setButtons([]);
}}
>
清除
</button>
<button
key="other"
onClick={() => {
setOpen((prevOpen) => !prevOpen);
}}
>
状态:{open ? '打开' : '关闭'}
</button>
</div>
{buttons.map((id) => {
return <ChildButton key={id} name={'按钮' + id} />;
})}
</div>
);
}
最后,我们创建了一个页面
我们在控制台可以看到,每次一个按钮点击的时候,另外一个按钮的组件数据也会自动变化。同时,父组件不需要重新render,这比Redux糟糕的从父组件到子组件渲染到底要好得多。要达到相同的效果,用MobX也能做。
7.2 变化key的store引用
import { useEffect } from 'react';
import { useState } from 'react';
import EventEmiter from '@/pages/myUse/EventEmiter';
import { useCallback } from 'react';
export type CounterEnum = 'fish' | 'cat';
type CounterObject = {
[key in CounterEnum]: number;
};
class CounterStore extends EventEmiter<CounterObject> {
constructor() {
super({
fish: 0,
cat: 0,
});
}
public inc = (target: CounterEnum) => {
let data = this.get();
//注意要返回新的对象,不能在原来的对象上面改
data = {
...data,
[target]: data[target] + 1,
};
this.set(data);
};
public dec = (target: CounterEnum) => {
let data = this.get();
data = {
...data,
[target]: data[target] - 1,
};
this.set(data);
};
}
let store = new CounterStore();
export default function useCounter(target: CounterEnum) {
let [counter, setCounter] = useState(store.get()[target]);
//加入[]依赖符,仅在首次render的时候进行subscribe操作
useEffect(
function () {
let emiterId = store.subscribe((data) => {
setCounter(data[target]);
});
//切换以后,要set一次
setCounter(store.get()[target]);
return () => {
store.unsubscribe(emiterId);
};
},
[target],
);
//useCallback可以缓存每次不同的callback
let inc = useCallback(
function () {
store.inc(target);
},
[target],
);
let dec = useCallback(
function () {
store.dec(target);
},
[target],
);
return [counter, inc, dec] as const;
}
我们使用另外一种CounterStore,这种CounterStore,允许用户获取不同mode的Counter,也允许用户中途切换另外一种mode的Counter
import { memo, useState } from 'react';
import useCounter, { CounterEnum } from './useCounter';
type Props = {
name: string;
mode: CounterEnum;
};
// memo可以使得只有props发生变化的时候才重新render
export default memo(function (props: Props) {
let [mode, setMode] = useState(props.mode);
let [counter, inc, dec] = useCounter(mode);
console.log('Child Render');
return (
<div>
<h2>{props.name}</h2>
<div>{'当前mode为:' + mode + ',当前值为:' + counter}</div>
<button onClick={inc}>加1</button>
<button onClick={dec}>减1</button>
<button
onClick={() => {
if (mode == 'fish') {
setMode('cat');
} else {
setMode('fish');
}
}}
>
{'切换mode'}
</button>
</div>
);
});
我们创建另外一种Button,允许用户切换mode
import { useState } from 'react';
import ChildButton from './Button';
export default function Parent() {
let [open, setOpen] = useState(false);
let [buttons, setButtons] = useState<number[]>([]);
console.log('Parent Render');
return (
<div>
<div>
<button
key="add"
onClick={() => {
setButtons((prevState) => [
...prevState,
prevState.length + 1,
]);
}}
>
添加一个
</button>
<button
key="clear"
onClick={() => {
setButtons([]);
}}
>
清除
</button>
<button
key="other"
onClick={() => {
setOpen((prevOpen) => !prevOpen);
}}
>
状态:{open ? '打开' : '关闭'}
</button>
</div>
{buttons.map((id) => {
return (
<ChildButton key={id} name={'按钮' + id} mode={'fish'} />
);
})}
</div>
);
}
最后,创建主页面代码
我们从测试中可以看到,只对mode为cat的计数器自增,那么只会触发2次render,而不是4次render,Hook依然提供了更好的性能。
8 反模式
代码在这里
8.1 不稳定的Hook
import { useState } from 'react';
//所有的Hook都不应该放在条件语句或者for循环中,它们都应该在代码前面首先声明使用
//因为Hook的实现依赖于声明的顺序
//Rendered more hooks than during the previous render.
export default function () {
let mode = 'nothing';
let [counter, setCounter] = useState(0);
if (counter > 0) {
let [innerMode, setMode] = useState('cat');
mode = innerMode;
}
return (
<div>
<div>当前计数为:{counter}</div>
<button
onClick={() => {
setCounter((prevState) => prevState + 1);
}}
>
+
</button>
{counter > 0 ? <div>mode</div> : null}
</div>
);
}
切勿在for循环或者if语句中使用Hook,Hook应该是稳定不变的,这样会让React找不到Hook对应的哪个组件。
8.2 使用Snapshot数据的闭包
在闭包里面使用Snapshot数据,这是Class组件的用户切换到Hook组件中最常遇到的问题。
8.2.1 问题
import { useEffect } from 'react';
import { useState } from 'react';
//在任何的callback或者effect中,都不应该使用snapshot的数据,snapshot的数据只能用于渲染
//这不是一个bug,也不是一个设计问题
//因为callback里面的就不能依赖于之前的state来执行业务逻辑
//站在redux的角度,callback只能发送action,在action里面执行业务逻辑,最后由action触发store的变化,引起view的变化
export default function () {
let [counter, setCounter] = useState(0);
useEffect(function () {
//我们期望每秒将计数器递增1,但实际是不行的
//因为effect是在一个闭包,每次都会捕捉counter这个局部变量,而这个变量仅在组件初始化时捕捉了,初值为0,因此定时器每次都是设置为1
let interval = setInterval(function () {
setCounter(counter + 1);
}, 1000);
return () => {
clearInterval(interval);
};
}, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
return (
<div>
<div>当前计数为:{counter}</div>
</div>
);
}
我们在useEffect中使用一个定时器,我们期望组件启动以后,计数器会自增
但实验证明,计数器在自增到1以后就不变了。这是因为闭包使用了空数组依赖,useEffect仅使用了首次创建的闭包,而这个闭包而是仅仅捕捉首次的counter实例,这个实例的值就是0。因此,在往后的每次interval里面,都是将0递增为1,然后传入到setCounter里面。
在使用Hook组件的时候,一个要转换的思维是,在Hook组件里面,每次render,数据,闭包,每次都是重新生成出来的。如果你用了依赖来限制闭包次数,那么这个闭包只会捕捉到某一次render的数据快照值,而不是数据的最新值。
8.2.2 糟糕的修正
import { useCallback, useRef } from 'react';
import { useEffect } from 'react';
import { useState } from 'react';
//第一种改进方法,这种方法是糟糕的,虽然能用
export default function () {
let [refresh, setRefresh] = useState(false);
let counterRef = useRef(0);
useEffect(function () {
let interval = setInterval(function () {
//ref在每次render都是不变的,变化的仅仅是ref.current
//因此,我们能在ref.current中获取最新值,而不是快照值
//这种方法相当的Hack,不推荐这样用,也会打破view到store的单向流
counterRef.current = counterRef.current + 1;
//强行刷新
setRefresh((prevState) => !prevState);
}, 1000);
return () => {
clearInterval(interval);
};
}, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
return (
<div>
<div>当前计数为:{counterRef.current}</div>
</div>
);
}
一个糟糕的修正是,强行使用useRef来存储状态,因为useRef每次返回的数据实例都不是不变的,变化的是ref.current,然后用另外一个状态来强行刷新页面。这种方法依然是企图用数据的最新值的思维来写代码,写出来的代码不优雅,也会破坏state状态的比较。
8.2.3 不太好的修正
import { useEffect } from 'react';
import { useState } from 'react';
//第二种改进方法,勉强过得去
//将业务捕捉在setCounter里面,这样每次都能获取到最新值,但是对于跨状态的业务组件会出问题
export default function () {
let [counter, setCounter] = useState(0);
useEffect(function () {
let interval = setInterval(function () {
setCounter((prevState) => {
return prevState + 1;
});
}, 1000);
return () => {
clearInterval(interval);
};
}, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
return (
<div>
<div>当前计数为:{counter}</div>
</div>
);
}
一个不太好的修正是,在setCounter里面传入参数,这样每次取得最新值,才执行。但是
- 将数据的修改逻辑散落在view层的各个位置,维护性并不好
- 当一次修改是需要多个状态的最新值的时候,这种方法就会无能为力。
8.2.4 好的修正
import { useEffect, useReducer } from 'react';
import { useState } from 'react';
//第三种改进方法,这种不错,对于只是内部使用的状态,不需要跨组件共享的状态最好
//将业务捕捉在setCounter里面,
export default function () {
let [counter, dispatch] = useReducer((state: number, action: string) => {
if (action == 'inc') {
return state + 1;
}
return state;
}, 0);
useEffect(function () {
let interval = setInterval(function () {
dispatch('inc');
}, 1000);
return () => {
clearInterval(interval);
};
}, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
return (
<div>
<div>当前计数为:{counter}</div>
</div>
);
}
这种方法,用useReducer来封装数据的修改逻辑,这种方法更好,但依然逃不掉一个问题是:
- 当一次修改是需要多个状态的最新值的时候,这种方法就会无能为力。
8.2.4 极好的修正
import { useEffect, useState } from 'react';
import EventEmiter from './EventEmiter';
class CounterStore extends EventEmiter<number> {
constructor() {
super(0);
}
public inc = () => {
this.set(this.get() + 1);
};
public dec = () => {
this.set(this.get() - 1);
};
}
let store = new CounterStore();
function useCounter() {
let [counter, setCounter] = useState(store.get());
useEffect(function () {
let emiterId = store.subscribe((data) => {
setCounter(data);
});
return () => {
store.unsubscribe(emiterId);
};
}, []);
return [counter, store.inc, store.dec] as const;
}
//第四种方法,这种方法是最好的,允许在跨组件中共享状态,而且清晰明了,同时避免在view层写业务逻辑
export default function () {
let [counter, inc, dec] = useCounter();
useEffect(function () {
let interval = setInterval(function () {
inc();
}, 1000);
return () => {
clearInterval(interval);
};
}, []); //组件挂载时只启动一次定时器,所以依赖是个空数组
return (
<div>
<div>当前计数为:{counter}</div>
</div>
);
}
这种方法是最好的,当数据移到一个固定的store存放,而这个store里面就已经存放好了数据的最新值。注意这个方法里面,store数据与组件数据是分离的,组件数据是通过store的subscribe来同步数据的。因此,我们可以在副作用代码里面,安全地获取store数据,并且能安全地确信store里面的数据都是最新值,而不是快照值,这样我们就能在副作用代码里面用回我们熟悉的命令式的编程方法。
而且,这种方法,对于需要跨store的最新值数据来更新,依然毫无压力。另外,这个方法也提供了跨组件通信的方法。
9 useImperativeHandle
Class Component有实例的说法,它是用类创建出来的,每个实例都有自身的成员变量,和成员方法。但是Function Component没有实例,它仅仅就是一个函数。如果我们获取了一个Fuction Component的ref,我们会得到什么。我们看一下吧。
代码在这里
9.1 forwardRef
import { ChangeEventHandler, LegacyRef, useEffect } from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback, forwardRef } from 'react';
type MyInputProps = {
value: string | undefined;
onChange: ChangeEventHandler<HTMLInputElement>;
};
const MyInput = forwardRef<HTMLInputElement, MyInputProps>((props, ref) => {
//将ref直接透传到input组件上面
return (
<div>
<h1>我是Input</h1>
<input
ref={ref}
style={{ border: '1px solid black' }}
value={props.value}
onChange={props.onChange}
/>
</div>
);
});
export default function Sample1() {
const [state, setState] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
return (
<div>
<div>你好</div>
<MyInput
ref={inputRef}
value={state}
onChange={(e) => {
setState(e.target.value);
}}
/>
<button
onClick={() => {
inputRef.current?.focus();
}}
>
获取焦点
</button>
<input value="测试2" />
</div>
);
}
要点如下:
- 一个函数组件能传入ref的话,需要用forwardRef包装起来。
- 因为ref是在多次render中都不变的实例,所以,它能直接传入到子组件的ref中,实现透传ref的目的。
9.2 useImperativeHandle
import {
ChangeEventHandler,
LegacyRef,
useEffect,
useImperativeHandle,
} from 'react';
import { useRef } from 'react';
import { memo, useState, useCallback, forwardRef } from 'react';
type MyInputProps = {
value: string | undefined;
onChange: ChangeEventHandler<HTMLInputElement>;
};
type MyInputRef = {
myFocus: () => void;
};
const MyInput = forwardRef<MyInputRef, MyInputProps>((props, ref) => {
const myRef = useRef<HTMLInputElement>(null);
//创建组件自身的ref,赋予更多的灵活性
useImperativeHandle(ref, () => ({
myFocus: () => {
myRef.current?.focus();
},
}));
return (
<div>
<h1>我是Input2</h1>
<input
ref={myRef}
style={{ border: '1px solid black' }}
value={props.value}
onChange={props.onChange}
/>
</div>
);
});
export default function Sample1() {
const [state, setState] = useState('');
const inputRef = useRef<MyInputRef>(null);
return (
<div>
<div>你好</div>
<MyInput
ref={inputRef}
value={state}
onChange={(e) => {
setState(e.target.value);
}}
/>
<button
onClick={() => {
inputRef.current?.myFocus();
}}
>
获取焦点2
</button>
<input value="测试2" />
</div>
);
}
要点如下:
- 如果我们需要将ref赋值自身的组件的方法,我们可以使用useImperativeHandle。默认情况下,它会在每次render的时候,重新计算ref的方法,并且赋值到ref.current中。
- 在非默认情况下,我们可以使用第三个参数deps,控制首次component挂载的时候,或者控制在某些state改变的时候,重新计算ref的方法,并且赋值到ref.current中。
10 总结
我觉得Hook总体还是不错的,它给与了复用业务逻辑的另外一种方法。可就是容易写错,快照值与最新值经常混淆,对开发者的要求更高了。最后,记住两点:
- 不要声明不稳定的Hook顺序
- 不要在闭包里面使用快照数据
参考资料:
- 本文作者: fishedee
- 版权声明: 本博客所有文章均采用 CC BY-NC-SA 3.0 CN 许可协议,转载必须注明出处!