React Hooks Introduction

812 阅读8分钟

React 自 16.8 版本引入了 Hooks,自此 Function Components (FC)中可以使用内部状态(State)。本文将对 Hooks 进行简单介绍,并对后续组件设计引入一些简单的思路。

在本文中你可以了解到:

  • 为什么引入 Hooks,目的是解决什么问题
  • React 自带 Hooks 的基本用法、使用场景与注意事项
  • Hooks 使用时的约定
  • 如何书写自定义 Hooks 对公用逻辑进行封装
  • 引入 Hooks 后组件设计思路探索

阅读本文需要的一些储备知识:

  • React 基本知识,包括组件、生命周期、State 等
  • ECMAScript 2015+ (ES6+)基本语法知识,包括对象结构、箭头函数等

文中术语列表:

  • FC: Function Component 函数组件
  • CC: Class Component 类组件
  • HOC: High Order Component 高阶组件

Why Hooks

需要注意的是,本节仅说明为什么引入 Hooks 以及引入 Hooks 解决什么问题,React 项目中并无计划移除 CC,同时也不可否认 CC 在使用上具备独特的优势(如 完整的生命周期函数),FC / CC 的使用时机还是需要开发者根据业务场景进行判断选择。

状态逻辑复用

不变更组件层级的前提下进行复用,解决 HOC 方案中的过多嵌套。React 中 Mixin / HOC 的使用可以解决相同 state 变更逻辑的复用,但是 Mixin 引入的代价已经远远超出了他所带来的问题(参考官方 blog:Mixins Considered Harmful)已经基本弃用,而 HOC 则会增加组件结构树的深度,Hooks 的引入同时解决了逻辑的复用又不增加组件树深度。

此处以官方文档中的案例&代码示例(代码仅作问题说明并不能直接运行):假设我们的 App 中好友列表项(FriendListItem)、好友信息(FriendInfo)两个组件都需要用到好友的在线状态(isOnline)。传统的做法是将这部分逻辑进行抽离放到 HOC 中,然后将两个组件用 HOC 进行包装。这种方式会造成组件层级的加重,组件树的深度会随着 HOC 的引入逐渐加深(可以在 devTool 中观察到)。Hooks 的引入可以解决这部分问题。参考以下示例。

HOC 方案:

// HOC definition
function withOnlineStatus(WrappedComponent) {
    return class extends React.Component {
        constructor(props) {
            super(props)
            this.state = {
                isOnline: null,
            }
        }
        
        componentDidMount() {
            const { id } = this.props
            ChatAPI.subscribeToFriendStatus(id, this.handleStatusChange)
        }
        
        componentWillUnmount() {
            const { id } = this.props
            ChatAPI.unsubscribeFromFriendStatus(id, this.handleStatusChange)
        }
        
        handleStatusChange = isOnline => this.setState({ isOnline })
        
        render() {
            const { isOnline } = this.state
            const onlineStatus = {null: 'loading', true: 'online', false: 'offline'}[isOnline]
            return <WrappedComponent isOnline={isOnline} {...this.props} />
        }
    }
}

// Mount FriendListItemWithOnlineStatus where the FriendListItem should be mounted
const FriendListItemWithOnlineStatus = withOnlineStatus(FriendListItem)

Hooks:

// your custom hook
function useFriendStatus(id) {
    const [isOnline, setIsOnline] = useState(null)
    
    useEffect(() => {
        const handleStatusChange = isOnline => setIsOnline(isOnline)
        
        ChatAPI.subscribeToFriendStatus(id, handleStatusChange)
        return () => ChatAPI.unsubscribeFromFriendStatus(id, handleStatusChange)
    })
    return {null: 'loading', true: 'online', false: 'offline'}[isOnline]
}

// in FriendListItem component
function FriendListItem({ id, ...restProps }) {
    const onlineStatus = useFriendStatus(id)
    // other codes ...
}

