React进阶(十二):HOOK

x33g5p2x  于2021-11-11 转载在 React  
字(20.0k)|赞(0)|评价(0)|浏览(554)

一、前言

HookReact 16.8.0 的新增特性,React Native 0.59及以上版本支持 Hook。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

请注意,要启用 Hook,所有 React 相关的 package 都必须升级到 16.8.0 或更高版本。如果你忘记更新诸如 React DOM 之类的 packageHook 将无法运行。

Hook 是一些可以让你在函数组件里“钩入React state 及生命周期等特性的函数。Hook 不能在 class 组件中使用 —— 这使得你不使用 class 也能使用 React

HOOK可以让我们在函数组件中使用 state 、生命周期以及其他 react 特性,而不仅限于 class 组件中使用。react hooks 的出现,标示着 react 中不会在存在无状态组件,而是包含类组件函数组件react hooks 即是应用在函数组件中。

如果你在编写函数组件并意识到需要向其添加一些 state,以前的做法是必须将其它转化为 class。现在你可以在现有的函数组件中使用 Hook

二、HOOK函数介绍

2.1 State hook

hooks 使我们在函数组件中拥有使用state的能力, 就是通过 useState 来实现的,首先来看一个简单的例子,这个例子用来显示一个计数器。当你点击按钮,计数器的值就会增加:

import React, { useState } from 'react';
function App () {
// 声明一个叫 “count” 的 state 变量。
  const [ count, setCount ] = useState(0)
  return (
    <div>
      点击次数: { count } 
      <button onClick={() => { setCount(count + 1)}}>点我</button>
    </div>
    )
}

其中,useState 就是一个 Hook。从代码中可以看到,useState 的使用非常简单,我们从 React 中拿到 useState 后,只需要在使用的地方直接调用 useState 函数就可以。 通过在函数组件里调用它来给组件添加一些内部 stateReact 会在重复渲染时保留这个 stateuseState 会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。它类似 class 组件的 this.setState,但是它不会把新的 state旧的 state 进行合并。

那么这里为什么叫 countsetCount?可以使用别的名字吗,这里使用了 es6解构赋值,所以你可以给它起任何名字,updateCount, doCountanything,当然,为了编码规范,所以建议统一使用一种命名规范,尤其是第二个值。

useState 唯一的参数就是初始 state。在上面的例子中,计数器是从零开始的,所以初始 state 就是 0。值得注意的是,不同于 this.state,这里的 state 不一定要是一个对象 —— 如果你有需要,它也可以是。这个初始 state 参数只有在第一次渲染时会被用到。

当我们在使用 useState 时,修改值时传入同样的值,我们的组件会重新渲染吗,例如这样

import React, { useState } from 'react';
function App () {
  const [ count, setCount ] = useState(0)
  console.log('component render count')
  return (
    <div>
      点击次数: { count } 
      <button onClick={() => { setCount(count)}}>点我</button>
    </div>
    )
}

结果是不会,这种设计思路提升了组件的渲染性能。

useState 默认值
useState 支持我们在调用的时候直接传入一个值,来指定 state 的默认值,比如这样 useState(0), useState({ a: 1 }), useState([ 1, 2 ]),还支持我们传入一个函数,来通过逻辑计算出默认值,比如这样:

import React, { useState } from 'react';
function App (props) {
   const [ count, setCount ] = useState(() => {
     return props.count || 0
   })
   return (
     <div>
       点击次数: { count } 
       <button onClick={() => { setCount(count + 1)}}>点我</button>
     </div>
     )
}

这个时候,就有小伙伴问了,那我组件每渲染一次,useState 中的函数就会执行一遍吗?如果是的话,就会产生性能问题。其实不会,useState 中的函数只会执行一次,我们可以做个测试:

import React, { useState } from 'react';
function App (props) {
    const [ count, setCount ] = useState(() => {
      console.log('useState default value function is call')
      return props.count || 0
    })
    return (
      <div>
        点击次数: { count } 
        <button onClick={() => { setCount(count + 1)}}>点我</button>
      </div>
      )
  }

