目录
- 前言
- useCallback
- 官方描述
- 官方源码
- 手写简易版源码
- 使用案例【常规版】
- 浅比较
- 举一反二【不更新】
- 举一反三【频繁更新】
- useRef
- 举一反四【来,再捋一捋】
- useEffect
- 官网描述
- 使用案例
- 手写源码
- useEffect
- 性能优化tips
- 参考文章
前言
阅读本文章需要对 React hooks 中 useState 和 useEffect 有基础的了解。
useCallback
官方描述
划重点:
- 传入一个回调函数和一个依赖数组
- 返回值是这个回调函数的被缓存的函数实例
- 所谓“memorized版本”,即依赖发生变化的时候,useCallback返回一个新的这个回调函数的被缓存的实例,否则就一只返回上一次被缓存的实例。
- 何时使用才是好的、有用的?
- 当该回调函数作为参数传递给子组件,并且该子组件用了React.memo或者shouldcomponentupdate这些依赖引用相等判断来阻止不必要的渲染时,可在父组件用useCallback配合实现子组件的这项阻止渲染的性能优化。
- 依赖数组不会作为参数传入回调函数。
- 每一项出现在回调函数里的value应该出现在依赖数组中。【关于这一点,其实有争议!参考这里】
官方源码
export function useCallback<T>(
callback: T,
inputs: Array<mixed> | void | null,
): T {
currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
workInProgressHook = createWorkInProgressHook();
// 以上两句几乎在hooks中都用到了
const nextInputs =
inputs !== undefined && inputs !== null ? inputs : [callback]; // 新的依赖项,如果为undefined或者null的话, 则用callback,否则用依赖项
const prevState = workInProgressHook.memoizedState;
if (prevState !== null) {
const prevInputs = prevState[1]; // 依赖项
if (areHookInputsEqual(nextInputs, prevInputs)) { //对比依赖项,相同从memoizedState获取
return prevState[0]; // 返回上一个memoizedState的callback
}
}
workInProgressHook.memoizedState = [callback, nextInputs]; // 首次或非首次不相同存入memoizedState
return callback; // 返回callback
}
手写简易版源码
我们知道useCallback有两个参数,所以可以先写
function useCallback(callback,dependencies){
}
跟useState一样,我们同样需要用全局变量把callback和dependencies保存下来。
let lastCallback;
let lastCallbackDependencies;
function useCallback(callback,dependencies){
}
首先useCallback会判断我们是否传入了依赖项,如果没有传的话,说明要每一次执行useCallback都返回最新的callback
let lastCallback;
let lastCallbackDependencies;
function useCallback(callback,dependencies){
if (lastCallbackDependencies) {
// 非第一次且之前传入过依赖项
} else {
// 第一次或者非第一次但没有传入依赖项(即useCallback(()=>{})
}
return lastCallback;
}
所以当我们没有传入依赖项的时候,实际上可以把它当作第一次执行,因此,要把lastCallback和lastCallbackDependencies重新赋值
let lastCallback;
let lastCallbackDependencies;
function useCallback(callback,dependencies){
if (lastCallbackDependencies) {
} else {
// 第一次或者非第一次但没有传入依赖项(即useCallback(()=>{})
lastCallback = callback;
lastCallbackDependencies = dependencies;
}
return lastCallback;
}
当有传入依赖项的时候(即dependencies为[XXXXX]或[]) ,需要看看新的依赖数组的每一项和来的依赖数组的每一项的值是否相等
let lastCallback;
let lastCallbackDependencies;
function useCallback(callback,dependencies){
if (lastCallbackDependencies) {
let changed = !dependencies.every((item,index)=>{
return item === lastCallbackDependencies[index];
});
} else {
// 第一次(即lastCallbackDependencies为undefined)
// 或者非第一次但没有传入依赖项(即dependencies为[])
lastCallback = callback;
lastCallbackDependencies = dependencies;
}
return lastCallback;
}
function Child({data}) {
console.log("天啊,我怎么被渲染啦,我并不希望啊");
return <div>child</div>;
}
当依赖项有值改变的时候,我们需要对lastCallback和lastCallbackDependencies重新赋值
import React, {useState,memo} from 'react';
import ReactDOM from 'react-dom';
let lastCallback;
let lastCallbackDependencies;
function useCallback(callback,dependencies){
if (lastCallbackDependencies) {
let changed = !dependencies.every((item,index)=>{
return item === lastCallbackDependencies[index];
});
if (changed) {
lastCallback = callback;
lastCallbackDependencies = dependencies;
}
} else {
// 第一次(即lastCallbackDependencies为undefined)
// 或者非第一次但没有传入依赖项(即dependencies为[])
lastCallback = callback;
lastCallbackDependencies = dependencies;
}
return lastCallback;
}
function Child({data}) {
console.log("天啊,我怎么被渲染啦,我并不希望啊");
return <div>child</div>;
}
Child = memo(Child);
function App() {
const [count, setCount] = useState(0);
const addClick = useCallback(() => { console.log("addClick") }, []);
return (
<div>
<Child data={123} onClick={addClick}></Child>
<button onClick={() => { setCount(count + 1)}}> 增加 </button>
</div>
);
}
function render() {
ReactDOM.render( <App />, document.getElementById('root') );
}
render();
使用案例【常规版】
在线代码: Code Sandbox
import React, { useState, useCallback } from 'react';
import Button from './Button';
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const [count3, setCount3] = useState(0);
const handleClickButton1 = () => {
setCount1(count1 + 1);
};
const handleClickButton2 = useCallback(() => {
setCount2(count2 + 1);
}, [count2]);
return (
<div>
<div>
<Button onClickButton={handleClickButton1}>Button1</Button>
</div>
<div>
<Button onClickButton={handleClickButton2}>Button2</Button>
</div>
<div>
<Button onClickButton={() => { setCount3(count3 + 1); }}>
Button3
</Button>
</div>
</div>
);
}
handleClickButton系列作为参数传给子组件Button,并且Button利用了React.memo判断参数引用相等来阻止不必要的渲染特性。
// Button.jsx
import React from 'react';
const Button = ({ onClickButton, children }) => {
return (
<>
<button onClick={onClickButton}>{children}</button>
<span>{Math.random()}</span>
</>
);
};
export default React.memo(Button);
在案例中可以分别点击Demo中的几个按钮来查看效果:
- 点击 Button1 的时候只会更新 Button1 和 Button3 后面的内容;
- 点击 Button2 会将三个按钮后的内容都更新;
- 点击 Button3 的也是只更新 Button1 和 Button3 后面的内容。
这里或许会注意到 Button 组件的 React.memo 这个方法,此方法内会对 props 做一个浅层比较,如果如果 props 没有发生改变,则不会重新渲染此组件。
浅比较
shallowEqual的源码
// 用原型链的方法
const hasOwn = Object.prototype.hasOwnProperty
// 这个函数实际上是Object.is()的polyfill
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y
} else {
return x !== x && y !== y
}
}
export default function shallowEqual(objA, objB) {
// 首先对基本数据类型的比较
if (is(objA, objB)) return true
// 由于Obejct.is()可以对基本数据类型做一个精确的比较, 所以如果不等
// 只有一种情况是误判的,那就是object,所以在判断两个对象都不是object
// 之后,就可以返回false了
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false
}
// 过滤掉基本数据类型之后,就是对对象的比较了
// 首先拿出key值,对key的长度进行对比
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
// 长度不等直接返回false
if (keysA.length !== keysB.length) return false
// key相等的情况下,在去循环比较
for (let i = 0; i < keysA.length; i++) {
// key值相等的时候
// 借用原型链上真正的 hasOwnProperty 方法,判断ObjB里面是否有A的key的key值
// 属性的顺序不影响结果也就是{name:'daisy', age:'24'} 跟{age:'24',name:'daisy' }是一样的
// 最后,对对象的value进行一个基本数据类型的比较,返回结果
if (!hasOwn.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])) {
return false
}
}
return true
}
回到最开始的问题,浅比较为什么没办法对嵌套的对象比较?
由上面的分析可以看到,当对比的类型为Object的时候并且key的长度相等的时候,浅比较也仅仅是用Object.is()对Object的value做了一个基本数据类型的比较,所以如果key里面是对象的话,有可能出现比较不符合预期的情况,所以浅比较是不适用于嵌套类型的比较的。
上面点击Button1引发handleClickButton1,触发setCount1(count1 + 1);,count1变化(状态或者props更新)引发App重新渲染,App每次渲染都会重新生成一个handleClickButton1函数实例。该新的函数实例作为参数传递给子组件Button,由于这个新的函数实例引用变化,React.memo做引用相等判断,对比后发现对象 props 改变则引发子组件重新渲染。Button3同理。
而此处useCallback由于count2没有变化,故返回的是被缓存的函数实例,React.memo做引用相等判断,对比后发现对象 props 没有改变则对应子组件跳过重新渲染。
const handleClickButton2 = useCallback(() => {
setCount2(count2 + 1);
}, [count2]);
上述代码我们的方法使用 useCallback 包装了一层,并且后面还传入了一个 [count2] 变量,这里 useCallback 就会根据 count2 是否发生变化,从而决定是否返回一个新的函数,函数内部作用域也随之更新。
举一反二【不更新】
import React, { useState, useCallback } from 'react';
import Button from './Button';
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const [count3, setCount3] = useState(0);
const handleClickButton1 = () => {
setCount1(count1 + 1);
};
const handleClickButton2 = useCallback(() => {
setCount2(count2 + 1);
}, []);
return (
<div>
<div>
<Button onClickButton={handleClickButton1}>{count1}</Button>
</div>
<div>
<Button onClickButton={handleClickButton2}>{count2}</Button>
</div>
<div>
<Button onClickButton={() => { setCount3(count3 + 1); }} >
{count3}
</Button>
</div>
</div>
);
}
我们调整了一下代码,将 useCallback 依赖的第二个参数变成了一个空的数组,这也就意味着这个方法没有依赖值,将不会被更新。且由于 JS 的静态作用域导致此函数内 count2 永远都 0。【参考 JavaScript深入之词法作用域和动态作用域】
可以点击多次 Button2 查看变化,会发现 Button2 后面的值只会改变一次。因为上述函数内的 count2 永远都是 0,就意味着每次都是 0 + 1,Button 所接受的 count props,也只会从 0 变成 1且一直都将是 1,而且 handleClickButton2 也因没有依赖项不会返回新的方法,就导致 Button 组件只会因 count 改变而更新一次。
举一反三【频繁更新】
const [text, setText] = useState('');
const handleSubmit = useCallback(() => {
// ...
}, [text]);
return (
<form>
<input value={text} onChange={(e) => setText(e.target.value)} />
<OtherForm onSubmit={handleSubmit} />
</form>
);
上述例子中可以看到我们的 handleSubmit 会依赖 text 的更新而去更新,在 input 的使用中 text 的变化肯定是相当频繁的,假如这时候我们的 OtherForm 是一个很大的组件,必须要进行优化这个时候可以使用 useRef 来帮忙。
useRef
划重点:
useRef返回值是一个object对象,其有一个.current属性,该属性初始值为传入的initialValue参数。- 你可以理解为
useRef像一个盒子一样,装有一个可变的.current属性。
- 你可以理解为
useRef的这个object对象返回值在组件的整个全生命周期内保持persist(不知道咋翻译为好,可以理解为数据库里保持的同一份不变的数据)。useRef创建一个plain JS对象,它和你自行创建的类似{current: ...}对象的区别就是useRef在每一次渲染时都提供给你同一个它所创建对象的引用。- 常见的一道面试题:如何保持点击button,button上的数字在多次渲染中保持状态不清零?把
.current属性设置为该数字状态,button上的显示为.current属性的值。
- 改变
.current属性不会引发重新渲染。 - 常见的用法:利用
ref属性来access the DOM。- 如果你通过
<div ref={myRef} />向React传递一个ref对象,React会自动赋值该ref对象的.current属性为对应的DOM节点(不管这个节点怎么变)。 - 当 React 将引用附加或分离到 DOM 节点时,如果你想运行一些代码,你可以用How can I measure a DOM node?
- 如果你通过
回到上面的例子,我们如何改?
const textRef = useRef('');
const [text, setText] = useState('');
const handleSubmit = useCallback(() => {
console.log(textRef.current);
// ...
}, [textRef]);
return (
<form>
<input value={text} onChange={(e) => {
const { value } = e.target;
setText(value)
textRef.current = value;
}} />
<OtherForm onSubmit={handleSubmit} />
</form>
);
使用 useRef 可以生成一个变量让其在组件每个生命周期内都能访问到,且 handleSubmit 并不会因为 text 的更新而更新,也就不会让 OtherForm 多次渲染。
举一反四【来,再捋一捋】
function Home() {
const [state, setState] = useState("initialization");
//普通函数
const fn = () => {
console.log("普通函数输出:", state);
};
//记忆函数,这里第二个参数设置为[],表示不依赖任何值,只在组件初始化时创建memoizedFn,组件更新时不更新memoizedFn
const memoizedFn = useCallback(() => {
console.log("memoized函数输出:", state);
}, []);
//组件Home,mount 和 update时都执行
fn();
memoizedFn();
const update = () => {
setState("initialization" + new Date().getTime());
};
return (
<div>
<div>state值:{state}</div>
<button onClick={update}>改变state</button>
</div>
);
}
1. 在组件初始化时,fn 和 memoizedFn 都会拿取到state的最新值initialization吗?
A: 可以看到在初始化时,可以看到普通函数 fn 和 记忆函数memoizedFn 都打印出了state的初始值:inittialization
2. 在组件更新后只要依赖的项没有发生变化,那么memoizedFn输出的结果永远是旧值?
A: 可以看到当组件更新后,普通函数fn 读取到了最新的state值:initialization1640850521426,而memoizedFn函数,输出的还是组件创建时的旧state值:inittialization。这就意味着当依赖项(我们这里设置的:[]),没有改变时无论我们执行多少次memoizedFn函数,始终输出的都是上一次更新或者时创建时的旧值,在memoizedFn使用的所有变量(state),都是被缓存的旧值。
3. 如果使用的是非响应式(useState())的普通变量,memoizedFn还会保留它吗?
A:
function Home() {
const [state, setState] = useState("initialization");
let normal = "init noraml"; //增加normal变量
//普通函数
const fn = () => {
console.log("普通函数输出:", state, "noraml:", normal); //增加输出
};
//记忆函数
const memoizedFn = useCallback(() => {
console.log("memoized函数输出:", state, "noraml:", normal); //增加输出
}, []);
//组件Home,mount 和 update时都执行
//...
const update = () => {
const date = new Date().getTime();
setState("initialization" + date);
normal = "init noraml" + date; //增加输出
};
//...
}
可以看到,在第一次更新state时,
memoizedFn 并没有缓存noraml的旧值init noraml,而在第二次更新时,使用的是第一次更新时的缓存值,这是一个很奇怪的点,官方上并没有给出答案,但是却推荐,不要在useCallback中使用外部的普通变量,尽量在useCallback类定义变量,以确保变量能得到我们预期的结果。
4. 多个memoizedFn嵌套使用,该如何缓存结果
A: 可以看出,在memoizedFn1中的执行的memoizedFn2,即便memoizedFn2中设置的依赖[state]发生更新,memoizedFn2读取的state仍是旧值,这就意味着在memoizedFn内部的函数,只要最外层的memoized不发生更新,那么内部函数使用的所有变量都为旧值。
function Home() {
const [state, setState] = useState("initialization");
//记忆函数1 无任何依赖
const memoizedFn1 = useCallback(() => {
console.log("memoized函数1 输出:", state);
console.log("memoized函数1调用memoizedFn2");
memoizedFn2();
}, []);
//记忆函数1 无任何依赖
const memoizedFn2 = useCallback(() => {
console.log("memoized函数2 输出:", state);
}, [state]);
//组件Home,mount 和 update时都执行
memoizedFn1();
console.log("直接调用memoizedFn2");
memoizedFn2();
const update = () => {
const date = new Date().getTime();
setState("initialization" + date); //使用setState改动state
console.log("=====组件更新=====");
};
return (
<div>
<div>state值:{state}</div>
<button onClick={update}>改变state</button>
</div>
);
}
而且嵌套useCallback 让代码可读性变差。如有下面代码:
const someFuncA = useCallback((d, g, x, y)=> {
doSomething(a, b, c, d, g, x, y);
}, [a, b, c]);
const someFuncB = useCallback(()=> {
someFuncA(d, g, x, y);
}, [someFuncA, d, g, x, y]);
useEffect(()=>{
someFuncB();
}, [someFuncB]);
在上面的代码中,变量依赖一层一层传递,最终要判断具体哪些变量变化会触发 useEffect 执行,是一件很头疼的事情。
其实这里不要用 useCallback,someFuncA和someFuncB又不满足会作为参数传递给子组件的条件,没必要要useCallback,直接裸写函数就好:
const someFuncA = (d, g, x, y)=> {
doSomething(a, b, c, d, g, x, y);
};
const someFuncB = ()=> {
someFuncA(d, g, x, y);
};
useEffect(()=>{
someFuncB();
}, [...]);
在 useEffect 存在延迟调用的场景下,可能造成闭包问题,那通过咱们万能的方法就能解决:
const someFuncA = (d, g, x, y)=> {
doSomething(a, b, c, d, g, x, y);
};
const someFuncB = ()=> {
someFuncA(d, g, x, y);
};
const someFuncBRef = useRef(someFuncB);
someFuncBRef.current = someFuncB;
useEffect(()=>{
setTimeout(()=>{
someFuncBRef.current();
}, 1000)
}, [...]);
“在 useEffect 存在延迟调用的场景下”是什么意思?见如下对useEffect的分析。
useEffect
官网描述
划重点:
- 在函数组件(称为 React 的渲染阶段)的主体内部不允许出现突变、订阅、计时器、日志记录和其他副作用。这样做将导致混淆的错误和 UI 中的不一致。
- 上述这些在主题内部不允许出现的,可以用
useEffect。 useEffect的调用时机:- 默认,effects在每次完整的渲染之后被调用。【重点:“每次”,“完整的”】
- “渲染之后”具体为“布局和绘制之后”,但是下一次渲染之前,React每次都会在下一次渲染之前就展现上一次渲染的结果。
- effects里的副作用应该是延迟事件,例如订阅和事件处理,即不阻塞浏览器更新页面的事件。
- 不是所有的事件都是延迟事件,例如对DOM的操作对用户可见,就不应该是延迟事件,就应该在下一次渲染前同步被触发,这样用户才不会感受到视觉上的不一致。对于这些非延迟事件,使用
useLayoutEffect。 - 从React 18开始,传入useEffect的函数,如果其是一次具体的用户输入例如一次点击,或者是一次用
flushSync(强行刷新)包裹的更新,那么该函数的会在**“布局和绘制之前”**被触发。
- 可以通过传入依赖项来跳过不必要的调用。
- 默认,effects在每次完整的渲染之后被调用。【重点:“每次”,“完整的”】
使用案例
它跟class组件中的componentDidMount,componentDidUpdate,componentWillUnmount具有相同的用途,只不过被合成了一个api。
import React, { useState, useEffect} from 'react';
import ReactDOM from 'react-dom';
function App() {
let [number, setNumber] = useState(0);
useEffect(()=>{
console.log(number);
}, [number])
return (
<div>
<h1>{number}</h1>
<button onClick={() => setNumber(number+1)}> + </button>
</div>
);
}
function render() {
ReactDOM.render( <App />, document.getElementById('root') );
}
render();
如代码所示,支持两个参数,第二个参数也是用于监听的。当监听数组中的元素有变化的时候再执行作为第一个参数的执行函数。
手写源码
原理发现其实和useMemo,useCallback类似,只不过,前面前两个有返回值,而useEffect没有。(当然也有返回值,就是那个执行componentWillUnmount函功能的时候写的返回值,但是这里返回值跟前两个作用不一样,因为你不会写。
let xxx = useEffect(()=>{
console.log(number);
},[number]);
来接收返回值。
所以,忽略返回值,你可以直接看代码,真的很类似,简直可以用一模一样来形容。
import React, { useState} from 'react';
import ReactDOM from 'react-dom';
let lastEffectDependencies;
function useEffect(callback,dependencies){
if (lastEffectDependencies) {
let changed = !dependencies.every((item,index)=>{
return item === lastEffectDependencies[index];
})
if (changed) {
callback();
lastEffectDependencies = dependencies;
}
} else {
callback();
lastEffectDependencies = dependencies;
}
}
function App() {
let [number, setNumber] = useState(0);
useEffect(()=>{
console.log(number);
},[number]);
return (
<div>
<h1>{number}</h1>
<button onClick={() => setNumber(number+1)}>+</button>
</div>
);
}
function render() {
ReactDOM.render( <App />, document.getElementById('root') );
}
render();
你以为这样就结束了,其实还没有,因为第一个参数的执行时机错了,实际上作为第一个参数的函数因为是在浏览器渲染结束后执行的。而这里我们是同步执行的。
所以需要改成异步执行callback
import React, { useState} from 'react';
import ReactDOM from 'react-dom';
let lastEffectDependencies;
function useEffect(callback,dependencies){
if (lastEffectDependencies) {
let changed = !dependencies.every((item,index)=>{
return item === lastEffectDependencies[index];
})
if (changed) {
setTimeout(callback());
lastEffectDependencies = dependencies;
}
} else {
setTimeout(callback());
lastEffectDependencies = dependencies;
}
}
function App() {
let [number, setNumber] = useState(0);
useEffect(()=>{
console.log(number);
},[number]);
return (
<div>
<h1>{number}</h1>
<button onClick={() => setNumber(number+1)}>+</button>
</div>
);
}
function render() {
ReactDOM.render( <App />, document.getElementById('root') );
}
render();
性能优化tips
对 useCallback 的建议就一句话:没事别用 useCallback。
Q:是否要把所有的方法都用 useCallback 包一层呢?
A:不要把所有的方法都包上 useCallback
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleClickButton1 = () => {
setCount1(count1 + 1)
};
const handleClickButton2 = useCallback(() => {
setCount2(count2 + 1)
}, [count2]);
return (
<>
<button onClick={handleClickButton1}>button1</button>
<button onClick={handleClickButton2}>button2</button>
</>
)
上面这种写法在当前组件重新渲染时会声明一个新的 handleClickButton1 函数,下面 useCallback 里面的函数也会声明一个新的函数,被传入到 useCallback 中,尽管这个函数有可能因为 inputs 没有发生改变不会被返回到 handleClickButton2 变量上。
那么在我们这种情况它返回新的函数和老的函数也都一样,因为下面 <button> 已经都会被渲染一下,反而使用 useCallback 后每次执行到这里内部要要比对 inputs 是否变化,还有存一下之前的函数,消耗更大了。
这也就是开头划的重点,也是React官网上说的:
- 何时使用才是好的、有用的?
- 当该回调函数作为参数传递给子组件,并且该子组件用了React.memo或者shouldcomponentupdate这些依赖引用相等判断来阻止不必要的渲染时,可在父组件用useCallback配合实现子组件的这项阻止渲染的性能优化。
否则就是反向优化。
当然,我建议一般项目中不用考虑性能优化的问题,也就是不要使用 useCallback 和 React.memo 了,除非有个别非常复杂的组件,单独使用即可。