此时好友列表组件(FriendList)直接是 FriendListItem 的父组件,解决了 HOC 带来的组件层级变化。

逻辑的内聚与解耦

Hooks 的引入方便我们将组件根据逻辑的相关性分割成更小的代码功能块,解决 CC 中常出现的两个问题:

  1. 同一个生命周期函数中包含不相关的逻辑,如页面标题更新,动画(当前激活的 tab 项底部 indicator 的过渡)
  2. 相关的逻辑要在不同的生命周期函数中处理,如订阅:componentDidMount中 subscribe,componentWillUnmount中 unsubscribe

同样是上面的例子,withOnlineStatus 返回的匿名 CC 组件中用户 状态的订阅与取消是相关的逻辑,但却要分割到两个生命周期函数中处理。引入 Hooks 后好友在线状态相关的处理逻辑被封装成了自定义 Hook useFriendStatus,而对于和好友在线状态不相关的逻辑(如 点赞数量、是否有未读消息等订阅)则可以封装成其他的自定义 Hook,以此达到了相关逻辑的聚合,无关逻辑的解耦

classthis 造成的困惑

classthis 的指向一直困扰了不少开发者,特别是在 CC 中对事件的处理函数中。如果没有 babel-plugin-transform-class-properties 的支持,CC 事件处理函数可能会很冗长(.bind 绑定 this 指向)。需要注意的是该 ES 语法提案尚未被正式引入,仍处于 Stage 2 阶段。

引入 Hooks 后 FC 的使用场景将更加多,而 functionthis 指针的指向相对于 class 更为清晰易懂。

AOT Compile

该部分是对未来的考虑。

AOT Compile(Ahead-of-time Compile 预先编译) 将高级语言转译成机器代码,对于运行时的资源消耗、性能平衡均做了优化。 目前 Svelte、Angular 均已经引入了 AOT Compile 机制。

尚处于实验阶段的 Facebook Prepack 包同样引入 AOT Compile 并对组件进行折叠(Component Folding)与精简化。但是实验过程中发现 CC 中的一些模式阻碍了这部分优化,同时 CC 对于现有的工具优化也有所阻碍(如 CC 在 hot reloading 中表现的并不可靠)。

更多的关于 Hooks 的 motivation,参考 RFC: React Hooks


React 内建 Hooks

useState:内部状态

const [yourState, setYourState] = useState(initialState)

在 FC 中定义 state,返回形如 [yourState, setYourState] 的数组(成对出现的内部状态及其设置方法),其中 initialState 参数有两种形式:

  • 确定的值,String / Number / Object 等, 此时 yourState 的初始值即为 initialState 的值
  • 函数,返回值用于初始化 yourState

和 CC 中 this.setState 一样,setYourState 首参支持函数形式返回新的 state,不同之处在于:

  1. 首参为函数时,setState 首参函数的参数为完整 state 对象,返回的是用于合并到 state 上的部分 state;而 setYourState 首参函数的参数为当前定义的 state 的值,返回的也是下一个下一次渲染时该 state 的值。
  2. setState 支持回调(第二个参数),而 setYourState 不支持,如需获取到新的 state 需要使用 useEffect 进行处理。

示例:

// in your Class Component
// state definition
state = {
    name: '',
    gender: 'male',
}

// update state
this.setState({ name: 'Sheldon' })
this.setState(
    preState => {
        console.log('previous state:', preState)
        return { name: 'Petter' }
    },
    () => console.log('current state after setState:', this.state)
)

//--------------------------------
// in your Function Component
// state definition
const [name, setName] = useState('')
const [gender, setGender] = useState(() => 'male')

// update state
setName('Sheldon')
setName(preName => {
    console.log('previous name:', preName)
    return 'Petter'
})

状态的惰性初始化

initialState 的值用于首次渲染时初始化 yourState,后续的渲染则会忽略他的值。如果初始状态的获取过程较为复杂,推荐将其封装成函数将该函数作为 initialState,此时只有第一次渲染时 initialState 函数会被调用。需要注意的是,initialState 是函数名或匿名函数,勿误用为函数调用

