likes
comments
collection
share

重读 React 官方文档

作者站长头像
站长
· 阅读数 14

前言

大家好,我是麦西。

近来发现 React 官方文档更新了。

仔细想来,学习使用 React 这么久还没有好好拜读过官方文档。于是认真读写了一遍官方教程。这里把学到的一些知识记录下来,分享给大家。

纯函数组件

React 官方推荐全面拥抱 hooks。这也就意味着,类组件已经是过去式了。这一点从官方文档也可以看出,新的官方文档已经不再介绍和使用类组件了。

部分 JavaScript 函数是存粹的,这类函数被称为纯函数

纯函数通常具有以下特征:

  • 只负责自己的工作。它不会更改函数调用前就存在的对象或变量。
  • 输入相同,则输出相同。给定相同的输入,纯函数总是返回相同的结果。

可简单的理解为,函数的执行不依赖且不改变外界。纯函数的优点是没有副作用,可移植性好。在 A 项目能够用,B 项目想要使用直接拿过来就好了。可以通过下面这几个例子感受下纯函数的概念:

// 纯函数
function add(a, b) {
    return a + b;
}

// 非纯函数,函数执行依赖外界变量all
let all = 100;
function plus(a) {
    return all + a;
}

// 非纯函数,函数执行改变了外界变量obj
let obj = {};
function fun(a) {
    obj.a = a;
    return a;
}

// 非纯函数,函数的执行依赖外界getCount()
async function(a, b) {
  const c = await getCount(); // 副作用
  return a + b +c;
}

// addConst是否是纯函数存在争议,我更倾向于它是
const data = 100;
function addConst(a) {
  return a + data;
}

最后一个例子,addConst 依赖于 data, 但 data 是常量。这种情况存在争议,有人认为是,也有人认为不是。我更倾向于addConst是纯函数。

官方建议建议我们使用纯函数来编写组件。非纯函数编写的组件可能会存在副作用,造成意料之外的影响。下面是一个非纯函数组件的例子:

let guest = 0;

function Cup() {
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

页面显示的结果是:

Tea cup for guest #1

Tea cup for guest #2

Tea cup for guest #3

在上面这个例子中,Cup 是非纯函数组件,它依赖于外界 guest 变量。由于多个 Cup 组件依赖的是同一个变量guest。当我们每次使用组件的时候,都会修改guest,这就会导致每次使用组件都会产生不同的结果。

因此,为了避免出现意想不到的结果,我们最好使用纯函数编写组件

渲染和提交

在 React 应用中一次屏幕更新都会发生以下三个步骤:

1. 触发

也就说触发一次渲染。有两种原因会导致组件渲染:

  • 组件的初次渲染: 当应用启动时,会触发初次渲染。也就是 render 方法的执行。
import Image from './Image.js';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(<Image />); // 初次渲染
  • 组件或者其祖先的状态发生了改变

一旦组件被初始渲染后,我们可以通过 set函数更新组件状态来触发之后的渲染。

2. 渲染

在我们触发渲染后,React 会调用组件来确定要在屏幕上显示的内容。渲染中 即 React 在调用你的组件函数。

  • 在进行初次渲染时, React 会调用根组件。

  • 对于后续的渲染, React 会调用 内部状态更新 触发了渲染 的函数组件。

3. 提交

在渲染(调用)您的组件之后,React 将会修改 DOM。

  • 对于初次渲染, React 会使用 appendChild() DOM API 将其创建的所有 DOM 节点放在屏幕上。

  • 对于再次渲染, React 将应用最少的必要操作(在渲染时计算),以使得 DOM 与最新的渲染输出相互匹配。

    React 仅在渲染之间存在差异时才会更改 DOM 节点。 如果渲染结果与上次一样,那么 React 将不会修改 DOM。

在渲染完成并且 React 更新 DOM 之后,浏览器就会重新绘制屏幕。

useState

使用 state 需要注意以下几点:

  • 当一个组件需要在多次渲染记住某些信息时,使用 state 变量。

  • 调用 Hook 时,包括 useState,仅在组件或者另一个 Hook 的顶层作用域调用。

  • state 是隔离且私有的。也就是说,将一个组件调用两次,他们内部的 state 不会互相影响。

1. state 如同一张快照

当 React 重新渲染一个组件时:

  1. React 会再次调用你的函数
  2. 你的函数会返回新的 JSX
  3. React 会更新界面来匹配你返回的 JSX

作为一个组件的记忆,state 不同于在你的函数返回之后就会消失的普通变量。state 实际上“活”在 React 本身中——就像被摆在一个架子上!——位于你的函数之外。当 React 调用你的组件时,它会为特定的那一次渲染提供一张 state 快照。你的组件会在其 JSX 中返回一张包含一整套新的 props 和事件处理函数的 UI 快照 ,其中所有的值都是 根据那一次渲染中 state 的值 被计算出来的!

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 1);
          setNumber(number + 1);
          setNumber(number + 1);
        }}>
        +3
      </button>
    </>
  );
}

