React 学习笔记

React 版本react@18.x

组件类型

类组件 - Class Components(已过时)

React 早期用类组件,ES6 class 语法,通过生命周期方法管理状态和副作用。现在基本不用了,了解一下就行。

import { Component } from "react";
import { createConnection } from "./chat.js";

export default class ChatRoom extends Component {
  state = {
    serverUrl: "https://localhost:1234",
  };

  // 挂载后调用
  componentDidMount() {
    this.setupConnection();
  }

  // 组件更新调用
  componentDidUpdate(prevProps, prevState) {
    if (
      this.props.roomId !== prevProps.roomId ||
      this.state.serverUrl !== prevState.serverUrl
    ) {
      this.destroyConnection();
      this.setupConnection();
    }
  }

  componentWillUnmount() {
    this.destroyConnection();
  }

  setupConnection() {
    this.connection = createConnection(this.state.serverUrl, this.props.roomId);
    this.connection.connect();
  }

  destroyConnection() {
    this.connection.disconnect();
    this.connection = null;
  }

  // 渲染函数
  render() {
    return (
      <>
        <label>
          Server URL:{" "}
          <input
            value={this.state.serverUrl}
            onChange={(e) => {
              this.setState({
                serverUrl: e.target.value,
              });
            }}
          />
        </label>
        <h1>欢迎来到 {this.props.roomId} 聊天室!</h1>
      </>
    );
  }
}

函数式组件 - Functional Components

现在都用函数式组件,用函数定义,代码更简洁,复用性也更强。这是 React 推荐的写法。

function Profile() {
  return <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />;
}

