React Hooks 手把手体验

674 阅读9分钟

React Hooks

  • Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性
  • 如果你在编写函数组件并意识到需要向其添加一些 state,以前的做法是必须将其它转化为 class。现在你可以在现有的函数组件中使用 Hook

解决的问题

  • 在组件之间复用状态逻辑很难,可能要用到render props和高阶组件,React 需要为共享状态逻辑提供更好的原生途径,Hook 使你在无需修改组件结构的情况下复用状态逻辑
  • 难以理解的 class,包括难以捉摸的this
  • 复杂组件变得难以理解,Hook将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据)

注意事项

  • 只能在函数最外层调用 Hook。不要在循环、条件判断或者子函数中调用。
  • 只能在 React 的函数组件中调用 Hook。不要在其他 JavaScript 函数中调用

开始学习

不知道你是否已经熟练掌握官方基础的hooks
作为刚学习react的小白和大家一起学习下,下面会以小例子的形式来实战

useState

  • 通过在函数组件里调用它来给组件添加一些内部 state,React 会在重复渲染时保留这个 state
  • useState 会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。它类似 class 组件的 this.setState,但是它不会把新的 state 和旧的 state 进行合并
  • useState 唯一的参数就是初始 state
  • 返回一个 state,以及更新 state 的函数
    • 在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同
    • setState 函数用于更新 state。它接收一个新的 state 值并将组件的一次重新渲染加入队列
 const [state, setState] = useState(initialState);

我们先来一个小例子看下效果吧

const Counter = (props: IParams) => {
  const [number, setNumber] = useState({
    sum: 0
  })
  const alertNumber = function () {
    setTimeout(() => {
      console.log(`value ${number.sum}`)
    }, 1500);
  }
  return (
    <div>
      <div>{number.sum}</div>
      {/* <button onClick={() => setNumber({ sum: number.sum + 1 })}>plus</button>
      <button onClick={() => setNumber({sum: number.sum - 1})}>minis</button> */}
      <button onClick={() => setNumber((state)=>({ sum: state.sum + 1 }))}>plus</button>
      <button onClick={() => setNumber((state) => ({ sum: state.sum - 1 }))}>minis</button>
      <button onClick={() => alertNumber()}>打印数据</button>
    </div>
  )
}

我们可以看到效果:

我们操作添加和减少可以修改number值
我们修改number值,可以通过setNumber传入当前新对象,或者一个函数返回新对象值
我们点击打印数据的时候操作添加或减少,可以发现打印数据的值和当前不一样,所以我们得出总结:

每次渲染都是独立的闭包

  • 每一次渲染都有它自己的 Props and State
  • 每一次渲染都有它自己的事件处理函数
  • 我们的组件函数每次渲染都会被调用,但是每一次调用中number值都是常量,并且它被赋予了当前渲染中的状态值
  • 在单次渲染的范围内,Props and State始终保持不变

我们再来试下lazy新增的例子:

// lazy新增
const Counter1 = (props: IParams) => {
  const [number, setNumber] = useState({
    sum: 0
  })
  // 不使用函数更新 如果有异步的操作,伴随着同步更新的操作会导致数据回流
  const lazy = function () {
    setTimeout(() => {
      setNumber({
        sum: number.sum + 1
      })
    }, 1500);
  }
  const lazyUpdate = function () {
    setTimeout(() => {
      setNumber(state => ({
        sum: state.sum + 1
      }))
      setNumber(state => ({
        sum: state.sum + 1
      }))
    }, 1500);
  }
  return (
    <div>
      <div>{number.sum}</div>
      <button onClick={() => setNumber((state)=>({ sum: state.sum + 1 }))}>plus</button>
      <button onClick={lazy}>lazy</button>
      <button onClick={lazyUpdate}>lazyUpdate</button>
    </div>
  )
}

我们点击lazy新增之后再点击plus新增 效果可以看到,最新对象的sum值只加了1

我们点击lazyUpdate新增之后再点击plus新增 效果可以看到,lazyUpdate和plus新增的操作都叠加上去了,最终的sum值为3
我们再试下一种场景,我们先点击lazyUpdate新增之后再点击lazy新增 效果可以看到,我们的页面会先显示2,再显示1
我们来分析下结论:
第一场景我们触发了lazy函数添加,异步函数内部用到的number是更新之前的,对象内sum值为0,延迟1s之后加1了,sum值一秒后变为1,我们又触发了同步的setNumber((state)=>({ sum: state.sum + 1 }))最新的结果sum为1,当前同步值和异步更新值都是1,所以显示不会变

