跳至主要內容

React 快速了解基础

Kevin 吴嘉文大约 22 分钟知识笔记webfront

在最新的前端开发技术中,React 是一个非常流行且被广泛采用的 JavaScript 库 之一。React 以声明式的方式处理用户界面,使开发者能够使用组件来构建可重用和可维护的界面。这篇文章将会带你从基础入门开始,愉快的学习 React!

参考 https://react.dev/learn

编辑器:VS Code(装插件:ESLint、Prettier、React Developer Tools 浏览器扩展)


1. 项目结构速览(脚手架生成)

许多工具都能够创建一个 react 模板项目,如 React Router (v7)。

npx create-react-router@latest

脚手架会生成一个以 数据路由(Data Routers) 为中心的结构,大致包含:

文件夹/文件说明
app/主要代码目录(类似 Remix)
routes/路由文件夹(文件即路由)
┃ ┣ home.tsx/home 页面组件
┃ ┗ routes.ts导出路由定义(自动生成用)
welcome/可选嵌套路由或组件目录
root.tsx根路由,包含全局布局、导航、错误边界等
app.css全局样式
react-router.config.tsReact Router 配置文件(例如 SSR 设置)
vite.config.tsVite 构建配置
tsconfig.jsonTypeScript 配置
Dockerfile容器部署配置(可忽略)

v7 的理念: 组件路由依然可用,但推荐路由模块(文件即路由)+ 数据 API(loader/action) ;同时 v7 保持与 v6 的非破坏升级,并向 React 19 的服务端能力过渡。React Routeropen in new window


2. 启动项目

# 进入项目
cd my-react-router-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

默认情况下启动在 5173 端口


3. React 基础:组件 + JSX + 状态

我们先从最核心的 React 概念讲起:

  1. 什么是组件?
export default function Profile() {
  return (
    <img
      src="https://i.imgur.com/MK3eW3Am.jpg"
      alt="Katherine Johnson"
    />
  )
}

在这个例子中:

  • Profile 是组件名。
  • 它返回一个 <img /> 标签。
  • 通过 export default 导出,便于在其他文件中 import Profile from './Profile' 使用。

  1. 如何使用组件?

你定义好了组件,就可以在其他组件中像使用 HTML 标签那样使用它:

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

export default function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

注意:

  • <section> 是 HTML 原生标签,因为首字母是小写。

  • <Profile /> 是我们定义的组件,因为首字母大写。

  • 组件可以被重复使用、嵌套组合,从而构建复杂界面。

  • 组件传递参数方式:

示例(简化版):

// 父组件
<Avatar 
  person={{ name: 'Lin Lanying', imageId: '1bX5QH6' }} 
  size={100} 
/>

Avatar 组件上,我们传递了两个 props:person(一个对象)和 size(一个数字)Reactopen in new window

  • 读取 props

在子组件中,你可以通过函数参数接收到 props 对象,例如:

function Avatar({ person, size }) {
  // 此处 person 和 size 可用
  return (
    <img
      src={getImageUrl(person)}
      alt={person.name}
      width={size}
      height={size}
    />
  );
}

说明:

  • function Avatar(props) 是传统形式;而 function Avatar({ person, size }) 是通过 解构赋值 (destructuring)来直接获取所需属性。 Reactopen in new window
  • 在子组件内部,就像使用局部变量一样使用这些 props。

React 组件可以接收 children prop,表示标签里嵌套的内容(children 是一个 component):

function Card({ children }) {
  return <div className="card">{children}</div>;
}

// 使用
<Card>
  <Avatar size={100} person={…} />
</Card>

在这个用法中,<Avatar …/> 被当作 Cardchildren,使 Card 能包裹任意内容。 Reactopen in new window 这是构建可组合 UI 组件(Panel、Wrapper、布局容器等)非常常见的模式。


默认导出(default export) vs 命名导出(named export)

::: important

引用和定义组件请使用大写开头。

注意:一个文件最多只能有一个默认导出,但可以有多个命名导出。

不要在一个 component 里面定义另一个 component。

