# React 进阶

# React Hook 简介

# React Hook 之前的状况

React 项目的开发是组件化的开发,组件可以彼此独立、可以自由组装、也可以复用到很多地方。可是随着项目变得更大更复杂,一些组件会变得非常冗长和复杂,特别是组件的componentDidXxx()等声明周期函数中充满了各种各样处理逻辑的代码,相互关联的代码修改可能还需要同步修改另一处,维护起来会无比麻烦。

又由于各自的组件有各自的状态以及状态逻辑,往往有部分状态逻辑的功能是基本相同的可以复用的,比如可以使用render props (opens new window)高阶组件 (opens new window)等方案,这类方案需要重新组织组件结构(render props 是将一个组件当作另一个组件的属性通过 props 传入,高阶组件是另外套一层组件),会很麻烦也使得代码难以理解。

react hook的出现,可以将相互关联的部分代码拆分成更小的函数(并非强制按照生命周期划分),也可以在无需修改组件结构的情况下复用状态逻辑,最后就是可以在非 class 的情况下可以使用更多的 React 特性(class 组件有诸如 this、压缩问题)。

# React Hook 是什么

hook(钩子)是一个处理消息的程序段,我们可以将钩子挂到需要被监听的程序里,用于截取被监听程序里的某段消息或特定事件。是在消息发出了但还没达到被监听程序前,就将消息截取了,然后可以加工处理消息,或不处理强制让消息停止传递。

react hook就利用了这样的机制,当 React 的函数式组件(hook 在类组件是不起作用的)想实现“API 数据访问”、“异步修改组件状态”、“组件生命周期”和“处理副作用”等功能时,就可以使用相对应的钩子将功能模块代码勾到组件中。也就是说react hook是一类特殊的函数,可以为 React函数式组件注入特殊的功能

# 常见钩子和使用规则

react hook 中常用的钩子useState()useEffect()useContext()useReducer()。当然也可以自定义 hook,但要遵循钩子的命名规范前缀是use,例如useXxx()。更多钩子介绍可以看后面章节或者查看官方文档Hook API 索引 (opens new window)

react hook是 js 函数,使用它会有两个额外的规则:

  • 只能在函数最外层调用 hook。不要在循环、条件判断或者子函数中调用。
  • 只能在 React 的函数式组件中调用 hook(自定义 hook 中也可以调用 hook)。不要在其他 js 函数中调用。

# useState 状态钩子

# 如何使用 useState

在类组件中使用 state,是在 constructor 函数中对this.state={xxx: xxx}进行初始化,在组件其他地方this.state.xxx使用这个state 变量的值,还可以通过this.setState()修改它的值。而在函数式组件中没有 this,使用 state 的话需要用useState()钩子将 state 功能勾入到这个函数组件里,形式是const [something, setSomething] = useState(param)(变量名自己取,第二个参数是一个方法,最好是setXxx的形式)。

import React, { useState } from "react";

function Example() {
  // 相当于class组件里的this.state = { count: 0 };
  const [count, setCount] = useState(0);
  // 其实Example可能会被多次调用(父组件更新等),那这段段代码会多次执行,不用担心多次声明初始化count,React内部做了优化处理

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

const [count, setCount] = useState(0);使用了 useState 钩子声明了一个state 变量(该变量实际在 react 内部),useState()会返回一个数组,数组的第一个值就是这个state 变量(临时给你用),数组的第二个值是修改该 state 变量的函数,useState()唯一入参是这个 state 变量最初的值。在后续的使用过程中,直接用count就可以了,比类组件的this.state.count方便多了。

注意声明接收变量时要使用const,不要使用let也别用count=8来修改 count(局部覆盖没什么意义),要使用 setCount 修改实际的 count,还能触发重新渲染(类似于类组件的this.setState())。

# 要合并多个 state 变量吗

你可能想在一次useState()调用中传入一个包含了所有 state 的对象(合并多个 state 变量),可以是可以的,例如

// 类组件的state
this.state = { left: 0, top: 0, width: 100, height: 100 };
// 函数式组件的state
const [state, setState] = useState({ left: 0, top: 0, width: 100, height: 100 });
1
2
3
4

但是我们仍然建议对多个 state 变量进行合并,因为拆分后的 state 变量可以在某些情况下单独抽取到一个函数方法中。当然你也可以合并一部分独立拆分另一部分,可能会比较混乱。如果 state 的逻辑开始变得复杂,可以用 reducer 来管理它 (opens new window),或使用自定义 Hook。

function Box() {
  // 拆分state变量,抽取到一个函数方法中
  const position = useWindowPosition();
  const [size, setSize] = useState({ width: 100, height: 100 });
  // ...
}

function useWindowPosition() {
  const [position, setPosition] = useState({ left: 0, top: 0 });
  useEffect(() => {
    // ...
  }, []);
  return position;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# useEffect 副作用钩子

# 纯函数与副作用

纯函数的特征:

    1)该函数不会对入参本身进行修改,必须得有 return 返回值;在相同的入参时,需产生相同的返回值。
    2)该函数得没有副作用;返回值可以与入参无关(有关也可以),但是不能与入参以外的变量或者其他影响有关(也是在说不能有副作用)。