export default function Gallery() {
  return (
    <section>
      <h1>了不起的科学家</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

注意:不要在函数组件内部再声明组件,会有性能问题。

为什么都用函数式组件?

Vue 和 React 都抛弃了 class 组件,主要是为了解决逻辑复用时的命名冲突和依赖注入问题。

  • 函数式提供底层抽象能力,面向对象提供业务组织能力
  • 函数式负责提高复用能力,简化代码实现,提升代码的信息密度
  • 面向对象负责描述组件/模块之间的关系和业务逻辑,提高代码可读性

组件基础

组件概念

组件就是可复用的 UI 元素,每个 UI 模块都可以是一个组件。

React 组件本质上是 JavaScript 函数,但有两个特点:

  • 名字必须以大写字母开头(小写会被当作 HTML 标签)
  • 返回 JSX 标签

JSX

React 中 HTML 代码都要写成 JSX 格式。

JSX 的几个要点

  1. 只能返回一个根元素:JSX 底层会被转成 JavaScript 对象,一个函数不能返回多个对象,所以多个 JSX 标签必须用一个父元素或 Fragment 包裹。

  2. 标签必须正确闭合:这个和 HTML 一样。

  3. 属性用驼峰命名:因为 JSX 会被转成 JavaScript 对象,属性名要符合 JS 变量命名规则。比如 class 要写成 classNameonclick 要写成 onClick

<img
  src="https://i.imgur.com/yXOvdOSs.jpg"
  alt="Hedy Lamarr"
  className="photo"
/>

子组件

组件的 prop 通过函数参数传入。

子组件会通过 children 属性传递,这个属性会自动包含在组件的 props 里。

function Card({ title, children }) {
  return (
    <div className="card">
      <div className="card-content">
        <h1>{title}</h1>
        {children}
      </div>
    </div>
  );
}

// 子组件通过 children 属性传递进来并渲染,子组件采用嵌套方式编写,而不是声明在属性中
export default function Profile() {
  return (
    <div>
      <Card title="Photo">
        <img
          className="avatar"
          src="https://i.imgur.com/OKS67lhm.jpg"
          alt="Aklilu Lemma"
          width={70}
          height={70}
        />
      </Card>

      <Card title="About">
        <p>
          Aklilu Lemma was a distinguished Ethiopian scientist who discovered a
          natural treatment to schistosomiasis.
        </p>
      </Card>
    </div>
  );
}

条件表达式

JSX 可以像条件判断那样分开渲染不同的内容。

function Item({ name, isPacked }) {
  let itemContent = name;
  if (isPacked) {
    itemContent = <del>{name + " ✔"}</del>;
  }
  return <li className="item">{itemContent}</li>;
}

export default function PackingList() {
  return (
    <section>
      <h1>Sally Ride 的行李清单</h1>
      <ul>
        <Item isPacked={true} name="宇航服" />
        <Item isPacked={true} name="带金箔的头盔" />
        <Item isPacked={false} name="Tam 的照片" />
      </ul>
    </section>
  );
}

条件渲染的几种方式

  • 可以用 if 语句选择性地返回 JSX
  • 可以把 JSX 赋值给变量,然后用 {变量} 嵌入
  • 三元表达式:{cond ? <A /> : <B />} - 条件为真渲染 A,否则渲染 B
  • 逻辑与:{cond && <A />} - 条件为真渲染 A,否则不渲染
  • 三元和逻辑与比较常用,但用 if 也可以,看个人习惯

事件处理

事件基础

事件处理就是把函数作为 prop 传给元素,比如 <button onClick={handleClick}>

重要:传的是函数本身,不是函数调用!onClick={handleClick} 是对的,onClick={handleClick()} 是错的。

事件处理函数可以在组件内部定义,也可以从父组件传下来。事件名用 onXXXX 格式,XXXX 就是浏览器事件名(首字母大写)。

事件会冒泡,用 e.stopPropagation() 阻止。阻止默认行为用 e.preventDefault()

export default function Button() {
  function handleClick() {
    alert("你点击了我!");
  }

  return <button onClick={handleClick}>点我</button>;
}

事件传递

事件处理函数可以作为 prop 传给子组件,prop 的名字可以自己随便起,比如 onSmashonClick 都可以。

function Button({ onSmash, children }) {
  return <button onClick={onSmash}>{children}</button>;
}

export default function App() {
  return (
    <div>
      <Button onSmash={() => alert("正在播放!")}>播放电影</Button>
      <Button onSmash={() => alert("正在上传!")}>上传图片</Button>
    </div>
  );
}

捕获被阻止冒泡的事件

如果子组件用 stopPropagation() 阻止了冒泡,父组件可以用 onXXXCapture 在捕获阶段捕获事件。

事件流程:捕获阶段 → 目标阶段 → 冒泡阶段。Capture 在捕获阶段执行。

<div
  onClickCapture={() => {
    /* 这会首先执行 */
  }}
>
  <button onClick={(e) => e.stopPropagation()} />
  <button onClick={(e) => e.stopPropagation()} />
</div>

useState Hook

useState 是最常用的 Hook,用来存储和修改组件的数据。

组件里的普通变量每次渲染都会重新定义,所以要用 useState 来保存状态。

要点

  • 需要在多次渲染间"记住"数据就用 state
  • Hook 是以 use 开头的函数,必须在组件顶层调用,不能在条件语句里
  • useState 返回 [当前值, 更新函数]
  • 一个组件可以有多个 state,React 按调用顺序匹配
  • 每个组件实例的 state 是独立的

实现原理示例

let componentHooks = [];
let currentHookIndex = 0;

// useState 在 React 中是如何工作的(简化版)
function useState(initialState) {
  let pair = componentHooks[currentHookIndex];
  if (pair) {
    // 这不是第一次渲染
    // 所以 state pair 已经存在
    // 将其返回并为下一次 hook 的调用做准备
    currentHookIndex++;
    return pair;
  }

  // 这是我们第一次进行渲染
  // 所以新建一个 state pair 然后存储它
  pair = [initialState, setState];

  function setState(nextState) {
    // 当用户发起 state 的变更,
    // 把新的值放入 pair 中
    pair[0] = nextState;
    updateDOM();
  }

  // 存储这个 pair 用于将来的渲染
  // 并且为下一次 hook 的调用做准备
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

function Gallery() {
  // 每次调用 useState() 都会得到新的 pair
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    setIndex(index + 1);
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  let sculpture = sculptureList[index];
  // 这个例子没有使用 React,所以
  // 返回一个对象而不是 JSX
  return {
    onNextClick: handleNextClick,
    onMoreClick: handleMoreClick,
    header: `${sculpture.name} by ${sculpture.artist}`,
    counter: `${index + 1} of ${sculptureList.length}`,
    more: `${showMore ? "Hide" : "Show"} details`,
    description: showMore ? sculpture.description : null,
    imageSrc: sculpture.url,
    imageAlt: sculpture.alt,
  };
}

function updateDOM() {
  // 在渲染组件之前
  // 重置当前 Hook 的下标
  currentHookIndex = 0;
  let output = Gallery();

  // 更新 DOM 以匹配输出结果
  // 这部分工作由 React 为你完成
  nextButton.onclick = output.onNextClick;
  header.textContent = output.header;
  moreButton.onclick = output.onMoreClick;
  moreButton.textContent = output.more;
  image.src = output.imageSrc;
  image.alt = output.imageAlt;
  if (output.description !== null) {
    description.textContent = output.description;
    description.style.display = "";
  } else {
    description.style.display = "none";
  }
}

关于 state

state 每次渲染时都会存储当前 state 渲染值的快照,所以在同个渲染内多次更改 state 值,最终结果只是执行一次的作用效果。

一个 state 变量的值永远不会在一次渲染的内部发生变化

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

// 结果只递增一次,即为1

重要理解

  • setState 会触发重新渲染
  • state 存在组件外面,React 帮你管理
  • 每次渲染时,useState 返回的是这次渲染的 state 快照
  • 每次渲染都是全新的,变量和函数都会重新创建
  • 事件处理函数"看到"的是创建它那次渲染的 state 值
  • 可以把 state 想象成每次渲染的快照,不会在渲染过程中改变

处理多次添加 - 更新函数

如果要在一次事件中多次更新 state,用更新函数:setNumber(n => n + 1) 而不是 setNumber(number + 1)

  • setState 不会立即改变当前渲染的值,只是请求重新渲染
  • React 会把事件处理函数里的所有 setState 批量处理(批处理)
  • 用更新函数可以基于最新的 state 计算新值

示例一

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);
        }}
      >
        增加数字
      </button>
    </>
  );
}