SyntaxExport statementImport statement
Defaultexport default function Button() {}import Button from './Button.js';
Namedexport function Button() {}import { Button } from './Button.js';

:::


  1. JSX

JSX 是 JavaScript 的一个语法扩展,允许你在 JS 文件里写看起来像 HTML 的标记(markup)来描述 UI。虽然不是必须,但大多数 React 开发者更喜欢使用 JSX,因为它让 “渲染逻辑” 与 “标记内容” 紧密结合。

在传统 Web 开发中:内容 (HTML)、样式 (CSS)、逻辑 (JavaScript) 往往分离;但随着交互变多,逻辑主导内容越来越常见。JSX 支持将逻辑 + 标记放在一起。

示例: 一个常见的 HTML 片段:

<h1>Hedy Lamarr's Todos</h1>
![相关图片](https://i.imgur.com/yXOvdOSs.jpg )
<ul>
  <li>Invent new traffic lights</li>
  <li>Rehearse a movie scene</li>
  <li>Improve the spectrum technology</li>
</ul>

如果直接复制到 JSX 中,会报错。原因是 JSX 有一些更严格或不同的规则。 Reactopen in new window

主要差别包括:

  • 必须有一个单一根元素(或使用 Fragment)
  • 所有标签必须闭合,包括自闭合标签(如 <img />
  • 属性名大多使用 camelCase(而不是 HTML 的 kebab-case 或 “class”)
  • 某些 HTML 属性名在 JSX 中被替换,比如 classclassNamestroke-widthstrokeWidth 等。

以下为正确 jsx 示例

export default function TodoList() {
  return (
    <>
      <h1>Hedy Lamarr's Todos</h1>
      <img 
        src="https://i.imgur.com/yXOvdOSs.jpg" 
        alt="Hedy Lamarr" 
        className="photo" 
      />
      <ul>
        <li>Invent new traffic lights</li>
        <li>Rehearse a movie scene</li>
        <li>Improve the spectrum technology</li>
      </ul>
    </>
  );
}


JSX 中的条件渲染

function Item({ name, isPacked }) {
  if (isPacked) {
    return <li className="item">{name} ✅</li>;
      // 什么都不想渲染,可以 return null
  }
  return <li className="item">{name}</li>;
}

JSX 中渲染列表

const people = [
  { id: 0, name: 'Creola Katherine Johnson', profession: 'mathematician', … },
  { id: 1, name: 'Mario José Molina-Pasquel Henríquez', profession: 'chemist', … },

];

const chemists = people.filter(person =>
  person.profession === 'chemist'
);

const listItems = chemists.map(person =>
  <li key={person.id}>
    <img src={getImageUrl(person)} alt={person.name} />
    <p>
      <b>{person.name}:</b> {person.profession} known for {person.accomplishment}
    </p>
  </li>
);

return <ul>{listItems}</ul>;


4. 事件处理

React 允许你在 JSX 中给元素添加事件处理器(event handlers),用来响应点击 (click)、悬浮 (hover)、输入焦点 (focus)、表单提交 (submit) 等用户交互。

在 JSX 元素上通过像 onClick={…} 的 prop 来传入一个函数。 React+1open in new window

常见步骤:

  1. 在组件内部声明一个函数(如 handleClick
  2. 在函数体内实现逻辑(例如 alert('You clicked me!')
  3. 在 JSX 中 <button onClick={handleClick}>…</button>Reactopen in new window

函数可以定义在组件里,也可以用内联(inline)匿名函数或箭头函数。 Reactopen in new window

重要坑 :必须 传递 函数,而不是 调用 函数。当你写 onClick={handleClick()} 则会在渲染时立即执行,而不是在点击时执行。

将事件处理器作为 Props 传递(Passing event handlers as props)

要点

  • 如果你有一个通用组件(例如 Button),它并不知道每次点击应该做什么。你可以把行为(函数)作为 prop 传给它:

    function Button({ onClick, children }) {
      return <button onClick={onClick}>{children}</button>;
    }
    
    
  • 父组件决定要做的事情,比如:

    function PlayButton({ movieName }) {
      function handlePlayClick() {
        alert(`Playing ${movieName}!`);
      }
      return <Button onClick={handlePlayClick}>Play "{movieName}"</Button>;
    }
    
    
  • 关于事件 handler prop 命名的约定:对于自定义组件,通常将 prop 名以 on… 开头,如 onClickonSmash 等。这样代码易读、意义明确。


5. 状态

在 React 中,用 Hook useState 来创建状态变量。文档示例:

import { useState } from 'react';

const [index, setIndex] = useState(0);

注意 Hooks 的使用规则:

  • 只能在函数组件的最顶层调用,不能在循环、条件或嵌套函数里调用。Reactopen in new window
  • 这保证每次渲染时 useState 被调用的次数与顺序不变,这是 React 区分不同状态变量的关键。

状态的「私有性」与“隔离”

  • 状态是 组件实例 专属的。若你在 UI 上渲染同一个组件两次, 每一个实例 都会有自己的状态拷贝,互不影响。
  • 父组件无法直接 “操控” 子组件内部声明的状态(除非你通过 props 或回调把状态提升上来)。状态默认只是组件自己的“私有记忆”。

在一次渲染周期(render)中,state 的值 对该渲染函数及其事件处理器而言是固定的 。如果在同一个渲染中多次 setState(state + 1),那么每次 state 的 “快照值” 均不会更新,因此可能不会按你预期累加多次。示例:点击 “+3” 按钮只加 1。

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

在写 useState + 事件处理的时候, 假设 state 在当前渲染中是固定不变的

避免在一系列 setState(state + 1) 中预期“连续更新”而不使用函数式更新。

如果你的处理逻辑需要 “最新” state 值(如 +3、+5、基于当前值累加等),建议改写为:

setNumber(prev => prev + 1);
setNumber(prev => prev + 1);
setNumber(prev => prev + 1);

或者直接:setNumber(prev => prev + 3);


文中强调:你应当把 state 中的对象当作“只读”的(read-only)。不要直接修改。

但如果你想要修改 state 中的对象,举例:

const [position, setPosition] = useState({ x:0, y:0 });
position.x = e.clientX;  // 直接修改对象 —— 问题在这里

这种做法会导致 React 无法检测到变化 ,所以组件不会重新渲染。

如果你的 state 存的是一个对象,而你只想更新对象中的一个字段,可以使用对象的拷贝语法(spread)来创建新的对象。

setPerson({
  ...person,           // 复制以前的所有字段
  firstName: e.target.value  // 覆盖某个字段
});

这样可以避免手动写出所有字段,比较方便。


  • 状态中的 Array

React 的状态更新机制依赖 引用变化 来决定是否需要重新渲染。若直接改变已有数组(如 arr.push()arr[0] = …),原数组引用不变,可能导致 React 无法识别变化 ,或者造成难以发现的副作用。

1. 添加元素(Add)

不推荐: 使用 push() 等会改变原数组的方法。 React+1open in new window推荐: 使用扩展运算符(spread)和/或 concat() 创建新数组。示例:

// TypeScript + React
const [items, setItems] = useState<MyItemType[]>([]);

// 在末尾添加:
setItems([
  ...items,
  newItem
]);

// 在开头添加:
setItems([
  newItem,
  ...items
]);

上述方法会生成一个新的数组引用,从而触发 React 重新渲染。 Reactopen in new window


2. 删除元素(Remove)

推荐使用 filter() 方法:

setItems(
  items.filter(item => item.id !== targetId)
);

这样得到的是一个 新数组 ,原数组不被修改。 Reactopen in new window


3. 转换/更新元素(Transform / Replace)

当你想更新数组中某些元素(例如改变某项属性):

推荐使用 map()

setItems(
  items.map(item => 
    item.id === targetId 
      ? { ...item, someProp: newValue }  // 创建新对象
      : item  // 保持原对象
  )
);

这种方式保证 “数组引用” 和 “被替换对象” 都是新的。否则如果你只是修改旧对象,就会和旧状态共享对象引用,可能引发难以察觉的 bug。 Reactopen in new window


4. 插入元素到中间位置(Insert)

如果要在数组中间某个位置插入一个新项:

const index = 1;
setItems([
  ...items.slice(0, index),
  newItem,
  ...items.slice(index)
]);

这里 slice() 返回的是一个新的子数组,因此你在拼出新的数组时也是在构造一个新的结构。 Reactopen in new window


5. 其他需要注意的变动(排序、反转、深层嵌套修改)

  • 方法如 sort()reverse()原地修改数组 ,如果直接在 state 的数组上调用,会破坏不可变原则。推荐先做 const newArr = [...oldArr],然后 newArr.sort()newArr.reverse(),最后 setItems(newArr)Reactopen in new window
  • 注意:即便你复制了数组(如 [...oldArr]),如果数组元素是对象,那么这些对象还是旧的引用。如果你修改了其中某个对象的属性,也可能是直接修改原对象,仍然会破坏 state 的不可变性。文档举了一个例子:拷贝数组但未深拷贝对象,结果两个状态 hook 共享对象引用,操作一个会影响另一个。 Reactopen in new window

6. Reducer

当有一个组件的状态逻辑非常复杂(比如需要进行添加,删除,修改等操作),可以考虑使用 reducer,而不是 state。在 Redux、MobX 等状态管理库流行之前,这种模式就已被广泛采用。React 自身也鼓励在适合场景下使用 useReducer

编写 reducer 函数

  • reducer 函数签名通常为 (state, action) => nextStateReactopen in new window

  • 在例子中,tasksReducer(tasks, action) 根据 action.type 返回新的数组状态:

    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 => t.id === action.task.id ? action.task : t);
        case 'deleted':
          return tasks.filter(t => t.id !== action.id);
        default:
          throw Error('Unknown action: ' + action.type);
      }
    }
    

    Reactopen in new window

  • 写好之后,可以把它提取到组件外部或独立文件,使组件更简洁。 Reactopen in new window

在组件中使用 useReducer

  • 在组件里用:

    const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
    

    替代之前的 const [tasks, setTasks] = useState(initialTasks)Reactopen in new window

  • 然后在事件处理器里调用 dispatch(...)。比如:

    function handleAddTask(text) {
      dispatch({ type: 'added', id: nextId++, text });
    }
    

    Reactopen in new window


7. Context

Context 是一种“跨层级”传递数据的方式,避免中间组件的重复传递。

使用 Context 的典型场景

  • 主题(theme):全局颜色、暗黑模式等。Reactopen in new window
  • 当前用户 (current account / auth):很多组件可能需要知道登录状态或用户信息。Reactopen in new window
  • 路由状态:其实路由库本身就用 context 来实现的。Reactopen in new window
  • 大型状态管理:结合 useReducer + Context 传递复杂状态至深层组件。

1. 创建 Context

import { createContext } from 'react';

export const LevelContext = createContext(1);

这里 1 是默认值,当组件在树中“没有”被对应的 Provider 包裹时,会用这个默认值。Reactopen in new window

记住:createContext(defaultValue) 的 defaultValue 是有意义的,在没有 Provider 时也能有“兜底”的机制。

2. 在需要读取数据的组件中使用 useContext

import { useContext } from 'react';
import { LevelContext } from './LevelContext';

export default function Heading({ children }) {
  const level = useContext(LevelContext);
  // …
}

这样 Heading 就不再依赖自己被传 level prop,而是“自己去找”最近的 LevelContext 提供者(根据当前 component 所在位置,往父组件方向寻找 LevelContext.Prodiver)。Reactopen in new window

3. 在提供数据的组件中使用 Provider

import { LevelContext } from './LevelContext';

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

这样 Sectionlevel 值“提供”给其下层所有用到 LevelContext 的组件


8. Ref

当一个组件希望 “记住(remember)” 某些信息,但 不希望这些信息的变化触发重新渲染(re-render) 时,就可以使用 useRef()

1. 导入 useRef

import { useRef } from 'react';

2. 在组件内部调用,指定初始值

const myRef = useRef(0);

这里 myRef.current 初始为 0Reactopen in new window

3. 访问或修改 ref.current

例如:

myRef.current = myRef.current + 1;
alert('当前值:' + myRef.current);

此操作不会导致组件重新渲染(除非你自己用 state 做更新)––因此,用来存储 “渲染之外的可变值” 十分合适。 Reactopen in new window

特性refs (useRef)state (useState)
返回值{ current: initialValue } 对象 (Reactopen in new window)[value, setValue] 数组 (Reactopen in new window)
变化是否触发渲染不会 (Reactopen in new window) (Reactopen in new window)
值是否可变可变(你可以直接写 ref.current = ...) (Reactopen in new window)“不可变”意义上,必须通过 setValue(...) 更新
在渲染期间读取不推荐在渲染过程中读取/写入 ref.current,因为 React 无法追踪它,可能导致不可预测行为。 (Reactopen in new window)可以直接在渲染函数中读取 state,React 会正确追踪
用途存储:定时器 ID、浏览器 APIs、DOM 元素引用、过程状态等,不影响 UI 渲染。 (Reactopen in new window)存储会用于 UI 显示或决定渲染逻辑的数据(用户输入、是否展示、计数器等)

Ref 使用示例

import { useRef } from 'react';

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

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

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>

为何需要 Refs(引用)? React 默认负责更新 DOM,以匹配组件输出。通常你不需要手动操作 DOM。但在某些情形(如:让输入框自动聚焦、滚动到某个元素、测量元素尺寸/位置)下,就需要获取 React 管理的 DOM 节点。 Reactopen in new window 在这些情况下,就可以用 “ref” 来访问 DOM 节点并调用浏览器原生 API。 Reactopen in new window

如何获取 DOM 节点引用?

  • 使用 useRef() Hook 创建一个 ref 对象(例如 const myRef = useRef(null);) Reactopen in new window
  • 在 JSX 元素上加上 ref={myRef},React 在 commit 阶段会把该 DOM 节点赋值给 myRef.current。 Reactopen in new window
  • 注意:在 render 阶段 myRef.current 可能仍为 null,因为 DOM 节点尚未创建或尚未更新。Reactopen in new window

何时使用 Refs? 常见场景包括:

  • 让输入框聚焦(focus) Reactopen in new window
  • 滚动到某个元素(scrollIntoView) Reactopen in new window
  • 测量元素大小、位置(getBoundingClientRect)
  • 或者调用浏览器 API(如 video 的 play/pause) Reactopen in new window 也就是说,Refs 是一种 “逃生舱”(escape hatch)——在 React 控制之外需要直接操作 DOM 时才用。

9. Effect

在 React 里,组件主要做两件事:

  • 渲染逻辑 :根据 propsstate 计算出要返回的 JSX(必须是“纯函数”——只算结果,不做副作用)。Reactopen in new window
  • 事件处理 :响应用户点击、输入等事件,去发请求、更新状态、跳转页面,这些都是“副作用”。Reactopen in new window

但是有一类事情比较尴尬: 它不是用户点了某个按钮才发生,而是 “只要组件出现在屏幕上,就应该发生” 。 比如:连接聊天室、播放视频、注册浏览器事件监听、埋点统计…… 这时候就轮到 Effect 上场了。

例子:用 Effect 控制 <video> 播放

假设我们有一个简单的视频播放器组件,希望通过 isPlaying 控制它是「播放」还是「暂停」:

import { useEffect, useRef } from "react";

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

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    if (isPlaying) {
      video.play();
    } else {
      video.pause();
    }
  }, [isPlaying]); // 只有 isPlaying 变化时才重新同步

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

