React Hooks 原理与应用

React Hooks 起源

  • React 一直都提倡使用函数式组件。更轻便,更优雅,性能更佳。函数式组件又称无状态组件(FSC)
  • 以前,需要使用 state ,生命周期等React 特性,必须重构为 class 组件。
  • Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
  • 现在,你可以直接在现有的函数式组件中使用 Hooks,而无须重构为 class 组件。
  • 全新的思维方式。no magic, just javascript and some rules。

类组件被诟病

  • 类(累):自js开天辟地,就是面向函数式编程(FP), 面向对象编程(OOP)为何物。烦人的构造函数。super是什么?……
  • this绑定:类方法不会自动绑定 this 到实例上。现有四种bind方式。不优雅,易出错,bind还影响性能(使用箭头函数后有所改善)。
  • setState(): 异步更新机制,state 浅合并机制。不理解这些概念,很容易踩坑。
  • 生命周期耦合:每个生命周期方法通常包含一堆不相关的逻辑;不同生命周期中的逻辑又有关联。

下面组件来自实际项目,经过简化和微调(方便演示和直观感受),基本上暴露出了上面所有问题。业务逻辑严谨性不用推敲:

class NumberInput extends React.Component {
  constructor(props) {
    // 为什么必须super,不传props会怎样
    super(props)
    this.state = {
      focus: false
    }
    this.tradingpwd = ''
    // 第一种bind,官方推荐
    ;['onBlur'].forEach(method => {
      this[method] = this[method].bind(this)
    })
  }
  // 下面两个生命周期得相互配合,实现某些功能
  componentDidMount() {
    this.tradingPwdHideInput.focus()
    // 处理某类兼容问题
    let bodyTop = document.body.getBoundingClientRect().top
    const styleText = 'position: fixed; width: 100%; top: ' + bodyTop + 'px'
    document.body.style.cssText = styleText
  }

  componentWillUnmount() {
    this.tradingPwdHideInput.blur()
    document.body.style.position = 'static'
  }

  tradingPwdChange(e) {
    // ...
    this.tradingpwd = e.target.value
    this.props.inputChangeCallback(e.target.value)
    // ...
  }
  // 第二种bind
  onFocus = () => {
    this.setState({
      focus: true
    })
  }
  onBlur() {
    this.setState({
      focus: false
    })
  }

  render() {
    return (
      <div className={classNames('NumberInput')}>
        <input
          type='tel'
          ref={ref => {
            this.tradingPwdHideInput = ref
          }}
          id='tradingPwdHideInput'
          /* 第三种bind */
          onClick={() => {
            this.tradingPwdHideInput.focus()
          }}
          onBlur={ this.onBlur }
          onFocus={ this.onFocus }
          /* 第四种bind,不推荐,在每次 render() 方法执行时绑定类方法,消耗性能*/
          onChange={ this.tradingPwdChange.bind(this) }
        />
      </div>
    )
  }
}

proposal-class-fields 新提案会改善上述情况,目前处于第三阶段。

随着类组件趋于复杂,还有其他诟病:

  • 难拆分,本地state逻辑到处都是,当组件越来越复杂,想拆分比较难。
  • 状态逻辑难复用:需要引入高阶特性进行代码重构,需要调整组件结构,成本高。
  • 抽象地狱:大型React往往使用render propsHOCContext 等高阶特性,形成大量包装组件(wrapping components)。层级冗余,逻辑难追踪。

Hooks 优越性

Hooks 引入的一个重要的原因,就是类组件存在着种种诟病。那他必然存在一些优越性。

在说明这些优越性之前,先了解一个概念:

副作用:React 中主要指那些没有发生在数据向视图(M-V)转换过程中的逻辑,如 Ajax 请求、访问原生 DOM 元素、本地持久化缓存、绑定/解绑事件、添加/取消订阅、设置定时器、记录日志等。

Hooks 的优越性:

  • 函数式编程:No class, No super, No this。对于不了解 OOPReact 初学者更友好。
  • 有状态逻辑易复用:可以通过 Custom Hook(后面讲解)重构,而不用修改组件结构。
  • 易拆分:状态管理和副作用管理松耦合,原子性强。很容易将一些相关联的逻辑拆分成更小的函数。
  • 可逐步引入:Hooks 向后兼容,与现有代码可并行工作,因此我们可以逐步采用它们。
  • 副作用分组:很多副作用逻辑分散在类组件生命周期函数中。而 Hooks 可以将每个副作用的设置和清理封装在一个函数中。
  • 副作用分离:副作用操作都在页面渲染之后。