React 会这样处理:

  1. setNumber(number + 5):当前 number 是 0,所以是 setNumber(5),React 把"替换为 5"加入队列
  2. setNumber(n => n + 1):这是更新函数,React 把函数加入队列

下次渲染时,React 按顺序处理队列:

更新队列 n 返回值
"替换为 5" 0(未使用) 5
n => n + 1 5 5 + 1 = 6

React 会保存 6 为最终结果并从 useState 中返回。

示例二

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) → 加入队列:"替换为 5"
  2. setNumber(n => n + 1) → 加入队列:更新函数
  3. setNumber(42) → 加入队列:"替换为 42"

下次渲染时处理队列:

更新队列 n 返回值
"替换为 5" 0(未使用) 5
n => n + 1 5 5 + 1 = 6
"替换为 42" 6(未使用) 42

然后 React 会保存 42 为最终结果并从 useState 中返回。

总结

  • 传更新函数(如 n => n + 1)→ 会加入队列,基于最新值计算
  • 传普通值(如 5)→ 会加入队列,直接替换(后面的会覆盖前面的)

事件处理函数执行完后,React 才会触发重新渲染并处理队列。更新函数必须是纯函数,不能有副作用。严格模式下 React 会执行两次更新函数(但只用第二次结果)来帮助发现错误。

命名习惯

更新函数的参数通常用 state 变量名的首字母:

setEnabled((e) => !e);

setLastName((ln) => ln.reverse());

setFriendCount((fc) => fc * 2);

状态队列实现伪代码

export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === "function") {
      // TODO: 调用更新函数
      finalState = update(finalState);
    } else {
      // TODO: 替换 state
      finalState = update;
    }
  }

  return finalState;
}

对象更新