示例:

// >>>>>> in your FC <<<<<<
// this is NOT recommanded, cause getInitialHundredItems will always be called in every rendering
const [state, setState] = useState(getInitialHundredItems())

// this is recomanded, getInitialHundredItems will only be called at the 1st rendering
const [state, setState] = useState(getInitialHundredItems)

完整例子参考 React - Lazy Initial State

关于为什么是 useState 而非 createState

函数组件内部状态仅需在其第一次渲染的时候被创建,而后需要的时候直接返回即可,create 的释义不能精确描述这个特征。

TIPS

  • state 粒度划分:对于多个内部状态,可以像 CC 中定义 state 一样多次调用 useState 创建多个 state/setState 对来更细地划分粒度
  • 初始状态获取过程较为复杂时可封装成函数进行惰性初始化,但注意 useState 的参数是函数,而非函数调用

useEffect:副作用处理

useEffect(didUpdate, ?[dependencies])

默认情况下组件每次 完整的渲染 后都会调用传给 useEffectdidUpdate 函数。 此时可以简单认为 FC 中的 useEffect 对应 CC 中 componentDidMount / componentDidUpdate

以好友列表为例:

// using hooks in FC
function FriendList() {
    const [onLineFriends, setOnlineFriends] = useState([])
    useEffect(() => document.title = `${onLineFriends.lengs} friends online`)
    
    // ...render jsx
}

// CC
class FriendList extends React.Component {
    constructor(props) {
        @super(props)
        state = {
            friends: []
        }
    }
    
    componentDidMount() {
        fetchFriendList().then(data => this.setState({ friends: data }))
    }
    
    // ...other codes
}

副作用的清理

didUpdate 函数可以返回一个清理函数(clean-up function)用于:

  • 下次副作用触发时 didUpdate 调用前进行上一个副作用的清理。如上 状态逻辑复用 中好友在线状态的订阅与取消:组件被移除前需要移除订阅。
  • 如果组件渲染多次的话,下一个副作用在触发时会先调用 clean-up 函数来清理上一个副作用。

didUpdate 的触发时机

通常可以简单的认为 FC 中 useEffect 注册的 didUpdate 函数对应 CC 中 componentDidMount / componentDidUpdate。 但其实前者的执行要滞后于后两个: didUpdate 的调用是在 layout 和 paint 之后,而 componentDidMount 是在组件挂载到 dom tree 后立即调用,componentDidUpdate 则是在更新触发时立即调用的。

通常副作用的处理不应阻塞 UI 的更新,因此使用 useEffect 将副作用的处理延后到 UI 更新后是合理的。但是在一些 dom 变更的事件上,可能需要同步处理这些事件,这时候使用 useEffect 在 UI 更新后再去处理显然是不合理的。此时 React 提供了另一个 Hook:useLayoutEffect,它和 useEffect 用法一致,区别仅在触发时机上。

条件性地触发副作用

默认情况下通过 useEffect 注册的 didUpdate 函数在每次 UI 更新后都会被调用,那么就会造成上例中对好友在线状态的订阅会被重复的取消与再订阅,这显然是不合理的。在线状态的订阅与取消仅依赖用户 id,因此我们只需要在 id 变更时触发副作用即可,那么这部分逻辑可以改进为:

// in your FC
const { id } = props
const [isOnline, setIsOnline] = useState(null)
useEffect(() => {
    if (id) {
        const handleStatusChange = isOnline => setIsOnline(isOnline)
        ChatAPI.subscribeToFriendStatus(id, handleStatusChange)
    }
    
    // this is your preEffect clean-up function
    return () => ChatAPI.unsubscribeFromFriendStatus(id, this.handleStatusChange)
}, [id])