抛弃类组件?

既然 Hooks 存在这么多优越性。那是不是就到了抛弃 class 组件的时候了。

对此,官方说:

  • 新版本依然支持 class 相关API,在相当一段时期内,class 组件 和 Hooks 组件并存。
  • 向后兼容,是加法。注意,是函数组件的加法,即 Hooks 只能用在函数组件中。
  • 推荐使用 函数组件 + Hooks

个人看法:

不抛弃,不放弃。class 组件将我们带到了 OOP 的世界,OOP在编程界举足轻重,其思想是值得学习的,还会长期存在。即便 class 组件已然成为一种历史产物,但他的存量巨大,依然需要去维护,去慢慢消化。

所以:

  • 对于 React 老司机:拥抱Hooks,是拥抱变化。这个变化,是加法,是学习新的API,新的技能,新的思想。
  • 对于 React 新手:拥抱Hooks,降低了学习门槛,可以更快入门。但是类组件也非常有必要去了解,理解。知己知彼,重构不殆。

说了这么多,我们来和这些 React HooksAPI 见个面:

基础 Hook

  • useState 简单状态管理
  • useEffect 副作用管理

常用 Hook

  • useContext 全局状态管理
  • useReducer 复杂状态管理
  • useRef 访问 Dom 元素

其他 Hook

  • useMemo
  • useCallback
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue
  • … 还会增加

useState hook

  • 功能:在函数组件中用来进行简单状态管理,创建一些本地 state
  • API:const [currentState, setFunction] = useState(initialState);。传一个参数,返回一个数组(包含两个值)- 三要素。
import React, { useState } from 'react';
function Form() {
  // ES6 解构
  const [name, setName] = useState('Mary');              // State 变量 1
  const [surname, setSurname] = useState('Poppins');     // State 变量 2
  const [width, setWidth] = useState(window.innerWidth); // State 变量 3

  function handleNameChange(e) {
    setName(e.target.value);
  }

  function handleSurnameChange(e) {
    setSurname(e.target.value);
  }

  return (
    <>
      <input value={name} onChange={handleNameChange} />
      <input value={surname} onChange={handleSurnameChange} />
      <p>Hello, {name} {surname}</p>
      <p>Window width: {width}</p>
    </>
  );
}
export default Form;

useState「粒度」问题

看到这里,对于写过class组件的我们,很容易产生一个疑问。 实际工作中,一个类组件的 this.state 中往往有十几项,用 Hooks 改写的话难道要写十几个 useState 么?

根据官方文档,总结下来,有几点:

  • 建议将 state 分割为多个 useState。粒度更细,更易于管理,更好复用。
  • 可能一起改变的 state 可合并成一个useState( 比如Dom元素的 top left)。
  • state 逻辑趋于复杂,建议使用 useReducerCustom Hook 管理(后面介绍)。

当组件的 state 很多的时候,为了提高代码的可读性,也可以把逻辑相关的一些 state 合并为一个 useState( 比如分页参数 )。但这些 state 并不是一起改变的,所以当其中一个 state 改变,调用对应的 setFunction 的时候。你需要做对象合并(不合并就丢了):

const [ pageData, setPageDate ] = useState({ pageSize: 20, current: 1, total:0, })

const onPageChange = current => {
  // 常规操作
  setPageDate( Object.assign( {}, pageData, { current } ) )
  // 官方建议
  setPageDate(currentPageData => ({ ...currentPageData, current}));
}

知识点:调用 useState 的更新函数时,可以传一个箭头函数,这个函数的参数是当前最新的 state, 返回值是要设置的 state

useEffect hook

API 可抽象为: useEffect(arrowFunction, [depsArr])

  • arrowFunction: 必须。执行函数,执行副作用操作。它决定了做什么。
  • depsArr: 非必须。一个依赖项数组。它决定了什么时候做(下面示例中介绍)。

根据实际情况,可细分为三种:

// 第一种
// 最基础的,只有箭头函数。没有依赖项,所以组件每次渲染都会执行。
// 相当于  componentDidMount + componentDidUpdate
useEffect(() => { 
  //side-effect 
})
// 第二种
// 有依赖项,是一个空数组,因为它永远不会变,所以只会首次执行。
// 相当于 componentDidMount
useEffect(() => { 
  //side-effect 
}, [])
// 第三种
// 有第二个参数,且非空数组。首次渲染会执行。重新渲染时,只有当依赖项的值改变了才会执行。
useEffect(() => { 
  //side-effect 
}, [...state])