对象不能直接修改属性,必须创建新对象。

  • state 里的对象都是不可变的,不能直接改
  • 直接改对象不会触发重渲染,还会破坏 state 快照
  • 要用展开运算符创建新对象:{...obj, key: newValue}
  • 展开运算符是浅拷贝,只复制一层
  • 嵌套对象要一层层创建新对象,比较麻烦
  • 可以用 Immer 库简化嵌套对象的更新
setPerson({
  ...person, // 复制上一个 person 中的所有字段
  firstName: e.target.value, // 但是覆盖 firstName 字段
});

或者使用 Immer

基于 Proxy 实现。

updatePerson((draft) => {
  draft.artwork.city = "Lagos";
});

为什么不推荐直接修改 state?

几个原因:

  • 调试方便:不直接修改的话,console.log 能看到每次渲染的 state 变化
  • 性能优化:React 用 prevObj === obj 判断是否需要重新渲染,直接修改会破坏这个机制
  • 支持新功能:React 的一些新功能依赖 state 快照机制,直接修改会有问题
  • 功能扩展:撤销/恢复、历史记录等功能需要保存 state 的历史版本,直接修改就做不到了
  • 实现简单:不需要用 Proxy 劫持属性,性能更好

数组更新

不能直接用 pushpopsplice 这些会修改原数组的方法,要创建新数组。

常用方法:

  • 添加:[...arr, newItem]arr.concat(newItem)
  • 删除:arr.filter(item => item.id !== id)
  • 修改:arr.map(item => item.id === id ? newItem : item)
  • 排序:先复制 [...arr].sort()

嵌套数组更新比较麻烦,可以用 Immer 库。

避免使用(会改变原始数组) 推荐使用(会返回一个新数组)
添加元素 pushunshift concat[...arr] 展开语法
删除元素 popshiftsplice filterslice
替换元素 splicearr[i] = ... 赋值 map
排序 reversesort 先将数组复制一份

兄弟组件共享状态

兄弟组件要共享状态,把 state 提升到共同的父组件,然后通过 props 传下去。

步骤:

  1. 把 state 移到父组件
  2. 通过 props 传给子组件
  3. 把更新函数也传下去,让子组件能改父组件的 state

组件分为两种:

  • 受控组件:由父组件通过 props 控制
  • 不受控组件:自己管理 state
import { useState } from "react";

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <h2>哈萨克斯坦,阿拉木图</h2>
      <Panel
        title="关于"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997
        年间都是首都。
      </Panel>
      <Panel
        title="词源"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        这个名字来自于 <span lang="kk-KZ">алма</span>
        ,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,
        <i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
      </Panel>
    </>
  );
}

function Panel({ title, children, isActive, onShow }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? <p>{children}</p> : <button onClick={onShow}>显示</button>}
    </section>
  );
}

组件保留和重置

React 根据组件在树中的位置来缓存组件,位置不变就不会重新创建。

想让组件重置,可以:

  • 改变组件在树中的位置
  • 给组件加不同的 key

key 和 Vue 里的 key 一样,用来区分组件。切换 key 会强制组件重新渲染。

注意:

  • state 和组件在树中的位置绑定,不是和 JSX 标签绑定
  • 不要嵌套定义组件(在组件内部定义另一个组件),会导致 state 意外重置

useReducer

useReducer 用来管理复杂的状态逻辑,类似 Vuex 的思路。可以代替 useState,传入两个参数:

  • reducer:处理函数,根据 action 返回新 state
  • initialState:初始值

useReducer 伪实现

import { useState } from "react";

export function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    setState(reducer(state, action));
  }

  return [state, dispatch];
}

返回 [state, dispatch]dispatch 用来触发 reducer,参数是 action 对象,传给 reducer 处理。

dispatch({
  type: "added", // 通常添加 type 来指定行为类型
  id: nextId++,
  text: text,
});

reducer 实现示例

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added": {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case "changed": {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case "deleted": {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error("未知 action: " + action.type);
    }
  }
}

使用步骤

  1. 写一个 reducer 函数,接收 (state, action) 返回新 state
  2. useReducer 替换 useState
  3. 在事件处理函数里 dispatch(action)