上述代码中 useEffect 的首参——匿名箭头函数即为副作用的处理函数(didUpdate),它返回了一个匿名箭头函数,即副作用的 clean-up 函数。第二个参数为该副作用处理函数的依赖数据项数组,一旦该数组中的任意一项数据发生变化,React 首先执行 clean-up 函数进行上一个副作用的清理,然后执行处理函数处理本次依赖项变更引起的副作用。

如果副作用的处理不依赖任何数据变更(props / state) 那么需为 useEffect 添加空的依赖项,此时他的作用类似 componentDidMount

// in your FC
const [friends, setFriends] = useState([])
useEffect(
    () => fetchFriendList().then(data => setFriends(data)),
    // pay attention to the next argument: []
    []
)

需要注意的是,在使用 `useEffect` 时需**确保副作用处理函数中用到的所有组件域内的数据(props、state 及由他们派生出的数据)均添加到了依赖数据数组中**,否则在执行副作用处理函数时,函数内部可能拿到的并不是最新的状态数据。这是因为 `useEffect` 注册的副作用处理函数在每次依赖项变更时都会被重新创建(可以理解 React 内部对其进行了一次 *闭包封装* )。

DO NOT lie about your dependencies

useEffect 使用总结

  • 默认情况下(不传第二个参数)每次 UI 更新后都会调用其注册的副作用处理函数
  • 如果需要实现类似 componentDidMount 的效果,需添加副作用的数据依赖项:[]
  • 如果副作用的触发需要依赖某些数据,需将所有的数据依赖添加数据依赖项中

useContext:上下文数据

本节需要对 React Context 有一定的理解。

const value = useContext(YourContext)

React 中除了 props、state 变更能够引起 re-render 外,Context 同样也是一种引起 re-render 的方法。相较于前两种 re-render,Context.value 变更引起的 Context.Consumer re-render 不会受控于 shouldComponentUpdateReact.memo,在优化使用 context 的组件时需要特别注意这一点,可以参考 使用 Context 的组件性能优化

通常 Context 被应用在那些需要被组件间共享但是通过 props 传递又显得过于笨重(体现在组件复用上)的数据上,如 UI 主题。

FC 中使用 useContext 类似 CC 中的 static contextType = MyContextContext.Consumer。即将组件转换为其父级组件树中最近一个 Context.Provider 的 consumer。

完整示例

useReducer:复杂 state 处理

const [state, dispatch] = useReducer(reducer, initialArg, ?init)

处理 state 的逻辑比较复杂时适用。state、reducer、dispath 类似 Redux 中的相关概念,reducer 为形如 (state, action) => newState 的函数;initialArg 为初始 state,当 init 有值时他作为 init 的参数;init 为获取初始状态的非必传函数。

useReducer vs. useState

两者都是用来创建与变更内部状态的,前者比后者稍显复杂。以下可以作为判断何时使用 useReduceruseState 的参考规则:

  • 当状态是 JS 中的基本数据类型时推荐用 useState
  • 当状态的变更比较简单时推荐 useState
  • 状态逻辑仅在当前组件中使用时推荐 useState
  • 多个状态属性要在不同的情况下配合变更时(如 请求的 loading、isSuccess、data)推荐 useReducer
  • 需要对状态的变更可预测、可测试时推荐 useReducer
  • 逻辑简单时 useState,稍复杂时推荐 useReducer,更复杂时则可以考虑使用 Redux 等

需要注意的是,以上参考规则并不是非黑即白的,多数情况下可以根据这些规则来决定使用哪个 Hook,但是还需考虑到具体的业务场景。

更多关于两者适用情况的文章可以参考:

useCallback:Memoized Callback

const memoizedCb = useCallback(() => doSomething(a, b), ?[a, b])

返回一个经 Memoized 的回调,用于一些优化场景。传入一个函数和该函数依赖的数据项,返回其经缓存(memoized)版本的回调,返回的函数只会在依赖项变更时变化。