外层再包一层按钮切换状态:

import { useState } from "react";

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);

  return (
    <>
      <button onClick={() => setIsPlaying(p => !p)}>
        {isPlaying ? "暂停" : "播放"}
      </button>
      <VideoPlayer
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
        isPlaying={isPlaying}
      />
    </>
  );
}

这里发生了几件事:

  1. 组件渲染时,只是返回了一个带 ref<video> 标签 —— 渲染本身不碰 DOM API
  2. React 把这段 JSX “提交(commit)”到真实 DOM 上。
  3. 提交结束后,React 才调用 useEffect 里的回调,在那里我们用 video.play()/video.pause() 去操作真实 DOM。Reactopen in new window

用一句话总结:

Effect = 渲染之后,用当前的 props/state 去“同步”外部系统的状态。

为什么需要依赖数组?

如果不给 useEffect 传依赖数组:

useEffect(() => {
  // ...
});

那它会在 每一次渲染后都执行 。有时这会:

  • 性能不好:比如每次输入都重新连一次服务器。Reactopen in new window
  • 行为错误:比如每次渲染都重新触发一次「淡入动画」。

所以我们通常会像上面的例子一样传入依赖数组:

useEffect(() => {
  // 根据 isPlaying 同步视频状态
}, [isPlaying]);

