4

React为什么要搞Hook?

React 的核心是组件。v16.8 版本之前,组件的标准写法是类(class),组件有无状态组件(Function)和有状态组件(Class)。

有状态组件(Class)【类组件】

有状态组件痛点:

  1. 在组件之间复用状态逻辑很难

    • 之前的解决方案是:render props 和高阶组件
    • 缺点是难理解、存在过多的嵌套形成“嵌套地狱”
  2. 复杂组件变的难以理解

    • 生命周期函数中充斥着各种状态逻辑和副作用
    • 这些副作用难以复用,且很零散

我们通常希望一个函数只做一件事情,但我们的生命周期钩子函数里通常同时做了很多事情。比如我们需要在componentWillMount中发起ajax请求获取数据,绑定一些事件监听等等, componentDidMount中初始化swiper。同时,有时候我们还需要在componentWillUpdatecomponentDidUpdate做一遍同样的事情。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。如此很容易产生 bug,并且导致逻辑不一致。

  1. 难以理解的Class

    • this指针问题
    • 组件预编译技术(组件折叠)会在class中遇到优化失效的case
    • class不能很好的压缩
    • class在热重载时会出现不稳定的情况

无状态组件(Function)【函数组件】

React 团队希望,组件不要变成复杂的容器,最好只是数据流的管道。开发者根据需要,组合管道即可。组件的最佳写法应该是函数,而不是类。

React 早就支持函数组件,但是,这种写法有重大限制,必须是纯函数,不能包含状态,也不支持生命周期方法,因此无法取代类。

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

类组件与函数组件区别

Hook 含义

你还在为该使用无状态组件(Function)还是有状态组件(Class)而烦恼吗? ——拥有了hook,你再也不需要写Class了,你的所有组件都将是Function。

你还在为搞不清使用哪个生命周期钩子函数而日夜难眠吗? ——拥有了Hook,生命周期钩子函数可以先丢一边了。

你在还在为组件中的this指向而晕头转向吗? ——既然Class都丢掉了,哪里还有this?你的人生第一次不再需要面对this。

Hook 是 React 16.8 的新增特性,是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。 它为已知的 React 概念【props, state,context,refs 以及生命周期】提供了更直接的 API,还提供了一种更强大的方式来组合他们。Hook 不能在 class 组件中使用 —— 这使得你不使用 class 也能使用 React。

Hook 介绍

useState():状态钩子

在 class 中,我们通过在构造函数中设置 this.state 来初始化 state。在函数组件中,我们没有 this,不能分配或读取 this.state,而useState用于在函数组件中创建state,并且修改state后会触发组件刷新,就像class组件中的state和setState一样。

栗子

首先让我们看一下一个简单的有状态组件:

import React, { Component } from 'react';

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

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

使用hook后:

// 引入 React 中的 useState Hook,它让我们在函数组件中存储内部 state
import React, { useState } from 'react';