第二个场景 我们都是使用函数式更新去更新同步和异步的修改,我们先调用了异步的lazyUpdate函数,但是新增的操作不是立即执行,是等1s后执行,在此期间,我们又调用了同步的新增,这是同步直接执行,sum值变为1,异步函数新增state函数触发,拿到了最新的state,新增之后就会使sum变为3。

第三种场景 通过上述2种场景,这种场景我们也就清楚了,同时调用2个异步新增函数,肯定会以最后触发的异步函数为结果,我们触发的时候lazy函数调用的时候,sum值是拿的初始化值0,异步接口调用完之后变为1,而lazyUpdate函数先触发,也是拿的初始化值去计算,新增完sum值会变为2,所以会出现先出现2,后出现1的情况

函数式更新

  • 如果新的 state 需要通过使用先前的 state 计算得出,那么可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值

以为这样就结束了么?我们再看下 惰性初始state
我们来看个例子

const Counter2 = (props: IParams) => {
  const [obj, setObj] = useState(function() {
    return {
      sum: 0,
      title: '测试数据'
    }
  });
  return (
    <div>
      <div>{obj.title}: {obj.sum}</div>
      <button onClick={() => setObj((state) => ({ ...state, sum: state.sum + 1 }))}>plus</button>
      <button onClick={() => setObj((state) => ({ sum: state.sum + 1 }))}>plus</button>
      <button onClick={() => setObj(obj)}>state</button>
    </div>
  )
}

我们可以看到页面上的展示

我们触发了第一个plus按钮,显示正常,对象中的sum值加1
我们再触发第二个plus按钮,返回了sum值加1,页面上显示的title值丢失
所以我们能得出结论:

  • initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略
  • 如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用
  • 与 class 组件中的 setState 方法不同,useState 不会自动合并更新对象。你可以用函数式的 setState 结合展开运算符来达到合并更新对象的效果

还有要补充的么?真的没有了,我们看下一个吧

useCallback

useCallback可以帮我做一些优化,减少渲染次数

  • 把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新

我们来看个例子:

/**
 * useCallback 只有当依赖变化的时候函数才会变化
 * 把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新
 * @param props 
 */
let lastSetNumberBack:any;
let lastSetNameBack:any;
const Counter3 = (props: IParams) => {
  const [number, setNumber] = useState(0);
  const [name, setName] = useState('wp');
  const setNumberCallback = useCallback(() => setNumber((number + 1)), [number]);
  console.log('lastSetNumberBack === setNumberCallback', lastSetNumberBack === setNumberCallback);
  lastSetNumberBack = setNumberCallback;
  const setNameCallback = useCallback(() => setName(Date.now() + ''), [name]);
  console.log('lastSetNameBack === setNameCallback', lastSetNameBack === setNameCallback);
  lastSetNameBack = setNameCallback;

  return (
    <div>
      <div>{name}:{number}</div>
      <button onClick={setNumberCallback}>修改数字</button>
      <button onClick={setNameCallback}>修改名称</button>
    </div>
  )
}

ReactDom.render(<Counter3 />, document.getElementById('root'));

我们这边设置了2个缓存变量来比对,判断下当前的函数和之前的函数是否一致

通过展示内容可以看到:

  • useCallback通过传入依赖值来判断当前回调函数是否需要更新
  • 如果当前的依赖未改动的话,useCallBack还是返回之前的函数

说到依赖值的优化问题,我们可以再说下useMemo,useEffect最后再说

useMemo

  • 把创建函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算
interface ChildProps { 
  data: Record<string, number>,
  addNumber: Function
}
/**
 * 子类
 */
function Child(props: ChildProps) {
  console.log('child render');
  return (
    <div>
      <div>
        {props.data.number}
        <button onClick={()=>props.addNumber()}>+</button>
      </div>
    </div>
  )
}

/**
 * 父类
 */