以下是这个按钮的点击事件处理函数通知 React 要做的事情:

  1. setNumber(number + 1)number 是 0 所以 setNumber(0 + 1)。 React 准备在下一次渲染时将 number 更改为 1。
  2. setNumber(number + 1)number 是 0 所以 setNumber(0 + 1)。 React 准备在下一次渲染时将 number 更改为 1。
  3. setNumber(number + 1)number 是 0 所以 setNumber(0 + 1)。 React 准备在下一次渲染时将 number 更改为 1。

尽管你调用了三次 setNumber(number + 1),但在这次渲染的 事件处理函数中 number 会一直是 0,所以你会三次将 state 设置成 1。这就是为什么在你的事件处理函数执行完以后,React 重新渲染的组件中的 number 等于 1 而不是 3。

为了更好理解,我们看下面这个例子:

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 5);
          setTimeout(() => {
            alert(number);
          }, 3000);
        }}>
        +5
      </button>
    </>
  );
}

点击+5 后,弹出的数字是 0,而不是 5. 点击按钮后的操作:

  1. setNumber(0+5)
  2. js setTimeout(() => { alert(0) })

2. 将 state 加入队列

React 会对 state 更新进行批处理。在上面的示例中,连续调用了三次setNumber(number + 1)并不能得到我们想要的结果。

React 会等到事件处理函数中的所有代码都运行完毕再处理你的 state 更新。

我们可以通过更新函数来在下次渲染之前多次更新同一个 state。比如:

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber((n) => n + 1);
          setNumber((n) => n + 1);
          setNumber((n) => n + 1);
        }}>
        +3
      </button>
    </>
  );
}

下面是 React 在执行事件处理函数时处理这几行代码的过程:

  1. setNumber(n => n + 1)n => n + 1 是一个函数。React 将它加入队列。
  2. setNumber(n => n + 1)n => n + 1 是一个函数。React 将它加入队列。
  3. setNumber(n => n + 1)n => n + 1 是一个函数。React 将它加入队列。

当你在下次渲染期间调用 useState 时,React 会遍历队列。之前的 number state 的值是 0,所以这就是 React 作为参数 n 传递给第一个更新函数的值。然后 React 会获取你上一个更新函数的返回值,并将其作为 n 传递给下一个更新函数,以此类推:

更新队列n返回值
n => n + 100 + 1 = 1
n => n + 111 + 1 = 2
n => n + 122 + 1 = 3

看看下面这个例子:

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button
        onClick={() => {
          setNumber(number + 5);
          setNumber((n) => n + 1);
          setNumber(42);
        }}>
        增加数字
      </button>
    </>
  );
}

以下是 React 在执行事件处理函数时处理这几行代码的过程:

  1. setNumber(number + 5)number 为 0,所以 setNumber(0 + 5)。React 将 “替换为 5” 添加到其队列中。
  2. setNumber(n => n + 1)n => n + 1 是一个更新函数。React 将该函数添加到其队列中。
  3. setNumber(42):React 将 “替换为 42” 添加到其队列中。

在下一次渲染期间,React 会遍历 state 队列:

更新队列n返回值
替换为 505
n => n + 155 + 1 = 6
替换为 42642

可以这样来理解状态队列的更新:

function getFinalState(baseState, queue) {
  let finalState = baseState;
  queue.forEach((update) => {
    finalState = typeof update === 'function' ? update(finalState) : update;
  });
  return finalState;
}