function ExampleHook() {
  // 声明 “count” 的 state 变量
  const [count, setCount] = useState(0);
  
  return (
    <div>
      // 读取 count” 的 state 变量
      <p>You clicked {count} times</p>
      // 更新 count” 的 state 变量
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

ExampleHook是一个函数,但这个函数有自己的状态(count),同时它还可以更新自己的状态(setCount)。这个函数之所以这样,就是因为它注入了一个hook -- useState,这个hook让ExampleHook变成了一个有状态的函数。

定义

const [state, setState] = useState(initialState);

useState是允许你在 React 函数组件中添加 state 的 Hook, React 会在重复渲染时保留这个 state。
如果是声明多个state变量,需要使用useState多次,给不同的 state 变量取不同的名称。
Hook 内部使用 Object.is 来比较新/旧 state 是否相等

调用 useState 方法的时候做了什么?

它定义一个 “state 变量”。我们的变量叫 count, 但是我们可以叫他任何名字,比如 banana。这是一种在函数调用时保存变量的方式 —— useState 是一种新方法,它与 class 里面的 this.state 提供的功能完全相同。一般来说,在函数退出后变量就会”消失”,而 state 中的变量会被 React 保留。

useState 参数

useState() 唯一的参数是初始 state, 这个初始 state 参数只有在第一次渲染时会被用到, 在后续的重新渲染中,useState 返回的第一个值将始终是更新后最新的 state。

useState返回值

返回一个有两个值的数组,第一个值是当前的 state,第二个值是更新 state 的函数。它与 class 里面 this.state.countthis.setState 类似,但是它不会把新的state 和旧的state 进行合并,而是新的state替换旧的state。

const countArray = useState(0); // 返回一个有两个元素的数组
const count = countArray[0]; // 数组里的第一个值
const setCount = countArray[1]; // 数组里的第二个值

// 等价于 【js的数组解构语法】
const [count, setCount] = useState(0);

函数式更新state

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

如果你的更新函数返回值与当前 state 完全相同,则随后的重渲染会被完全跳过。

setNumber(number => number + 1);

// useState不会自动合并更新对象, 用函数式的 setState 结合展开运算符来达到合并更新对象的效果
setState(prevState => {
  // 也可以使用 Object.assign
  return {...prevState, ...updatedValues};
});

惰性初始化 state

如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state。

function Table(props) {
  // ⚠️ createRows() 每次渲染都会被调用
  const [rows, setRows] = useState(createRows(props.count));
}

/*
* 为避免重新创建被忽略的初始 state,传一个函数给 useState
*/
function Table(props) {
  // ✅ createRows() 只会在首次渲染时调用这个函数
  const [rows, setRows] = useState(() => createRows(props.count));
}

useEffect

Effect Hook 可以让你在函数组件中执行副作用操作。数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。在 React 组件中有两种常见副作用操作:需要清除的和不需要清除的。

useEffect Hook 看做 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个函数的组合。

栗子

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

   onPageScroll = () => {
      const scrollTopNow = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
      this.setState({ scrollTop: scrollTopNow });
   };

  componentDidMount() {
    // 不需要清除的effect
    document.title = `You clicked ${this.state.count} times`;
    // 需要清除的effect
    window.addEventListener('scroll', this.onPageScroll);
  }
  
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentWillUnmount() {
     window.removeEventListener('scroll', this.onPageScroll);
  }

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

注意:

  1. 编写重复的代码。因为需要在组件加载和更新时执行同样的操作, 所以在componentDidMountcomponentDidUpdate两个生命周期函数中编写重复的代码
  2. 一个生命周期函数经常包含不相关的逻辑。eg. componentDidMount
  3. 相关逻辑分离到了几个不同方法中。componentDidMount 的scroll事件和 componentWillUnmount 之间相互对应, 使用生命周期函数迫使我们拆分这些逻辑代码,即使这两部分代码都作用于相同的副作用。

使用hook后:

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

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

  // 与componentDidMount和componentDidUpdate相似
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]); // 仅在 count 更改时更新
  
  useEffect(() => {
    const onPageScroll = () => {
      const scrollTopNow = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
      setScrollTop(scrollTopNow);
    };
    window.addEventListener('scroll', onPageScroll);
    
    // 清除
    return () => {
      window.removeEventListener('scroll', onPageScroll);
    };
  }, []);

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

Hook 允许我们按照代码的用途分离他们, 而不是像生命周期函数那样。React 将按照 effect 声明的顺序依次调用组件中的每一个 effect。

定义

useEffect(() => {}, []);

useEffect: 该 Hook 接收一个包含命令式、且可能有副作用代码的函数。

useEffect 做了什么?

通过使用这个 Hook,你可以告诉 React 组件在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它。

为什么在组件内部调用 useEffect?

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

useEffect 的条件执行

默认情况下,每次渲染后都执行清理或者执行 effect 可能会导致性能问题,实际我们不需要在每次组件更新时都创建新的effect,而是仅需要在某些依赖值改变时重新创建。在 class 组件中,我们可以通过在 componentDidUpdate 中添加对 prevPropsprevState 的比较逻辑解决,在hook中,只要传递数组作为 useEffect 的第二个可选参数即可,它是 effect 所依赖的值数组。数组中的某一个元素发生变化,react才会执行effect。如果这个值数组在两次重渲染之间没有发生变化,React会 跳过对 effect 的调用。