function App() {
  const [number, setNumber] = useState(0);
  const [name, setName] = useState('wp');
  function changeObj(number: number) {
    console.log('===changeObj===')
    return { number }
  }
  const data = changeObj(number);
  return (
    <div>
      <input type="text" value={name} onChange={e => setName(e.target.value)} />
      <Child addNumber={() => setNumber(number + 1)} data={data}/>
    </div>
  )
}

/**
 * 父类
 * 把创建函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算
 */
let lastAddClick:Function;
let lastData:Record<string, number>;
function App1() {
  const [number, setNumber] = useState(0);
  const [name, setName] = useState('wp');
  const addNumber = useCallback(() => setNumber(number => number + 1), [number]);
  console.log('lastAddClick===addNumber', lastAddClick === addNumber)
  lastAddClick = addNumber;
  const data = useMemo(() => {
    console.log('===App1 useMemo===')
    return { number }
  }, [number]);
  console.log('data===lastData', data === lastData);
  lastData = data;
  
  return (
    <div>
      <input type="text" value={name} onChange={e => setName(e.target.value)} />
      <Child addNumber={()=>addNumber()} data={data}/>
    </div>
  )
}

我们先使用App和App1组件来做比较
我们看下App1组件调用的结果

我们可以看到,使用了useMemo,我们可以像使用useCallback一样使用缓存,但是useMemo和useCallback的对象不一样,useMemo服务的地方可能是一个变量,可能是一个dom元素,只要依赖值不变,对应的值就不会变

接下来,让我们来探索下useEffect吧

useEffect

  • 在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因为可能会产生莫名其妙的 bug 并破坏 UI 的一致性
  • 使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道
  • useEffect 就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount、componentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API

下面我们通过useEffect来模拟class函数中的ComponentDidMount和ComponentWillUnmount

type StateParam = {
  num: number
}

/**
 * useEffect 使用
 * @param props 
 */
function Counter1(props: any) {
  const [state, setState] = useState({ num: 0 });
  useEffect(() => { 
    document.title = `当前数据${state.num}`
  }, [state])
  // 第二个参数设置为[],则表示只会执行一次
  useEffect(() => { 
    const $time = setInterval(() => {
      setState(data => ({
        num: data.num + 1
      }))
    }, 1000);
    // 为了防止内存泄露,effect的返回函数会在销毁的时候执行
    // 为防止内存泄漏,清除函数会在组件卸载前执行。另外,如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已被清除
    return () => clearInterval($time);
  }, [])
  return <div>
    <div>{state.num}</div>
    <button onClick={() => { 
      setState(data => ({
        num: data.num + 1 
      }))
    }}>+</button>
  </div>
}

可以根据结果来分析下:

  • useEffect根据依赖值的变化而调用
  • useEffect通过传入 [] ,来实现只调用一次的函数,模拟了 componentDidMount ,我们useEffect的副作用函数里面调用了setInterval每秒执行+1操作,一直执行的话可能会导致内存泄露,那就需要调用class的ComponentWillUnmount去清除掉interval,好在useEffect提供给我们回调函数给我们清除副作用
  • 每次我们重新渲染,都会生成新的 effect,替换掉之前的。某种意义上讲,effect 更像是渲染结果的一部分 —— 每个 effect 属于一次特定的渲染

说到useEffect,我们就会联想到useReducer hook

useReducer

第一眼看上去,给人感觉是可以替代 redux 数据流是么?用法真的是一样一样的,其实并不是,后面会提到

  • useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法
  • 在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等

我们来看一下useReducer的使用

type StateParam = {
  number: number
}

function reducer(prevState: StateParam, action: any) {
  switch (action.type) {
    case 'INCREMENT':
      return {number: prevState.number + 1}
      break;
    case 'DECREMENT':
      return {number: prevState.number - 1}
      break;
    default:
      return prevState;
      break;
  }
}
/**
 * useReducer使用
 */