结果如下:

声明多个 state 变量

当我们使用多个 useState 的时候,React 怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用顺序。Hook 的调用顺序在每次渲染中都是相同的,所以它能够正常工作:

import React, { useState } from 'react';
function App (props) {
  let count, setCount
  let sum, setSum
  if (count > 2) {
    [ count, setCount ] = useState(0)
    [ sum, setSum ] = useState(10)
  } else {
    [ sum, setSum ] = useState(10)
    [ count, setCount ] = useState(0)
  }
  return (
    <div>
      点击次数: { count } 
      总计:{ sum }
      <button onClick={() => { setCount(count + 1); setSum(sum - 1)}}>点我</button>
    </div>
    )
}

当我们在运行时改变 useState 的顺序,数据会混乱,增加 useState, 程序会报错。

2.2 Effect hook

Effect Hook 可以让你在函数组件中执行副作用操作,什么是副作用呢,就是除了状态相关的逻辑,比如网络请求监听事件查找 dom等动作均视为副作用。

React 组件中有两种常见副作用操作:需要清除的不需要清除的。我们来更仔细地看一下他们之间的区别。

无需清除的 effect

有时候,我们只想在 React 更新 DOM 之后运行一些额外的代码。比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。因为我们在执行完这些操作之后,就可以忽略他们了。让我们对比一下使用 class 和 Hook 都是怎么实现这些副作用的。

使用 class 的示例
Reactclass 组件中,render 函数是不应该有任何副作用的。一般来说,在这里执行操作太早了,我们基本上都希望在 React 更新 DOM 之后才执行我们的操作。

这就是为什么在 React class 中,我们把副作用操作放到 componentDidMountcomponentDidUpdate 函数中。以 React 实现计数器的 class 组件为例。它在 ReactDOM 进行操作之后,立即更新了 documenttitle 属性:

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

在上面的代码中,需要在 class组件中在两个生命周期函数中编写重复的代码逻辑。

这是因为很多情况下,我们希望在组件加载和更新时执行同样的操作。从概念上说,我们希望它在每次渲染之后执行 —— 但 Reactclass 组件没有提供这样的方法。即使我们提取出一个方法,我们还是要在两个地方调用它。

现在让我们来看看如何使用 useEffect 执行相同的操作。

使用 Hook 的示例

useEffect 解决了 class 组件存在的生命周期臃肿问题。

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffect 做了什么? 通过使用这个 Hook,你可以告诉 React 组件需要在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它。在这个 effect 中,我们设置了 document 的 title 属性,不过我们也可以执行数据获取或调用其他命令式的 API。

为什么在组件内部调用 useEffect? 将 useEffect 放在组件内部让我们可以在 effect 中直接访问 count state 变量(或其他 props)。我们不需要特殊的 API 来读取它 —— 它已经保存在函数作用域中。Hook 使用了 JavaScript 的闭包机制,而不用在 JavaScript 已经提供了解决方案的情况下,还引入特定的 React API

useEffect 会在每次渲染后都执行吗? 是的, 默认情况下,它在第一次渲染之后和每次更新之后都会执行。 你可能会更容易接受 effect 发生在“渲染之后”这种概念,不用再去考虑“挂载”还是“更新”。React 保证了每次运行 effect 的同时,DOM 都已经更新完毕。

需要清除的 effect

之前,我们研究了如何使用不需要清除的副作用,还有一些副作用是需要清除的。例如订阅外部数据源。这种情况下,清除工作是非常重要的,可以防止引起内存泄露!现在让我们来比较一下如何用 Class 和 Hook 来实现。

使用 Class 示例
React class 中,你通常会在 componentDidMount 中设置订阅,并在 componentWillUnmount 中清除它。例如,假设我们有一个 ChatAPI 模块,它允许我们订阅好友的在线状态。以下是我们如何使用 class 订阅和显示该状态:

class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}