目的:跳过 Effect 进行性能优化。

useEffect 第二个参数

  • useEffect(fn): 挂载/重新渲染都会运行
  • useEffect(fn, []) : hook 只在组件挂载时运行一次,重新渲染时不会再次运行
  • useEffect(fn, [a, b]): 挂载/依赖数组中的元素改变,会运行

effect清除机制

在 effect 中返回一个函数,这是 effect 可选的清除机制,每个 effect 都可以返回一个清除函数。

effect 执行时机

useEffect 必然会在第一次 render 的时候执行一次,其他的运行时机取决于以下情况:

  • 有没有第二个参数。
  • 有没有返回值。 React 会在执行当前 effect 之前对上一个 effect 进行清除【effect 在每次渲染的时候都会执行】,而不是只在卸载组件的时候执行一次。此默认行为保证了一致性,避免了在 class 组件中因为没有处理更新逻辑而导致常见的 bug。

effect 的延迟执行

componentDidMountcomponentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。

然而,并非所有 effect 都可以被延迟执行。例如,在浏览器执行下一次绘制前,用户可见的 DOM 变更就必须同步执行,这样用户才不会感觉到视觉上的不一致。(概念上类似于被动监听事件和主动监听事件的区别。)React 为此提供了一个额外的 useLayoutEffect Hook 来处理这类 effect。它和 useEffect 的结构相同,区别只是调用时机不同。

虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行。React 将在组件更新前刷新上一轮渲染的 effect。

依赖数组该怎么处理?

依赖数组依赖的值最好不要超过 3 个,否则会导致代码会难以维护。
如果发现依赖数组依赖的值过多,我们应该采取一些方法来减少它:

  • 去掉不必要的依赖。
  • 将 Hook 拆分为更小的单元,每个 Hook 依赖于各自的依赖数组。放到不同 useEffect 中
  • 通过合并相关的 state,将多个依赖值聚合为一个。合并成一个state
  • 通过 setState回调函数获取最新的state,以减少外部依赖。使用useCallback来减少一些依赖
  • 通过 ref 来读取可变变量的值,不过需要注意控制修改它的途径。

依赖方法应该写在哪里?

推荐把依赖的函数移动到 effect 内部。例如:

useEffect(() => {
  // 把这个函数移动到 effect 内部,我们可以清楚地看到它用到的值。
  async function fetchProduct() {
    const response = await fetch(productId);
    setProduct(response.data);
  }

  fetchProduct();
}, [productId]); 

如果处于某些原因你无法把一个函数移动到 effect 内部,还有一些其他办法:

  • 你可以尝试把那个函数移动到你的组件之外。那样一来,这个函数就不会依赖任何 props 或 state,并且也不用出现在依赖列表中了。
  • 如果你所调用的方法是一个纯计算,并且可以在渲染时调用,你可以 转而在 effect 之外调用它, 并让 effect 依赖于它的返回值。
  • 万不得已的情况下,你可以把函数加入 effect 的依赖,但把它的定义包裹 进 useCallback Hook。这就确保了它不随渲染而改变,除非 它自身 的依赖发生了改变:
function ProductPage({ productId }) {
  // 用 useCallback 包裹以避免随渲染发生改变
  const fetchProduct = useCallback(() => {
    const response = await fetch(productId);
    setProduct(response.data);
  }, [productId]); // useCallback 的所有依赖被指定

  return <ProductDetails fetchProduct={fetchProduct} />;
}

function ProductDetails({ fetchProduct }) {
  useEffect(() => {
    fetchProduct();
  }, [fetchProduct]); // useEffect 的所有依赖都被指定了
}

在依赖列表中省略函数是否安全?

useEffect 完整指南

useContext():共享状态钩子

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。