含义是: 只有当 isPlaying 发生变化时,才需要再次运行这个 Effect

一个经典坑:在 Effect 里直接 setState

看下面这段“反例”代码(简化自文档):Reactopen in new window

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

useEffect(() => {
  setCount(count + 1);
});

发生了什么?

  1. 组件渲染完,Effect 运行,setCount(count + 1)
  2. 状态变了,又触发一次渲染。
  3. 渲染完又运行 Effect,再次 setCount……
  4. 无限循环 🔄,页面直接炸掉。

记住一条经验:

Effect 更适合“和外部世界同步”,而不是在内部“纯粹改 state”。 如果只是想根据一个 state 推出另一个 state,通常可以用计算属性或在事件里处理,而不是上来就写 Effect。Reactopen in new window


10. React 生命周期

在 React 中,我们通常熟悉组件的生命周期:组件会挂载(mount)、更新(update)、卸载(unmount)。但当你使用 React Hooks 中的 useEffect、或其他 “副作用” 时,其实需要换一种视角来看 — 每一个 Effect(副作用)本身也有自己的“同步启动/停止”过程 。文档指出:

“Effects have a different lifecycle from components… An Effect can only do two things: to start synchronizing something, and later to stop synchronizing it.” Reactopen in new window 也就是说:我们不再单纯去想“组件何时挂载/更新/卸载”,而是去想:“这个 Effect 是什么时候开始同步?什么时候停止同步?”。