注意

  • reducer 必须是纯函数,不能有副作用
  • 每个 action 代表一个用户操作
  • reducer 代码会多些,但调试和测试更方便
  • 嵌套对象更新可以用 Immer 简化

useState vs useReducer

简单对比:

  • 代码量useState 代码少,useReducer 需要写 reducer 和 action。但多个地方用相似方式改 state 时,useReducer 更简洁
  • 可读性:简单逻辑用 useState,复杂逻辑用 useReducer 更清晰
  • 调试useReducer 可以在 reducer 里打日志,看每个 action 做了什么,更容易定位问题
  • 测试:reducer 是纯函数,可以单独测试
  • 个人习惯:看个人喜好,可以混用

什么时候用 useReducer

  • 状态更新逻辑复杂
  • 多个地方用相似方式改 state
  • 需要更好的调试体验

一个组件里可以同时用 useStateuseReducer

写 reducer 的注意事项

两点:

  1. reducer 必须是纯函数:输入相同输出相同,不能有副作用(异步请求、定时器等)。要用不可变的方式更新对象和数组。

  2. 一个 action 代表一个用户操作:比如表单重置,dispatch 一个 reset_form action,而不是分别 dispatch 五个 set_field action。这样日志清晰,方便调试。

useContext

useContext 类似 Vue 的 provide/inject,用来跨组件传递数据。

三部分:

  1. createContext:创建 context
  2. context.Provider:包裹组件,提供值(类似 provide)
  3. useContext:获取值(类似 inject)
import { createContext } from "react";

export const LevelContext = createContext(0);

context.Provider 使用

useContext 获取的是最近一层 Provider 的 value 值。

import { useContext } from "react";
import { LevelContext } from "./LevelContext.js";