在一个典型的 React 应用中,数据是通过 props 属性自上而下(由父及子)进行传递的,但这种做法对于某些类型的属性而言是极其繁琐的(例如:地区偏好,UI 主题),这些属性是应用程序中许多组件都需要的。Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props。

定义

能够读取 context 的值以及订阅 context 的变化。

推荐使用条件: 如果需要在组件之间共享状态,可以使用useContext()。

使用前提:创建 Context 对象

// 创建一个 Context 对象
const MyContext = React.createContext(defaultValue);

// Provider React 组件
<MyContext.Provider value={/* 某个值 */}>

创建一个 Context 对象。当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中离自身最近的那个匹配的 Provider 中读取到当前的 context 值。

只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。注意:将 undefined 传递给 Provider 的 value 时,消费组件的 defaultValue 不会生效。

需要在上层组件树中使用 <MyContext.Provider> 来为下层组件提供context。

参数与返回值是什么?

参数: useContext的参数必须是 context 对象本身(React.createContext 的返回值)

  • 正确: useContext(MyContext)
    相当于 class 组件中的 static contextType = MyContext 或者 <MyContext.Consumer>
  • 错误: useContext(MyContext.Consumer)
  • 错误: useContext(MyContext.Provider)

返回值:该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value 或 props 决定。

什么时候会触发重新渲染?

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。

Provider 及其内部 consumer 组件都不受制于 React.memo 和 shouldComponentUpdate 函数,因此当 consumer 组件在其祖先组件退出更新的情况下也能更新。

如何优化渲染?

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

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

// 1. 创建context
const ThemeContext = React.createContext(themes.light);

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

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

function ThemedButton() {
  // 3. 取出 context
  const theme = useContext(ThemeContext);

  return (
    <button style={{ background: theme.background }}>
      I am styled by theme context!
    </button>
  );
}

useReducer: 管理state

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

定义

useState的替代方案。 useReducer通过dispatch触发reducer数据的改变。

参数

可以接收三个参数:

  1. 参数1【reducer】: (state, action) => newState 的 reducer函数;
    在reducer方法中state表示上一次的状态值,action表示传递的参数。state的值可以是一个数字也可以是一个对象,同样action也是如此。
  2. 参数2【initialArg】: 直接创建初始 state;
  3. 参数3【init】: 惰性地创建初始 state,初始 state 将被设置为 init(initialArg)

返回值

返回一个包含2个元素的数组,类似于useState。 第一个元素是当前状态state,第二个元素是dispatch函数;

推荐使用场景

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

初始化 state 方式

有两种不同初始化 useReducer state 的方式,

  • 将初始 state 作为第二个参数传入 useReducer
const [state, dispatch] = useReducer(reducer, { count: initialCount });
  • 惰性地创建初始 state
const init = (initialCount) => { count: initialCount };

const [state, dispatch] = useReducer(reducer, initialCount, init);

跳过 dispatch

如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染及副作用的执行。
(React 使用 Object.is 比较算法来比较 state。)

栗子

import React, { useReducer } from 'react';

const people = [
  { name: 'Jay', alive: true },
  { name: 'Kailee', alive: true },
  { name: 'John', alive: true },
  { name: 'Mia', alive: true }
];

const reducer = (people, action) => {
  if (action.type == 'chomp') {
    return people.map(person => {
      if(person.name == action.payload) {
        person.alive = false;
      }
      return person;
    })
  }
  if (action.type == 'revive') {
    return people.map(person => {
      if(person.name == action.payload) {
        person.alive = true;
      }
      return person;
    })
  }
};

function Counter() {
  const [state, dispatch] = useReducer(reducer, people);

 function devour(name) {
  dispatch({ type: 'chomp', payload: name });
 }

 function spitOut(name) {
  dispatch({ type: 'revive', payload: name });
 }

  return (
    <div>
      {state.map((person) => (
        <div>
          <div>{person.name}</div>
          {
            person.alive ? <div>
               ✨✨ ALIVE ✨✨ 
               <button onClick={() => devour(person.name)}> 🐊 DEVOUR 🐊 </button>
            </div> : <div>
               ☠ ☠ DEAD ☠ ☠ 
               <button onClick={() => spitOut(person.name)}> 🥵 SPIT OUT 🥵 </button>
            </div>
          }
        </div>
      ))}
    </div>
  );
}

