React基础:常用Hooks

本文总结了React(v16)中的常用Hooks,包括useState、useEffect、useMemo、useCallback、useRef等等......

useState

声明state

可以使用字面量或函数形式声明state的初始值

无论值为字面量还是函数,它都只用于初始化

建议使用函数返回值作为初始值的场景

  1. 初始值需要复杂计算

当初始值需要复杂计算时,如果我们将其写在组件内,那么组件每次重新渲染时,都会执行这个计算

TypeScript 复制代码
// ❌
export default () => {
  const initialCount = xxx * yyy
  const [count, setCount] = useState(initialCount)
}

// ✅
export default () => {
  const [count, setCount] = useState(() => {
    return xxx * yyy
  })
}
  1. 初始值是复杂的对象

如下第一种写法,会在组件每次重新渲染时,创建一个新的initialInfo对象

TypeScript 复制代码
// ❌ Bad case
export default () => {
  const initialInfo = {
    name: 'xxx',
    age: 15,
    ...
    extra: {
       ...
    }
  }
  const [info, setInfo] = useState(initialInfo)
}

// ✅ Good case
export default () => {
  const [info, setInfo] = useState({
    name: 'xxx',
    age: 15,
    ...
    extra: {
       ...
    }
  })
}

更新state

可以使用字面量或者函数形式更新state

使用函数形式时,函数的第一个参数是队列里面最新的state

TypeScript 复制代码
export default () => {
  const [count, setCount] = useState(0)
  
  function update1() {
    setCount(100)
  }
  
  function update2() {
    setCount(count => count + 1)
  }
}

使用immer更新对象类型的state

由于state是只读的、不可变的(immutable),所以只能通过setState而不是直接给state赋值来驱动组件重新渲染

举个例子:要更新数组类型的state,需要把一个新的数组传入setState中,因此当我们需要使用如下表格中右边一列的方法

避免使用 推荐使用
添加元素 push、unshift concat、展开符(...)
删除元素 pop、shift、splice filter、slice
替换元素 splice、arr[i] = ... 赋值 map
排序 reverse、sort 先复制一份:slice()或[...arr]

使用immer可以让我们的代码更加简洁

举个例子:修改列表中特定元素的值

  1. 使用原生useState
  • 使用map遍历列表
  • 找到列表中的特定元素
  • 新建一个对象并填入值
  • 调用setState,传入map结果
TypeScript 复制代码
import { useState } from 'react'

const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
]

export default () => {
  const [list, setList] = useState(initialList)
  
  function updateListById({id, title, seen}){
    setState(list => list.map(item => {
      if (item.id === id) {
        return {
          id,
          title,
          seen
        }
      } else {
        return item
      }
    }))
  }
}
  1. 使用immer
  • 调用immer的set方法
  • 找到列表中的特定元素
  • 直接修改元素的值
TypeScript 复制代码
import { useState } from 'react'
import { useImmer } from 'use-immer'

const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
]

export default () => {
  const [list, setList] = useImmer(initialList)
  
  function updateListById({id, title, seen}){
    updateList(draft => {
      const targetItem = draft.find(item => item.id === id)
      if (targetItem) {
        targetItem = {
          id,
          title,
          seen
        }
      }
    })
  }
}

state更新原理

  1. 一个state的值永远不会在一次渲染的内部发生变化

当React重新渲染一个组件时

  • React会再次调用当前函数
  • 函数会返回新的JSX快照
  • React会更新界面来匹配第2步生成的快照

例如:在一次事件处理函数中连续以字面量的形式、调用三次setState

TypeScript 复制代码
export default function Counter() {
  const [number, setNumber] = useState(0);
  
  function onClick() {
    setNumber(number + 1);
    setNumber(number + 1);
    setNumber(number + 1);
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={onClick}>+3</button>
    </>
  )
}

每次点击button,最终结果只会让number递增一次,例如在第一次点击button时,onClick函数访问到的number永远都是0,因此,第一次执行onClick函数是这样的

TypeScript 复制代码
function onClick() {
  // React准备在下一次渲染时将number更改为1
  setNumber(0 + 1)
  // React准备在下一次渲染时将number更改为1
  setNumber(0 + 1)
  // React准备在下一次渲染时将number更改为1
  setNumber(0 + 1)
}

例如:在事件处理函数中加上定时器,使得它在组件重新渲染之后才触发

TypeScript 复制代码
export default function Counter() {
  const [number, setNumber] = useState(0);
  
  function onClick() {
    setNumber(number + 5);
    setTimeout(() => {
      console.log(number)
    }, 3000)
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={onClick}>+3</button>
    </>
  )
}