你会注意到 componentDidMountcomponentWillUnmount 之间的代码逻辑相互对应。使用生命周期函数迫使我们拆分这些逻辑代码,即使这两部分代码都作用于相同的副作用。

使用 Hook 示例
你可能认为需要单独的 effect 来执行清除操作。但由于添加和删除订阅代码的紧密性,所以 useEffect 的设计原则是相关的业务逻辑需要在同一个地方执行。如果 effect 返回一个函数,React 将会在执行清除操作时调用它:

import React, { useState, useEffect } from 'react';

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // Specify how to clean up after this effect:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

为什么要在 effect 中返回一个函数? 这是 effect 可选的清除机制。每个 effect 都可以返回一个清除函数。如此可以将添加和移除订阅的逻辑放在一起。它们都属于 effect 的一部分。

React 何时清除 effectReact 会在组件卸载的时候执行清除操作。 正如之前学到的,effect 在每次渲染的时候都会执行。这就是为什么 React 会在执行当前 effect 之前对上一个 effect 进行清除。
注意:

并不是必须为 effect 中返回的函数命名。这里我们将其命名为 cleanup 是为了表明此函数的目的,但其实也可以返回一个箭头函数或者给起一个别的名字。

useEffect 生命周期

如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个函数的组合。

以往我们在绑定事件、解绑事件、设定定时器、查找 dom 的时候,都是通过 componentDidMountcomponentDidUpdatecomponentWillUnmount 生命周期来实现的,而 useEffect 会在组件每次 render 之后调用,就相当于这三个生命周期函数,只不过可以通过传参来决定是否调用。

需要注意的是,useEffect 会返回一个回调函数,作用于清除上一次副作用遗留下来的状态,如果useEffect 只调用一次,该回调函数相当于 componentWillUnmount 生命周期。

具体看下面例子:

function App () {
    const [ count, setCount ] = useState(0)
    const [ width, setWidth ] = useState(document.body.clientWidth)

    const onChange = () => {
      setWidth(document.body.clientWidth)
    }

    useEffect(() => {
      window.addEventListener('resize', onChange, false)
      return () => {
        window.removeEventListener('resize', onChange, false)
      }
    })

    useEffect(() => {
      document.title = count
    })

    return (
      <div>
        页面名称: { count } 
        页面宽度: { width }
        <button onClick={() => { setCount(count + 1)}}>点我</button>
      </div>
      )
  }

上面例子要处理两种副作用逻辑,这里我们既要处理 title,还要监听屏幕宽度改变,按照 class 的写法,我们要在生命周期中处理这两种逻辑,但在 hooks 中,我们只需要两个 useEffect 就能解决这些问题。

我们之前提到,useEffect 能够返回一个函数,用来清除上一次副作用留下的状态,这个地方我们可以用来解绑事件监听,这个地方存在一个问题,就是 useEffect 是每次 render 之后就会调用,比如 title 的改变,相当于 componentDidUpdate,但我们的事件监听不应该每次 render 之后,进行一次绑定和解绑,就是我们需要 useEffect 变成 componentDidMount, 它的返回函数变成 componentWillUnmount,这里就需要用到 useEffect 函数的第二个参数。

useEffect 的第二个参数

useEffect 的第二个参数,分三种情况:

  • 什么都不传,组件每次 render 之后 useEffect 都会调用,相当于 componentDidMountcomponentDidUpdate;
  • 传入一个空数组 [], 只会调用一次,相当于 componentDidMountcomponentWillUnmount;
  • 传入一个数组,其中包括变量,只有这些变量变动时,useEffect 才会执行;

具体看下面例子:

function App () {
    const [ count, setCount ] = useState(0)
    const [ width, setWidth ] = useState(document.body.clientWidth)

    const onChange = () => {
      setWidth(document.body.clientWidth)
    }

    useEffect(() => {
      // 相当于 componentDidMount
      console.log('add resize event')
      window.addEventListener('resize', onChange, false)

      return () => {
        // 相当于 componentWillUnmount
        window.removeEventListener('resize', onChange, false)
      }
    }, [])

    useEffect(() => {
      // 相当于 componentDidMount、componentDidUpdate
      document.title = count
    })

    useEffect(() => {
      // count变动时,`useEffect` 才会执行;
      console.log(`count change: count is ${count}`)
    }, [ count ])

    return (
      <div>
        页面名称: { count } 
        页面宽度: { width }
        <button onClick={() => { setCount(count + 1)}}>点我</button>
      </div>
      )
  }

根据上面例子的运行结果,第一个 useEffect 中的 ‘add resize event’ 只会在第一次运行时输出一次,无论组件怎么 render,都不会在输出;第二个 useEffect 会在每次组件 render 之后都执行,title 每次点击都会改变; 第三个 useEffect, 只有在第一次运行和 count 改变时,才会执行,屏幕发生改变引起的 render 并不会影响第三个 useEffect

2.3 Context hook

context 中的 ProviderConsumer,在类组件和函数组件中都能使用,contextType 只能在类组件中使用,因为它是类的静态属性,具体如何使用 useContext 呢?

const value = useContext(MyContext);

接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider>value prop 决定。

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext providercontext value 值。即使祖先使用 React.memoshouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。

useContext 的参数必须是 context 对象本身:

  • 正确: useContext(MyContext)
  • 错误: useContext(MyContext.Consumer)
  • 错误: useContext(MyContext.Provider)

调用了 useContext 的组件总会在 context 值变化时重新渲染。如果重渲染组件的开销较大,你可以通过使用 memoization 来优化。

提示

如果你在接触 Hook 前已经对 context API 比较熟悉,那应该可以理解,useContext(MyContext) 相当于 class 组件中的 static contextType = MyContext 或者 <MyContext.Consumer>

useContext(MyContext) 只是让你能够读取 context 的值以及订阅 context 的变化。你仍然需要在上层组件树中使用 <MyContext.Provider> 来为下层组件提供 context

把如下代码与 Context.Provider 放在一起

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
}

2.4 Memo hook

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

useMemo 是什么呢?它跟 memo 有关系吗?memo 就是类组件的 PureComponent,用来做性能优化的手段,useMemo 也是,useMemoVuecomputed 计算属性类似,都是根据依赖的值计算出结果,当依赖的值未发生改变的时候,不触发状态改变,useMemo 具体如何使用呢,看下面例子:

function App () {
  const [ count, setCount ] = useState(0)
  const add = useMemo(() => {
    return count + 1
  }, [count])
  return (
    <div>
      点击次数: { count }
      <br/>
      次数加一: { add }
      <button onClick={() => { setCount(count + 1)}}>点我</button>
    </div>
    )
}

上面的例子中,useMemo 也支持传入第二个参数,用法和 useEffect 类似

  • 不传数组,每次更新都会重新计算;
  • 空数组,只会计算一次;
  • 依赖对应的值,当对应的值发生变化时,才会重新计算(可以依赖另外一个 useMemo 返回的值);

需要注意的是,useMemo 会在渲染的时候执行,而不是渲染之后执行,这一点和 useEffect 有区别,所以 useMemo 不建议有副作用相关的逻辑。

同时,useMemo 可以作为性能优化的手段,但不要把它当成语义上的保证,将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们。

2.5 Callback hook

useCallbackuseMemo 的语法糖,能用 useCallback 实现的,都可以使用 useMemo。在 react 中我们经常面临一个子组件渲染优化的问题,尤其是在向子组件传递函数props时,每次 render 都会创建新函数,导致子组件不必要的渲染,浪费性能,这个时候,就是 useCallback 的用武之地了,useCallback 可以保证,无论 render 多少次,我们的函数都是同一个函数,减小不断创建的开销,具体如何使用看下面例子

const onClick = useMemo(() => {
  return () => {
    console.log('button click')
  }
}, [])

const onClick = useCallback(() => {
 console.log('button click')
}, [])

同样,useCallback 的第二个参数和useMemo一样,没有区别。

