React Hooks常用hooks和原理简析

889 阅读9分钟

类组件有什么痛点

  1. 在组件之间复用状态逻辑很难。
  2. 在class组件中,有许多的lifecycle 函数,你需要在各个函数的里面去做对应的事情。这种方式带来的痛点是:逻辑分散在各处,开发者去维护这些代码会分散自己的精力,理解代码逻辑也很吃力。
  3. class组件的困惑:this问题等,同时,基于class的组件难以优化。

hooks就很好的解决了上面的问题

hooks

Hooks并不是神秘,它就是函数式组件。更准确的概述是:有状态的函数式组件。

useState

每次渲染,函数都会重新执行。我们知道,每当函数执行完毕,所有的内存都会被释放掉。因此想让函数式组件拥有内部状态,并不是一件理所当然的事情。useState就是帮助我们做这个事情,useState利用闭包,在函数内部创建一个当前函数组件的状态。并提供一个修改该状态的方法。

每一个JS模块都可以认为是一个独立的作用域,当代码执行时,该词法作用域创建执行上下文,如果在模块内部,创建了可供外部引用访问的函数时,就为闭包的产生提供了条件,只要该函数在外部执行访问了模块内部的其他变量,闭包就会产生。

再来一个例子。定义一个名为State的模块,代码如下:

// state.js
let state = null;

export const useState = (value: number) => {
  // 第一次调用时没有初始值,因此使用传入的初始值赋值
  state = state || value;

  function dispatch(newValue) {
    state = newValue;
    // 假设此方法能触发页面渲染
    render();
  }

  return [state, dispatch];
}

在其他模块中引入并使用。

import React from 'react';
import {useState} from './state';

function Demo() {
  // 使用数组解构的方式,定义变量
  const [counter, setCounter] = useState(0);

  return (
    <div onClick={() => setCounter(counter + 1)}>hello world, {counter}</div>
  )
}

export default Demo();

执行上下文state(模块state)以及在state中创建的函数useState。当useState在Demo中执行时,访问了state中的变量对象,那么闭包就会产生。根据闭包的特性,state模块中的state变量,会持久存在。因此当Demo函数再次执行时,我们也能获取到上一次Demo函数执行结束时state的值。这就是React Hooks能够让函数组件拥有内部状态的基本原理。

无论是在class中,还是hooks中,state的改变,都是异步的。

setState(obj)如果obj地址不变,那么React就认为数据没有变化。
// 错误代码
const [user,setUser] = useState({name:'lifa', age: 18})
const onClick = () => {
  // 在原来的引用地址上修改name属性,不会起作用
  user.name = 'jack' //修改后通过setUser是无法触发视图更新的,但是其他的触发视图更新,name为jack会显示到视图上
  setUser(user)
}

// 正确代码
const [user,setUser] = useState({name:'lifa', age: 18})
const onClick = () => {
  // 重新生成一个引用地址
  setUser({
    ...user,
    name: 'jack'
  })
}

利用上面的这个特性,param这个变量对于DOM没有影响,只是请求数据用的,此时将他定义为一个异步变量并不明智。好的方式是将其定义为一个同步变量。

export default function AsyncDemo() {
  const [param] = useState<Param>({});
  const [listData, setListData] = useState<ListItem[]>([]);

  function fetchListData() {
    // @ts-ignore
    listApi(param).then(res => {
      setListData(res.data);
    })
  }

  function searchByName(name: string) {
    param.name = name;
    fetchListData();
  }

  return [
    <div>data list</div>,
    <button onClick={() => searchByName('Jone')}>search by name</button>
  ]
}

useEffect

useEffect用于处理大多数副作用,其中的回调函数会在render执行之后在调用(渲染时异步调用,渲染完成后再执行),确保不会阻止浏览器的渲染,这跟componentDidMount和componentDidUpdate是不一样的,他们会在渲染时同步执行。

useEffect的特点:

  1. 有两个参数 callback 和 dependencies 数组
  2. 如果 dependencies 不存在,那么 callback 每次 render 都会执行
  3. 如果 dependencies 存在,只有当它发生了变化, callback 才会执行
let deps; // deps 记录 useEffect 上一次的依赖

function useEffect(callback, depsArray) {
  const hasNoDeps = !depsArray; // 如果 dependencies 不存在
  const hasChangedDeps = deps
    ? !depsArray.every((el, i) => el === deps[i]) // 两次的 dependencies 是否完全相等
    : true;
  /* 如果 dependencies 不存在,或者 dependencies 有变化*/
  if (hasNoDeps || hasChangedDeps) {
    callback();
    deps = depsArray;
  }
}

useLayoutEffect

在大多数情况下,我们都可以使用useEffect处理副作用,但是,如果副作用是跟DOM相关的,就需要使用useLayoutEffect。useLayoutEffect中的副作用会在DOM更新之后同步执行。

