React Hooks 指北(五):useCallback

265 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第8天,点击查看活动详情

1.React更新机制

1.1数据更新,组件重新加载

我们都清楚,React响应式数据发生改变时,组件会从上至下被重新加载。如下所示

import {useState} from "react"
function App() {
  console.log("组件被渲染")
  const [num,setNum] = useState(1)
  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
  </div>
}
export default App;

很明显当组件的num数据发生改变时,组件会被重新从上到下执行一次。

1.gif

1.2父组件数据更新,子组件也更新

我们清楚了当组件数据更新时,默认组件会被重新执行。因此可以推理当父组件数据更新时,子组件肯定也会重新执行。如下所示

App.js

import Son from "./views/Son"
import {useState} from "react"
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)
  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son/>
  </div>
}
export default App;


Son.js

export default function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 

  </div>
}

首次加载,显示父组件加载,子组件加载。当我们修改父组件的数据时,发现父子组件都重新加载了。

2.gif

2.memo

上面提到了,当父组件的数据更新时,父组件重新加载的同时,子组件也会被重新加载。想想是不是很不合理啊。父亲犯法,儿子连坐。合理的做法应该是父亲如果和儿子一起参与了犯罪,那么二者都违法,如果只是父亲参与了犯罪,那父亲一人承担,与儿子没关系。

使用memo包裹子组件就可以做到上面的理想状态。

  1. 如果父组件传递给儿子组件的数据无变化时,当父组件因数据更新而重新加载时,子组件不会二次加载。
  2. 如果父组件传递给儿子组件的数据变化了,此时父组件因数据更新而重新加载时,子组件也二次加载

2.1父组件传递给儿子组件数据无变化时

App.js

import Son from "./views/Son"
import {useState} from "react"
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son title='son title'/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 
    {props.title}
  </div>
})

可以发现,经过memeo优化的子组件,当父组件传递给子组件的数据始终没有变化时,虽然父组件二次加载,但是子组件始终没有二次加载。

3.gif

2.2父组件传递给子组件的数据变化时

App.js

import Son from "./views/Son"
import {useState} from "react"
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)
  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son num={num}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 
    {props.num}
  </div>
})

此次父组件在修改数据num时,重新加载父组件时,给儿子组件传递的数据num发生了改变,符合父子联合犯罪的要求,因此二者都要承受制裁.二者都要重新加载。

4.gif

2.3疑问点:react如何判断父亲给儿子组件传递的数据是否变化了?

这个问题面试90%会考,许多小盆友看面筋会一口回答,浅比较。对,没错,判断是否更改数据的机制就是浅比较,那什么是浅比较?

浅度比较:两个对象,永远只对比第一层,如果第一层的键值相同,对应的值相同,则两个对象浅度相同

例子1

很明显,obj1和obj2的info的对象是不同的,它们地址值是不同的,因此浅比较返回false

let obj1 = {
    name:"dzp"
    age:"22",
    info:{}
}
let obj2 = {
    name:"dzp"
    age:"22",
    info:{}
}

例子2

obj1和obj2的对象浅度比较是相同的。许多人可能懵了,这是相同?自己看上面浅度比较的定义。永远只比较第一层,obj1和obj2的info对象地址始终指向一个对象。所以浅度比较是true

 let info = {a:1}
 
 let obj1 = {
    name:"dzp"
    age:"22",
    info:info
}

info.b = 2

let obj2 = {
    name:"dzp"
    age:"22",
    info:info
}

2.4 实际例子

例子1

App.js

import Son from "./views/Son"
import {useState} from "react"

function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  let name = "dzp"
  let obj = {other:"other"}

  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    
    <Son name={name} obj={obj}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 
    {props.num}
  </div>
})

5.gif

我们发现父组件数据更新时,父子组件都被重新加载了。一部分人可能骂娘了。这子组件数据不是没变化?为什么还会重新加载。仔细分析,当父组件的数据num更新时,父组件被重新加载,父组件的name和对象obj也重新创建。子组件在进行浅度比较时,发现obj对象的地址已经改变,因此返回false.触发子组件的更新。

例子2

App.js

import Son from "./views/Son"
import {useState} from "react"
let obj = {other:"other"}
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  let name = "dzp"
  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    
    <Son name={name} obj={obj}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 
    {props.num}
  </div>
})

6.gif

我对例子1的数据做了小小改变,将obj对象放到了父组件的头部,因此父组件因数据更新重新加载时,传递给儿子组件的obj始终是上一份的obj.浅度比较返回的就是true。因此父组件的更新,不会触发儿子组件的更新