每次onClick函数执行时,setTimeout读取的都是当前这个时刻的state值

因此,在第一次点击button后的3秒后,将会打印出number原来的值0

  1. 只有当事件处理函数里的所有同步代码执行完毕后,才会重新渲染 组件 ,也就是批处理

例如:在一个事件处理函数中更新多个state

TypeScript 复制代码
export default function Counter() {
  const [number, setNumber] = useState(0);
  const [color, setColor] = useState('#fff')
  
  function onClick() {
    setNumber(number + 1);
    setColor('#1664ff')
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={onClick}>+3</button>
    </>
  )
}

当点击button的时候,setNumbersetColor会在下一次渲染时一起更新

  1. 更新队列
  • 每次setState的执行结果是:队列里面最新的state值发生改变
  • 当setState使用函数形式时,函数的第一个参数是队列里面最新的state值

例如:在下次渲染前多次更新同一个state

(1)先替换,后更新

TypeScript 复制代码
export default function Counter() {
  const [number, setNumber] = useState(0);
  
  function onClick() {
    setNumber(number + 1)
    setNumber(n => n + 1)
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={onClick}>+3</button>
    </>
  )
}

更新队列如下:

  • setNumber(number + 1)

    • number是0,相当于执行setNumber(1)
    • 此时队列中最新的的值为1
  • setNumber(n => n +1)

    • 队列中最新的值为1
    • 相当于执行setNumber(1 + 1)
    • 此时队列中最新的值为2

因此,下一次渲染结果是:number = 2

(2)先更新,后替换

TypeScript 复制代码
export default function Counter() {
  const [number, setNumber] = useState(0);
  
  function onClick() {
    setNumber(n => n + 1)
    setNumber(n + 10)
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={onClick}>+3</button>
    </>
  )
}

更新队列如下:

  • setNumber(n => n + 1)

    • n是0,相当于执行setNumber(1)
    • 此时队列中最新的值为1
  • setNumber(n + 10)

    • n是0,相当于执行setNumber(10)
    • 此时队列中最新的值为10

因此,下一次渲染结果是 number = 10

useEffect

基础用法

接收2个参数

  • 第一个参数是回调函数callback
  • 第二个参数是数组deps

每当数组deps中的状态发生变化的时候,就会执行一次callback

deps前后的比较是浅比较,即针对对象类型的状态,只比较引用地址是否相同

若不传第二个参数deps,那么每次组件重新渲染,都会执行一次callback

若第二个参数deps为空数组,那么只会在组件挂载时执行一次callback

回调函数callback允许返回一个回调函数cleanup

在下一次执行callback之前,会执行一次cleanup

举个例子:利用useEffect写一个每秒递增一个单位的计时器

❌错误写法:

TypeScript 复制代码
function Counter() {
  const [count, setCount] = useState(0)
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)
    }, 1000)
    return () => {
      clearInterval(id)
    }
  }, [])
  
  return <h1>{count}</h1>
}

实际结果:

  • 进入页面后,count=0
  • 设置定时器,定时器内setCount(count + 1)读到的count是0
  • 1秒后,执行setCount(0 + 1),组件重新渲染,count=1
  • 1秒后,执行setCount(0 + 1),组件不会重新渲染
  • ...

✅正确写法:利用setState的函数形式,每次setState都基于最新的state进行递增

TypeScript 复制代码
function Counter() {
  const [count, setCount] = useState(0)
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)
    return () => {
      clearInterval(id)
    }
  }, [])
  
  return <h1>{count}</h1>
}

移除不必要的effect

  1. 根据props或state来更新state

假设组件包含2个state:firstNamelastName,你希望基于这2个state计算出fullName

❌错误写法:使用useEffect监听firstNamelastName,在effect里面更新fullName

TypeScript 复制代码
function F() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  
  const [fullName, setFullName] = useState('')
  useEffect(() => {
    setFullName(firstName + ' ' + lastName)
  }, [firstName, lastName])
}

✅正确写法:直接在渲染时计算这个值(当计算较为复杂时可以使用useMemo包裹)

TypeScript 复制代码
function F() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  
  const fullName = firstName + ' ' + lastName
}
  1. 当props变化时重置所有state

假设组件接收一个props:userId,你希望在userId变化时,重置组件内部的所有state

❌错误写法:使用useEffect监听userId,在effect里面重置state

TypeScript 复制代码
function F({userId}){
  const [comment, setComment] = useState('')
  
  useEffect(() => {
    setComment('')
  }, [userId])
}