由于 JS 线程和浏览器渲染线程是互斥的,即使内存中的真实 DOM 已经变化,浏览器也没有立刻渲染到屏幕上,此时会进行收尾工作,同步执行对应的生命周期方法,我们说的componentDidMount,componentDidUpdate 以及 useLayoutEffect(create, deps) 的 create 函数(已经可以拿到最新的 DOM 节点)都是在这个阶段被同步执行。commit阶段的操作执行完,浏览器把发生变化的 DOM 渲染到屏幕上,到此为止 react 仅用一次回流、重绘的代价,就把所有需要更新的 DOM 节点全部更新完成。浏览器渲染完成后,浏览器通知 react 自己处于空闲阶段,react 开始执行自己调度队列中的任务,此时才开始执行 useEffect(create, deps) 的产生的函数。

function App() {
    const [width, setWidth] = useState(0);
    useLayoutEffect(() => {
        const title = document.querySelector('#title');
        const titleWidth = title.getBoundingClientRect().width;
        if (width !== titleWidth) {
            setWidth(titleWidth);
        }
    });
    return <div>
        <h1 id="title">hello</h1>
        <h2>{width}</h2>
    </div>
}

useReducer

useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数 。

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

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);  
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'reset', payload: initialCount})}>        
        Reset
      </button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

useContext

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

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

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>
  );
}

useRef

useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` 指向已挂载到 DOM 上的文本输入元素
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。如果想要在 React 绑定或解绑 DOM 节点的 ref 时运行某些代码,则需要使用回调 ref 来实现。

useMemo

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

返回一个 memoized 值。

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

记住,传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo。

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

useCallback

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

返回一个 memoized 回调函数。

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。

Hooks只是数组

代码关键在于:

  1. 初次渲染的时候,按照 useState,useEffect 的顺序,把 state,deps 等按顺序塞到 memoizedState 数组中。
  2. 更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。
let memoizedState = []; // hooks 存放在这个数组
let cursor = 0; // 当前 memoizedState 下标

function useState(initialValue) {
  memoizedState[cursor] = memoizedState[cursor] || initialValue;
  const currentCursor = cursor;
  function setState(newState) {
    memoizedState[currentCursor] = newState;
    render();
  }
  return [memoizedState[cursor++], setState]; // 返回当前 state,并把 cursor 加 1
}

function useEffect(callback, depArray) {
  const hasNoDeps = !depArray;
  const deps = memoizedState[cursor];
  const hasChangedDeps = deps
    ? !depArray.every((el, i) => el === deps[i])
    : true;
  if (hasNoDeps || hasChangedDeps) {
    callback();
    memoizedState[cursor] = depArray;
  }
  cursor++;
}

看一个使用useState的例子:

function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi");
  const [lastName, setLastName] = useState("Yardley");

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

1、初始化

初始化的时候,会创建两个数组: state和setters,把光标的位置设为0.

2、第一次渲染

调用useState时,第一次渲染,会将一个set函数放入setters数组中,并且把初始state放入到state数组中.

3、后续渲染

每一次重新渲染,光标都会重新设为0,然后从对应的数组中读取状态和set函数

4 事件处理

每次调用set函数时,set函数将会修改state数组中对应的状态值,这种对应的关系是通过cursor光标来确定的

共享同一个 memoizedState,共享同一个顺序。

真正的 React 实现

虽然我们用数组基本实现了一个可用的 Hooks,了解了 Hooks 的原理,但在 React 中,实现方式却有一些差异的。 React 中是通过类似单链表的形式来代替数组的。通过 next 按顺序串联所有的 hook。

type Hooks = {
    memoizedState: any, // 指向当前渲染节点 Fiber
  baseState: any, // 初始化 initialState, 已经每次 dispatch 之后 newState
  baseUpdate: Update<any> | null,// 当前需要更新的 Update ,每次更新完之后,会赋值上一个 update,方便 react 在渲染错误的边缘,数据回溯
  queue: UpdateQueue<any> | null,// UpdateQueue 通过
  next: Hook | null, // link 到下一个 hooks,通过 next 串联每一 hooks
}

type Effect = {
  tag: HookEffectTag, // effectTag 标记当前 hook 作用在 life-cycles 的哪一个阶段
  create: () => mixed, // 初始化 callback
  destroy: (() => mixed) | null, // 卸载 callback
  deps: Array<mixed> | null,
  next: Effect, // 同上 
};
memoizedState,cursor 是存在哪里的?如何和每个函数组件一一对应的?

我们知道,react 会生成一棵组件树(或Fiber 单链表),树中每个节点对应了一个组件,hooks 的数据就作为组件的一个信息,存储在这些节点上,伴随组件一起出生,一起死亡。