核心示例:ChatRoom

文档中给了一个非常直观的示例:

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

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

  return <h1>Welcome to the {roomId} room!</h1>;
}

这个例子帮助我们理解 Effect 的“启动”(connect)与“清理/停止”(disconnect)机制。 Reactopen in new window

为什么不仅仅是“挂载/卸载”?

假设用户最初进入 "general" room,然后切换到 "travel" room。组件并没有卸载——只是 roomId 这个 prop 发生了变化。那 Effect 应该怎么做?

  1. 停止同步旧的 roomId (断开 “general” 连接)
  2. 启动同步新的 roomId (连接 “travel”) 文档里描述:

“At this point, you want React to do two things: 1. Stop synchronizing with the old roomId … 2. Start synchronizing with the new roomId.” Reactopen in new window 这正说明:Effect 的同步并非只在挂载或卸载时触发一次,而可能在组件仍然挂载的状态下反复 “停止旧的→启动新的”。


从 Effect 角度思考

文档强调一种思考方式:

“Instead, always focus on a single start/stop cycle at a time. … All you need to do is to describe how to start synchronization and how to stop it.” Reactopen in new window 也就是说:你写 useEffect 时,别总想着 “组件什么时候挂载/更新/卸载”。而直接问自己两个问题:

  • 当这个 Effect 启动(synchronize)时,我该做什么?
  • 当这个 Effect 停止(cleanup)时,我该做什么?