其中 baseState 是初始状态,queue 是状态更新队列,包括数据和更新函数。

3. set 函数一定会触发更新吗?

看下面这个例子:

export default function Counter() {
  const [number, setNumber] = useState(0);
  const [person, setPerson] = useState({ name: 'jack' });
  console.log('渲染');

  return (
    <>
      <button
        onClick={() => {
          setNumber(number);
        }}>
        增加数字
      </button>
      <h1>{number}</h1>
      <button
        onClick={() => {
          person.age = 18;
          setPerson(person);
        }}>
        修改对象
      </button>
      <h1>{JSON.stringify(person)}</h1>
    </>
  );
}

组件的更新意味着组件函数的重新执行。对于上面这个例子,无论是点击 增加数字 还是 改变对象 都没有打印 渲染

set 函数触发更新的条件:

  • 值类型,state 的值改变
  • 引用类型,state 的引用改变

对于上面的例子:

  • number 是值类型。点击增加数字,值没有改变,不会触发更新。
  • person 是引用类型。点击修改对象,虽然 person 对象的值虽然变化了,但是引用地址没有变化,因此也不会触发更新。

4. 构建 state 的原则

  1. 合并关联的 state

有时候我们可能会不确定使用单个 state 还是多个 state 变量。

const [x, setX] = useState(0);
const [y, setY] = useState(0);

const [position, setPosition] = useState({ x: 0, y: 0 });

从技术上讲,我们可以使用其中任何一种方法。但是,如果某两个 state 变量总是一起变化,则将它们统一成一个 state 变量可能更好。这样你就不会忘记让它们始终保持同步。

  1. 避免矛盾的 state
import { useState } from 'react';

export default function Send() {
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);
  return (
    <>
      <button
        onClick={() => {
          setIsSent(false);
          setIsSending(true);
          setTimeout(() => {
            setIsSending(false);
            setIsSent(true);
          }, 2000);
        }}>
        发送
      </button>
      {isSending && <h1>正在发送...</h1>}
      {isSent && <h1>发送完成</h1>}
    </>
  );
}

尽管这段代码是有效的,但也会让一些 state “极难处理”。例如,如果你忘记同时调用 setIsSentsetIsSending,则可能会出现 二者 同时为 true 的情况。

可以用一个 status 变量来代替它们。代码如下:

import { useState } from 'react';

export default function Send() {
  const [status, setStatus] = useState('init');
  return (
    <>
      <button
        onClick={() => {
          setStatus('sending');
          setTimeout(() => {
            setStatus('sent');
          }, 2000);
        }}>
        发送
      </button>
      {status === 'sending' && <h1>正在发送...</h1>}
      {status === 'sent' && <h1>发送完成</h1>}
    </>
  );
}
  1. 避免冗余的 state
import { useState } from 'react';

export default function Name() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');

  return (
    <>
      <span>First Name</span>
      <input
        value={firstName}
        onChange={(e) => {
          setFirstName(e.target.value);
          setFullName(e.target.value + ' ' + lastName);
        }}
      />
      <span>Last Name</span>
      <input
        value={lastName}
        onChange={(e) => {
          setLastName(e.target.value);
          setFullName(firstName + ' ' + e.target.value);
        }}
      />
      <h1>{fullName}</h1>
    </>
  );
}

能够看出,fullName 是冗余的 state。我们可以直接:

const fullName = firstName + ' ' + lastName;

无需再把 fullName 存放到 state 中。

  1. 避免重复的 state

有时候,在我们存储的 state 中,可能有两个 state 有重合的部分。这时候我们就要考虑是不是有重复的问题了。

具体例子见这里

5. 保存和重置 state

前面我们说过,组件内部的 state 是互相隔离的。一个组件 state 的改变不会影响另外一个。然而,我们看下面这个例子

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
      <label>
        <input
          type='checkbox'
          checked={isFancy}
          onChange={(e) => {
            setIsFancy(e.target.checked);
          }}
        />
        使用好看的样式
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div className={className} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}>
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>加一</button>
    </div>
  );
}

当我们修改了 Counter组件 的 state 后,点击 checkbox 切换到另一个 Counter,旧 Counter 的 state 并没有变为 0,而是保留了下来。如下图:

重读 React 官方文档

在 React 中,相同位置的相同组件会使得 state 保留下来。那么怎么才能让上述例子的 state 重置呢?

有两种方法:

1. 将组件渲染在不同的位置

{
  isFancy ? (
    <Counter isFancy={true} />
  ) : (
    <div>
      <Counter isFancy={false} />
    </div>
  );
}

2. 使用 key 来标识组件

{
  isFancy ? <Counter isFancy={true} key={fancyTrue} /> : <Counter isFancy={false} key={fancyFalse} />;
}

效果如下:

重读 React 官方文档

useRef

基本用法

提起 useRef,很多人都会把它跟 DOM 联系起来。其实 useRef 不止可以用来存储 DOM 元素。它的定义是:

如果希望组件记住某些信息,但又不想让这些信息触发新的渲染,可以使用 useRef。

比如下面这个例子

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

let timer = null;

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

  const onStart = () => {
    timer = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);
  };

  const onStop = () => {
    clearInterval(timer);
  };

  useEffect(() => {
    return () => {
      clearInterval(timer);
    };
  }, []);

  return (
    <>
      <h1>{count}</h1>
      <button onClick={onStart}>开始</button>
      <button onClick={onStop}>停止</button>
    </>
  );
}

这个例子里,我们写了一个 Counter 组件。点击开始按钮 count 每秒增加 1,点击停止按钮 count 停止增加。

看上去,这个组件好像很 OK。

但是如果在一个页面使用 Counter 组件两次,就会发现,第一个定时器停止不了。

export default function App() {
  return (
    <div className='App'>
      <Counter />
      <Counter />
    </div>
  );
}

如下图:

重读 React 官方文档

这是因为两个组件公用同一个 timer 变量,第二个组件修改 timer 后,导致第一个组件中 clearInterval 处理的是第二个组件的 timer。 因此第一个组件无法停止定时增加。

官网推荐我们使用纯函数编写组件也是基于此。

估计有人会说,我可以把 timer 变量放到 Counter 内部。组件内部的变量是互相隔离的, 这样就不会把第一个 Counter 组件的 timer 给覆盖了。

放到内部有两种情况:

1. 直接使用变量。 当组件更新的时候,组件函数重新执行,会导致 timer 重新创建,因此并不能清除之前的 timer

2. 使用 state。 使用 state 可以解决问题,但是会导致不必要的渲染。每次 timer 变化都会导致组件重新渲染。

其实对于这种定时器清理的问题,我们可以使用 useRef。useRef 创建一个变量,变量里有一个 current 属性。

const timeRef = useRef(null);

比如上面这段代码,会创建一个变量 timeRef, 它的结构类似 { current: null }

使用 ref 修改上述例子中的代码:

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

  const onStart = () => {
    timeRef.current = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);
  };

  const onStop = () => {
    clearInterval(timeRef.current);
  };

  useEffect(() => {
    return () => {
      clearInterval(timeRef.current);
    };
  }, []);

  return (
    <>
      <h1>{count}</h1>
      <button onClick={onStart}>开始</button>
      <button onClick={onStop}>停止</button>
    </>
  );
}

运行试试,完美解决了之前的问题。

可以这样理解,ref 跟 state 的区别是,ref 不会导致组件重新渲染。

使用 ref 操作 DOM

我们来看一个 ref 操作 DOM 的例子

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>聚焦输入框</button>
    </>
  );
}

效果:点击聚焦输入框按钮,输入框将会聚焦。

重读 React 官方文档

这段代码主要做了以下事情:

  1. 使用 useRef Hook 声明 inputRef
  2. <input ref={inputRef}> 告诉 React 将这个 input 的 DOM 节点放入 inputRef.current
  3. handleClick 函数中,从 inputRef.current 读取 input DOM 节点并调用它的 focus()
  4. 给按钮添加点击事件handleClick

forwardRef

我们可以使用 ref 属性配合 useRef 直接调用 DOM。那么可不可以给组件添加 ref 调用组件的 DOM 呢?让我们来试一下

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>聚焦输入框</button>
    </>
  );
}

我们给 MyInput 组件加上了 ref,可是当我们点击 聚焦输入框按钮,则会报错:Cannot read properties of null (reading 'focus')