userId变化时,组件首先会用旧的comment值渲染,触发effect执行setComment('')后重新渲染

而且你可能需要在一个effect里面清除所有的state

✅正确写法:从父组件传递一个key给子组件

TypeScript 复制代码
function Parent() {
  return (
    <F key={userId} userId={userId} />
  )
}

function F({userId}){
  const [comment, setComment] = useState('')
  
}

userId变化时,React会直接销毁旧的组件F,并创建新的组件F

  1. 通知父组件有关state变化的信息

假设组件Toggle有一个state名为isOn,你希望在它变化的时候通知父组件,于是组件Toggle将会接收一个onChange的props

❌错误写法:使用useEffect监听isOn,在effect中调用onChange

TypeScript 复制代码
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);


  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

每当isOn更新的时候,Toggle组件会重新渲染一次,然后触发effect、调用onChange,可能导致父组件重新渲染一次

✅正确写法:在setState更新isOn的时候就调用onChange,让父组件和当前组件同时开启重新渲染

TypeScript 复制代码
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateIsOn(value) {
    setIsOn(value)
    onChange(value)
  }

  function handleClick() {
    updateIsOn(!isOn)
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateIsOn(true)
    } else {
      updateIsOn(false);
    }
  }

  // ...
}

从effect中提取非响应式逻辑

假设组件ChatRoom的其中一个功能是给聊天室建立网络连接,并在聊天室roomId变化时重新连接

那么就有如下的effect:当roomId变化时,清除旧的连接,使用新的roomId建立新的连接

TypeScript 复制代码
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      Notification.show('Connected!');
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId])
}

假设你希望在原有代码的基础上,在展示连接通知的时候同时展示当前的主题theme,这个主题的值从props当中获取

❌错误写法:把theme加到effect的依赖项里

TypeScript 复制代码
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      Notification.show('Connected!', theme);
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId, theme])
}

因为theme也是一个依赖项,所以每次切换主题的时候,即使roomId没有变化,effect也会执行,导致聊天室重新连接

换而言之,你不希望这行代码Notification.show('Connected!', theme)是响应式的

✅写法1:封装showNotification函数,在函数内读取theme

TypeScript 复制代码
function ChatRoom({ roomId, theme }) {
  function showErrorNotification(message) {
    Notification.show(message, theme)
  }

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showErrorNotification('connected!')
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId])
}

但这种写法会引起一个eslint的warning:缺失依赖项showErrorNotification

✅写法2:使用useEffectEvent 封装showNotification函数

注意:截止到React17正式版,这个实验性API还没有正式发布

TypeScript 复制代码
function ChatRoom({ roomId, theme }) {
  const showErrorNotification = useEffectEvet((message) => {
    Notification.show(message, theme)
  })

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showErrorNotification('connected!')
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId])
}

showNotification是effect逻辑的一部分,它内部的逻辑不是响应式的,而且能一直捕获到最新的props和state

需要注意的是useEffectEvent也有它的局限性

  • 只能在effect内部调用它
  • 不能把它传递给其他的组件或者hook

useMemo

接收2个参数,第一个参数是函数callback,第二个参数是依赖数组deps(同样是浅比较),返回某个js值

当依赖改变时,才重新执行callback计算返回值,适用于复杂计算的场景

TypeScript 复制代码
function calculateNum(num: number) {
  // 复杂计算
  return ...
}

function App() {
  const result = useMemo(() => {
    return calculateNum(num)
  }, [num])
}

类似的还有memo,它们之间的区别在于:

  • useMemo用于返回某个js值

  • memo用于返回一个React函数组件

memo接收2个参数,第一个参数是React函数组件,第二个参数是函数areEqual、返回值为布尔值

不传入第二个参数:会对该组件的props做浅比较,如果没有变化就不会重新渲染

传入第二个参数:用于自定义重新渲染的条件,接收旧props和新props,返回true则重新重新

TypeScript 复制代码
const App = memo(({list}: {list: number[]}) => {
  return xxx
}, areEqual)

function areEqual(prevProps, nextProps) {
  if(prevProps.list.length === nextProps.list.length) {
    return false
  }
  return true
}

useCallback

基础用法

接收2个参数,第一个参数是原函数callbck,第二个参数是依赖数组deps,返回一个callback的memoized版本

函数组件每次渲染时,组件内部定义的函数引用都会更新

常见的使用场景是:

  • 子组件接收一个来自父组件的函数A作为入参
  • 当父组件重新渲染时,函数A的引用更新,导致子组件也重新渲染