总结下来:

  • 功能:管理 React 函数组件的副作用,赋予生命周期能力。
  • 怎么管:组件每次渲染到屏幕之后,根据依赖项的情况判断是否调用执行函数。
  • 二要素:执行函数,依赖项。
  • 清理机制:你可以在执行函数中返回另一个函数-清理函数,清理函数会在组件卸载的时候,会在组件重新渲染,且useEffect的依赖项值改变的时候调用。起到了 class 组件中componentWillUnmount的作用, 后续会在场景实例中介绍。
  • 使用上:和 useState 一样,可使用多个。建议一个副作用对应一个 useEffect

以定时器为例,让我们来实现一个秒表组件。这是一个学习和理解 useEffect 非常有意思的例子:

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

function App() {
  // 秒表开关
  const [isOn, setIsOn] = useState(false);
  // 计数
  const [timer, setTimer] = useState(0);

  useEffect(() => {
    let interval;
    //开关打开的时候才执行
    if (isOn) {
      // 通过定时器增加计数
      interval = setInterval(
        () => setTimer(val => val + 1),
        1000,
      );
    }
    // 需要清除定时器
    return () => clearInterval(interval);
  },[isOn]);

  return (
    <>
      <p>{timer}</p>

      {!isOn && (
        <button type="button" onClick={() => setIsOn(true)}>
          Start
        </button>
      )}

      {isOn && (
        <button type="button" onClick={() => setIsOn(false)}>
          Stop
        </button>
      )}
    </>
  );
}

export default App;

规则:useEffect 不能接收 async 作为执行函数。useEffect 接收的函数,要么返回一个能清除副作用的函数,要么就不返回任何内容。而 async 返回的是 promise
useEffect 调用的函数如果依赖 state 或者 props。最好在执行函数中定义。这样依赖容易追踪。

useEffect 的使用,看起来很简单。但是要做到不滥用,正确使用也不是那么容易。主要在使用之前要多一些思考。

Custom Hooks

它并不是 React hooksAPI,而是自定义 hook。顾名思义,React允许你构建自己的 hooks。在学习完前面两个最受欢迎的 hooks 以后,你完全具备了实现自定义 hooks 的能力。

官网定义: 自定义 Hook 是一个 JavaScript 函数,其名称以 ”use” 开头,可以调用其他 Hook

为什么需要Custom Hooks?

  • useState 解决了函数组件无状态的问题。
  • useEffect 实现了副作用管理,生命周期的功能。
  • Custom Hooks 将解决有状态(stateful)逻辑共享的问题(相当于类组件中Hoc的功能)。👇

我们来到一个实际场景。如今 HTML5 移动应用或 Web app 中越来越普遍的使用了离线浏览技术,所以用 JS 检测浏览器在线/离线状态非常常见。首先,我们用 React Hooks 来实现这个功能:

import React, { useState, useEffect } from 'react';
function App() {
  const [isOffline, setIsOffline] = useState(window.navigator.onLine);
  // 离线事件处理方法
  function onOffline() {
    setIsOffline(true);
  }
  // 在线事件处理方法
  function onOnline() {
    setIsOffline(false);
  }
  useEffect(() => {
    // 事件监听
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);
    // 清理函数
    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []); // 只需要首次执行
  return (
    <>
       { 
         isOffline
         ? <div>网断已断开 ...</div>
         : <div>网络已连接 ...</div>
       }
    </>
  )
}
export default App;

无论浏览器是否在线,navigator.onLine 属性都会提供一个布尔值。 如果浏览器在线,则设置为 true ,否则设置为 false

OK,我们实现了一个很不错的功能。很明显,这个功能是可复用的,应该共享的。
我们把功能逻辑提取出来,把它封装成一个 Custom hook 就可以了:

import React, { useState, useEffect } from 'react';
// 自定义 hook
function useOffline() {
  const [isOffline, setIsOffline] = useState(window.navigator.onLine);
  function onOffline() {
    setIsOffline(true);
  }
  function onOnline() {
    setIsOffline(false);
  }
  useEffect(() => {
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);
    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []);
  return isOffline; // 只暴露一个 state
}