一个login栗子

useMemo: 主要用于做性能的优化

当状态发生变化时,没有设置关联状态的 useEffect 会全部执行。同样的,通过计算出来的值或者引入的组件也会重新计算/挂载一遍,即使与其关联的状态没有发生任何变化

在类组件中我们有 shouldComponetUpdate 以及 React.memo 去做性能优化,于是在函数组件中就有了 useMemo useCallBack钩子。

定义

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

返回一个 memoized 。缓存计算数据的值。

通俗理解:ab的变量值不变的情况下,memoizedValue的值不变。即:useMemo函数的第一个入参函数不会被执行,从而达到节省计算量的目的。

参数:其接受两个参数,第一个参数为一个 Getter方法,返回值为要缓存的数据或组件。第二个参数为依赖项数组。当没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值;当某个依赖项改变,重新调用 Getter 方法,返回新的 memoized 值;如果依赖数组 [a, b] 自上次赋值没有改变,useMemo 不会重新计算,直接返回缓存的值。

用途:useMemo 不仅可以用来做计算结果的缓存,返回值是一个数字或字符串,还可以返回一个组件,实现对组件挂载/重新挂载的性能优化。一般用于密集型计算大的一些缓存。

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

栗子

function HookDemo() {
  const [count1, changeCount1] = useState(0);
  const [count2, changeCount2] = useState(10);

  // 返回一个数字,只在count1改变时,计算新的值
  const calculateCount = useMemo(() => {
    console.log('重新生成计算结果');
    return count1 * 10;
  }, [count1]);
  
  // 返回一个组件,只在count2改变时,重新挂载组件
  const componentRefine = useMemo(() => {
    console.log('重新生成组件');
    return <Child count={count2} />;
  }, [count2]);
  
  return (
    <div>
      {calculateCount}
      {componentRefine}
      <button onClick={() => { changeCount1(count1 + 1); }}>改变count1</button>
      <button onClick={() => { changeCount2(count2 + 1); }}>改变count2</button>
    </div>
  );
}

useCallback

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

返回一个 memoized 回调函数。缓存函数的引用。

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

通俗理解:在ab的变量值不变的情况下,memoizedCallback的引用不变。即:useCallback的第一个入参函数会被缓存,从而达到渲染性能优化的目的。

用途:当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

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

function Foo({ bas, bar, baz }) {
  useEffect(() => {
    const options = { bas, bar, baz };
    console.log('Foo:', options);
  }, [bas, bar, baz]);
  return <div>Foo</div>;
}

function FooMemoized({ bas, bar, baz }) {
  useEffect(() => {
    const options = { bas, bar, baz };
    console.log('FooMemoized:', options);
  }, [bas, bar, baz]);
  return <div>FooMemoized</div>;
}

// CallbackHook每次渲染,生成新的bar传入,造成bar与baz的引用每次不相等,Foo的useEffect每次都会调用。barMemoized与bazMemoized不重现执行,FooMemoized的useEffect不会再次调用。

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

  const bas = 'i am bas';
  const bar = () => {}; // 函数,每一次渲染都有一个“新版本”的bar
  const baz = [1, 2, 3]; // 数组

  const barMemoized = useCallback(() => {}, []);
  const bazMemoized = useMemo(() => [1, 2, 3], []);

  return (
    <div>
      <p>You clicked {count} times.</p>
      <button onClick={() => setCount(count + 1)}>
        点我再次render
      </button>
      <Foo bas={bas} bar={bar} baz={baz} />
      <FooMemoized bas={bas} bar={barMemoized} baz={bazMemoized} />
    </div>
  );
}

useCallBack与useMemo比较