TypeScript 复制代码
function Parent() {
  function A() {
  
  }
  
  return <Child onOk={A} />
}

function Child({onOk}) {
  return <div>child</div>
}

为避免这种情况,就用memo包裹子组件,用useCallback包裹函数A

TypeScript 复制代码
function Parent() {
  const A = useCallback(() {
  
  }, [])
  
  return <Child onOk={A} />
}

const Child = memo(({onOk}) {
  return <div>child</div>
})

常见问题

  1. 隐式依赖

当useCallback的依赖项更新时

  • 依赖数组deps包含了此callback的 其他callback的引用也会随之更新
  • 依赖数组deps包含了此callback的 useEffect会执行

这就造成了依赖的高耦合,同时不容易被人察觉

  1. 依赖链路
TypeScript 复制代码
const callbackC =() => {
  // do something
  };

const callbackB =() => {
  callbackC();
    // do something
  };
 
const callbackA = useCallback(() => {
  const res = xxx /xxxx
  const res2 = callbackB(res);
}, [xxx, xxxx]);

当函数A调用了函数B,函数B调用了函数C的时候·

如果你想用useCallback包裹函数A,那么eslint会提示你用useCallback同时包裹函数B和函数C

最佳实践

  • 没有访问组件内部的state,可以直接把函数定义在组件外面
  • 访问了组件内部的state,使用ahooks的useMemoizedFn包裹函数

文档参考useMemoizedFn - ahooks 3.0

综上所述:我们的目的可以概括为2点

  • 保持函数引用不变
  • 使函数内部可以访问到最新的state

useRef

使用useRef需要注意:

  • 不要在渲染过程(即jsx)中访问ref.current值
  • 不要基于ref.current的值去写入ref.current值

使用ref引用值

当你需要在组件内维护某个状态,但又不希望此状态更新触发组件重新渲染时,可以使用useRef

因为使用useRef声明的值,它的引用在组件整个生命周期内都不会改变

✅推荐使用ref引用值的场景:存储timeoutID、intervalID

例如:你希望在组件挂载后,每隔1秒更新count值并打印在屏幕上

TypeScript 复制代码
function App() {
  const [count, setCount] = useState(0)
  const intervalRef = useRef(null)
  
  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)
    
    return () => {
      clearInterval(intervalRef.current)
      intervalRef.current = null
    }
  }, [])
  
  return <h1>{count}</h1>
}

使用ref操作DOM

在单个组件内使用时,把ref挂在DOM节点上,这样就可以通过ref调用DOM的一些API

TypeScript 复制代码
function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

当我们需要访问另一个组件的DOM节点时,需要配合forwardRefuseImperativeHandle一起使用

在编写代码前梳理清楚:

  • 父组件:需要调用子组件的方法or访问子组件的数据
  • 子组件:需要被调用或访问的组件
  • 需要被调用的方法or需要被访问的数据是哪些

父组件App,定义并传递ref给子组件

TypeScript 复制代码
export default function App() {
  const inputRef = useRef(null);

  const onBtnClick = () => {
    console.log(inputRef.current?.data);
  };

  return (
    <div className="App">
      <h2 onClick={onBtnClick}>点击这里打印数据data</h2>
      <Child ref={inputRef} />
    </div>
  );
}

子组件Child

  • forwardRef包裹组件
  • useImperativeHandle定义 需要被调用的方法或数据
TypeScript 复制代码
const Child = forwardRef((props, ref) => {
  const [count, setCount] = useState(0)
  
  useImperativeHandle(ref, () => ({
    data: {
      count
    } 
  }))
  
  return <h1>{count}</h1>
})
相关推荐
zqx_714 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
TonyH20021 天前
webpack 4 的 30 个步骤构建 react 开发环境
前端·css·react.js·webpack·postcss·打包
掘金泥石流1 天前
React v19 的 React Complier 是如何优化 React 组件的,看 AI 是如何回答的
javascript·人工智能·react.js
lucifer3111 天前
深入解析 React 组件封装 —— 从业务需求到性能优化
前端·react.js
秃头女孩y2 天前
React基础-快速梳理
前端·react.js·前端框架
sophie旭2 天前
我要拿捏 react 系列二: React 架构设计
javascript·react.js·前端框架
BHDDGT2 天前
react-问卷星项目(5)
前端·javascript·react.js
liangshanbo12152 天前
将 Intersection Observer 与自定义 React Hook 结合使用
前端·react.js·前端框架
黄毛火烧雪下2 天前
React返回上一个页面,会重新挂载吗
前端·javascript·react.js