Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性,无需转化成类组件
// hook 组件 function Counter() { const [count, setCount] = useState(0); const log = () => { setCount(count + 1); setTimeout(() => { console.log(count); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> ); } // 等效的类组件 class Counter extends Component { state = { count: 0 }; log = () => { this.setState({ count: this.state.count + 1, }); setTimeout(() => { console.log(this.state.count); }, 3000); }; render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={this.log}>Click me</button> </div> ); } }
快速点击下的情况下,想想 Hook 组件和函数式组件控制台打印出来的是什么?
类组件打印出来的是 3 3 3undefinedClass 组件的 state 是不可变的,通过 setState 返回一个新的引用,this.state 指向一个新的引用undefinedsetTimeout 执行的时候,通过 this 获取最新的 state 引用,所以这个输出都是 3函数组件打印的结果是 0 1 2undefined函数组件闭包机制,函数组件每一次渲染都有独立的 props 和 stateundefined每一次渲染都有独立的事件处理函数undefined每一次渲染的状态不会受到后面事件处理的影响既然每次渲染都是一个独立的闭包,可以尝试代码拆解函数式组件的渲染过程
// 第一次点击 function Counter() { const [0, setCount] = useState(0); const log = () => { setCount(0 + 1); // 只能获取这次点击按钮的 state setTimeout(() => { console.log(0); }, 3000); }; } // 第二次点击 function Counter() { const [1, setCount] = useState(0); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(1); }, 3000); }; } // 第三次点击 function Counter() { const [2, setCount] = useState(0); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(2); }, 3000); }; }三次点击,共 4 次渲染,count 从 0 变为 3页面第一次渲染,页面看到的 count = 0第一次点击,事件处理器获取的 count = 0,count 变成 1, 第二次渲染,渲染后页面看到 count = 1,对应上述代码第一次点击第二次点击,事件处理器获取的 count = 1,count 变成 2, 第三次渲染,渲染后页面看到 count = 2,对应上述代码第二次点击第三次点击,事件处理器获取的 count = 2,count 变成 3, 第四次渲染,渲染后页面看到 count = 3,对应上述代码第三次点击
有种比较简单并且能解决问题的方案,借用 useRef
useRef 返回一个可变的 ref 对象,其 current 属性被初始化为传入的参数(initialValue)useRef 返回的 ref 对象在组件的整个生命周期内保持不变,也就是说每次重新渲染函数组件时,返回的 ref 对象都是同一个useRef 可以类比成类组件实例化后的 this,在组件没有销毁的返回的引用都是同一个function Counter() { const count = useRef(0); const log = () => { count.current++; setTimeout(() => { console.log(count.current); }, 3000); }; return ( <div> <p>You clicked {count.current} times</p> <button onClick={log}>Click me</button> </div> ); }这样修改一下,控制台输出的确实是 3 3 3? 既然 Ref 对象整个生命周期都不变,修改 current 属性也只是修改属性,那除了打印,这里的 You clicked 0 times ,点击三次,会变成 3 么?显然不能,这个组件没有任何的属性和状态改变,会重新渲染才怪,所以这里虽然点击了 3 次,但是不会像 useState 一样,渲染 4 次,这里只会渲染 1 次,然后看到的都是 You clicked 0 times修复一个问题把另外一个更大的问题引进来,这很程序员。。。
通过 useRef 虽然能解决打印的问题,但是页面渲染是不对的,这里还是使用 useState 的方案,配合 useEffect 可以实现我们想要的效果
function useEffect(effect: EffectCallback, deps?: DependencyList): void;看下 useEffect 的签名,effect 是函数类型,并且必填, 还有第二个可选参数,类型是只读数组useEffect 是处理副作用的,其执行时机在 每次 Render 渲染完毕后,换句话说就是每次渲染都会执行,在真实 DOM 操作完毕后。
配合这个 hook, 如果每次 state 改变后渲染完之后,把 ref 里面的值更新,然后控制台打印 ref 的值,参考React实战视频讲解:进入学习
function Counter() { const [count, setCount] = useState(0); const currentCount = useRef(count); useEffect(() => { currentCount.current = count; }); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> ); }
这样子写可以符合我们的预期效果,页面展示从 0 1 2 3, 然后控制台输出 3 3 3,然后我们拆解下渲染过程。
三次点击,共 4 次渲染,count 从 0 变为 3页面初始化渲染,count = 0, currentCount.current = 0, 页面显示 0, 渲染完成,触发 useEffect, currentCount.current = 0第一次点击,count = 0, 渲染完成后,count = 1, 页面显示 1,触发 useEffect,currentCount.current = 1第二次点击,count = 1, 渲染完成后,count = 2, 页面显示 2,触发 useEffect,currentCount.current = 2第三次点击,count = 2, 渲染完成后,count = 3, 页面显示 3,触发 useEffect,currentCount.current = 3三次点击完成,currentCount.current = 3,第四次渲染,页面看到 count = 3, setTimeout 中调用的是 currentCount 这个对象,输出都是 3type EffectCallback = () => void | (() => void | undefined);
useEffect 的回调函数可以返回空,也可以返回一个函数,如果返回一个函数的话,在 effect 执行回调函数的时候,会先执行上一次 effect 回调函数返回的函数
useEffect(() => { console.log('after render'); return () => { console.log('last time effect return'); }; });
这个 useEffect ,每次渲染完之后,控制台会先输出 last time effect return,然后再输出 after render
之前提到,useEffct 有两个参数,第二参数是个可选参数,是 effect 的依赖列表, React 根据这些列表的值是否有改变,决定渲染完之后,是否执行这个副作用的回调
如果不传这个参数,React 会认为这个 effect 每次渲染然之后都要执行,等同于 componentDidUpdate 这个生命周期无约束执行
useEffect(() => { currentCount.current = count; }); componentDidUpdate() { currentCount.current = this.state.count; }
如果这个参数是空数组,React 会认为组件内任何状态和属性改变,都不会触发这个 effect,相当于这个 effect 是仅仅在组件渲染完之后,执行一次,后面组件任何更新都不会触发这个 effect,等同 componentDidMount
useEffect(() => { currentCount.current = count; }, []); componentDidMount() { currentCount.current = this.state.count; }
如果配合 useEffect 回调函数的返回函数,可以实现类似 componentWillUnmount 的效果,因为如果是空数组的话,组件任何更新都不会触发 effect,所以回调函数的返回函数只能在组件销毁的时候执行
useEffect(() => { return () => { console.log('will trigger ar willUnmount') } }, []); componentWillUnmount() { console.log('will trigger ar willUnmount') }
如果依赖列表里面有值,则类似componentDidMount有条件约束更新,只有当上一次的状态和这次的不一样,才执行
useEffect(() => { currentCount.current = count; }, [count]); componentDidUpdate(prevProps, prevState) { if (prevState.count !== this.state.count) { currentCount.current = this.state.count; } }
假设组件需要在初始化的时候,定义一个定时器,让 count 自增,自然而然的可以写出以下的代码
// 初始化的 count = 0 useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); }, []); componentDidMount() { setInterval(() => { this.setState({ count: this.state.count + 1 }); }, 1000); }
但是实际运行的时候,类组件展示是对的,函数组件从 0 递增到 1 之后,页面渲染就再也不变了
之前有提过,类组件因为有 this 这个引用,很容易通过 state 拿到最新的值函数组件每次渲染都是独立的闭包,这里因为写的依赖值是 [],所以只有首次渲染后,才会这行这个 effect,首次渲染后, count 就是 0,所以 setCount(count + 1) 每次都是执行 setCount(0 + 1),所以定时器工作是正常的,不过取的值有问题。闭包问题,大多发生在,有些回调函数执行,依赖到组件的某些状态,但是这些状态并没有写到 useEffect 的依赖列表里面。导致执行回调函数的时候,拿到组件的状态不是最新的。
主要的场景有:
定时器事件监听的回调各种 Observer 的回调这些场景,通常只要在组件初始化渲染完之后,定义一次回调函数就好,但是如果回调函数依赖到组件的转态或者属性,这时候就要小心,闭包问题
function Router() {
const [state, setState] = useState<string>('');
useEffect(() => {
window.addEventListener<'hashchange'>(
'hashchange',
() => {
// 监听 hash 变化,这里依赖到 state
},
false
);
}, []);
}
例如这里的写法,在组件渲染完监听 hashchange ,回调函数是拿不到后续更新的 state 的,只能能到初始化时候的空字符串。
既然回调函数要每次都拿到最新的 state,可以监听 state 的变化,state 变化的时候,重新定义事件监听器,改写一下
function Router() {
const [state, setState] = useState<string>('');
useEffect(() => {
window.addEventListener(
'hashchange',
() => {
// 监听 hash 变化,这里依赖到 state
},
false
);
}, [state]);
}
以上代码能用,但是 state 每次改变,就会重新定义一个 hashchange 回调函数,但是上一次的 hashchange 的事件监听器并没有清除,代码能跑,但是内存泄漏也太严重了,可以配合 useEffect 回调函数返回的函数配合清掉上一次的事件监听器
function Router() {
const [state, setState] = useState<string>('');
useEffect(() => {
const callback = () => {};
window.addEventListener('hashchange', callback, false);
return () => window.removeEventListener('hashchange', callback, false);
}, [state]);
}
这样内存泄漏的问题被解决了,但是这种事情监听,正常来说设置一次就好,没必要重新定义,还有别的更好的方法么?
useState 返回的更新状态的函数,除了可以传一个值,还可以传一个回调函数,回调函数带一个参数,这个参数是最新的 state,像这样的话,之前那个定时器的例子,可以修改成这样。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// setCount(count + 1)
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
这里我们改了一行代码,setCount(count + 1) 改成了 setCount((c) => c + 1),这样修改之后,其实定时器回调已经没有依赖到 count 这个值了,由 setCount 里面的回调函数,返回最新的 count 的值,就是setCount((c) => c + 1),里面的 c.
同样的,对于事件监听器里面,我们也可以通过这个方式去获取最新的 state,但是这里有几个问题
这个回调函数,其实也只要获取最新的 state,所以在调用 setState 的时候,拿到最新的值的同时,记得把 setState 的值,设置成和当前同一个,如果没有返回,那调用 setState 之后, state 的值会变成 undefinedsetState 返回一个同样的值,会不会导致组件和它的子组件重新渲染?找了下文档说明是这样的:调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心看起来可行的,做一下简单的修改其实可以改成这样function Router() {
const [state, setState] = useState<string>('');
useEffect(() => {
const callback = () => {
let latestState = ‘’;
setState((stateCallback) => {
// stateCallback 是最新的 state
latestState = stateCallback;
// 记得把 stateCallback 返回去,不然 state 会被改成 undefined
return stateCallback;
});
// latestState 已经被赋值为最新的 state 了
};
window.addEventListener<'hashchange'>('hashchange', callback, false);
}, [])
}
这样基本就没问题了,做到了只定义了一次回调,然后也可以获取最新的 state,一举两得,但是还是有问题的
setState 回调函数如果不写 return stateCallback; 这段代码,会导致 state 莫名其妙被设置成 undefined ,而且非常不好发现,维护性太差setState 是用来改变组件的 state 的,不是让你这样用的的,虽然这样用完全没问题。但是可维护性太差了,如果你的代码被接手,别人就会疑惑这里为什么要这么写,无注释和变量命名太糟糕的情况下,代码可以维护性基本为 0设置一个同样的 state,虽然不会导致子组件重新渲染,但是本组件还是有可能重新渲染的,按官网的说法这个方案不完美。思路再发散一下?执行回调函数的时候,需要获取到最新的 state,能不能用一个不变的值缓存 state ? 等等?? 不变的值???
useRef的返回是在整个组件生命周期都是不变的一个对象,可以借助 useRef 来获得最新的 state。例如这个例子可以改成这样:
function Router() {
const [state, setState] = useState<string>('');
const stateRef = useRef<string>(state);
// 这样,可以把 stateRef 和最新的 state 绑定起来
stateRef.current = state;
// 或者这样,可以把 stateRef 和最新的 state 绑定起来
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
const callback = () => {
const latestState = stateRef.current;
};
window.addEventListener<'hashchange'>('hashchange', callback, false);
}, []);
}
stateRef.current 上面两种写法,都可以获得最新的 count,回调函数里面里面直接读取 stateRef.current 的值,可以拿到最新的 state 闭包问题的最优解,节本就是这样了。
例如以下的例子
// 函数组件 const Child = React.memo(() => { // count 参与页面渲染 const [count, setCount] = useState(0); // userInfo 不参与渲染 const userInfo = useRef(null); }); // 类组件 class Child extends React.PureComponent { constructor(props) { super(props); // 不参与渲染 this.userInfo = null; // 参与渲染的属性 this.state = { count: 0, }; } }
type EffectCallback = () => (void | (() => void | undefined)); return void | (() => void | undefined)
确定是没有返回或者返回一个函数,所以下面这种写法是有问题的,虽然也没有明显标明返回体,就是没有返回一样,但是这个回调函数是异步函数,异步返回默认返回一个 Promise 对象,所以这种写法是不提倡的
const [data, setData] = useState({ hits: [] }); useEffect(async () => { const result = await axios( 'url‘, ); setData(result.data);}, []);
为了规避这个问题,可以修改一下写法
useEffect(() => { const fetchData = async () => { const result = await axios('url'); setData(result.data); }; fetchData(); }, []);
把函数写进里面没什么问题,官方也推荐,但是万一我的副作用里面需要处理多个函数或者一个超长的函数的话,一个是不美观,一个是太难维护
这个适用可以利用 useCallback 把函数抽离出去,useCallback 返回一个记忆化的函数,当且仅当依赖列表有任何属性改变的时候,它才会返回一个新的函数,所以这个特性比较适合传给子组件的回调函数
function Counter() { const [count, setCount] = useState(0); const getFetchUrl = useCallback(() => { return 'https://v?query=' + count; }, [count]); useEffect(() => { getFetchUrl(); }, [getFetchUrl]); return <h1>{count}</h1>; }
这里如果 count 改变的时候,getFetchUrl的值也会改变,从而导致 useEffect 也触发
React.memo() 返回一个记忆化的值,如果 React 内部会判定,如果重新渲染 props` 不相等,就会重新渲染,如果没有改变,就不会触发组件渲染
这个特性比较有用,因为如果父组件重新渲染的时候,子组件就会重新渲染,使用这个特性可以减少不必要的子组件重新渲染
const Child = memo((props) => { useEffect(() => { }, []) return ( // ... ) }, (prevProps, nextProps) => { // 判定相等的逻辑 // 假如某些属性的改变不需要重新渲染 // 可以编写这个函数 })
父组件用 useCallback 包裹函数,子组件用 memo 包裹组件,要不就都不用
// 子组件 // callback 为父组件传过来的回调函数 const Child = ({ callback }) => {} // 子组件用 React.memo 包裹 export default React.memo(Child); // 父组件const Parent = () => { // 子组件的回调函数用 useCallback 包裹 const callback = React.useCallback(() => {}, []); return <Child callback={callback} /> };
export const Store = React.createContext(null); export default function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={{ dispatch, state }}> <Step /> <Count /> </Store.Provider> ); } export const Count = memo(() => { const { state, dispatch } = useContext(Store); const setCount = () => dispatch({ type: 'changeCount' }); return ( <> <span>count: {state.count}</span> <button onClick={setCount}>change count</button> </> ); }); export const Step = memo(() => { const { state, dispatch } = useContext(Store); const setCount = () => dispatch({ type: 'changeStep' }); return ( <> <span>count: {state.step}</span> <button onClick={setStep}>change step</button> </> ); });上面的组件,count 或者 step 任意这个属性改变,都会导致两个子组件重新渲染,这显然是不对的。
useMemo 和 memo 一样,返回一个记忆化的值,如果依赖项没有改变,会返回上一次渲染的结果,它和 useCallback 的差别就在一个是返回记忆化的函数,一个是返回记忆化的值,如果 useMemo 的回调函数执行返回一个函数,那它的效果和 useCallback 是一样的。
因而上面的组件可以改一下,下面这种写法就可以防止任意一个属性改变会导致两个子组件重新渲染的问题
export const Count = () => { const { state, dispatch } = useContext(Store); const setCount = () => dispatch({ type: 'changeCount' }); return useMemo( () => ( <> <span>count: {state.count}</span> <button onClick={setCount}>change count</button> </> ), [state.count] ); }; export const Step = () => { const { state, dispatch } = useContext(Store); const setStep = () => dispatch({ type: 'changeStep' }); return useMemo( () => ( <> <span>step: {state.step}</span> <button onClick={setStep}>change step</button> </> ), [state.step] ); };
React Hook 有自定义 Hook,React 类组件有高阶组件或者渲染属性
有个比较常见的场景,进入页面需要调用后端接口的问题,如果每个组件都写一次,很繁琐,假设处理数据的接口长这样子
interface Response<T> {
/** 是否有错误 */
hasError: boolean;
/** 是否有数据 */
hasData: boolean;
/** 是否请求中 */
Loading: boolean;
/** 请求回来的数据 */
data: T;
}
高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。这种组件复用还挺常见的,比如 React-redux 里面的 connect,React Router 的 withRouter
它可以做到:
属性代理,比如多个组件都使用到的公共属性,注入属性包裹组件,比如将组件包裹在写好的容器里面渲染挟持,比如权限控制用处
代码复用性能监测 打点权限控制,按照不懂的权限等级,渲染不同的页面按上面请求的需求,做一个组件渲染完之后,就立即开始请求初始数据
function requestWrapper(options) { return (Component) => { return class WapperComponent extends React.Component { constructor(props) { super(props); this.state = { loading: false, hasError: false, hasData: false, data: null, }; } httpRequest = async () => { this.setState({ loading: true }); // 这里做一些请求工作,我这里就不写了,没必要 this.setState({ hasData: true }); this.setState({ hasError: false }); this.setState({ data: { /** 这里是请求回来的数据 */ }, }); this.setState({ loading: false }); }; componentDidMount() { this.httpRequest(); } render() { // 透传外部传过来的属性,然后合并 this.state,传给包裹组件 const combineProps = { ...this.props, ...this.state }; return this.state.loading ? ( <Loading /> ) : ( <Component {...combineProps} /> ); } }; }; }
使用方面,高阶组件可以修饰类组件或者函数组件
function Page(props) { // props 包含 loading hasError hasData data prop1 // 其中 prop1 来源于外部属性,其他的属性来源于包裹的组件 } // 类组件使用,使用装饰器 @requestWrapper({url: '', param: {} }) class ClassComponent extends React.PureComponent { } // 类组件使用,不适用装饰器 export default requestWrapper({url: '', param: {} })(ClassComponent); // 函数式组件使用 const WrapPage = requestWrapper({ url: '', param: {} })(Page); export default WrapPage; // 使用 <WrapPage prop1="1" />;
自定义 Hook 的编写,一个简单的数据请求的 Hook
function useRequest(options) { const [loading, setLoading] = useState(false); const [hasError, setHasError] = useState(false); const [hasData, setHasData] = useState(false); const [data, setData] = useState(null); useEffect(() => { async function reqeust() { setLoading(true); /** 这里依旧是请求,没写 */ setHasError(false); setHasData(true); setData({ /** 请求回来的数据 */ }); setLoading(false); } reqeust(); }, []); return { loading, hasError, hasData, data }; }
自定义 hook 只能在函数式组件使用,不能在类组件里面用
function Page(props) { // 这次的 props 只有 prop1 这个属性 const { loading, hasError, hasData, data } = useRequest({ url: ‘’, param: {}, }); } <Page prop1={1} />;
class Button extends React.PureComponent { static defaultProps = { type: "primary", onChange: () => {} }; } // 不论是函数式还是类都可以这么玩,这也是类静态属性的另外一种写法 Button.defaultProps = { type: "primary", onChange: () => {} }; function Button({ type, onChange }) {} // 这样写看起来没问题,但是实际上,如果父组件没传 onChange,onChange // 每次组件渲染都会生成一个新的函数,引用类型都有这个问题 function Button({ type = "primary", onChange = () => {} }) {} // 这很OK,你可真是个小机灵鬼 const changeMet = () => {} function Button({ type = “primary”, onChange = changeMet }) {}
复用组件状态逻辑难
依赖自定义的 Hook,可以解决组件状态和逻辑复用的问题,但是自定义 Hook 编写需要对 Hook 运行机制非常了解,门槛并不比高阶组件低生命周期带来的负面影响,逻辑拆分严重
生命周期拆分逻辑的问题,在 Hook 里面切实被解决了,不会存在同一个逻辑被拆分在 N 个生命周期里面了This 的指向问题
这个问题在 Hook 里面也是解决了,因为函数没有 this,就不会有 this 的问题,但是相对的,如果需要一个不变的对象,请使用 useRef