【2024官方文档版学习笔记】React-脱围机制

发布于:2024-04-28 ⋅ 阅读:(28) ⋅ 点赞:(0)

系列文章

一、 React快速入门
二、React描述IU
三、React添加交互
四、React状态管理
五、React脱围机制


五、脱围机制

“脱围机制”(Fallback Mechanism)通常指的是在组件加载过程中,当遇到加载失败或者加载时间过长的情况时,能够显示备用内容或者提供备用行为的机制。

1. 使用 ref 引用值

1.1 组件添加ref

导入

import {useRef} from 'react'

调用并传参初始值

const ref = useRef(0);

useRef返回一个这样的对象:可以使用ref.current访问current当前的值

{ current = 0 //传入的值}

简单小栗子:点击+1

import { useRef } from 'react'

export default function Add() {
    const ref = useRef(0)
    return (
        <div>
            <button onClick={() => {
                ref.current = ref.current + 1
                alert(`你点击了${ref.current}`)
            }}> 点击+1</button>
        </div>
    )
}

1.2 制作秒表

stateref结合起来使用

栗子1:只计时

import { useState } from 'react'
export default function StopWatch() {
    const { startTime, setStartTime } = useState(null)
    const { nowTime, setNowTime } = useState(null)
    function handleStart() {
        setStartTime(Date.now())
        setNowTime(Date.now())
        setInterval(() => {
            //10ms更新一下时间
            setNowTime(Date.now())
        }, 10);
    }
    // 计算出过去的时间
    let secondPassed = 0;
    if (startTime != null && nowTime != null) {
        secondPassed = (nowTime - startTime)
    }


    return (
        <div>
            <p>时间过去了:{secondPassed}</p>
            <button onClick={handleStart}>开始</button>
        </div>
    )
}

栗子2:计时时可以重新开始/停止

import { useState, useRef } from 'react';

export default function Stopwatch() {
    const [startTime, setStartTime] = useState(null);
    const [now, setNow] = useState(null);
    const intervalRef = useRef(null);

    function handleStart() {
        setStartTime(Date.now());
        setNow(Date.now());
		//清除之前的定时器
        clearInterval(intervalRef.current);
        //创建一个新的定时器
        intervalRef.current = setInterval(() => {
            setNow(Date.now());
        }, 10);
    }

    function handleStop() {
        //清除定时器
        clearInterval(intervalRef.current);
    }

    let secondsPassed = 0;
    if (startTime != null && now != null) {
        secondsPassed = (now - startTime) / 1000;
    }

    return (
        <>
            <h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
            <button onClick={handleStart}>
                开始
            </button>
            <button onClick={handleStop}>
                停止
            </button>
        </>
    );
}

当一条信息用于渲染时,将它保存在 state 中。当一条信息仅被事件处理器需要,并且更改它不需要重新渲染时,使用 ref 可能会更高效。

1.3 ref 和 state 的区别

区别 ref state
返回 返回{current:value} 返回state当前值valuestate设置函数
是否重新渲染
是否可变 可变,可以修改更新current的值 不可变,需使用设置函数修改state变量
读取 渲染期间不可读取写入 随时读取

1.4 ref 的使用场景和使用规则

当组件需要跳出React与外部API(不影响页面)进行通信时,会用到ref

组件需要存储一些值但这些值不会影响渲染效果,那么将这些值存在ref里面

规则:

  • ref视为脱围机制
  • 不要再渲染过程中使用ref.current

ref就是一个普通的js对象

ref最常用的用法是访问DOM元素,具体在之后会学习到。

1.5 小结

  • ref是用来保留不用渲染的值,不要在渲染过程中读取写入ref.current

  • ref具有一个名为current的值,可以进行读取和设置,但是不会触发渲染。

  • 你可以通过调用 useRef Hook 来让 React 给你一个 ref。


2. 使用ref操作DOM

2.1 获取指向节点的ref

引入:

import {useRef} from 'react'

组件中声明一个ref:

const myRef = useRef(null)

作为ref属性传递给DOM节点的JSX标签:

<div ref="myRef"></div>

使用:

myRef.current.scrollIntoView(); //使用API

一个组件可以有多个ref

举个栗子:使文本框获取焦点

import { useRef } from 'react';
export default function Form() {
    const ref = useRef(null)
    function handleFocus() {
        ref.current.focus()  //获取焦点,current自动设置为相应的DOM节点
    }
    return (
        <>
            <input type='text' ref={ref} />
            <button onClick={handleFocus}>聚焦输入框</button>
        </>
    )
}

2.2 访问另一个组件的DOM节点

默认情况下,React 不允许组件访问其他组件的 DOM 节点。甚至自己的子组件也不行。

想要 暴露其 DOM 节点的组件必须选择该行为。一个组件可以指定将它的 ref “转发”给一个子组件

MyInput使用forwardRefAPI:

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

工作原理:

  1. <MyInput ref={inputRef} /> 告诉 React 将对应的 DOM 节点放入 inputRef.current 中。但是,这取决于 MyInput 组件是否允许这种行为, 默认情况下是不允许的。
  2. MyInput 组件是使用 forwardRef 声明的。 这让从上面接收的 inputRef 作为第二个参数 ref 传入组件,第一个参数是 props
  3. MyInput 组件将自己接收到的 ref 传递给它内部的 <input>

还是那个输入框获取焦点的栗子:

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

2.3 refs使用场景

不在渲染期间用。react在提交阶段设置ref.current

通常我我们从事件处理器上访问refs

Refs 是一种脱围机制。你应该只在你必须“跳出 React”时使用它们。这方面的常见示例包括管理焦点、滚动位置或调用 React 未暴露的浏览器 API

避免更改由 React 管理的 DOM 节点。

2.4 小结

  • Refs 是一个通用概念,但大多数情况下你会使用它们来保存 DOM 元素。
  • 你通过传递 <div ref={myRef}> 指示 React 将 DOM 节点放入 myRef.current
  • 通常,你会将 refs 用于非破坏性操作,例如聚焦、滚动或测量 DOM 元素。
  • 默认情况下,组件不暴露其 DOM 节点。 您可以通过使用 forwardRef 并将第二个 ref 参数传递给特定节点来暴露 DOM 节点。
  • 避免更改由 React 管理的 DOM 节点。
  • 如果你确实修改了 React 管理的 DOM 节点,请修改 React 没有理由更新的部分。

3.使用Effect同步

有些组需要和外部系统同步,比如你希望根据React state控制非React的组件、设置服务器连接或在组件出现系在屏幕上时发送分析日志。

3.1 Effect是什么?和事件(event)的不同?

react两种逻辑类型:渲染逻辑代码、事件处理程序。

副作用:指的是在组件生命周期中 处理和react渲染无关的逻辑 的过程。

使用Effect目的:将副作用逻辑和组件的渲染逻辑分离,提高代码的可维护性和可测试性。

Effect 是一种用于处理副作用逻辑的机制,它帮助我们在组件中进行各种与渲染无关的操作,从而更好地管理组件的行为和状态。

Effect和event的区别:

区别 useEffect event
用途 处理副作用逻辑,比如数据获取、订阅事件、手动操作DOM 页面用户触发的交互腥味,例如点击、键盘输入、鼠标移动。
触发时机 以根据依赖项数组的变化,在组件挂载、更新或卸载时触发副作用逻辑的执行。通过控制依赖项数组的内容,可以灵活地控制副作用的触发时机。 用户在页面上触发的操作所触发的。例如点击按钮、输入文本。
调用方式 在函数顶层使用,作为组件的一部分,通过useEffect函数来注册副作用逻辑。 在JSX绑定相应元素,通过onClickonChange属性来指定事件处理函数。
清理机制 返回一个清理函数,在组建卸载时执行清理操作。例如取消订阅、清除定时器。 不需要手动清理。一些特殊情况下需要手动取消事件监听器或者清除其他资源。

不要随意在组件使用Effect,按需引入,详细在后面会学习到。

3.2 如何编写Effect

3.2.1 步骤1:声明Effect

默认情况下,每次渲染之后都会执行

引入:

import {useEffect} from 'react'

顶部调用:

function MyComponent() {
    useEffect(() => {
        //渲染后执行的代码    
    });
    return <div />
}

举个栗子:有一个video组件VideoPlayer你希望通过传入一个数据isPlaying来控制它的一个播放或暂停

<VideoPlayer isPlaying={isPlaying} />;

但是video里没有isPlaying属性,控制的方法是调用play()或者是pause()方法,因此需要将isPlayingplay()或者是pause()的值同步。但是在渲染期间调用play()或者是pause()是错的。

function VideoPlayer({ src, isPlaying }) {
  // TODO:使用 isPlaying 做一些事情
  return <video src={src} />;
}

不能在渲染期间对DOM节点进行操作!!!

我们应该将对应DOM操作的方法封装在Effect中,这样渲染完成后再执行这段操作代码,也就是useEffect 包裹副作用,把它分离到渲染逻辑的计算过程之外

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

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

请注意!!,不要如下这样使用,会陷入死循环:

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});
3.2.2 步骤2:指定Effect依赖