相同点:

  • 都在组件第一次渲染的时候执行,之后在依赖项改变时再次执行;
  • 都会返回缓存数据;
  • 使用场景:

    1. 引用相等
    2. 昂贵的计算

不同点:

  • useCallback是根据依赖(deps)缓存第一个入参的(callback),useMemo是根据依赖(deps)缓存第一个入参(callback)执行后的值。useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

性能优化总是会有成本,但并不总是带来好处。推荐: 什么时候使用 useMemo 和 useCallback

多谈一点

useEffect、useMemo、useCallback都是自带闭包的, 每一次组件的渲染,其都会捕获当前组件函数上下文中的状态(state, props),所以每一次这三种hooks的执行,反映的都是当前的状态,无法使用它们来捕获上一次的状态。对于这种情况,应该使用ref来访问。

useRef: 获得目标节点实例/保存状态

const refContainer = useRef(initialValue);

不仅可以用于 DOM refs,也可以用作 current 属性可变且可以容纳任意值的通用容器,类似于一个 class 的实例属性。【类组件里的 this.something 也和hooks里的 something.current 相似,他们代表了同一个概念】。

useRef 保存的变量不会随着每次数据的变化重新生成,而是保持在我们最后一次赋值时的状态。

该钩子会返回一个可变的 ref 对象,对象中的 current 字段就是我们 指向的实例 / 保存的变量

initialValue为它的初始值。

设置目标节点实例

React提供的这个ref属性,表示为对组件真正实例的引用,就是ReactDOM.render()返回的组件实例。挂到组件(有状态组件)上 ref 表示对组件实例的引用,而挂载到dom元素上 ref 表示具体的dom元素节点。创建获得目标节点实例的方式有:

1. createRef

使用 React.createRef() 创建的,并通过 ref 属性附加到 React 元素。

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.myRef = React.createRef();
  }
  
  render() {
    return <div ref={this.myRef} />;
  }
}

2. 回调 Refs

传递一个函数, 这个函数中接收 React 组件实例(class组件) 或 HTML DOM 元素作为参数,以使它们能在其他地方被存储和访问。

class CustomTextInput extends React.Component {
  constructor(props) {
    super(props);
    this.textInput = null;
  }

  componentDidMount() {
     if (this.textInput) this.textInput.focus();
  }

  render() {
    // 使用 `ref` 的回调函数将 text 输入框 DOM 节点的引用存储到 React
    return (
      <input type="text" ref={element => this.textInput = element }} />
    );
  }
}

3. useRef

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

如果你将 ref 对象以 <div ref={myRef} /> 形式传入组件,则无论该节点如何改变,React 都会将 ref 对象的 .current 属性设置为相应的 DOM 节点。

然而,useRef()ref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。 这是因为它创建的是一个普通 Javascript 对象, useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。

保存变量

修改 Ref 的值不会引起视图的变化

当我们将使用 useState 创建的状态赋值给 useRef 用作初始化时,手动更改 Ref 的值并不会引起关联状态的变动。从该现象来看,useRef 似乎只是在内存空间中开辟了一个堆空间将初始化的值存储起来,该值与初始化的值存储在不同的内存空间,修改 Ref 的值不会引起视图的变化。

function HookDemo() {
  const [count] = useState({ count: 1 });
  const countRef = useRef(count);

  return (
    <div>
      {count.count}
      <button onClick={() => { countRef.current.count = 10; }}>
        改变ref
      </button>
    </div>
  );
}

自定义 HOOK

目前为止,在 React 中有两种流行的方式来共享组件之间的状态逻辑: render props高阶组件,现在通过自定义 Hook,也可以将组件逻辑提取到可重用的函数中,而不需要向 tree 中增加更多组件。

定义

自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook。

自定义 Hook 必须以 use 开头吗?

必须如此。不遵循的话,无法判断某个函数内部是否包含对 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 的规则。

在两个组件中使用相同的 Hook 会共享 state 吗?

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

自定义 Hook 如何获取独立的 state?

每次调用 Hook,它都会获取独立的 state。从 React 的角度来看,我们的组件只是调用了 useStateuseEffect。 就像我们可以在一个组件中多次调用 useStateuseEffect,它们是完全独立的。