2.6 Ref hook

const refContainer = useRef(initialValue);

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

useRef 有两种用法:

  • 获取子组件的实例(只有类组件可用);
  • 在函数组件中的一个全局变量,不会因为重复 render 重复声明, 类似于类组件的 this.xxx

获取子组件实例
上面提到了一点,useRef 只能获取子组件的实例,这在类组件中也是同样的道理,具体看下面的例子:

// 使用 ref 子组件必须是类组件
class Children extends PureComponent {
  render () {
    const { count } = this.props
    return (
      <div>{ count }</div>
    )
  }
}

function App () {
  const [ count, setCount ] = useState(0)
  const childrenRef = useRef(null)
  // const 
  const onClick = useMemo(() => {
    return () => {
      console.log('button click')
      console.log(childrenRef.current)
      setCount((count) => count + 1)
    }
  }, [])
  return (
    <div>
      点击次数: { count }
      <Children ref={childrenRef}  count={count}></Children>
      <button onClick={onClick}>点我</button>
    </div>
    )
}

useRef 在使用的时候,可以传入默认值来指定默认值,需要使用的时候,访问 ref.current 即可访问到组件实例。

类组件属性

有些情况下,我们需要保证函数组件每次 render 之后,某些变量不会被重复声明,比如说 Dom 节点,定时器的 id 等等,在类组件中,我们完全可以通过给类添加一个自定义属性来保留,比如说 this.xxx, 但是函数组件没有 this,自然无法通过这种方法使用,使用useState 来保留变量的值,会触发组件 render,在这里完全是不需要的,我们就需要使用 useRef 来实现了,具体看下面例子:

function App () {
  const [ count, setCount ] = useState(0)
  const timer = useRef(null)
  let timer2 
  
  useEffect(() => {
    let id = setInterval(() => {
      setCount(count => count + 1)
    }, 500)

    timer.current = id
    timer2 = id
    return () => {
      clearInterval(timer.current)
    }
  }, [])

  const onClickRef = useCallback(() => {
    clearInterval(timer.current)
  }, [])

  const onClick = useCallback(() => {
    clearInterval(timer2)
  }, [])

  return (
    <div>
      点击次数: { count }
      <button onClick={onClick}>普通</button>
      <button onClick={onClickRef}>useRef</button>
    </div>
    )
}

当我们们使用普通的按钮去暂停定时器时发现定时器无法清除,因为 App 组件每次 render,都会重新申明一次 timer2, 定时器的 id 在第二次 render 时,就丢失了,所以无法清除定时器,针对这种情况,就需要使用到 useRef,来为我们保留定时器 id,类似于 this.xxx,这就是 useRef 的另外一种用法。

2.7 Reducer hook

useReducer 类似 redux 中的功能,相较于 useState,它更适合一些逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等特定场景, useReducer 总共有三个参数:

  • 第一个参数是 一个 reducer,就是一个类似 (state, action) => newState 的函数,传入上一个 state 和本次的 action;
  • 第二个参数是初始 state,也就是默认值,是比较简单的方法;
  • 第三个参数是惰性初始化,这么做可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 stateaction 做处理提供了便利;

具体使用方法看下面的例子:

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, {
    count: 0
  });
  return (
    <>
      点击次数: {state.count}
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
    </>
  );
}

2.8 ImperativeHandle hook

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值,说简单点就是,子组件可以选择性的暴露给父组件一些方法,这样可以隐藏一些私有方法和属性,官方建议,useImperativeHandle应当与 forwardRef 一起使用,具体如何使用看下面例子:

function Kun (props, ref) {
  const kun = useRef()

  const introduce = useCallback (() => {
    console.log('i can sing, jump, rap, play basketball')
  }, [])
  useImperativeHandle(ref, () => ({
    introduce: () => {
      introduce()
    }
  }));

  return (
    <div ref={kun}> { props.count }</div>
  )
}

const KunKun = forwardRef(Kun)