按需执行,而不是渲染之后执行。这样会拖慢运行进度;还可能导致程序逻辑错误,比如动画淡入效果只需要出现时播放一次。

依赖数组传入useEffect的第二个参数,告诉react跳过不必要的重新运行Effect。

栗子中使用isPlaying控制逻辑,要告诉Effect需要依赖这个属性,我们将它添加到依赖数组中。

  useEffect(() => {
    if (isPlaying) { // isPlaying 在此处使用……
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ……所以它必须在此处声明!

不声明依赖将会报错。依赖数组可以包含多个依赖项。

如果isPlaying和上一次渲染的值相同,则不会重新运行Effect。如下栗子,输入框输入内容时isPlaying未变,Effect不会运行,点击按钮isPlaying会改变,Effect会重新运行.

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

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

依赖数组是否书写影响着代码的运行效果:

useEffect(() => {
// 这里的代码会在每次渲染后执行
});

useEffect(() => {
// 这里的代码只会在组件挂载后执行
}, []);

useEffect(() => {
//这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);
3.2.3 步骤3:必要时添加清理函数cleanup

举个 聊天室 的栗子:编写一个ChatRoom组件,该组件使用时需要连接到聊天室。

现在提供了一个createConnection() API ,这个API返回一个对象:包含connect()disconnection()方法。要确保组件展示给用户的时候保持连接。

Effect逻辑:

//添加空依赖数组代表的是,只在挂载时执行一次代码
useEffect(() => {
    const connection = createConnection();
    connection.connect();
}, []);

栗子代码:

//App.js
import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>欢迎来到聊天室!</h1>;
}
//chat.js
export function createConnection() {
  // 真实的实现会将其连接到服务器,此处代码只是示例
  return {
    connect() {
      console.log('✅ 连接中……');
    },
    disconnect() {
      console.log('❌ 连接断开。');
    }
  };
}

**连接中会打印两次!!**当用户切换到含有ChatRoom组件的页面时,组件挂载时会打印一次,调用方法connect()连接到服务器。切换到设置页面的时候ChatRoom组件会被销毁,再次切换回含有ChatRoom组件的页面时会再次加载。组件被卸载的时候没有关闭链接

为了解决这个问题,可以在Effect中返回一个清理函数cleanup。这样的话控制台会符合预期地打印“连接中 连接断开 连接中”三条记录。

useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
        connection.disconnect();
    }
}, []);

3.3 Effect执行两次的解决办法

仅在开发环境下才会重复挂载组件,以帮助你找到需要清理的 Effect

我们要探究的是:如何让Effect在重复挂载后能够正常工作?通常的解决办法是:实现清理函数

下面是常用的Effect应用模式

3.3.1 控制非React组件

添加不是使用 React 编写的 UI 小部件

举个栗子:向页面添加地图组件,调整 缩放级别 并和zoomLevel变量保持同步。

//Effect代码
useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

开发环境中,React 会调用 Effect 两次,但zoolLevel没变,所以跳过第二次Effect运行。这种情况不需要清理

某些API不允许连续调用两次,比如dialog标签的showModal方法连续调用两次会报错,此时实现清理函数并关闭其对话框。

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

在开发环境中,Effect 将调用 showModal(),然后立即调用 close(),然后再次调用 showModal()。这与调用只一次 showModal() 的效果相同

3.3.2 订阅事件

如果 Effect 订阅了某些事件,清理函数应该退订这些事件

useEffect(() => {
    function handleScroll(e) {
        console.log(window.scrollX, window.scrollY)
    }
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
}, []);

在开发环境中,Effect 会调用 addEventListener(),然后立即调用 removeEventListener(),然后再调用相同的 addEventListener(),这与只订阅一次事件的 Effect 等效;这也与用户在生产环境中只调用一次 addEventListener() 具有相同的感知效果

3.3.3 触发动画

如果 Effect 对某些内容加入了动画,清理函数应将动画重置

useEffect(() => {
    const code = ref.current
    code.style.opacity = 1;  //触发动画
    return () => code.style.opacity = 0; //重置为初始值
}, []);

与上同理

3.3.4 获取数据

如果 Effect 将会获取数据,清理函数应该要么 中止该数据获取操作,要么忽略其结果:

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);  //等待执行完成
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