其中副作用指的是函数除了返回函数值,还对函数产生了附加的影响(与外界有关了,结果不一定可控),例如修改了全局变量(函数外的)、使用了 ajax、修改了 dom 元素、console 输出等。

let a = 0,
  b = 1,
  c = 2;

function handle(p1, p2) {
  a++; // 这行是副作用,去掉这行那这个函数就是纯函数
  return p1 + p2; // 纯函数必须有return
}
1
2
3
4
5
6
7
8

# 如何使用 useEffect

useEffect 钩子可以让开发者在函数式组件中执行副作用操作,其形式是useEffect(() => {}, [xxx]),第一个参数是副作用处理函数,第二个参数是组件的依赖列表(数组形式)。useEffect 钩子默认情况每次渲染后执行,包括挂载时的第一次渲染。

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

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

  // useEffect钩子
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]); // 第二个参数是[count]这样的一个数组

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 第二个参数不传时(默认情况),在挂载每次更新后执行(相当于componentDidMountcomponentDidUpdate);
  • 第二个参数为[xxx]时,在挂载每次更新后并且判断了特定状态之后考虑是否跳过执行,例如下面这个例子;有点像 Vue 中的watch
  • 第二个参数为[]空数组时,只在挂载后执行(相当于只componentDidMount,即跳过了componentDidUpdate),因为每次更新后由于依赖列表为空认为状态没有改变就跳过了每次更新后执行 effect这个步骤。
componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) { // 对于特定状态的判断,解决性能问题
    document.title = `You clicked ${this.state.count} times`;
  }
}
// 用useEffect改写上面的,只是使用useEffect钩子更方便更精准。
useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);
1
2
3
4
5
6
7
8
9

ps:useEffect 钩子实际上与componentDidMountcomponentDidUpdate有些地方不一样,调度 effect 时不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快。

在开发 class 组件时,经常在componentDidMount中添加订阅,在componentWillUnmount中删除订阅,代码比较分散。使用useEffect 钩子清除机制可以解决这个问题,在 useEffect 最后 return 一个清除函数即可,会在组件卸载的时候执行清除操作。

// 模拟componentDidMount
useEffect(() => {
  const timer = setInterval(() => {
    // ...
  }, 1000);
  // 模拟componentWillUnmount
  return function cleanup() {
    clearInterval(timer);
  };
}, []);
1
2
3
4
5
6
7
8
9
10

其实 useEffect 中 return 的清除函数,不是只在卸载组件的时候执行一次,而是在执行新的 effect的时候会清除上一个 effect(并调用上一个 effect 的 return 的清除函数),这是 hook 设计的一个默认行为,是为了简化 id 变化时要先删除原先的订阅再去添加新订阅来更新展示信息这样一个麻烦的步骤,例如下面这个例子。

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
componentDidUpdate(prevProps) {
  // 取消订阅之前的 friend.id
  ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
  );
  // 订阅新的 friend.id
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
// 用useEffect改写上面的,执行新effect时会先运行上一个effect的return的清除函数
useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  // 返回一个清除函数。它会在上一次的Effect执行完就会调用
  return function cleanup() {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

最后就是使用多个 useEffect的场景,主要就是为了分割无关的代码,将相同用途的代码放在同一个 effect 中。

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  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);
    };
  });
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# useEffect 注意事项

# 避免不必要的 Effect

副作用处理函数内部如果有 props 或 state 的相关变量,基本上都会在依赖列表中添加这些相关变量。除非业务需求就是只在挂载时运行一次,即使父组件传来的 props 或者本组件的 state 改变了也不影响,这样倒是可以让依赖列表为空数组。像上一小节中的props.friend.id变了,最好是在第二个参数中加上[props.friend.id]

// 类组件中用prevState.count和this.state.count进行判断,避免不必要的逻辑
componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}
1
2
3
4
5
6
// 函数组件中useEffect,要在第二个参数加上依赖数据,比如下面的props.friend.id
useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 仅在 props.friend.id 发生变化时,重新订阅
1
2
3
4
5
6
7
8
9
10
11