export default function Section({ children }) {
  const level = useContext(LevelContext);
  return (
    <section className="section">
      <LevelContext.Provider value={level + 1}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}

useContext 使用

import { useContext } from "react";
import { LevelContext } from "./LevelContext.js";

export default function Heading({ children }) {
  const level = useContext(LevelContext);
  switch (level) {
    case 0:
      throw Error("Heading 必须在 Section 内部!");
    case 1:
      return <h1>{children}</h1>;
    case 2:
      return <h2>{children}</h2>;
    case 3:
      return <h3>{children}</h3>;
    case 4:
      return <h4>{children}</h4>;
    case 5:
      return <h5>{children}</h5>;
    case 6:
      return <h6>{children}</h6>;
    default:
      throw Error("未知的 level:" + level);
  }
}

如果组件递归使用且包含 Provider,嵌套的子组件会获取到最近的 Provider 的值。比如 <Section> 组件,每层都会把 level + 1 传给子组件。

<Section>
  假如 level 为 1,Provider 传递了 level + 1 = 2
  <Section>此处 useContext 获取到的 level 为 2,传递下去的 level 为 3</Section>
</Section>

使用步骤

  1. export const MyContext = createContext(defaultValue) 创建 context
  2. 在父组件用 <MyContext.Provider value={...}> 包裹子组件
  3. 在子组件用 useContext(MyContext) 获取值

Context 会穿透中间所有组件,直接传给需要的组件。

注意:能用 props 传的就用 props,context 适合跨多层传递的场景。

useContext 的替代方法

  • 递归传 props:思路清晰但麻烦
  • 用 children:父组件通过 children prop 传递子组件

应用场景

  • 主题切换:在顶层放 theme context,需要的地方读取
  • 用户信息:登录状态、用户信息等全局数据
  • 路由:大多数路由库内部都用 context 保存当前路由
  • 状态管理:配合 useReducer 做全局状态管理

Context 可以传动态值,Provider 的 value 变化时,所有使用该 context 的组件都会更新。所以经常和 state 一起用。

useRef

React 中的渲染内容

直接在函数里计算,值变化会触发重新渲染。

function App() {
  const val = val1 + val2; // 相当于 vue 的 computed

  return (
    <>
      <div>{val}</div>
    </>
  );
}

val1val2 变化时,组件会重新渲染。

useRef 避免视图更新

useRef 用来存值,修改不会触发重新渲染,是 React 的"脱围机制"。

返回一个对象 { current: initialValue },通过修改 current 属性来存取值。

const ref = useRef(0);
ref.current = 5;
console.log(ref.current); // 5

useRef 实现伪代码

// React 内部
function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

ref 和 state 的区别

ref 可以直接改 current,state 必须用 setState。大部分情况用 state,ref 是特殊情况下的"脱围机制"。

对比:

ref state
useRef(initialValue)返回 { current: initialValue } useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ([value, setValue])
更改时不会触发重新渲染 更改时触发重新渲染。
可变——你可以在渲染过程之外修改和更新 current 的值。 "不可变"——你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。
你不应在渲染期间读取(或写入) current 值。 你可以随时读取 state。但是,每次渲染都有自己不变的 state快照

useRef 使用场景

  • 存定时器 ID
  • 存 DOM 元素引用
  • 存不需要触发渲染的值

原则:需要触发渲染用 state,不需要触发渲染用 ref。

useRef 注意事项

  • ref 是脱围机制:主要用于外部系统或浏览器 API,不要过度依赖
  • 不要在渲染时读写 ref.current:会导致行为不可预测。唯一例外是初始化:if (!ref.current) ref.current = new Thing()

ref 和 state 的区别:

  • state 是快照,不会同步更新
  • ref 的 current 是实时的,改了立即生效
ref.current = 5;

console.log(ref.current); // 5

这是因为ref 本身是一个普通的 JavaScript 对象,所以它的行为就像对象那样。

useRef 操作 DOM

类似 Vue 的 ref,可以获取 DOM 元素。

// 1
import { useRef } from 'react';

// 2
const myRef = useRef(null);

// 3
<div ref={myRef}>

// 4
myRef.current.scrollIntoView();

操作列表

操作列表时,ref 属性可以传回调函数,参数是 DOM 节点。用 Map 存多个节点的引用。

// 创建ref
const itemsRef = useRef(null);

// ref 获取函数,用于初始化和后续操作
function getMap() {
  if (!itemsRef.current) {
    // 首次运行时初始化 Map。
    itemsRef.current = new Map();
  }
  return itemsRef.current;
}

// 修改ref保证 map 内存储的节点被实时更新
<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    if (node) {
      // 添加到 Map
      map.set(cat.id, node);
    } else {
      // 从 Map 删除
      map.delete(cat.id);
    }
  }}
>

如果只需要一个节点,可以用三元表达式判断赋值给哪个节点。

const selectedRef = useRef(null);

// 通过三元表达式判断 selectedRef 赋值给那个单独的元素节点
{
  catList.map((cat, i) => (
    <li key={cat.id} ref={index === i ? selectedRef : null}>
      <img
        className={index === i ? "active" : ""}
        src={cat.imageUrl}
        alt={"猫猫 #" + cat.id}
      />
    </li>
  ));
}

flushSync 实现实时更新渲染

有时候用 ref 操作 DOM 时,需要获取 state 更新后的最新值。但 setState 不会立即更新 state,会在下次渲染时更新,所以更新前 state 还是旧值。

flushSync 包裹 setState,可以同步更新,让 ref 能拿到最新的值。

类似 Vue 的 nextTick,但 Vue 是微任务队列,React 是同步更新。

import { useRef, useState, flushSync } from "react";