我们无法撤消已经发生的网络请求,但是清理函数应当确保获取数据的过程以及获取到的结果不会继续影响程序运行。如果 userId'Alice' 变为 'Bob',那么请确保 'Alice' 响应数据被忽略,即使它在 'Bob' 之后到达。

在开发环境中,浏览器调试工具的“网络”选项卡中会出现两个 fetch 请求。这是正常的。使用上述方法,第一个 Effect 将立即被清理,而 ignore 将被设置为 true。因此,即使有额外的请求,由于有 if (!ignore) 判断检查,也不会影响程序状态。

在生产环境中,只会显示发送了一条获取请求。如果开发环境中,第二次请求给你造成了困扰,最好的方法是使用一种可以删除重复请求、并缓存请求响应的解决方案:

function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

这不仅可以提高开发体验,还可以让你的应用程序速度更快

3.3.5 发送分析报告

考虑在访问页面时发送日志分析

useEffect(() => {
  logVisit(url); // 发送 POST 请求
}, [url]);

在开发环境中,logVisit 会为每个 URL 发送两次请求,不建议修改这处代码,用户看不到差异

在生产环境中,不会产生有重复的访问日志

3.4 初始化应用不使用Effect的情况

某些逻辑在应用程序启动时只运行一次,比如登录状态、加载本地程序的数据。可以将这些放在组件之外。

if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ……
}

保证这种逻辑在浏览器加载页面后只运行一次。

3.5 小结

  • 与事件不同,Effect 是由渲染本身,而非特定交互引起的。

  • Effect 允许你将组件与某些外部系统(第三方 API、网络等)同步。

  • 默认情况下,Effect 在每次渲染(包括初始渲染)后运行。

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

  • 不能随意选择依赖项,它们是由 Effect 内部的代码决定的。

  • 空的依赖数组([])对应于组件“挂载”,即添加到屏幕上。

  • 仅在严格模式下的开发环境中,React 会挂载两次组件,以对 Effect 进行压力测试。

  • 如果 Effect 因为重新挂载而中断,那么需要实现一个清理函数。

  • React 将在下次 Effect 运行之前以及卸载期间这两个时候调用清理函数。


4.不使用Effect

4.1 移除不必要的Effect

不必使用 Effect 的常见情况:转化渲染所需数据处理用户事件

的确 可以使用 Effect 来和外部系统 同步

比起直接在你的组件中写 Effect,现代 框架 提供了更加高效的,内置的数据获取机制。

下面时一些常见的示例:

4.2 根据 props 或 state 来更新 state

如果一个值可以基于现有的 propsstate 计算得出,不要把它作为一个 state,直接计算。

// 🔴 避免:多余的 state 和不必要的 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);

4.3 缓存高成本的计算

不相关的state变量变化是,不想重新执行某计算,可以使用 useMemo Hook缓存一个昂贵的计算。

import { useMemo } from 'react';

function TodoList({ todos, filter }) {
    const [newTodo, setNewTodo] = useState('');
    const visibleTodos = useMemo(() => {
        //除非todos或者filter发生变化,否则不会执行
        return getFilteredTodos(todos, filter);
    }, [todos, filter])
    //...
}