栗子

需求: 一个带有loading状态,可以避免在加载结束之前反复点击的按钮

function Article() {
  const [isLoading, setIsLoading] = useState(false);
  const [content, setContent] = useState('origin content');
    
  function handleClick() {
    setIsLoading(true);
    loadArticle().then(content=>{
      setIsLoading(false);
      setContent(content);
    })
  }

  return (
    <div>
      <button onClick={handleClick} disabled={isLoading} >
        {isLoading ? 'loading...' : 'refresh'}
      </button>
      {
        isLoading ? <img src={spinner}  alt="loading" />
          : <article>{content}</article>
      }
    </div>
  );
}

很显然,loading Button的逻辑是非常通用且与业务逻辑无关,因此完全可以将其抽离出来成为一个独立的LoadingButton组件:

function LoadingButton(props){
  const [isLoading, setIsLoading] = useState(false);

  function handleClick(){
    props.onClick().finally(()=>{
      setIsLoading(false);
    });    
  }

  return (
    <button onClick={handleClick} disabled={isLoading} >
      {isLoading ? 'loading...' : 'refresh'}
    </button>
  );
}

// 1. 组件复用:将状态逻辑和UI组件打包成一个可复用的整体
function Article(){
  const {content, setContent} = useState('');

  function clickHandler(){
    return fetchArticle().then(data=>{
      setContent(data);
    });
  }
    
  return (
    <div>
      <LoadingButton onClick={this.clickHandler} />
      <article>{content}</article>
    </div>
  )
}

// 2. Article里也添加loading,仍然要自定一个isLoading状态
function Article() {
  const [isLoading, setIsLoading] = useState(false);
  const {content, setContent} = useState('origin content');

  function clickHandler() {
    setIsLoading(true);
    return fetchArticle().then(data =>{
      setIsLoading(false);
      setContent(data);
    });
  }

  return (
    <div>
      <LoadingButton onClick={this.clickHandler} />
      <article>{content}</article>
    </div>
  );
}

现在的抽象方案将isLoading状态和button标签耦合在一个组件里了,这种复用的粒度只能整体复用这个组件,而不能单独复用一个状态。新的解决方案是:

// 提供loading状态的抽象
export function useIsLoading(initialValue, callback) {
  const [isLoading, setIsLoading] = useState(initialValue);

  function onLoadingChange() {
    setIsLoading(true);

    callback && callback().finally(() => {
      setIsLoading(false);
    })
  }

  return {
    value: isLoading,
    disabled: isLoading,
    onChange: onLoadingChange, // 适配其他组件
    onClick: onLoadingChange,  // 适配按钮
  }
}

function Article() {
  const loading = useIsLoading(false, fetch);
  const [content, setContent] = useState('origin content');

  function fetch() {
    return loadArticle().then(setContent(data));
  }

  return (
    <div>
      <button {...loading}>
        {loading.value ? 'loading...' : 'refresh'}
      </button>

      {
        loading.value ? 
          <img src={spinner} alt="loading" />
          : <article>{content}</article>
      }
    </div>
  );
}

进一步封装:

// 封装按钮
function LoadingButton(props){
  const { value, defaultText = '确定', loadingText='加载中...' } = props;
  return (
    <button {...props}>
      {value ? loadingText: defaultText}
    </button>
  );
}

// 封装loading动画
function LoadingSpinner(props) {
  return (
    <div>
      {props.value && <img src={spinner} className="spinner" alt="loading" />}
    </div>
  );
}

return (
  <div>
    <LoadingButton {...loading} />
    <LoadingSpinner {...loading}/>
    { loading.value || <article>{content}</article> }
  </div>
);

Hook 规则

Hook 本质就是 JavaScript 函数,但是在使用它时需要遵循两条规则。

  • 只在最顶层使用 Hook

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

  • 只在 React 函数中调用 Hook ,不要在普通的 JavaScript 函数中调用 Hook。

    在 React 的函数组件中调用 Hook;
    在自定义 Hook 中调用其他 Hook ;