export default function CatFriends() {
  const selectedRef = useRef(null);
  const [index, setIndex] = useState(0);

  return (
    <>
      <nav>
        <button
          onClick={() => {
            flushSync(() => {
              // flushSync 实现 state 的同步更新
              if (index < catList.length - 1) {
                setIndex(index + 1);
              } else {
                setIndex(0);
              }
            });

            // selectedRef.current 获取到的是 index 更新后的值,保证渲染正常
            selectedRef.current.scrollIntoView({
              behavior: "smooth",
              block: "nearest",
              inline: "center",
            });
          }}
        >
          下一步
        </button>
      </nav>
      <div>
        <ul>
          {catList.map((cat, i) => (
            <li key={cat.id} ref={index === i ? selectedRef : null}>
              <img
                className={index === i ? "active" : ""}
                src={cat.imageUrl}
                alt={"猫猫 #" + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

forwardRef 传递 ref 到子组件

Vue 里 ref 绑组件获取的是组件实例,要用 expose 暴露方法。

React 用 forwardRef 包裹组件,ref 作为第二个参数传入,然后赋值给内部的 DOM 元素。

import { useRef } from "react";
import SearchButton from "./SearchButton.js";
import SearchInput from "./SearchInput.js";

export default function Page() {
  const inputRef = useRef(null);
  return (
    <>
      <nav>
        <SearchButton
          onClick={() => {
            inputRef.current.focus();
          }}
        />
      </nav>
      <SearchInput ref={inputRef} />
    </>
  );
}

这样父组件就能通过 ref 操作子组件的 DOM 元素了。

export default function SearchButton({ onClick }) {
  return <button onClick={onClick}>搜索</button>;
}

import { forwardRef } from "react";

export default forwardRef(function SearchInput(props, ref) {
  return <input ref={ref} placeholder="找什么呢?" />;
});

注意

  • ref 主要用于保存 DOM 元素引用
  • 传给元素:<div ref={myRef}>,React 会把 DOM 节点放到 myRef.current
  • 通常用于非破坏性操作:聚焦、滚动、测量等
  • 组件默认不暴露 DOM,用 forwardRef 才能暴露
  • 不要直接改 React 管理的 DOM,除非是 React 不会更新的部分(比如用 ref 改 input 的 value)

useEffect

useEffect 类似 Vue 的 watchEffect,用来处理副作用。

注意:开发环境严格模式下会执行两次,这是正常的,用来检查清理函数是否正确。生产环境只执行一次。

useEffect 在渲染之后执行,所以里面不能有触发渲染的代码,否则会死循环。

useEffect 三种用法

  1. 没有依赖数组:每次渲染后都执行(类似 watchEffect)
useEffect(() => {
  // 每次渲染后执行
});
  1. 空依赖数组:只在挂载时执行一次
useEffect(() => {
  // 只在组件挂载时执行
}, []);
  1. 有依赖数组:依赖变化时执行
useEffect(() => {
  // a 或 b 变化时执行
}, [a, b]);

添加清理函数

类似 Vue 的 watch/watchEffect 返回清理函数,useEffect 返回的函数会在组件卸载或依赖变化前执行。

useEffect(() => {
  const connection = createConnection();
  connection.connect();
  return () => {
    connection.disconnect(); // 清理函数
  };
}, []);

适用场景

  • 添加/移除事件监听器
  • 创建/清除定时器
  • 取消未完成的请求(避免竞态条件)
useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    // 设置 ignore 去阻止执行
    ignore = true;
  };
}, [userId]);

不适用场景

  • 更新渲染列表(应该在事件处理函数里做)
  • 响应事件更新 UI(effect 在渲染后执行,会导致两次渲染)

清理函数会在下次 effect 执行前或组件卸载时执行。

effect 替代方案

在 Effect 里用 fetch 请求数据很常见,但有几个问题:

  • 不能在服务端执行(SSR 不友好)
  • 容易产生网络瀑布(父组件请求完,子组件再请求)
  • 无法预加载和缓存
  • 需要处理竞态条件

更好的方案

  • 用框架的数据获取机制(Next.js、Remix 等)
  • 用数据获取库:React Query、useSWR、React Router v6.4+
  • 自己实现缓存逻辑

什么时候不用 Effect

  • 能在渲染期间计算的,直接用变量
  • 缓存计算结果用 useMemo,不用 useEffect
  • 重置 state 用不同的 key
  • 响应事件更新 UI,用事件处理函数

原则:Effect 用于"显示时执行"的副作用,其他情况优先考虑其他方案。

useMemo 代替 useEffect

Vue 用 computed 做计算属性,React 用 useMemo。需要缓存计算结果时用 useMemo

console.time("筛选数组");
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter); // 如果 todos 或 filter 没有发生变化将跳过执行
}, [todos, filter]);
console.timeEnd("筛选数组");