也就是说 inputRef.currentnull。我们并不能拿到组件的 DOM 元素。

默认情况下,React 不允许组件访问其他组件的 DOM 节点。这是因为 ref 是应急方案,应当谨慎使用。如果组件想要暴露自己的的 DOM,则需要使用forwardRef来包装,并把 ref 转发给自己的子元素。 比如这样:

import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>聚焦输入框</button>
    </>
  );
}

它是这样工作的:

  1. <MyInput ref={inputRef} /> 告诉 React 将对应的 DOM 节点放入 inputRef.current 中。但是,这取决于 MyInput 组件是否允许这种行为, 默认情况下是不允许的。

  2. MyInput 组件是使用 forwardRef 声明的。 这让从上面接收的 inputRef 作为第二个参数 ref 传入组件,第一个参数是 props 。

  3. MyInput 组件将自己接收到的 ref 传递给它内部的 <input>

这样就通过 forwardRef 向父组件暴露了子组件的 DOM 节点。

useEffect

useEffect 是使用频率仅低于 useState 的 hook。很多人把 useEffect 当做监听器来使用。这是不太妥当的。

useEffect 是用来处理由渲染本身而不是点击事件引起的副作用

基本用法

useEffect(setup, dependencies?)

  • setup 处理逻辑,是一个函数。可以返回一个清理函数

  • dependencies 是依赖项,当依赖项变化会执行, 会执行setup函数

值得注意的几个问题

1. useEffect 的执行时机

  • useEffect 在组件挂载完成后,也就是说 DOM 更新完毕后,按照定义的顺序执行。

比如这个例子:

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

export default function App() {
  const [number, setNumber] = useState(0);

  useEffect(() => {
    console.log('-- 空依赖1,useEffect执行 --');
  }, []);

  useEffect(() => {
    console.log('-- 非空依赖,useEffect执行 --', number);
  }, [number]);

  useEffect(() => {
    console.log('-- 空依赖2,useEffect执行 --');
  }, []);

  console.log('渲染');

  return (
    <>
      <h1>{number}</h1>
    </>
  );
}

结果打印为:

渲染
-- 空依赖1,useEffect执行 --
-- 非空依赖,useEffect执行 -- 0
-- 空依赖2,useEffect执行 --

执行顺序依次为:

所有 DOM 更新完毕 => 空依赖 1 useEffect 执行 => 非空依赖 useEffect 执行 => 空依赖 2 useEffect 执行

  • useEffect 的清理函数在组件卸载期间调用或者下次运行之前调用。 比如下面这个例子:
import React, { useState, useEffect } from 'react';

function Title() {
  const [title, setTitle] = useState('这里是标题');

  useEffect(() => {
    console.log('空依赖,useEffect执行');
    return () => console.log('空依赖,useEffect清理函数执行');
  }, []);

  useEffect(() => {
    console.log('非空依赖,useEffect执行');
    return () => console.log('非空依赖,useEffect清理函数执行');
  }, [title]);

  return <h1 onClick={() => setTitle((title) => `${title}1`)}>{title}</h1>;
}

export default function App() {
  const [titleVisible, setTitleVisible] = useState(true);

  return (
    <>
      {titleVisible && <Title />}
      <button onClick={() => setTitleVisible(!titleVisible)}>{`${titleVisible ? '隐藏' : '显示'}标题`}</button>
    </>
  );
}

由前面我们知道,组件挂载完成后才会按照顺序执行 useEffect, 因此打印结果是:

空依赖,useEffect执行
非空依赖,useEffect执行

然后点击标题,会打印:

非空依赖,useEffect清理函数执行
非空依赖,useEffect执行

最后,我们点击 隐藏标题 按钮,会打印:

空依赖,useEffect清理函数执行
非空依赖,useEffect清理函数执行

也就是说,空依赖的 useEffect 只会在组件挂载完成后执行,清理函数只会在组件卸载后执行

非空依赖的 useEffect 则有两种情况:

  1. 组件挂载完成后执行,清理函数在组件卸载后执行
  2. 依赖发生变化时执行,清理函数会在依赖发生变化,useEffect 内的逻辑执行前调用