在上面 ChatRoom 的例子里:

  • 启动 → createConnection(...).connect()
  • 停止 → connection.disconnect()

无论是因为 roomId 变了,还是组件卸载了,这两个流程都要被正确执行。


依赖数组(Dependencies)要点

Effect 的行为关键还在于依赖数组:[][roomId] 等。文档指出:

  • 如果你在 Effect 内读取了一个可能随渲染改变的值(即“reactive value”),就必须把它放进依赖数组。 React+1open in new window

  • 在前例中,roomId 是 prop,会变,因此放 [roomId]。而 serverUrl = 'https://localhost:1234' 是一个固定常量,不会变,因此不必放进数组。 Reactopen in new window

  • 如果你把所有用到的 reactive 值都“移出”组件或 effect 内部(即变成常量),那就可以用空数组 [],表示“只启动一次、停止一次”。例如:

    const serverUrl = 'https://localhost:1234';
    const roomId = 'general';
    
    function ChatRoom() {
      useEffect(() => {
        const connection = createConnection(serverUrl, roomId);
        connection.connect();
        return () => connection.disconnect();
      }, []);
      return <h1>Welcome to the {roomId} room!</h1>;
    }
    

    Reactopen in new window


小结

  • 每个 Effect 是一个“同步过程”:启动→运行→停止,而不是简单绑定组件生命周期。
  • 当你写 useEffect(() => { … }, [deps]) 时,要思考 “我什么时候启动”“我什么时候停止”这个同步。
  • 依赖数组的每一个值都必须是你在 Effect 内读取的、且可能变的 reactive 值。漏掉就容易出 bug。
  • 用空依赖([])意味着 “组件挂载时启动,同卸载时停止;中间不随 prop/state 变动而重新同步”。
  • 用非空依赖(如 [roomId])意味着 “只要某个关键值变了,就停止旧同步、启动新同步”。