useMemo vs useEffect

  • 执行时机:useMemo 在渲染前,useEffect 在渲染后
  • 用途:useMemo 缓存计算结果(类似 computed),useEffect 处理副作用(类似 watch/watchEffect)

useEffectEvent 添加无需依赖的响应式值

useEffectEvent 是 React 18 新增的,用来在 useEffect 中读取最新值但不触发重新执行。

问题:Effect 里读取的值必须加依赖,但有时只想读取最新值,不想响应变化。

例子:只在 url 变化时记录访问,但需要读取最新的 shoppingCart 长度。

function Page({ url, shoppingCart }) {
  const onVisit = useEffectEvent((visitedUrl) => {
    // 这里可以读取最新的 shoppingCart,但不会触发 Effect 重新执行
    logVisit(visitedUrl, shoppingCart.length);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // 只有 url 变化时才执行

  // ...
}

注意:useEffectEvent 返回的函数不是响应式的,不要加到依赖数组里。

自定义 Hook

自定义 Hook 就是封装逻辑的函数,类似 Vue 的 composable。通常把重复的逻辑提取出来,不包含业务代码。

规则

  • 名字必须以 use 开头
  • 可以在组件间共享逻辑
  • 共享的是逻辑,不是状态本身
  • Hook 之间可以传值,会保持最新
  • 每次渲染都会重新运行
  • 必须是纯函数
  • 事件处理函数用 useEffectEvent 包裹
  • 不要写太通用的 Hook(如 useMount),保持具体

使用:把 useEffect 里的逻辑提取成自定义 Hook,让组件更简洁。

思考

1. 为什么都用函数式组件?

解决了逻辑复用时的命名冲突和依赖注入问题。函数式提供底层抽象,面向对象提供业务组织。函数式代码更简洁,复用性更好。

2. useState 更新函数和直接传值的区别?

直接传值会替换整个 state,更新函数基于当前值计算。多次 setState 时,更新函数会依次执行,直接传值后面的会覆盖前面的。

3. useEffect 和 useMemo 的区别?

  • useMemo:渲染前执行,缓存计算结果(类似 computed)
  • useEffect:渲染后执行,处理副作用(类似 watch/watchEffect)

计算值用 useMemo,副作用用 useEffect。

4. 什么时候用 useReducer?

状态更新逻辑复杂,或多个地方用相似方式改 state 时。useReducer 把逻辑分离出来,代码更清晰,也更好调试。

总结

  • 组件:函数式组件 + Hooks,解决了类组件的逻辑复用问题
  • 状态:useState 简单状态,useReducer 复杂状态,useContext 跨组件传值
  • 副作用:useEffect 处理副作用,useMemo 缓存计算
  • DOM:useRef 获取 DOM 引用,不触发渲染
  • 复用:自定义 Hook 封装逻辑

参考内容

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • 文档地址:https://react.dev/learn[https://react.dev/learn] rea...
    欢欣的膜笛阅读 247评论 0 1
  • jsx语法 遇到{ } 就把里面的代码当js解析 遇到< > 就把里面的代码当html解析 声明组件 组件使用cl...
    zyghhhh阅读 432评论 0 1
  • jsx语法规则 定义虚拟dom时,不需要写引号 ' ' 标签中混入js表达式,需要加{} 标签中定义样式名要用cl...
    sosoYU阅读 444评论 0 0
  • 3. JSX JSX是对JavaScript语言的一个扩展语法, 用于生产React“元素”,建议在描述UI的时候...
    pixels阅读 2,971评论 0 24
  • 组件的生命周期 React中组件也有生命周期,也就是说也有很多钩子函数供我们使用, 组件的生命周期,我们会分为四个...
    千锋HTML5学院阅读 463评论 0 1

友情链接更多精彩内容