3.useCallback

上面我们已经优化了父组件数据更新,子组件二次渲染问题。我们继续看一个栗子。

3.1 父组件给子组件传递函数

App.js

import Son from "./views/Son"
import {useState} from "react"
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  const fn = () => {
    console.log("fn")
  }
  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son fn={fn}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 

  </div>
})

7.gif

其实看懂了上面的浅比较,此处你就明白,为什么传递给子组件是一个函数时,父组件更新,子组件始终会更新。因为父组件重新加载时,函数对象是新创建的,和上一次进行浅比较返回的是false.因此子组件必定重新渲染。如何解决这个问题?我们继续看

3.2 方案一:传递子组件的函数放到外面

方案一:既然是浅度比较,我们就可以将传递函数单独放到函数组件的外面,这样我们就可以始终保证传递的函数是始终不变的

App.js

import Son from "./views/Son"
import {useState} from "react"
const fn = () => {
  console.log("fn")
}
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son fn={fn}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 

  </div>
})

8.gif

继续考虑,如果这个函数是有关修改useState数据的?那函数放到组件外面肯定是报错的,因为这个函数外面没有useState的相关定义啊。如何继续设计? 终于终于等到今天的主角了,那就是useCallback。

方案二:useCallback

此时,你已经清楚了useCallback的作用了。useCallback就是解决父组件给儿子组件传递函数时,父组件因数据改变重新加载导致子组件二次渲染问题。

使用

  1. 第一个参数传入回调函数

  2. 第二个参数是回调函数的依赖项。当依赖项改变时,回调函数重新加载

     useCallback(()=>{},[])
    

示例

App.js

import Son from "./views/Son"
import {useState,useCallback} from "react"
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  const memory = useCallback(()=>{
    console.log("fn")
  },[])
  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son fn={memory}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 
  </div>
})

9.gif

我们发现,父组件因为数据修改导致父组件重新加载,但是此处useCallback根据依赖项判断无变化,因此传递给子组件的函数始终是同一份函数,子组件不更新。

栗子1

App.js

import Son from "./views/Son"
import {useState,useCallback} from "react"
function App() {
  console.log("父组件被渲染")
  const [num,setNum] = useState(1)

  const memory = useCallback(()=>{
        
  },[num])

  return <div className="app">
    <div>{num}</div>
    <div onClick={()=>setNum(num+1)}>修改num</div>
    <Son fn={memory}/>
  </div>
}
export default App;

Son.js

import { memo } from "react"
export default memo(function Son(props) { 
  console.log("儿子组件被渲染")
  return <div> 

  </div>
})

10.gif

我们对上面的依赖项重新添加了num,也就是说只要num改变,这个函数就会被重新创建一份。因此父组件因数据改变导致父组件重新加载时,给儿子组件传递的函数发生了改变。所以儿子组件也会二次加载。

注意点

  1. useCallback必须和memo结合,因为只有memo才让组件提供了浅比较判断是否更新,离开了memeo的useCallback是毫无作用的。
  2. 当子组件数据和结构不是很复杂时,尽量不要使用useCallback去优化,facebook核心开发人员指出过,useCallback本身也是耗费性能的。对于简单的应用和组件,使用useCallback可能会反向优化。

4.注意

useCallback使用时尤其要注意,第二个参数依赖项。规律:useCallback内部修改state的地方都要加入到依赖数组中,否则会由于闭包产生bug。

function App() {
  const [num,setNum] = useState(1);
  const addNum = useCallback(()=>{
    setNum(num+1);
  },[])
  return (
    <div className="App">
      num:{num} 
      <p onClick={addNum}>add</p>
    </div>
  );
}
export default App;

上面的代码,useCallback依赖数组是空,表示函数始终加载1次,当我们触发addNum时数据在第一次发生更新,后面点击修改都是无效的。正确的姿势就是在依赖数组中添加num

总结

  1. 父组件更新,子组件默认也会更新
  2. 对子组件使用memo包裹的函数组件,当父组件更新时,会根据props浅比较判断是否更新子组件
  3. 对子组件使用memo包裹的函数组件,并且父组件给子组件传递父组件内部函数时,当父组件更新时,子组件一定会更新(浅比较,父组件更新时,内部函数地址也会变化)
  4. 对子组件使用memeo包裹的函数组件,并且父组件通过useCallback包裹内部函数传递给子组件时,如果useCallback的依赖未发生变化,传递的函数始终是同一份函数地址,此时子组件根据浅比较不会发生更新。