2. 依赖项

  • 依赖项为空,则只会在组件挂载完成后执行一次。当组件再次更新时候,不会执行。

  • 如果 React 的所有依赖项都具有与上次渲染期间相同的值,则 React 将跳过 Effect

  • 您不能“选择”您的依赖项。它们由 Effect 中的代码决定。

  • 依赖项需要是能够触发组件更新的变量,比如 state 或者 props

不需要 effect 的情况

effect 是 React 的应急方案。它允许我们能够与一些外部系统同步,比如 ajax 请求和浏览器 DOM。如果不涉及外部系统,则不需要 effect。删除不必要的 effect 可以使代码更容易理解,运行速度更快并且不容易出错。

下面是几种常见的不需要 effect 的情况:

  1. 如果您可以在渲染期间计算某些东西,则不需要 Effect。
  2. 要缓存昂贵的计算,请添加 useMemo 而不是 useEffect.
  3. 要重置整个组件树的状态,请将不同的传递 key 给它。
  4. 要重置特定位的状态以响应属性更改,请在渲染期间设置它。
  5. 因为显示组件而运行的代码应该在 Effects 中,其余的应该在事件中。
  6. 如果您需要更新多个组件的状态,最好在单个事件期间执行。
  7. 每当您尝试同步不同组件中的状态变量时,请考虑提升状态。
  8. 您可以使用 Effects 获取数据,但您需要实施清理以避免竞争条件。

具体例子可以参考官网react.docschina.org/learn/you-m…

我的理解就是,尽可能少用 useEffect,除非不用不行的情况。

useLayoutEffect

useLayoutEffect 跟 useEffect 唯一的不同就是二者的执行时机不同。

前面说过,对于一次更新有三个阶段:触发渲染(render)提交(commit)

render 阶段主要是组件函数执行,jsx 转化为 Fiber 等工作。

commit 阶段主要是把更改反映到浏览器上,类似 document.appendChild()之类的操作。

useEffect 在 commit 阶段完成后执行。

useLayoutEffect 在 commit 阶段之前执行。

由于 commit 阶段主要是页面更新的操作,因此useLayoutEffect 会阻塞页面更新。

比如这个例子:

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

export default function App() {
  const [text, setText] = useState('11111');

  useEffect(() => {
    console.log('useEffect');
    let i = 0;
    while (i < 100000000) {
      i++;
    }
    setText('00000');
  }, []);

  // useLayoutEffect(() => {
  //   console.log("useLayoutEffect");
  //   let i = 0;
  //   while (i < 100000000) {
  //     i++;
  //   }
  //   setText("00000");
  // }, []);

  return <h1>{text}</h1>;
}

使用 useEffect 页面会有明显的从 11111 变成 00000 的过程。使用 useLayoutEffect 则不会。

让我们梳理下执行流程:

useEffect: render => commit(反映到页面上) => useEffect => render => commit(反映到页面上)

useLayoutEffect: render => useLayoutEffect => render => commit(反映到页面上)

useLayoutEffect 执行后发现 state 更新,就不再把 11111 反映到页面上了,直接再次执行 react 渲染。因此我们没有看到从 11111 闪烁成 00000 的过程。

自定义 hook

自定义 hook 是一个函数,它允许我们在组件之间共享逻辑。

在使用自定义 hook 之前,我们代码复用的最小单元是组件。使用自定义 hook 之后,我们可以方便地复用组件里的逻辑。

基本使用

编写自定义 hook 需要遵循以下规则:

  1. 命名必须是 use 后跟大写字母,比如useLogin, useForceUpdate

  2. 自定义 hook 中至少要使用一个其他 hook

比如我们写一个强制刷新的 hook:

import { useState } from 'react';
function useForceUpdate() {
  const [, setForceState] = useState({});
  return () => setForceState({});
}

在组件里使用:

export default function App() {
  const forceUpdate = useForceUpdate();
  console.log('render');
  return <button onClick={forceUpdate}>强制刷新</button>;
}

当我们点击 强制刷新 按钮的时候,会打印 render。也就是App组件重新渲染了。

何时使用

就个人理解,我觉得有两种情况比较适合使用自定义 hook:

  1. 有经常复用的组件逻辑时

  2. 使用自定义 hook 后能够让代码逻辑,数据流向更清晰