为什么Hook 需要在组件的最顶层调用?

在单个组件中使用多个 State Hook 或 Effect Hook:

function Form() {
  const [name, setName] = useState('Mary');

  useEffect(function persistForm() {
    localStorage.setItem('formData', name);
  });

  const [surname, setSurname] = useState('Poppins');

  useEffect(function updateTitle() {
    document.title = name + ' ' + surname;
  });
}

每次声明useState,useEffect,React 怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用的顺序。在示例中,Hook 的调用顺序在每次渲染中都是相同的,所以它能够正常工作。

// 首次渲染 -------------
useState('Mary')           // 1. 使用 'Mary' 初始化变量名为 name 的 state
useEffect(persistForm)     // 2. 添加 effect, 保存 form
useState('Poppins')        // 3. 使用 'Poppins' 初始化变量名为 surname 的 state
useEffect(updateTitle)     // 4. 添加 effect, 更新标题

// 二次渲染 -------------
useState('Mary')           // 1. 读取变量名为 name 的 state(参数忽略)
useEffect(persistForm)     // 2. 替换保存 form 的 effect
useState('Poppins')        // 3. 读取变量名为 surname 的 state(参数忽略)
useEffect(updateTitle)     // 4. 替换更新标题的 effect

只要 Hook 的调用顺序在多次渲染之间保持一致,React 就能正确地将内部 state 和对应的 Hook 进行关联。如果顺序改变了会怎样?

// 在条件语句中使用 Hook【违反第一条规则】
if (name !== '') {
  useEffect(function persistForm() {
    localStorage.setItem('formData', name);
  });
}

在第一次渲染中 name !== '' 这个条件值为 true,所以我们会执行这个 Hook。但是下一次渲染时我们`setName(''),表达式值变为 false。此时的渲染会跳过该 Hook,Hook 的调用顺序发生了改变:

// setName('')后再次渲染 -------

useState('Mary')           // 1. 读取变量名为 name 的 state(参数忽略)
// useEffect(persistForm)  // 此 Hook 被忽略!
useState('Poppins')        // 2. (之前为 3)读取变量名为 surname 的 state 失败
useEffect(updateTitle)     // 3. (之前为 4)替换更新标题的 effect 失败

React 不知道第二个 useState 的 Hook 应该返回什么。React 会以为在该组件中第二个 Hook 的调用像上次的渲染一样,对应的是 persistForm 的 effect,但并非如此。从这里开始,后面的 Hook 调用都被提前执行,导致 bug 的产生。

如果想要有条件地执行一个 effect,可以将判断放到 Hook 的内部

useEffect(function persistForm() {
  // 将条件判断放置在 effect 中
  if (name !== '') {
    localStorage.setItem('formData', name);
  }
});

Hook对象

Hook对象是相对于组件存在的,所以要实现对象在组件内多次渲染时的共享,只需要找到一个和组件全局唯一对应的全局存储,用来存放所有的Hook对象即可。对于一个React组件而言,唯一对应的全局存储就是ReactNode,在React 16x之后,这个对象应该是FiberNode。我们暂时不研究Fiber,我们只需要知道一个组件在内存里有一个唯一表示的对象即可。

type FiberNode = {
  memoizedState: any, // 当前组件所有的hook
}

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

每个hook都对应一个Hook对象,他们按照执行的顺序以类似链表的数据格式存放在FiberNode.memoizedState上。React 的 Hooks 相当于一个单向链表,Hook.next 指向下一个 Hook。

因为 memoizedState 是按 Hooks 定义的顺序来放置数据的,如果 Hooks 的顺序变化,memoizedState 并不会感知到,所以hook只能在最顶层调用,不能放在循环与if条件里。

React Hooks 源码解析(3):useState

参考文档(推荐阅读):

React 源码解析

组件的思维

React Hooks应用场景

React Hook的创造过程

你不知道的 useCallback


时倾
794 声望2.4k 粉丝

把梦想放在心中