美文网首页
重读 React 官方文档

重读 React 官方文档

作者: 麦西的西 | 来源:发表于2023-06-11 20:10 被阅读0次

    前言

    大家好,我是麦西。

    近来发现 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 + 1 0 0 + 1 = 1
    n => n + 1 1 1 + 1 = 2
    n => n + 1 2 2 + 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 返回值
    替换为 5 0 5
    n => n + 1 5 5 + 1 = 6
    替换为 42 6 42

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

    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 中,相同位置的相同组件会使得 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} />;
    }
    

    效果如下:

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

    如下图:

    这是因为两个组件公用同一个 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>
        </>
      );
    }
    

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

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

    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 获取数据,但您需要实施清理以避免竞争条件。

    具体例子可以参考官网https://react.docschina.org/learn/you-might-not-need-an-effect#caching-expensive-calculations

    我的理解就是,尽可能少用 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 前的效果:

    使用useMemo前

    使用 useMemo 后的效果:

    使用useMemo后

    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官方文档

    相关文章

      网友评论

          本文标题:重读 React 官方文档

          本文链接:https://www.haomeiwen.com/subject/hlkfydtx.html