注意,如果不传依赖数据(第二个参数),`useCallback` 在每次渲染时都会返回新的回调函数。因此如果回调函数体内如果没有数据依赖项第二个参数可以传一个空数组。另外,第一个参数中用到的所有非此函数内部作用域的数据依赖都必须列入依赖项(参数 2)中(没错,还是闭包的原因)。

useMemo

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

传入数据的计算函数与数据依赖项,返回经 Memoized 的数据,返回的数据只会在依赖项变更时才会重新计算返回。同样用于一些优化场景,例如获取数据的过程比较复杂,可以使用 useMemo 对数据进行缓存,只要获取数据的依赖项不变更,每次在获取他时都直接返回前一次计算出的结果。和 useCallback 一样,用于计算数据的所有依赖项都必须明确的列出来作为第二个参数传入,并且如果不传入依赖项每次渲染时都会调用计算函数生成数据。

useMemo vs. useCallback

两者都用于一些优化场景:

  • 使用 useCallback 来为回调添加依赖项,避免重复渲染
  • 使用 useMemo 来处理复杂数据,避免重复计算浪费性能

更多关于useMemouseCallback 的使用场景可以参考:React Hooks: useCallback and useMemo

useRef

const ref = useRef(initialVal)

创建并返回一个在组件整个生命周期中都持续存在的可变对象,其 .current 属性使用 initialVal 初始化。类似 React 中用于访问组件实例对象的 ref (React.createRef),但是它可以用于报错任意类型的可变数据。

useRef 返回的对象在数据变更时是无感的,同样也不会引起 re-render(这点同样可以用来优化组件),所以如果对其变化做处理,需要结合 useEffect 来使用。

useImperativeHandle

useImperativeHandle(ref, createHandle, ?[deps])

用于在使用 ref 时定制化当前组件中暴露给父级组件的数据,通常应该和 forwardRef 一起使用。通常情况下通过 ref 获取到的组件实例对象包含了当前组件的所有属性数据,使用该 Hooks 可以对其进行订制,参考示例:

// pay attention to the React.forwardRef function
const MyInput = React.forwardRef((props, ref) => {
  const [val, setVal] = React.useState('');
  const inputRef = React.useRef();

  // after using React.useImperativeHandle, parent component can only get the blur property in ref.current by using ref
  React.useImperativeHandle(ref, () => ({
    blur: () => {
      document.title = val;
      inputRef.current.blur();
    }
  }));

  return (
    <input
      ref={inputRef}
      val={val}
      onChange={e => setVal(e.target.value)}
      {...props}
    />
  );
});

const App = () => {
  const ref = React.useRef(null);
  const onBlur = () => {
    // only contains one property!
    console.log(ref.current);
    ref.current.blur();
  };

  return <MyInput ref={ref} onBlur={onBlur} />;
};

ReactDOM.render(<App />, document.getElementById("app"));

useLayoutEffect

useLayoutEffect(didUpdate, ?[dependencies])

useEffect 用法类似,区别在于调用时机:useLayoutEffect 是在 dom 变化后浏览器绘制前同步调用,他会阻塞浏览器的绘制。使用场景较为少见,通常能用 useEffect 的情况下推荐使用 useEffect,除非需要同步地知晓页面元素间的间距等。

useDebugValue

useDebugValue(value, ?displayFormatter)

用于在 DevTools 中自定义 Hooks 上展示一个含有 value 值的标签,非必传的 displayFormatter 用于将 value 格式话输出展示。不推荐随意使用该 Hook,如果自定义 Hook 是共享库时使用该 Hook 更具价值(提高开发效率)。

更多关于何时使用 useImperativeHandleuseLayoutEffectuseDebugValue 的详细介绍参考 Stack Overflow


Hooks 使用约定

Hooks 的调用必须放在 FC 函数体的顶级

不要将 Hooks 调用使用 if-else 语句包裹,不要放在内部循环中,不要放在内部函数中。总之必须要放在函数体顶级以保证 React 能够正确处理多个 Hooks 的调用时的状态、数据依赖等。具体解释参考 React Official Doc