function App () {
  const [ count, setCount ] = useState(0)
  const kunRef = useRef(null)

  const onClick = useCallback (() => {
    setCount(count => count + 1)
    kunRef.current.introduce()
  }, [])
  return (
    <div>
      点击次数: { count }
      <KunKun ref={kunRef}  count={count}></KunKun>
      <button onClick={onClick}>点我</button>
    </div>
    )
}

2.9 LayoutEffect hook

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

尽可能使用标准的 useEffect 以避免阻塞视觉更新。
提示

如果你正在将代码从 class 组件迁移到使用 Hook 的函数组件,则需要注意 useLayoutEffect
componentDidMountcomponentDidUpdate 的调用阶段是一样的。但是,建议你一开始先用
useEffect,只有当它出问题的时候再尝试使用 useLayoutEffect

如果你使用服务端渲染,请记住,无论 useLayoutEffect 还是 useEffect 都无法在 Javascript
代码加载完成之前执行。这就是为什么在服务端渲染组件中引入 useLayoutEffect 代码时会触发 React
告警。解决这个问题,需要将代码逻辑移至 useEffect
中(如果首次渲染不需要这段逻辑的情况下),或是将该组件延迟到客户端渲染完成后再显示(如果直到 useLayoutEffect 执行之前
HTML 都显示错乱的情况下)。

若要从服务端渲染的 HTML 中排除依赖布局 effect 的组件,可以通过使用 showChild && <Child />
进行条件渲染,并使用 useEffect(() => { setShowChild(true); }, [])
延迟展示组件。这样,在客户端渲染完成之前,UI 就不会像之前那样显示错乱了。

2.10 DebugValue hook

useDebugValue(value)

useDebugValue 可用于在 React 开发者工具中显示自定义 hook 的标签。

例如,

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ...

  // 在开发者工具中的这个 Hook 旁边显示标签
  // e.g. "FriendStatus: Online"
  useDebugValue(isOnline ? 'Online' : 'Offline');

  return isOnline;
}

提示

不建议你向每个自定义 Hook 添加 debug 值。当它作为共享库的一部分时才最有价值。

延迟格式化 debug 值

在某些情况下,格式化值的显示可能是一项开销很大的操作。除非需要检查 Hook,否则没有必要这么做。

因此,useDebugValue 接受一个格式化函数作为可选的第二个参数。该函数只有在 Hook 被检查时才会被调用。它接受 debug 值作为参数,并且会返回一个格式化的显示值。

例如,一个返回 Date 值的自定义 Hook 可以通过格式化函数来避免不必要的 toDateString 函数调用:

useDebugValue(date, date => date.toDateString());

2.11 自定义hook

通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。

示例代码如下:

import React, { useState, useEffect } from 'react';

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

现在我们假设聊天应用中有一个联系人列表,当用户在线时需要把名字设置为绿色。我们可以把上面类似的逻辑复制并粘贴到 FriendListItem 组件中来,但这并不是理想的解决方案:

import React, { useState, useEffect } from 'react';

function FriendListItem(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

相反,我们希望在 FriendStatus 和 FriendListItem 之间共享逻辑。

目前为止,在 React 中有两种流行的方式来共享组件之间的状态逻辑: render props高阶组件,现在让我们来看看 Hook 是如何在让你不增加组件的情况下解决相同问题的。

提取自定义 Hook

当我们想在两个函数之间共享逻辑时,我们会把它提取到第三个函数中。而组件和 Hook 都是函数,所以也同样适用这种方式。

自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook。 例如,下面的 useFriendStatus 是我们第一个自定义的 Hook:

import { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

此处并未包含任何新的内容——逻辑是从上述组件拷贝来的。与组件中一致,请确保只在自定义 Hook 的顶层无条件地调用其他 Hook。

React 组件不同的是,自定义 Hook 不需要具有特殊的标识。我们可以自由的决定它的参数是什么,以及它应该返回什么(如果需要的话)。换句话说,它就像一个正常的函数。但是它的名字应该始终以 use 开头,这样可以一眼看出其符合 Hook 的规则。

此处 useFriendStatus 的 Hook 功能是订阅某个好友的在线状态。这就是我们需要将 friendID 作为参数,并返回这位好友的在线状态的原因。

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ...

  return isOnline;
}

现在让我们看看应该如何使用自定义 Hook。

使用自定义 Hook

我们一开始的目标是在 FriendStatus 和 FriendListItem 组件中去除重复的逻辑,即:这两个组件都想知道好友是否在线。

现在我们已经把这个逻辑提取到 useFriendStatus 的自定义 Hook 中,然后就可以使用它了:

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}
function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