memo

在 React 中,父组件的重新渲染会导致子组件的重新渲染。memo 允许我们在 props 不变的情况下避免渲染子组件。

语法

memo(Component, arePropsEqual?):包装一个组件,并获得改组件的缓存版本。

Component: 要包装的组件。 arePropsEqual(prevProps, nextProps): 接收两个参数,前一次的 props 和后一次的 props。返回值是一个布尔类型,true表示新旧 props 相等,false表示两次 props 不相等。

下面用一个例子感受它的用法。

缓存子组件的例子

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

function Hello({ text }) {
  console.log('子组件重新渲染');
  return <h1>{`hello ${text}!`}</h1>;
}

export default function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const onAddCount = () => {
    setCount((count) => count + 1);
  };

  const onChangeText = () => {
    setText('world');
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={onAddCount}>+1</button>

      <Hello text={text} />
      <button onClick={onChangeText}>改变子组件文本</button>
    </>
  );
}

当我们点击 +1按钮时,会打印 子组件重新渲染。也就是说当我们的父组件更新的时候,子组件也会相应更新。

但是如果我们用 memo 来包裹子组件,代码如下:

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

const Hello = memo(function Hello() {
  console.log('子组件重新渲染');
  return <h1>Hello, world!</h1>;
});

// ...

当我们点击 +1按钮时, 子组件重新渲染 不会再打印。也就说我们通过 memo 实现了子组件的缓存。

需要注意的是,当上下文或者子组件内部状态变化的时,依然会触发更新。 memo 缓存组件只是针对 props 不发生改变的情况。

prop 是对象、数组或函数的情况

当传递给子组件的 prop 是对象、数组或函数时,由于它们是引用类型,父组件重新渲染会导致它们被重新定义。也就是说,props 发生了变化。这种情况下,依然会触发子组件更新。

比如下面这个例子

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

const List = memo(function List({ list }) {
  console.log('子组件重新渲染');
  return (
    <>
      {list.map((item) => (
        <div key={item.id}>{item.content}</div>
      ))}
    </>
  );
});

export default function App() {
  const [title, setTitle] = useState('父组件');
  const [todoList, setTodoList] = useState([
    { id: 1, content: '吃饭', isDone: true },
    { id: 2, content: '睡觉', isDone: false },
    { id: 3, content: '洗澡', isDone: true },
    { id: 4, content: '刷牙', isDone: false },
    { id: 5, content: '刷抖音', isDone: false }
  ]);

  const changeTitle = () => {
    setTitle('父组件' + Math.random().toFixed(2));
  };

  const list = todoList.filter((item) => item.isDone);

  return (
    <>
      <h1 onClick={changeTitle}>{title}</h1>
      <List list={list} />
    </>
  );
}

点击父组件,依然会触发子组件渲染。这是由于每次父组件渲染都会重新定义一个变量 list, 两次的 list 不是同一个引用。

这种情况要怎么处理才能避免子组件渲染呢?有两种办法:

1. 使用比较函数

我们可以给 memo 添加第二个参数arePropsEqual:

// ...
(prevProps, nextProps) => {
  return (
    prevProps.list.length === nextProps.list.length &&
    prevProps.list.every((item) => {
      let allOk = true;
      for (let key in item) {
        if (prevProps[key] !== nextProps[key]) {
          allOk = false;
        }
      }
      return allOk;
    })
  );
};
//   ...

这样,当修改 title 时,list 的内容没有变化,并不会触发子组件更新。

个人建议,尽可能避免使用比较函数。主要出于两个考虑:一来别人需要阅读你的比较函数来确定你的组件更新规则;二来我们重写比较函数就意味着每次父组件更新都会执行比较函数。如果比较函数比较复杂且耗时,那么使用比较函数就不再是好的选择了。

2. 使用 useCallback 或者 useMemo 来缓存引用类型

useCallback 用来缓存一个函数。在这个例子里,使用 useMemo 比较合适。

修改 list 的定义,代码如下:

// ...
// 使用useMemo缓存list, 这样title改变不会再触发子组件渲染
const list = useMemo(() => todoList.filter((item) => item.isDone), [todoList]);
// ...