11. Hook

写 React 的时候,你经常会遇到“两个地方做了同一件事”的情况:有一段 state + handler + JSX 逻辑,在一个组件里出现两次,或者在多个组件里重复。官方的建议是:把 可复用的、有状态的逻辑 抽成自定义 Hook。注意这里的关键词是“逻辑”,不是“状态”——每次调用 Hook 都会创建自己独立的一份 state。 Reactopen in new window

例子:把表单输入的重复逻辑抽出来

先看一个没有自定义 Hook 的 Form 组件:它对 firstName/lastName 两个输入框分别维护 state,并写两个几乎一样的 onChange 处理函数。

import { useState } from "react";

export default function Form() {
  const [firstName, setFirstName] = useState("Mary");
  const [lastName, setLastName] = useState("Poppins");

  return (
    <>
      <label>
        First name:
        <input
          value={firstName}
          onChange={(e) => setFirstName(e.target.value)}
        />
      </label>

      <label>
        Last name:
        <input
          value={lastName}
          onChange={(e) => setLastName(e.target.value)}
        />
      </label>

      <p><b>Good morning, {firstName} {lastName}.</b></p>
    </>
  );
}

重复点很明显:

  • 每个字段都要一份 state
  • 每个字段都要一个 change handler
  • 每个 input 都要写 valueonChange

于是我们可以提炼出一个 useFormInput

import { useState } from "react";

export function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  function handleChange(e) {
    setValue(e.target.value);
  }

  return {
    value,
    onChange: handleChange,
  };
}

然后 Form 变得更干净:我们只声明“我要两个输入框”,至于输入框怎么同步 state,由 Hook 内部处理。

import { useFormInput } from "./useFormInput";

export default function Form() {
  const firstNameProps = useFormInput("Mary");
  const lastNameProps = useFormInput("Poppins");

  return (
    <>
      <label>
        First name:
        <input {...firstNameProps} />
      </label>

      <label>
        Last name:
        <input {...lastNameProps} />
      </label>

      <p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
    </>
  );
}

这里最重要的点:

  • useFormInput 只写了一份逻辑
  • Form 调了两次 useFormInput
  • 所以得到的是两份 互不影响 的 state(firstName 的输入不会改 lastName)。

这正是“复用逻辑,不复用状态”的含义。 Reactopen in new window


小结

当你发现组件里出现可重复的 stateful 逻辑时:

  1. 先确认它确实包含 Hook(比如 useState/useEffect
  2. 再把重复部分抽成 useXxx
  3. 让组件只表达意图,逻辑细节交给 Hook

自定义 Hook 的价值就在于:让你的组件更像“声明式的 UI”,而不是“到处散落着重复的实现细节”。