这段代码等价于原来的示例代码吗?等价,它的工作方式完全一样。如果你仔细观察,你会发现我们没有对其行为做任何的改变,我们只是将两个函数之间一些共同的代码提取到单独的函数中。自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。

自定义 Hook 必须以 “use” 开头吗?必须如此。这个约定非常重要。不遵循的话,由于无法判断某个函数是否包含对其内部 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 的规则。

在两个组件中使用相同的 Hook 会共享 state 吗?不会。自定义 Hook 是一种重用状态逻辑的机制(例如设置为订阅并存储当前值),所以每次使用自定义 Hook 时,其中的所有 state 和副作用都是完全隔离的。

自定义 Hook 如何获取独立的 state?每次调用 Hook,它都会获取独立的 state。由于我们直接调用了 useFriendStatus,从 React 的角度来看,我们的组件只是调用了 useStateuseEffect。 正如我们在之前章节中了解到的一样,我们可以在一个组件中多次调用 useStateuseEffect,它们是完全独立的。

在多个 Hook 之间传递信息

由于 Hook 本身就是函数,因此我们可以在它们之间传递信息。

我们将使用聊天程序中的另一个组件来说明这一点。这是一个聊天消息接收者的选择器,它会显示当前选定的好友是否在线:

const friendList = [
  { id: 1, name: 'Phoebe' },
  { id: 2, name: 'Rachel' },
  { id: 3, name: 'Ross' },
];

function ChatRecipientPicker() {
  const [recipientID, setRecipientID] = useState(1);
  const isRecipientOnline = useFriendStatus(recipientID);

  return (
    <>
      <Circle color={isRecipientOnline ? 'green' : 'red'} />
      <select
        value={recipientID}
        onChange={e => setRecipientID(Number(e.target.value))}
      >
        {friendList.map(friend => (
          <option key={friend.id} value={friend.id}>
            {friend.name}
          </option>
        ))}
      </select>
    </>
  );
}

我们将当前选择的好友 ID 保存在 recipientID 状态变量中,并在用户从 <select> 中选择其他好友时更新这个 state

由于 useState 为我们提供了 recipientID 状态变量的最新值,因此我们可以将它作为参数传递给自定义的 useFriendStatus Hook:

const [recipientID, setRecipientID] = useState(1);
  const isRecipientOnline = useFriendStatus(recipientID);

如此可以让我们知道当前选中的好友是否在线。当我们选择不同的好友并更新 recipientID 状态变量时,useFriendStatus Hook 将会取消订阅之前选中的好友,并订阅新选中的好友状态。

三、Hook 规则

Hook 本质就是 JavaScript 函数,但是在使用它时需要遵循两条规则。可以通过 eslint-plugin-react-hooks 插件来强制执行这些规则:

  • 只在最顶层使用 Hook
  • 不要在循环条件嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层调用他们。遵守这条规则,就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useStateuseEffect 调用之间保持 hook 状态正确。
  • 只在 React 函数中调用 Hook;
  • 不要在普通的 JavaScript 函数中调用 Hook。你可以:

✅ 在 React 的函数组件中调用 Hook;
✅ 在自定义 Hook 中调用其他 Hook;
遵循此规则,确保组件的状态逻辑在代码中清晰可见。

四、拓展阅读

  • HOOK手册
  • 《React进阶(六):组件生命周期》
  • 《ReactNative进阶(十六):React-Native 组件生命周期》

相关文章