// 函数组件
function App() {
  const isOffline = useOffline();
  return (
    <>
       { 
         isOffline
         ? <div>网断已断开 ...</div>
         : <div>网络已连接 ...</div>
       }
    </>
  )
}
export default App;

从重构层面来说,就是把组件中的一些 hooks 抽离到一个函数中,再使用这个函数。这个函数就是 React custom hooks

书写 custom hooks 需要注意些什么呢:

  • 自定义 Hooks 自然地遵循 Hooks 设计的约定。即遵循所有你用到的 Hooks 的规则。
  • 请使用 use 开头。这个习惯非常重要。如果没有它,我们就不能自动检查该 Hook 是否违反了 Hooks 的规则,因为我们无法判断某个函数是否包含对其内部 Hooks 的调用。

useRef Hook

在典型的 React 数据流中,DOM 元素的修改都是通过 M-V 的形式重新渲染。M 主要是组件的本地 state ,以及父组件传递给子组件的 props
但是,在某些情况下,你需要在典型数据流之外,手动使用/操作 DOM

  • 管理焦点,媒体播放,文本选择,触发强制动画等。
  • 集成第三方 DOM 库。
import React, { useState, useEffect, useRef } from "react";
function App() {
  const [value, setValue] = useState("hellow hooks");
  //创建 refs 只有这里有修改
  const inputRef = useRef();
  // 日志A
  console.log(inputRef.current) 
  useEffect(() => {
    // 日志B
    console.log(inputRef.current);
    inputRef.current.focus();
  });
  function inputHandler(val) {
    setValue(val.target.value);
  }
  return (
    <>
      <input type="text" value={value} ref={inputRef} onChange={inputHandler} />
    </>
  );
}
export default App;
  • 类组件中:React16.3 及以上版本建议使用 React.createRef 这个API,低版本使用 回调refs
  • 函数组件中:为性能考虑,建议都使用 useRef 这个 hook

useContext Hook

React 16.3.0 中引入了 Context 系列 API。用于共享那些对于一个组件树而言是 “全局” 的 state。可用于解决 prop drilling 问题。

useContext Hook 只是新增了一种方式,让你在函数组件中更方便,更优雅的消费 context:

//
// 目标子组件 - D
// 使用 context 对象的 Consumer 组件获取共享数据,通过更新函数更新数据
const D = () => (
  const { value, setValue } = React.useContext(MyContext)
  return (
    <input
      type="text"
      value={value}
      onChange={e => {
        setValue(e.target.value);
      }}
    />
  )
);

可读性更高了,还可很方便的消费多个 context

UseReducer Hook

useReducer hook 用于 React 函数组件中管理复杂的 state 。它把一个reducer方法,和初始state作为输入,包含当前state,和一个dispatch方法的解构数组作为输出。

API( 对照 useState ):

// useReducer hook API
const [current, dispatch] = useReducer(reducer, initState);

// useState hook API
const [current, setFunc] = useReducer(initState);

来看一个使用 useReducer 的简单例子:

const initialState = {count: 0};

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 Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

上述示例中的 state 很简单,其实使用 useState 就可以。那么,我们什么时候该使用 useReducer 呢?

适用于useReducer 的复杂 state 的场景主要有:

  • state 逻辑较复杂且包含多个子值(大的对象,数组)。
  • state 更新依赖于之前的 state
  • state 组件树深层更新。使用useReducer可以向子组件传递 dispatch 而不是回调函数,这样可以优化性能。

原理

  • 集中管理:每个组件都有一个 “内存单元” 的内部列表,作为 Hooks管理中心。
  • just js:只是 JavaScript 对象 + 闭包。你可以想象它是一个数组(实际上是一个单向链表),我们可以在其中放置一些数据。
  • 顺序调用:当组件调用 useState()Hook 时,它读取当前单元格(或在第一次呈现时初始化它),然后将指针(cursor) 移动到下一个单元格。这就是多个 useState() 调用各自获取独立本地state的方式。

缺点

需要开发者遵从许多规则。理解并合理运用这些规则,能写出优雅的,可读性高的,性能好的代码。反之,很容易出现死循环,数据重复请求等问题。最让人担心的是性能,很多时候业务功能实现了,但是其实存在很多不必要的开销。


参考资料
官网-Hooks
What Are React Hooks
React Hooks 详解 + 项目实战