Hooks 只能在 FC 中调用

不能在 JS 的普通函数中调用(自定义 Hooks 函数封装除外,因为他们终将被用在 FC 中)。

不要在依赖项上对 React 撒谎

useEffectuseLayoutEffectuseMemouseCallback 等处理函数中有依赖外部数据时,一定要将这些数据列入依赖项作为参数传给 Hooks 调用。否则在处理函数中可能并不能获取到正确的数据与状态。

正确使用依赖项

useEffectuseLayoutEffectuseMemouseCallback 等 Hooks,如果不传依赖项(为 undefined),那么在每次组件的 re-render 时都会调用处理函数,如果不需要这么做,需将 [] 作为依赖项传入 Hook 调用。


自定义 Hooks

文初的 状态逻辑复用 一节中已经展示了一个自定义 Hook:useFriendStatus。自定义 Hook 其实是一个函数:它接收一个初始化数据或获取初始数据的函数,在内部调用一些 React 自带 Hooks 定义一些内部状态及其变更方法、副作用处理等,最后返回暴露给组件的状态、状态变更方法等。

我们对上文中的 useFriendStatus 自定义 Hook 进行完善:

function useFriendStatus(id) {
    // inner component state
    const [isOnline, setIsOnline] = useState(null)
    
    // side effect triggered by id changing
    useEffect(() => {
        const handleStatusChange = isOnline => setIsOnline(isOnline)
        
        ChatAPI.subscribeToFriendStatus(id, handleStatusChange)
        return () => ChatAPI.unsubscribeFromFriendStatus(id, handleStatusChange)
    }, [id])
    
    // state exposed to component
    return isOnline
}

function FriendOnlineStatus({ id, ...restProps }) {
    const isOnline = useFriendStatus(id)
    const statusClass = {
        null: 'loading',
        true: 'online',
        false: 'offline',
    }[isOnline]
    
    return <span className={`icon-onlineStatus ${statusClass}`} />
}

推荐自定义 Hook 使用以 use 开始的小驼峰命名法。


引入 Hooks 后组件设计思路的变化

关注点:依赖项 vs. 组件生命

以好友在线状态订阅消息为例,在引入 Hooks 之前,订阅逻辑需分散在:

  1. componentDidMount 订阅初始用户的在线状态消息
  2. componentDidUpdate 在用户(id)变化时取消当前订阅,并重新订阅
  3. componentWillUnmount 在组件卸载前取消当前订阅

此时对于消息订阅的逻辑在设计时我们更多地需要关注组件的生命周期,但实质上消息的取消与订阅仅依赖于用户。识别出真正的依赖项,转移关注点可以使组件逻辑更加内聚。

面向过程 vs. 面向对象

Hooks 只能在 FC 中调用,这也体现了 Facebook 在软件开发中一直秉承的理念:函数式编程。这里两种编程、设计思路孰优孰劣我们不做过多讨论,在不同的业务场景,两种思路各有优劣。引入 Hooks 后 FC 的使用场景将更加多,组件的设计也将更加灵活。

UI 与业务逻辑

在引入 Hooks 前,由于 FC 中没有内部状态,通常设计 FC 仅用于 UI 展示。为了更好的设计与划分组件,我们曾引入容器型组件与展示型组件的概念(参考 Presentational and Container Components),但是设计过程中会发现一些展示型组件其实是需要内部状态的,这时候我们只能将状态提升(Lifting State Up)到其 container 组件中,随着开发的推进,container 组件中将包含各种 presentational 组件中需要的处理逻辑。而 Hooks 的引入则改善了这种状态,容器型与展示型组件的概念依然适用于未来的组件设计,只是 UI 逻辑将高度聚合在展示型组件中,业务处理逻辑则可以内聚在 container 中,做到 UI 于业务逻辑上的解耦。