这会告诉 React,除非 todosfilter 发生变化,否则不要重新执行传入的函数

在下一次渲染中,它会检查 todosfilter 是否发生了变化。如果它们跟上次渲染时一样,useMemo 会直接返回它最后保存的结果。如果不一样,React 将再次调用传入的函数(并保存它的结果)。

4.4 当props变化重置所有state

ProfilePage 组件接收一个 prop:userId。页面上有一个评论输入框,你用了一个 state:comment 来保存它的值。

当你从一个人的个人资料导航到另一个时,comment 没有被重置。这导致很容易不小心把评论发送到不正确的个人资料,现在我们要解决这个问题。

以通过为每个用户的个人资料组件提供一个明确的键来告诉 React 它们原则上是 不同 的个人资料组件。将你的组件拆分为两个组件,并从外部的组件传递一个 key 属性给内部的组件:

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
  const [comment, setComment] = useState('');
  // ...
}

通过将 userId 作为 key 传递给 Profile 组件,使 React 将具有不同 userId 的两个 Profile 组件视为两个不应共享任何状态的不同组件

4.5 当props变化时调整部分state

有时候,当 prop 变化时,你可能只想重置或调整部分 state ,而不是所有 state。

以使用 useEffect 配合 useState 来在 props 变化时调整部分 state

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

function MyComponent(props) {
  const [derivedState, setDerivedState] = useState(null);

  // useEffect 用于监听 props 的变化
  useEffect(() => {
    // 在这里根据 props 来更新 derivedState
    const newDerivedState = /* 根据 props 计算新的状态 */;
    setDerivedState(newDerivedState);
  }, [props]); // 仅在 props 发生变化时执行

  return (
    <div>
      {/* 使用 derivedState */}
      {derivedState && <p>{derivedState}</p>}
    </div>
  );
}

export default MyComponent;

4.6 在事件处理函数在共享逻辑

不要在Effect中处理特定的事件逻辑,特定事件逻辑要在事件处理函数中处理

假设你有一个产品页面,上面有两个按钮(购买和付款),都可以让你购买该产品。当用户将产品添加进购物车时,你想显示一个通知。

Effect 只用来执行那些显示给用户时组件 需要执行 的代码

function ProductPage({ product, addToCart }) {
  // 🔴 避免:在 Effect 中处理属于事件特定的逻辑
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`已添加 ${product.name} 进购物车!`);
    }
  }, [product]);
function ProductPage({ product, addToCart }) {
  // ✅ 非常好:事件特定的逻辑在事件处理函数中处理
  function buyProduct() {
    addToCart(product);
    showNotification(`已添加 ${product.name} 进购物车!`);
  }

4.7 发送POST请求

这个 Form 组件会发送两种 POST 请求。它在页面加载之际会发送一个分析请求。当你填写表格并点击提交按钮时,它会向 /api/register 接口发送一个 POST 请求。

分析请求应该保留在 Effect 中。这是 因为 发送分析请求是表单显示时就需要执行的。

发送到 /api/register 的 POST 请求并不是由表单 显示 时引起的,将该 POST 请求移入事件处理函数中。

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ 非常好:这个逻辑应该在组件显示时执行
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ 非常好:事件特定的逻辑在事件处理函数中处理
    post('/api/register', { firstName, lastName });
  }
  // ...
}

如果这个逻辑是由某个特定的交互引起的,请将它保留在相应的事件处理函数中。如果是由用户在屏幕上 看到 组件时引起的,请将它保留在 Effect 中。

4.8 链式计算

有时候你可能想链接多个 Effect,每个 Effect 都基于某些 state 来调整其他的 state。

避免:链接多个 Effect 仅仅为了相互触发调整 state,这样很低效,很难维护。

 useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('游戏结束!');
  }, [isGameOver]);

我们尽可能在渲染期间进行计算,在事件处理函数中调整state

 // ✅ 在事件处理函数中计算剩下的所有 state
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('游戏结束!');
        }
      }
    }

在某些情况下,你 无法 在事件处理函数中直接计算出下一个 state。例如,试想一个具有多个下拉菜单的表单,如果下一个下拉菜单的选项取决于前一个下拉菜单选择的值。这时,Effect 链是合适的,因为你需要与网络进行同步。

4.9 初始化应用

有些逻辑需要在应用加载时只执行一次。

// 🔴 避免:把只需要执行一次的逻辑放在 Effect 中
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);

如果某些逻辑必须在 每次应用加载时执行一次,而不是在 每次组件挂载时执行一次,可以添加一个变量来记录它是否已经执行过了

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ 只在每次应用加载时执行一次
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

4.10 通知父组件有关state变化的信息

假设你正在编写一个有具有内部 state isOnToggle 组件,你希望在 Toggle 的 state 变化时通知父组件。

 // 🔴 避免:onChange 处理函数执行的时间太晚了
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

Toggle 首先更新它的 state,然后 React 会更新屏幕。然后 React 执行 Effect 中的代码,调用从父组件传入的 onChange 函数。现在父组件开始更新它自己的 state,开启另一个渲染流程。更好的方式是在单个流程中完成所有操作。