function App() {
  const initState: StateParam = { number: 0 };
  const [state, dispath] = useReducer(reducer, initState)
  return (
    <div>
      <div>number: {state.number}</div>
      <button onClick={() => dispath({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispath({ type: 'DECREMENT' })}>-</button>
    </div>
  )
}

像极了redux的用法是不是

useReducer和redux概念一样,会返回一个 state 值和 dispatch 值, state 用来显示状态, dispatch 调用 action ,派发给 reducer 处理结果
都说 useReducer 可以替代 useEffect 了,那我们来用 useReducer 实现个 useEffect 吧

/**
 * 通过useReducer来重写useState钩子函数
 */
type StatePram = Record<string, any>;
function useState(initState: StatePram) {
  const [state, dispatch] = useReducer((prevState: StatePram, action: any) => action, initState);
  function setState(data: StatePram) {
    return dispatch(data)
  }
  return [state, setState];
}

function App1() {
  const initState: StateParam = { number: 0 };
  // const [state, dispath] = useReducer(reducer, initState)
  const [obj, setObj] = useState(initState)
  return (
    <div>
      <div>number: {obj.number}</div>
      <button onClick={() => setObj({ number: obj.number + 1 })}>+</button>
      <button onClick={() => setObj({ number: obj.number - 1 })}>-</button>
    </div>
  )
}

效果和上面一样

使用 useReducer 如何重写 useEffect 钩子函数呢? 首先 useReducer 返回的是state和dispatch, 我们通过 setState函数 去调用 dispatch函数 ,触发我们的 reducer ,我们这边的 reducer函数 是接收到 action 返回 action 值做为最新状态值,从而实现了 useEffect 的功能
事实摆在面前,useReducer牛皮? 确实这么牛么?我们后续会分析

useContext

  • 接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值
  • 当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定
  • 当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值
  • useContext(MyContext) 相当于 class 组件中的 static contextType = MyContext 或者 <MyContext.Consumer>
  • useContext(MyContext) 只是让你能够读取 context 的值以及订阅 context 的变化。你仍然需要在上层组件树中使用 <MyContext.Provider> 来为下层组件提供 context

使用例子

type StateParam = {
  number: number
}

function reducer(prevState: StateParam, action: any) {
  switch (action.type) {
    case 'INCREMENT':
      return {number: prevState.number + 1}
      break;
    case 'DECREMENT':
      return {number: prevState.number - 1}
      break;
    default:
      return prevState;
      break;
  }
}
const AppContext: Context<ContextParam> = React.createContext({});
/**
 * useReducer
 */
function App() {
  const initState: StateParam = { number: 0 };
  const [state, dispath] = useReducer(reducer, initState)
  return (
    <AppContext.Provider value={{ state, dispath }}>
      <Child />
    </AppContext.Provider>
  )
}

interface ContextParam { 
  state?: any,
  dispath?: Function
}

/**
 * useContext
 */
function Child() {
  const { state, dispath } = useContext<ContextParam>(AppContext)
  return (
    <div>
      <div>number: {state.number}</div>
      <button onClick={() => dispath({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispath({ type: 'DECREMENT' })}>-</button>
    </div>
  )
}

上面我们讲到了 useReducer 用法,那么怎么和 redux 一样共享操作和状态呢, redux 怎么做的呢? redux 也是通过 react-redux Provider 提供 store ( state, dispatch, subscribe ), connect 封装了子组件, 封装了 subscribe 和 unsubcribe 调用刷新 state ,让子组件可以调用到 store 的 state 和 dispatch 功能。那我们函数组件使用hook来实现肯定是类似了,我们需要通过 createContext 创建个上下文,通过 Provider 去把 useReducer 返回的 state 和 dispatch 操作向下传,子组件通过 useContext 相当于 consumer 消耗父级传下来的数据,从而实现改动父级状态值
实现了类似于redux的功能是不是?
react hooks试图替换redux的方案具体可见 juejin.cn/post/684490…

useRef

  • useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)
  • 返回的 ref 对象在组件的整个生命周期内保持不变

我们先来看个例子

type IParam = Record<string, any>;
function Parent(props: IParam) {
  const [number, setNumber] = useState(0);
  const inputRef:React.MutableRefObject<HTMLInputElement> = useRef();
  const getFocus = () => {
    inputRef.current.focus();
  }
  return <>
    <Child inputRef={inputRef} getFocus={getFocus}/>
    <div>
      <div>
        {number}
        <button onClick={()=>setNumber(number + 1 )}>+</button>
      </div>
    </div>
  </>
}

function Child(props: IParam) {
  // const inputRef:React.MutableRefObject<HTMLInputElement> = useRef();
  // const getFocus = () => {
  //   inputRef.current.focus();
  // }
  const { inputRef, getFocus } = props;
  return <>
    <input type="text" ref={inputRef}/>
    <button onClick={ getFocus }>触发焦点</button>
  </>
}

我们可以通过 useRef 创建一个 ref 对象,然后操作当前对象demo
这种方式看着怪怪的,向下传递ref怎么处理呢?
forwardRef

  • 将ref从父组件中转发到子组件中的dom元素上
  • 子组件接受props和ref作为参数

修改下代码再来看下

function Child(props,ref){
  return (
    <input type="text" ref={ref}/>
  )
}
Child = forwardRef(Child);
function Parent(){
  let [number,setNumber] = useState(0); 
  const inputRef = useRef();
  function getFocus(){
    inputRef.current.value = 'focus';
    inputRef.current.focus();
  }
  return (
      <>
        <Child ref={inputRef}/>
        <button onClick={()=>setNumber({number:number+1})}>+</button>
        <button onClick={getFocus}>获得焦点</button>
      </>
  )
}

除了这种操作,偷偷告诉一个骚操作,如果你想使用一个不可变值,从上到下保持统一,也可以使用useRef使用

const count = useRef(0);
// 使用变量
const runCount = count.current;

useLayoutEffect

  • 其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect
  • 可以使用它来读取 DOM 布局并同步触发重渲染
  • 在浏览器执行绘制之前useLayoutEffect内部的更新计划将被同步刷新
  • 尽可能使用标准的 useEffect 以避免阻塞视图更新

例子如下:

type IParam = Record<string, any>;
function App(props: IParam) {
  const [color, setColor] = useState('red')
  useEffect(() => {
    console.log('当前颜色:', color);
  }, [color]);
  useLayoutEffect(() => { 
    alert(color);
  })
  return (
    <div style={{ background: color, padding:10 }}>
      <div>
        {color}内容数据
      </div>
      <div>
        <button onClick={() => setColor('red')}>红色</button>
        <button onClick={() => setColor('yellow')}>黄色</button>
        <button onClick={() => setColor('blue')}>蓝色</button>
      </div>
    </div>
  )
}

我们点击切换颜色的时候会发现 useLayoutEffect 会先执行,先打印出内容,阻塞渲染,然后再执行 useEffect 执行变量变化

自定义hook

  • 有时候我们会想要在组件之间重用一些状态逻辑
  • 自定义 Hook 可以让你在不增加组件的情况下达到同样的目的
  • Hook 是一种复用状态逻辑的方式,它不复用 state 本身
  • 事实上 Hook 的每次调用都有一个完全独立的 state
  • 自定义 Hook 更像是一种约定,而不是一种功能。如果函数的名字以 use 开头,并且调用了其他的 Hook,则就称其为一个自定义 Hook

像 umi 有 useRequest钩子 ,功能酷似 axios ,我们这边就模拟个简单的 xhr 请求的 useRequest hook

function useReqeust(url: string) {
  const limit = 5;
  const [data, setData] = useState([]);
  const [offset, setOffset] = useState(0);
  function loadMore() {
    const fetchUrl = `${url}?offset=${offset}&limit=${limit}`
    fetch(fetchUrl).then(response => response.json()).then(resData => { 
      setData([
        ...data,
        ...resData
      ]);
      setOffset(offset + resData.length);
    })
  }
  useEffect(() => loadMore(), []);
  return [data, loadMore];
}

/**
 * useReqeust调用
 */
function App() {
  const [list, loadMore] = useReqeust('http://localhost:8000/api/userList');
  if (!list || !list.length) {
    return <div>loading...</div>
  }
  return <>
    <ul>
      {
        list && (list as Array<any>).map((item, index) => (
          <li key={index}>
            {item.id}:{item.name}
          </li>
        ))
      }
    </ul>
    <div><button onClick={() => (loadMore as Function)()}>loadmore</button></div>
  </>
}

我们在自己写的 useRequest 里面调用了useState 和 useEffect 功能,返回了请求后数据 data,还有获取更多的 loadMore 方法

结束语

大家可以发挥自己的想法,切合自己的业务场景拆分出 tool hooks 或者组件 hooks
大家有什么想讨论的,可以发评论区,看到会及时回复~~~