# 注意闭包问题

副作用处理函数是个闭包,这意味着这个闭包会记住副作用处理函数被创建时所处的环境。像上面这个例子,当props.friend.id变化时,触发本组件重新渲染,执行新的 effect,执行前会调用旧 effect 的清除函数并且清除旧 effect 的闭包形式,然后继续执行这个新的 effect。

如果 effect 中有频繁变化的值,例如在 effect 里使用setInterval并在其内部执行setState,这种定时器需求一般都是写在componentDidMount中(如果写在componentDidMount中,每次都创建一个新定时器也是不大对的,还容易形成死循环),那么使用useEffect时其依赖列表得是个空数组,就算每隔一段时间调用setState触发重新渲染,但由于useEffect的依赖列表为空数组就会跳过每次更新后的新 effect 调用。

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

  useEffect(() => {
    // 我们只想让定时器生成一次,不让它在页面更新后再重新创建,那么第二个参数必须是[]空数组
    const id = setInterval(() => {
      // 但我们又依赖了state中的count,它还是闭包
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // 只在挂载后执行

  return <h1>{count}</h1>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上面这个写法逻辑上没啥问题,但是由于闭包的原因,setCount(count + 1)中的count其实一直是0(当时 setInterval 内的这个函数创建时 count 就是 0),那就需要使用setState函数入参式写法(setState 的第一个形参是个函数,这个函数里的形参可以拿到以前的 count)。

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount((c) => c + 1); // 在这不依赖于外部的 `count` 变量
    }, 1000);
    return () => clearInterval(id);
  }, []); // 我们的 effect 不使用组件作用域中的任何变量

  return <h1>{count}</h1>;
}
1
2
3
4
5
6
7
8
9
10
11
12

# 注意 async 问题

useEffect 中处理异步操作时,一般就用promise常用的then处理法;如果要使用async/await,不能直接在处理函数头上加 async 标识,得新建一个函数加上 async 标识,最后在处理函数中调用这个新函数。

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    // 不能在这里加async
    // 把这个函数移动到 effect 内部后,我们可以清楚地看到它用到的值productId。
    async function fetchProduct() {
      const response = await fetch("http://myapi/product/" + productId);
      const json = await response.json();
      setProduct(json);
    }
    // 调用这个fetchProduct函数
    fetchProduct();
  }, [productId]);
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这个新函数是可以定义在effect 外部甚至是组件的外部,如果没有依赖的 props 和 state 是比较容易挪到外面的,但是如果有依赖还要放在外部,那可以使用useCallback来包裹这个新函数以避免随渲染发生改变,在 effect 的依赖列表中添加这个新函数的引用。

function ProductPage({ productId }) {
  const fetchProduct = useCallback(() => {
    // ... Does something with productId ...
  }, [productId]);

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

function ProductDetails({ fetchProduct }) {
  useEffect(() => {
    fetchProduct();
  }, [fetchProduct]);
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# useRef 钩子

在类组件中会用到React.createRef()创建 ref,用这个创建出来的 ref 给组件打上标记,再用它来操作组件的 DOM 或者获取控件的 value 等。

在函数组件中使用useRef钩子也可以创建一个 ref 给组件打标记从而获取组件的 DOM 以及一些属性,例子如下:

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` 指向已挂载到 DOM 上的input元素
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13

useRefReact.createRef()的区别:React.createRef()一般用于类组件,用于函数组件的话,一般就只能用在函数外部,原因是用在内部每次渲染都会重新生成一个新引用(每次重新渲染,函数组件都会运行一次);useRef一般用于函数组件内部,即使每次重新渲染,它都是相同的引用,这样的话它可以模拟类组件中的this

下面这个例子就是 useRef 钩子和 useEffect 钩子结合使用场景,useEffect 的闭包场景可能是想拿 props,正常拿因为闭包的原因可能拿的值一直没有变化。那么可以用useRef钩子来保存 props(模拟this.props),或许还要创建一个 useEffect 用来更新保存的最新值。当然,下面这种在定时器每隔一段时间读最新 props 场景不太多见。

function Example(props) {
  // 把最新的 props 保存在一个 ref 中。useRef钩子相当于React.createRef()
  const latestProps = useRef(props);
  useEffect(() => {
    latestProps.current = props;
  });

  useEffect(() => {
    function tick() {
      // 在任何时候读取最新的 props
      console.log(latestProps.current);
    }

    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []); // 这个 effect 从不会重新执行
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

尽可能少的使用React.createRef()useRef钩子。

# useContext 钩子