删掉Effect,在同一个事件处理函数同时更新两个组件的state。

 function updateToggle(nextIsOn) {
    // ✅ 非常好:在触发它们的事件中执行所有更新
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

或者直接在父组件接收isOn

每当你尝试保持两个不同的 state 变量之间的同步时,试试状态提升!

// ✅ 也很好:该组件完全由它的父组件控制
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

4.11 将数据传递给父组件

Child 组件获取了一些数据并在 Effect 中传递给 Parent 组件:

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 避免:在 Effect 中传递数据给父组件
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...

既然子组件和父组件都需要相同的数据,那么可以让父组件获取那些数据,并将其 向下传递 给子组件:

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ 非常好:向子组件传递数据
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

这更简单,并且可以保持数据流的可预测性:数据从父组件流向子组件。

4.12 订阅外部store

有时候,你的组件可能需要订阅 React state 之外的一些数据。这些数据可能来自第三方库或内置浏览器 API。需要在你的组件中手动订阅它们。

React 为此针对性地提供了一个 Hook 用于订阅外部 store useSyncExternalStore

function useOnlineStatus() {
  // ✅ 非常好:用内置的 Hook 订阅外部 store
  return useSyncExternalStore(
    subscribe, // 只要传递的是同一个函数,React 不会重新订阅
    () => navigator.onLine, // 如何在客户端获取值
    () => true // 如何在服务端获取值
  );
}

与手动使用 Effect 将可变数据同步到 React state 相比,这种方法能减少错误。

4.13 获取数据

使用 Effect 来发起数据获取请求。像这样在 Effect 中写一个数据获取请求是相当常见的

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 避免:没有清除逻辑的获取数据
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

不需要 把这个数据获取逻辑迁移到一个事件处理函数中。

这可能看起来与之前需要将逻辑放入事件处理函数中的示例相矛盾!

但是,考虑到这并不是 键入事件,这是在这里获取数据的主要原因。搜索输入框的值经常从 URL 中预填充,用户可以在不关心输入框的情况下导航到后退和前进页面。

pagequery 的来源其实并不重要。只要该组件可见,你就需要通过当前 pagequery 的值,保持 results 和网络数据的 同步。这就是为什么这里是一个 Effect 的原因。

然而,上面的代码有一个问题。假设你快速地输入 “hello”。那么 query 会从 “h” 变成 “he”“hel”“hell” 最后是 “hello”。这会触发一连串不同的数据获取请求,但无法保证对应的返回顺序。

例如,“hell” 的响应可能在 “hello” 的响应 之后 返回。由于它的 setResults() 是在最后被调用的,你将会显示错误的搜索结果。这种情况被称为 “竞态条件”:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。

为了修复这个问题,你需要添加一个 清理函数 来忽略较早的返回结果:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

这确保了当你在 Effect 中获取数据时,除了最后一次请求的所有返回结果都将被忽略。

处理竞态条件并不是实现数据获取的唯一难点。你可能还需要考虑缓存响应结果(使用户点击后退按钮时可以立即看到先前的屏幕内容),如何在服务端获取数据(使服务端初始渲染的 HTML 中包含获取到的内容而不是加载动画),以及如何避免网络瀑布(使子组件不必等待每个父组件的数据获取完毕后才开始获取数据)。

这些问题适用于任何 UI 库,而不仅仅是 React。解决这些问题并不容易,这也是为什么现代 框架 提供了比在 Effect 中获取数据更有效的内置数据获取机制的原因。

如果你不使用框架(也不想开发自己的框架),但希望使从 Effect 中获取数据更符合人类直觉,请考虑像这个例子一样,将获取逻辑提取到一个自定义 Hook 中:

function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setData(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return data;
}

你可能还想添加一些错误处理逻辑以及跟踪内容是否处于加载中。你可以自己编写这样的 Hook,也可以使用 React 生态中已经存在的许多解决方案。虽然仅仅使用自定义 Hook 不如使用框架内置的数据获取机制高效,但将数据获取逻辑移动到自定义 Hook 中将使后续采用高效的数据获取策略更加容易。

一般来说,当你不得不编写 Effect 时,请留意是否可以将某段功能提取到专门的内置 API 或一个更具声明性的自定义 Hook 中,比如上面的 useData。你会发现组件中的原始 useEffect 调用越少,维护应用将变得更加容易。

4.14 小结

  • 果你可以在渲染期间计算某些内容,则不需要使用 Effect。

  • 想要缓存昂贵的计算,请使用 useMemo 而不是 useEffect

  • 想要重置整个组件树的 state,请传入不同的 key

  • 想要在 prop 变化时重置某些特定的 state,请在渲染期间处理。

  • 组件 显示 时就需要执行的代码应该放在 Effect 中,否则应该放在事件处理函数中。

  • 如果你需要更新多个组件的 state,最好在单个事件处理函数中处理。

  • 当你尝试在不同组件中同步 state 变量时,请考虑状态提升。

  • 你可以使用 Effect 获取数据,但你需要实现清除逻辑以避免竞态条件。


5.响应式Effect的周期

Effect 与组件有不同的生命周期。组件可以挂载、更新或卸载。

Effect 只能做两件事:开始同步某些东西,然后停止同步它。

如果 Effect 依赖于随时间变化的 props 和 state,这个循环可能会发生多次。

React 提供了代码检查规则来检查是否正确地指定了 Effect 的依赖项,这能够使 Effect 与最新的 props 和 state 保持同步。

5.1 Effect生命周期

React组件的生命周期:挂载、更新、卸载

Effect 描述了如何将外部系统与当前的 props 和 state 同步。随着代码的变化,同步的频率可能会增加或减少。

5.2 为什么同步需要多次进行

举个聊天室的栗子

ChatRoom 组件接收 roomId 属性,用户可以在下拉菜单中选择。

一开始用户选择了 "general" 作为 roomId。应用程序会显示 "general" 聊天室。页面渲染后Effect开始同步,连接到"general" 聊天室。

之后用户选择"travel"作为 roomId,页面重新渲染,react断开与 "general" 的连接,连接到"travel"

5.3 如何同步Effect

ChatRoom 组件已经接收到了 roomId 属性的新值。之前它是 "general",现在变成了 "travel"。React 需要重新同步 Effect,以重新连接到不同的聊天室。

为了 停止同步,React 将调用 Effect 返回的清理函数,该函数在连接到 "general" 聊天室后返回。由于 roomId"general",清理函数将断开与 "general" 聊天室的连接:

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // 连接到 "general" 聊天室
    connection.connect();
    return () => {
      connection.disconnect(); // 断开与 "general" 聊天室的连接
    };
    // ...

然后,React 将运行在此渲染期间提供的 Effect。这次,roomId"travel",因此它将 开始同步"travel" 聊天室(直到最终也调用了清理函数):

function ChatRoom({ roomId /* "travel" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // 连接到 "travel" 聊天室
    connection.connect();
    // ...

每当组件使用不同的 roomId 重新渲染后,Effect 将重新进行同步。例如,假设用户将 roomId"travel" 更改为 "music"。React 将再次通过调用清理函数 停止同步 Effect(断开与 "travel" 聊天室的连接)。然后,它将通过使用新的 roomId 属性再次运行 Effect 的主体部分 开始同步(连接到 "music" 聊天室)。

最后,当用户切换到不同的屏幕时,ChatRoom 组件将被卸载。现在没有必要保持连接了。React 将 最后一次停止同步 Effect,并从 "music" 聊天室断开连接。

5.4 从 Effect 的角度思考

让我们总结一下从 ChatRoom 组件的角度所发生的一切:

  1. ChatRoom 组件挂载,roomId 设置为 "general"
  2. ChatRoom 组件更新,roomId 设置为 "travel"
  3. ChatRoom 组件更新,roomId 设置为 "music"
  4. ChatRoom 组件卸载

在组件生命周期的每个阶段,Effect 执行了不同的操作:

  1. Effect 连接到了 "general" 聊天室
  2. Effect 断开了与 "general" 聊天室的连接,并连接到了 "travel" 聊天室
  3. Effect 断开了与 "travel" 聊天室的连接,并连接到了 "music" 聊天室
  4. Effect 断开了与 "music" 聊天室的连接

现在让我们从 Effect 本身的角度来思考所发生的事情:

  useEffect(() => {
    // Effect 连接到了通过 roomId 指定的聊天室...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      // ...直到它断开连接
      connection.disconnect();
    };
  }, [roomId]);

这段代码的结构可能会将所发生的事情看作是一系列不重叠的时间段:

  1. Effect 连接到了 "general" 聊天室(直到断开连接)
  2. Effect 连接到了 "travel" 聊天室(直到断开连接)
  3. Effect 连接到了 "music" 聊天室(直到断开连接)

之前,你是从组件的角度思考的。当你从组件的角度思考时,很容易将 Effect 视为在特定时间点触发的“回调函数”或“生命周期事件”,例如“渲染后”或“卸载前”。这种思维方式很快变得复杂,所以最好避免使用。

相反,始终专注于单个启动/停止周期。无论组件是挂载、更新还是卸载,都不应该有影响。只需要描述如何开始同步和如何停止。如果做得好,Effect 将能够在需要时始终具备启动和停止的弹性

这可能会让你想起当编写创建 JSX 的渲染逻辑时,并不考虑组件是挂载还是更新。描述的是应该显示在屏幕上的内容,而 React 会 解决其余的问题

5.5 React 如何验证 Effect 可以重新进行同步

这里有一个可以互动的实时示例。点击“打开聊天”来挂载 ChatRoom 组件:

//App.js
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
  return <h1>欢迎来到 {roomId} 房间!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">所有</option>
          <option value="travel">旅游</option>
          <option value="music">音乐</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? '关闭聊天' : '打开聊天'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}
//chat.js
export function createConnection(serverUrl, roomId) {
  // 实际的实现将会连接到服务器
  return {
    connect() {
      console.log('✅ 连接到 "' + roomId + '" 房间,位于' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ 断开 "' + roomId + '" 房间,位于' + serverUrl);
    }
  };
}

请注意,当组件首次挂载时,会看到三个日志:

  1. ✅ 连接到 "general" 聊天室,位于 https://localhost:1234... (仅限开发环境)
  2. ❌ 从 "general" 聊天室断开连接,位于 https://localhost:1234. (仅限开发环境)
  3. ✅ 连接到 "general" 聊天室,位于 https://localhost:1234...

前两个日志仅适用于开发环境。在开发环境中,React 总是会重新挂载每个组件一次。

React 通过在开发环境中立即强制 Effect 重新进行同步来验证其是否能够重新同步。这可能让你想起打开门并额外关闭它以检查门锁是否有效的情景。React 在开发环境中额外启动和停止 Effect 一次,以检查 是否正确实现了它的清理功能

实际上,Effect 重新进行同步的主要原因是它所使用的某些数据发生了变化。在上面的示例中,更改所选的聊天室。注意当 roomId 发生变化时,Effect 会重新进行同步。

然而,还存在其他一些不寻常的情况需要重新进行同步。例如,在上面的示例中,尝试在聊天打开时编辑 serverUrl。注意当修改代码时,Effect会重新进行同步。将来,React 可能会添加更多依赖于重新同步的功能。

5.6 React 如何知道需要重新进行 Effect 的同步

这是因为 你告诉了 React 它的代码依赖于 roomId,通过将其包含在 依赖列表 中。

function ChatRoom({ roomId }) { // roomId 属性可能会随时间变化。
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // 这个 Effect 读取了 roomId
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // 因此,你告诉 React 这个 Effect "依赖于" roomId
  // ...

工作原理:

  1. 你知道 roomId 是 prop,这意味着它可能会随着时间的推移发生变化。
  2. 你知道 Effect 读取了 roomId(因此其逻辑依赖于可能会在之后发生变化的值)。
  3. 这就是为什么你将其指定为 Effect 的依赖项(以便在 roomId 发生变化时重新进行同步)。

每次在组件重新渲染后,React 都会查看传递的依赖项数组。如果数组中的任何值与上一次渲染时在相同位置传递的值不同,React 将重新同步 Effect。

5.7 每个 Effect 表示一个独立的同步过程。

每个 Effect 应该代表一个独立的同步过程。

5.8 Effect 会“响应”于响应式值

Effect 读取了两个变量(serverUrlroomId),但是只将 roomId 指定为依赖项:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

为什么 serverUrl 不需要作为依赖项呢?

这是因为 serverUrl 永远不会因为重新渲染而发生变化。无论组件重新渲染多少次以及原因是什么,serverUrl 都保持不变。既然 serverUrl 从不变化,将其指定为依赖项就没有意义。毕竟,依赖项只有在随时间变化时才会起作用!

另一方面,roomId 在重新渲染时可能会不同。在组件内部声明的 props、state 和其他值都是 响应式 的,因为它们是在渲染过程中计算的,并参与了 React 的数据流

如果 serverUrl 是状态变量,那么它就是响应式的。响应式值必须包含在依赖项中:

function ChatRoom({ roomId }) { // Props 随时间变化
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State 可能随时间变化

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Effect 读取 props 和 state
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // 因此,你告诉 React 这个 Effect "依赖于" props 和 state
  // ...
}

通过将 serverUrl 包含在依赖项中,确保 Effect 在其发生变化后重新同步。

5.8 没有依赖项的 Effect 的含义

从组件的角度来看,空的 [] 依赖数组意味着这个 Effect 仅在组件挂载时连接到聊天室,并在组件卸载时断开连接。

然而,如果你 从 Effect 的角度思考,根本不需要考虑挂载和卸载。重要的是,你已经指定了 Effect 如何开始和停止同步。目前,它没有任何响应式依赖。但是,如果希望用户随时间改变 roomIdserverUrl(它们将变为响应式),Effect 的代码不需要改变。只需要将它们添加到依赖项中即可。

5.9 在组件主体中声明的所有变量都是响应式的

Props 和 state 并不是唯一的响应式值。从它们计算出的值也是响应式的。

Effect 使用的组件主体中的所有变量都应该在依赖列表中。

假设用户可以在下拉菜单中选择聊天服务器,但他们还可以在设置中配置默认服务器。假设你已经将设置状态放入了 上下文,因此从该上下文中读取 settings。现在,可以根据 props 中选择的服务器和默认服务器来计算 serverUrl

function ChatRoom({ roomId, selectedServerUrl }) { // roomId 是响应式的
  const settings = useContext(SettingsContext); // settings 是响应式的
  const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl 是响应式的
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Effect 读取了 roomId 和 serverUrl
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // 因此,当它们中的任何一个发生变化时,它需要重新同步!
  // ...
}

在这个例子中,serverUrl 不是 prop 或 state 变量。它是在渲染过程中计算的普通变量。但是它是在渲染过程中计算的,所以它可能会因为重新渲染而改变。这就是为什么它是响应式的。

组件内部的所有值(包括 props、state 和组件体内的变量)都是响应式的。任何响应式值都可以在重新渲染时发生变化,所以需要将响应式值包括在 Effect 的依赖项中

5.10 React 会验证是否将每个响应式值都指定为了依赖项

如果检查工具 配置了 React,它将检查 Effect 代码中使用的每个响应式值是否已声明为其依赖项

注意:在某些情况下,React 知道 一个值永远不会改变,即使它在组件内部声明。例如,从 useState 返回的 set 函数和从 useRef 返回的 ref 对象是 稳定的 ——它们保证在重新渲染时不会改变。稳定值不是响应式的,因此可以从列表中省略它们。包括它们是允许的:它们不会改变,所以无关紧要。

5.11 当你不想进行重新同步时该怎么办

可以通过向检查工具“证明”这些值不是响应式值,即它们 不会 因为重新渲染而改变。例如,如果 serverUrlroomId 不依赖于渲染并且始终具有相同的值,可以将它们移到组件外部。现在它们不需要成为依赖项

const serverUrl = 'https://localhost:1234'; // serverUrl 不是响应式的
const roomId = 'general'; // roomId 不是响应式的

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ 声明的所有依赖
  // ...
}

也可以将它们 移动到 Effect 内部。它们不是在渲染过程中计算的,因此它们不是响应式的:

function ChatRoom() {
  useEffect(() => {
    const serverUrl = 'https://localhost:1234'; // serverUrl 不是响应式的
    const roomId = 'general'; // roomId 不是响应式的
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ 声明的所有依赖
  // ...
}

Effect 是一段响应式的代码块。它们在读取的值发生变化时重新进行同步。与事件处理程序不同,事件处理程序只在每次交互时运行一次,而 Effect 则在需要进行同步时运行。

不能“选择”依赖项。依赖项必须包括 Effect 中读取的每个 响应式值。代码检查工具会强制执行此规则。有时,这可能会导致出现无限循环的问题,或者 Effect 过于频繁地重新进行同步。不要通过禁用代码检查来解决这些问题!下面是一些解决方案:

  • 检查 Effect 是否表示了独立的同步过程。如果 Effect 没有进行任何同步操作,可能是不必要的。如果它同时进行了几个独立的同步操作
  • 如果想读取 props 或 state 的最新值,但又不想对其做出反应并重新同步 Effect,可以将 Effect 拆分为具有反应性的部分(保留在 Effect 中)和非反应性的部分(提取为名为 “Effect Event” 的内容)。
  • 避免将对象和函数作为依赖项。如果在渲染过程中创建对象和函数,然后在 Effect 中读取它们,它们将在每次渲染时都不同。这将导致 Effect 每次都重新同步。

5.12 小结

  • 组件可以挂载、更新和卸载。
  • 每个 Effect 与周围组件有着独立的生命周期。
  • 每个 Effect 描述了一个独立的同步过程,可以 开始停止
  • 在编写和读取 Effect 时,要独立地考虑每个 Effect(如何开始和停止同步),而不是从组件的角度思考(如何挂载、更新或卸载)。
  • 在组件主体内声明的值是“响应式”的。
  • 响应式值应该重新进行同步 Effect,因为它们可以随着时间的推移而发生变化。
  • 检查工具验证在 Effect 内部使用的所有响应式值都被指定为依赖项。
  • 检查工具标记的所有错误都是合理的。总是有一种方法可以修复代码,同时不违反规则。

6. 将事件从Effect中分开

一个 Effect 只在响应某些值时重新运行,但是在其他值变化时不重新运行。

6.1 事件处理函数只在响应特定的交互操作时运行

从用户角度出发,发送消息是 因为 他点击了特定的“Send”按钮。如果在任意时间或者因为其他原因发送消息,用户会觉得非常混乱。这就是为什么发送消息应该使用事件处理函数。事件处理函数是让你处理特定的交互操作的:

function handleSendClick() {
    sendMessage(message);
  }

借助事件处理函数,你可以确保 sendMessage(message) 在用户点击按钮的时候运行。

6.2 每当需要同步,Effect 就会运行

回想一下,你还需要让组件和聊天室保持连接。代码放哪里呢?

运行这个代码的 原因 不是特定的交互操作。用户为什么或怎么导航到聊天室屏幕的都不重要。

用户根本还没有执行任何交互,仍然应该需要保持连接。这就是这里用 Effect 的原因:

function ChatRoom({ roomId }) {
  // ...
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

6.3 响应式值和响应式逻辑

直观上,你可以说事件处理函数总是“手动”触发的,例如点击按钮。Effect 是自动触发:每当需要保持同步的时候他们就会开始运行和重新运行。

组件内部声明的 state 和 props 变量被称为 响应式值。本示例中的 serverUrl 不是响应式值,但 roomIdmessage 是。他们参与组件的渲染数据流:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  // ...
}

像这样的响应式值可以因为重新渲染而变化。

事件处理函数和 Effect 对于变化的响应是不一样的:

  • 事件处理函数内部的逻辑是非响应式的。除非用户又执行了同样的操作(例如点击),否则这段逻辑不会再运行。事件处理函数可以在“不响应”他们变化的情况下读取响应式值。
  • Effect 内部的逻辑是响应式的。如果 Effect 要读取响应式值,你必须将它指定为依赖项。如果接下来的重新渲染引起那个值变化,React 就会使用新值重新运行 Effect 内的逻辑。

6.4事件处理函数内部的逻辑是非响应式的

发送消息的逻辑不应该是响应式的。它不应该仅仅因为 响应式值 变化而再次运行。这就是应该把它归入事件处理函数的原因

 function handleSendClick() {
    sendMessage(message);
  }

事件处理函数是非响应式的,所以 sendMessage(message) 只会在用户点击“Send”按钮的时候运行。

6.5 Effect 内部的逻辑是响应式的

roomId 的变化意味着他们的确想要连接到不同的房间。换句话说,连接房间的逻辑应该是响应式的。你 需要 这几行代码和响应式值“保持同步”,并在值不同时再次运行。这就是它被归入 Effect 的原因

useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId]);

Effect 是响应式的,所以 createConnection(serverUrl, roomId)connection.connect() 会因为 roomId 每个不同的值而运行。Effect 让聊天室连接和当前选中的房间保持了同步。

6.6 从 Effect 中提取非响应式逻辑

假设你想在用户连接到聊天室时展示一个通知。并且通过从 props 中读取当前 theme(dark 或者 light)来展示对应颜色的通知:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    // ...

但是 theme 是一个响应式值(它会由于重新渲染而变化),并且Effect 读取的每一个响应式值都必须在其依赖项中声明。现在你必须把 theme 作为 Effect 的依赖项之一:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId, theme]); // ✅ 声明所有依赖项
  // ...

你需要一个将这个非响应式逻辑和周围响应式 Effect 隔离开来的方法。

实验性API还未发布,暂不学习


7. 移除Effect依赖-指南

不必要的依赖可能会导致 Effect 运行过于频繁,甚至产生无限循环。

请按照本指南审查并移除 Effect 中不必要的依赖 :指南


8. 使用自定义Hook复用逻辑

React 有一些内置 Hook,例如 useStateuseContextuseEffect

有时你需要一个用途更特殊的 Hook:例如获取数据,记录用户是否在线或者连接聊天室。虽然 React 中可能没有这些 Hook,但是你可以根据应用需求创建自己的 Hook。学习链接

总结

祝大家学习顺利
在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到