这样,由于我们缓存了 list, 当修改 title 时,list 仍为同一个 list,并不会触发子组件更新。

useMemo

useMemo 允许我们缓存一个计算结果。当再次渲染的时候,返回上一次的结果而不是重新计算。

语法

const cachedValue = useMemo(calculateValue, dependencies)

  • calculateValue: 缓存的计算结果。 当它是一个函数时,会缓存这个函数的返回值。

  • dependencies: 依赖项。当依赖项变化时,重新计算结果。

使用场景

  1. 防止组件重新渲染

比如前面的例子:

当 prop 是对象、数组或函数的情况,这时候可以使用 useMemo 配合 memo 缓存组件。

  1. 避免昂贵的计算

比如下面这个例子

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

export default function App() {
  const [count, setCount] = useState(0);

  // 模拟复杂的运算,需要两秒钟
  const getResult = async () => {
    await new Promise((resolve) => {
      setTimeout(() => resolve(), 2000);
    });
    return 2;
  };

  const onAddCount = async () => {
    const result = await getResult();
    setCount((count) => count + result);
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={onAddCount}>+随机数</button>
    </>
  );
}

getResult 是一个耗时的计算,需要两秒钟。这就会导致我们每次点击按钮,都要等待两秒才能响应。如果我们使用 useMemo 缓存结果,那么只有第一次需要等待两秒,后面都会快速响应。

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

export default function App() {
  const [count, setCount] = useState(0);

  // 使用useMemo缓存复杂的计算结果
  const getResult = useMemo(async () => {
    await new Promise((resolve) => {
      setTimeout(() => resolve(), 2000);
    });
    return 2;
  }, []);

  const onAddCount = async () => {
    // 使用useMemo直接缓存计算结果,getResult是结果不是函数
    const result = await getResult;
    setCount((count) => count + result);
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={onAddCount}>+随机数</button>
    </>
  );
}

使用 useMemo 前的效果:

重读 React 官方文档

使用 useMemo 后的效果:

重读 React 官方文档

useCallback

useMemo 允许我们缓存一个函数。当再次渲染的时候,返回上一次的函数而不是重新定义。

语法

const cachedFn = useCallback(fn, dependencies)

  • fn: 缓存的函数。

  • dependencies: 依赖项。当依赖项变化时,重新计算结果。

使用场景

  1. 防止组件重新渲染

当我们传给子组件的属性有函数的时候,比如下面这个例子

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

const Hello = memo(function Hello({ text, onClick }) {
  console.log('子组件重新渲染');
  return <h1 onClick={onClick}>{`hello ${text}!`}</h1>;
});

export default function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const onAddCount = () => {
    setCount((count) => count + 1);
  };

  const onChangeText = () => {
    setText('world');
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={onAddCount}>+1</button>

      <Hello text={text} onClick={onChangeText} />
    </>
  );
}

当我们点击 count 会造成子组件的渲染,这是因为 onChangeText 是引用类型,每次父组件渲染,它都被重新定义。这导致了每次 props 都发生了变化。我们可以使用 useCallback 来缓存 onChangeText:

// 使用useCallback来缓存onChangeText
const onChangeText = useCallback(() => {
  setText('world');
}, []);

使用 useMemo 也可以实现相同的结果,只不过需要再多包一层函数:

// 使用useMemo缓存onChangeText
const onChangeText = useMemo(() => {
  return () => {
    setText('world');
  };
}, []);
  1. 优化自定义 hook 如果您正在编写自定义 Hook,建议将它返回的任何函数包装到 useCallback:
function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback(
    (url) => {
      dispatch({ type: 'navigate', url });
    },
    [dispatch]
  );

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack
  };
}

这确保了 Hook 的使用者可以在需要时优化他们自己的代码。

争议

有人认为应当给所有的函数包上 useCallback, 我并不认同。主要是出于以下两个考虑:

  1. 使用 useCallback 后代码可读性变差
  2. 创建一个函数的性能消耗几乎可以忽略不计,不应作为优化点

最后

官方文档内容较多,这里只整理个人认为比较常用的知识点。想要查漏补缺的小伙伴可以去看官网

参考

React官方文档

转载自:https://juejin.cn/post/7243725455735832636
评论
请登录