React全家桶 - 【React】 - 【2】组件基础(组件定义及使用、useState、受控表单组件、组件样式、useRef)

1,037 阅读21分钟

前言

什么是组件?

  • 组件是一个广泛的概念,现在流行的框架中都有组件;
  • 一个组件就是用户界面的一部分,它可以有自己 的 逻辑 和 外观组件之间 可以 相互嵌套,也可以 复用 多次

一、React组件

1.1 基本概念 及 注意事项

  • 在 React 中,一个 组件 就是 首字母 大写 的 函数,内部存放了 组件的 逻辑 和 视图UI,渲染组件只需要把组件 当成 标签 书写 即可;
  • 注意
    • React组件是常规的JS函数,但 组件的名称 必须以 大写字母 开头,否则它们将无法运行;
    • React组件 必须 要有一个 根标签
      • 通常跟标签都是使用<></>一对空标签表示的;
      • 但,有时候我们需要使用map循环列表,需要添加key属性,在空标签上不能添加key属性,所以此时就需要使用<Fragment></Fragment>
      • <></> 是对 <Fragment></Fragment> 的简写;
      • 它们都允许你在不添加额外节点的情况下将子元素组合;
    • React组件函数也可以是箭头函数;
    • 如果你的标签和return关键字不在同一行,则必须把它包裹在一对括号中(小括号);
      • 没有括号包裹的话,任何在 return 下一行的代码 都将被忽略
    • 所有的标签都必须是闭合标签;
      • 如果是单标签:必须自闭合;

1.2 定义组件

// recat 17之前是要写的,17之后可以省略
import React from 'react';

// 1. 定义函数
function Button() {
    // 组件内部逻辑
    // 2. 添加标签
    return <button> click me </button>
}

// 3. 导出组件
export default Button;

1.3 使用组件(渲染组件)

  • 正常的定义组件都是以上三个步骤,这里为了简单方便,就直接在 App.js 中定义组件;
// 定义组件
const Button = () => {
  // 业务逻辑 及 组件逻辑
  const onClick = (name, e) => {
    console.log(name, e);
  };
  return <button onClick={(e) => onClick('禁止摆烂_才浅', e)}>Click Me</button>;
}

// 使用组件
function App() {
  return (
    <div>
      {/* 单标签  ===>  自闭合 */}
      <Button />
      
      {/* 双标签 */}
      <Button></Button>
    </div>
  );
}

export default App;

二、useState 基础使用

2.1 基本介绍

  • useState是一个 React Hook(函数),它允许我们向组件添加一个 状态变量,从而控制影响组件的渲染结果;
  • 本质
    • 和普通JS变量不同的是,状态变量一旦发生变化,组件的视图UI也会跟着变化(数据驱动试图);
    • 就比如说,我将 状态变量 count 的值 从 0 => 1,那么视图上的显示结果也会从 0 => 1;
      • image.png

2.2 语法

  • 注意
    • 使用之前需要先导入;
    • React Hooks 必须在 React函数组件自定义Hook函数 的最顶层 调用
import { useState } from 'react';

const [状态变量, 修改状态变量的函数] = useState(初始值);
// const [状态变量, set状态变量] = useState(初始值);
  • image.png
  • 作用
    • 向组件添加一个状态变量;
  • 语法
    • 组件顶层 调用 useState 来声明一个状态变量;
    • 使用 数组解构 来命名状态变量;
    import { useState } from 'react';
    
    const App = () => {
        const [num, setNum] = useState(初始值);
    };
    
    export default App;
    
  • useState() 参数(初始值):
    • 任何类型的数据;
    • useState参数 将作为 状态变量初始值
    • 注意
      • 如果传递 函数 作为初始值,则它将被视为 初始化函数
      • 该函数一定是一个 纯函数不应该 接受 任何参数,并且应该返回一个任何类型的值;
      • 初始化组件时,React将 调用该初始化函数,并将其 返回值 存储为 初始状态
  • useState() 返回值
    • 返回一个由 两个值 组成的 数组
    • 当前的state
      • 在首次渲染时,它将与你传递的初始值相同;
    • set函数
      • 它可以让你将state更新为不同的值,并触发重新渲染;
      • 给这个set函数传递什么,对应的state就会更新成什么;
      • set函数没有返回值;
  • useState 是一个函数返回值 是 由两个值组成的 数组
  • 代码展示:
    import { useState } from 'react';
    
    const Button = () => {
      // 调用 useState 添加一个状态变量
      // num ===> 状态变量
      // setNum ===> 修改状态变量的函数
      const [num, setNum] = useState(0);
    
      // 点击事件 - num自增
      const onClick = () => {
        // 调 setNum 的作用
        // 1. 修改 状态变量 num 的值(用传入的新值修改状态变量)
        // 2. 重新使用新的 状态变量 num 渲染视图
        setNum(num + 1);
      };
      
      return (
        <div>
          <button onClick={onClick}>Click Me</button>
          <br />
          <span>状态变量的值 --- {num}</span>
        </div>
      );
    };
    
    function App() {
      return (
        <div>
          <Button />
        </div>
      );
    }
    
    export default App;
    

2.3 修改状态的规则

2.3.1 修改 基本数据类型 的状态

  • 在 React 中,状态 被认为是 只读 的,我们应该始终 替换 而不是 修改 它,直接修改 状态 不能引起视图更新
  • 注意
    • 直接修改 状态变量 的 值,状态变量 能被 修改,但是 不会引起 视图 的 更新;
    • 既要 修改状态 变量的值,还想要 视图同时更新,只能通过 useStateset 函数去修改状态变量的值;
  • 代码展示:
    import { useState } from 'react';
    
    const Button = () => {
      // 调用 useState 添加一个状态变量
      // num ===> 状态变量
      // setNum ===> 修改状态变量的函数
      let [num, setNum] = useState(0);
    
      // 点击事件 - num自增
      const onClick = () => {
        setNum(num + 1);
      };
    
      const onClick1 = () => {
        // 事实上 `num++` 修改的是当前渲染闭包内的局部副本,**并非 React 内部存储的 state**,下次渲染会被恢复。
        num++;
      };
      return (
        <div>
          <button onClick={onClick1}>Click Me</button>
          <br />
          <span>直接修改状态变量的值 --- {num}</span>
          <br />
          <hr />
          <br />
          <button onClick={onClick}>Click Me</button>
          <br />
          <span>使用useState的set函数状态变量的值 --- {num}</span>
        </div>
      );
    };
    
    function App() {
      return (
        <div>
          <Button />
        </div>
      );
    }
    
    export default App;
    
  • 演示效果: 111.gif

2.3.2 修改 对象、数组 的状态

  • 规则
    • 对于对象类型的状态变量,应该始终传给 set 一个 全新的对象 来进行替换;
  • 代码展示:
    import { useState } from 'react';
    
    const Button = () => {
      // 对象格式
      const [info, setInfo] = useState({
        name: '张三',
        age: 22,
        gender: '男'
      });
      
      const onClick = () => {
        setInfo({
          ...info,
          name: '李四',
          age: 58
        });
      };
      
      const onClick1 = () => {
        info.name = '王麻子';
        info.age = 44;
        console.log(info);
      };
    
      // 数组形式
      const [numArr, setNumArr] = useState([0, 1, 2, 3, 4]);
    
      const onChange = () => {
        setNumArr([1, 2, 3, 4, 5]);
      };
    
      const onChange1 = () => {
        numArr[0] = 100;
        console.log(numArr);
      };
    
      return (
        <div>
          <button onClick={onClick1}>Click Me</button>
          <br />
          <span>
            直接修改状态变量的值 --- {info.name} - {info.age}
          </span>
          <br />
          <hr />
          <br />
          <button onClick={onClick}>Click Me</button>
          <br />
          <span>
            使用useState的set函数状态变量的值 --- {info.name} - {info.age}
          </span>
          <br />
    
          <hr />
          <br />
          <button onClick={onChange1}>Click Me</button>
          <br />
          <span>直接修改状态变量的值 --- {numArr}</span>
          <br />
          <hr />
          <br />
          <button onClick={onChange}>Click Me</button>
          <br />
          <span>使用useState的set函数状态变量的值 --- {numArr}</span>
        </div>
      );
    };
    
    function App() {
      return (
        <div>
          <Button />
        </div>
      );
    }
    
    export default App;
    
  • 演示效果: 111.gif

2.4 注意事项

  • 注意
    • useState是一个Hook,因此只能在 组件的顶层自己的Hook 中调用它;
    • 不能在循环或条件语句中调用它;
      • 如果非要这样使用,需要提取一个新组件并将状态移入其中;
    • 严格模式中,React将 两次调用初始化函数,使用其中的一个值而忽略另一个值;
    • set函数 仅更新 下一次 渲染的状态变量。如果在调用 set 函数后读取状态变量,则仍然得到的是调用之前的旧值;
      • 不要在更新state的函数中使用state,因为此时的state还是上一次的state,并不是最新的;
      import { useState } from 'react';
      
      const App = () => {
          const [num, setNum] = useState(0);
          const onUpdateNum = () => {
              setNum(num + 1);
              console.log(num); // 0,此时的num还是组件初始化时的num,并不是最新的
          };
      
          return <button onClick={onUpdateNum}>state:{ num }</button>
      };
      
      export deault App;
      
  • react state 更新机制:
    • react 中的 state 更新的时候,整个组件函数里面的所有代码都会重新执行;

三、组件的样式处理

  • React组件基础的样式控制有两种控制方案:
    • ✅ class类名控制(单独写样式,导入到组件文件中);
      • image.png
    • ❌ 行内样式(极不推荐);
      • image.png

3.1 ✅ class类名控制

3.1.1 固定类名

  • 代码展示:
    .box {
      width: 100px;
      height: 100px;
      background-color: red;
    }
    
    import './App.css';
    
    const App = () => {
      return <div className="box box1"></div>;
    };
    
    export default App;
    
  • 注意
    • 在 React 中,使用类名的时候,需要使用 className 关键字 替代之前的 class

3.1.2 动态 添加 或 删除 类名

动态判断添加 单类名
<div className={item.readState === 0 ? 'no-read' : null}></div>
已有多类名,动态判断再添加类型
// 数组方法
<div className={['box', classA, item.readState === 0 ? 'no-read' : null].join(' ')}></div>
<div className={['box', classA, item.readState === 0 && 'no-read'].join(' ')}></div>

// 模板字符串方法
<div className={`box ${classA} ${item.readState === 0 ? 'no-read' : null}`}></div>
<div className={`box ${classA} ${item.readState === 0 && 'no-read'}`}></div>
  • 注意
    • 数组方法时:
      • 要使用 空格 将数组转为字符串;
    • 模板字符串方法时:
      • 类名之间 必须要有 空格
✅ 使用 classnames 依赖

在实际开发中,我们通常需要根据某个条件去判断类名,此时我们可以使用 classnames 这个第三方包进行设置;

// 安装依赖
npm i classnames
import classNames from 'classnames';
<div className={classNames('box', {'no-read': item.readState === 0 })}></div>

3.2 ❌ 行内样式

  • 行内样式有两种方案:
    • 直接将样式写在行内;
    • 将样式属性写在一个对象中,将这个对象绑定到对应的元素上;
  • 代码展示:
    • 将样式写在行内:
      const App = () => {
          return <div style={{ width: '100px', height: '100px', backgroundColor: 'red' }}>Hello World</div>;
      };
      
      export default App;
      
    • 使用对象:
      const style = {
          width: '100px',
          height: '100px',
          backgroundColor: 'red',
          color: '#fff'
      };
      
      const App = () => {
          return <div style={style}>Hello World</div>;
      };
      
      export default App;
      

3.3 CSS 方案对比

  • 仅用全局 .css 编写样式会带来类名冲突、样式覆盖不可控、难以追踪来源、无法 tree-shake 等全局污染问题。生产项目中应根据场景选择合适的样式方案:
方案代表作用域主要优点主要弊端
全局 CSSimport './index.css'❌ 全局简单直接、零配置命名冲突、样式互相覆盖、复用差、难维护
CSS Modulesxxx.module.css✅ 文件级自动哈希类名、零运行时、与原生 CSS 一致跨组件共享变量需借助 :global / CSS 变量
CSS-in-JSstyled-components、emotion✅ 组件级动态样式、props 驱动、TS 友好运行时开销、SSR 配置较重、调试样式不直观
原子化 CSSTailwindCSS、UnoCSS✅ 类名级无命名负担、HTML 即样式、构建产物小、设计一致性强长 className 可读性差、需团队约定抽离规范
Zero-runtime CSS-in-JSvanilla-extract、Linaria✅ 文件级类型安全 + 零运行时生态较新、学习成本
  • 全局 CSS 的弊端示例:
/* a.css */
.title { color: red; }

/* b.css,另一个组件作者写 */
.title { color: blue; } /* 不知不觉覆盖了 a.css */
  • 打包后两份样式合并到同一份 stylesheet,后加载者覆盖前者,且 IDE/编译器无任何告警。

  • 推荐选型:

    • 小型 demo / 一次性页面 → 全局 CSS 也无妨。
    • 中大型 React 项目 → CSS ModulesTailwindCSS
    • 强动态样式(主题切换、props 驱动颜色)→ CSS-in-JS 或 CSS 变量 + Tailwind arbitrary value
    • 极致性能 + 类型安全 → vanilla-extract

一句话:任何 ≥ 3 人协作的 React 项目都不应再以全局 .css 作为主样式方案,至少应使用 CSS Modules 或原子化 CSS 隔离作用域,避免不可见的样式污染。

四、受控表单组件绑定

4.1 概念

  • 使用 React 组件状态的状态(useState)控制表单的状态;
  • image.png

4.2 使用步骤

  • 准备一个React状态值
    • 使用useState声明状态;
    const [value, setValue] = useState('');
    
  • 通过 value 属性绑定状态,通过onChange事件绑定状态同步的函数,通过事件对象e拿到输入框最新的值,反向修改react的状态;
    <input
      type="text"
      value={value}
      onChange={(e) => setValue(e.target.value)}/>
    
  • image.png
  • ❗ 注意
    • 只设置 value 而没有 onChange」时 React 会发警告并使其只读;二者同设才是标准受控组件。
    • 如果该字段应该是可变的,则使用 defaultValue
    • 否则,设置 onChangereadOnly;

五、useRef

  • 在 React 中 获取 / 操作 DOM,需要使用 useRef 钩子函数,分为两步:
    • 使用 useRef 创建 ref 对象,并于 JSX 绑定;
      • 在组建内部调用;
      const inputRef = useRef(null);
      
      <input type="text" ref={inputRef} />
      
  • 在DOM可用时,通过 ref名称.current 拿到DOM对象;
    console.log(inputRef.current);
    
    image.png
  • ❗ 什么是DOM可用?
    • 组件渲染完毕之后才可用的;
    • 渲染之前获取DOM得到的是 null
  • useRef 除了用来获取DOM之外,还有以下用途:
    • 保存任意可变值且不触发重渲染(定时器ID、上一次值、首次渲染标记等)

六、React 中 refstate 的对比

1. 一句话总结

stateref
本质触发渲染的"快照数据"不触发渲染的"可变盒子"
更新方式setState / setXxx直接赋值 ref.current = ...
变化是否引起 re-render✅ 是❌ 否
读到的值当前渲染时的快照(可能旧)永远是最新值
典型 APIuseStatethis.stateuseRefcreateRef

2. 核心机制差异

state:响应式快照

  • 调用 setState → React 调度 re-render → 组件函数重新执行 → 拿到新的快照
  • 在同一次渲染中,state 是不可变的;多次 setCount(count + 1) 同帧只生效一次(除非用函数式更新)。
const [count, setCount] = useState(0);
// 同一渲染内,count 始终是这次渲染的快照值

ref:可变容器

  • ref.current = x同步直接赋值,不会让 React 知道,也不会重新渲染。
  • 在闭包中访问 ref.current 永远拿到最新值,绕过闭包陷阱。
const countRef = useRef(0);
countRef.current += 1; // 直接修改,不触发渲染

3. 何时用 state、何时用 ref

✅ 用 state

  • 数据需要显示在 UI 上(影响渲染输出)。
  • 需要随数据变化触发副作用(useEffect 依赖)。
  • 表单受控组件、列表数据、loading 状态、开关等。

✅ 用 ref

  1. 访问 DOM 元素

    const inputRef = useRef<HTMLInputElement>(null);
    const handleFocus = () => inputRef.current?.focus();
    return <input ref={inputRef} aria-label="输入框" />;
    
  2. 保存"实例变量"——跨渲染保持但不影响渲染

    const timerRef = useRef<NodeJS.Timeout | null>(null);
    const prevValueRef = useRef<string>('');
    
  3. 解决闭包陷阱(在 effect/callback 里读最新值)

    const latestPropsRef = useRef(props);
    useEffect(() => {
      latestPropsRef.current = props;
    });
    
    useEffect(() => {
      const id = setInterval(() => {
        // 永远是最新 props,不会因为闭包而拿到旧值
        console.log(latestPropsRef.current);
      }, 1000);
      return () => clearInterval(id);
    }, []);
    
  4. 暴露子组件方法(配合 forwardRef + useImperativeHandle

    export const Modal = forwardRef<{ open: () => void }>((_, ref) => {
      useImperativeHandle(ref, () => ({ open: () => { /* ... */ } }));
      return null;
    });
    

4. 对比示例:定时器计数

❌ 错误做法(state 闭包陷阱)

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 永远是 0!闭包捕获了初次渲染的 count
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖,导致 count 永远是初始值
};

✅ 方案 A:函数式更新(推荐)

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount((prev) => prev + 1); // 拿到最新 prev
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
};

✅ 方案 B:用 ref 镜像最新值

const Counter = () => {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;

  useEffect(() => {
    const id = setInterval(() => {
      console.log('最新值:', countRef.current); // 永远最新
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
};

5. 易错点

误用结果正解
用 state 存 DOM 节点DOM 变化触发渲染、可能死循环ref
用 ref 存要展示的数据数据更新但 UI 不刷新state
在渲染过程中读写 ref.current破坏纯函数特性,行为不可预测只在事件、effect、回调中读写
用 ref 存派生数据容易和 state 不同步优先 useMemo 或直接用 state

官方原则:"如果一份数据不需要在 UI 中展示、且变化不应导致重渲染,就用 ref;否则用 state。"


6. 类组件中的对应关系

函数组件类组件
const [v, setV] = useState(0)this.state = { v: 0 } / this.setState({ v: 0 })
const ref = useRef(null)this.ref = createRef()this.someVar = ...(实例属性)

类组件里实例属性 this.xxx = ... 天然就是"ref 式"的可变变量——不触发渲染。


一句话决策

会渲染的数据用 state,不会渲染但要跨渲染保留的用 ref

七、Hook 规则的"为什么"——底层原因详解

  • Hook 规则只讲「是什么」(顶层调用、不能在循环/条件中)
  • 未讲「为什么」(依赖调用顺序识别状态槽)
  • 也未提调用场所限制(必须在 React 函数组件 / 自定义 Hook 中)。
  • 下面把「是什么 → 为什么 → 在哪用」三层讲透。

1. Hook 的两条核心规则(是什么)

  1. 只在最顶层调用 Hook:不要在循环、条件、嵌套函数中调用。
  2. 只在 React 函数中调用 Hook:只能在函数组件自定义 Hook中调用,不能在普通 JS 函数、类组件、事件回调中调用。

2. 为什么必须"顶层调用"——React 靠调用顺序识别状态槽

2.1 React 内部如何存储 Hook 状态

  • React 不会用变量名来识别 useStateuseEffect,因为函数组件每次渲染都是一次普通函数调用,闭包里的变量名对 React 透明。

  • React 内部为每个组件实例维护一条单链表(Fiber.memoizedState),链表节点按 Hook 被调用的顺序依次串起来:

组件首次渲染时调用顺序:
  useState('A')   →  节点①  { state: 'A' }
  useState(0)     →  节点②  { state: 0 }
  useEffect(...)  →  节点③  { deps: [...] }

形成链表:① → ② → ③
  • 下次渲染时,React 重新执行函数组件,按调用顺序逐个对齐链表节点:
    • 第 1 个 Hook 调用 → 取节点①的 state
    • 第 2 个 Hook 调用 → 取节点②的 state
    • 第 3 个 Hook 调用 → 取节点③的 state

简化心智模型:React 内部维护一个游标 currentHookIndex,每次进入组件渲染时重置为 0,每调用一次 Hook 游标 +1,去 hooks 数组的对应下标读写状态。

2.2 条件/循环调用为什么会"错位"

const Demo = ({ showName }: { showName: boolean }) => {
  if (showName) {
    const [name, setName] = useState('Tom'); // ❌ 条件内
  }
  const [age, setAge] = useState(18);
  return null;
};
渲染次数条件实际调用顺序React 链表对齐
第 1 次showName=trueuseState('Tom')useState(18)①='Tom' ②=18
第 2 次showName=falseuseState(18)(只剩这一个)①=18(被错认成 name!)

age 的状态读到了 name 的槽位,数据彻底乱套,且 React 无法察觉——这就是为什么必须顶层、无分支地调用:保证每次渲染调用顺序、数量完全一致,链表才能稳定对齐。

2.3 一个直观的简化实现

伪代码还原 React 内部机制:

let hooks: any[] = [];
let cursor = 0;

const useState = <T>(initial: T): [T, (v: T) => void] => {
  const i = cursor;
  hooks[i] = hooks[i] ?? initial;     // 按下标取/存
  const setState = (v: T) => {
    hooks[i] = v;
    rerender();
  };
  cursor += 1;                         // 游标前进
  return [hooks[i], setState];
};

const renderComponent = (Comp: () => JSX.Element) => {
  cursor = 0;                          // 每次渲染重置
  return Comp();
};

可以看到:下标 i 完全依赖调用顺序。一旦某次渲染少调用一个 Hook,后续所有 Hook 的下标都会左移一位 → 状态全部错位。


3. 为什么必须"在 React 函数组件 / 自定义 Hook 中调用"——调用场所限制

3.1 React 怎么知道"当前是哪个组件在调 Hook"

每个 Hook 内部最终调用形如:

const dispatcher = ReactCurrentDispatcher.current;
return dispatcher.useState(initial);

ReactCurrentDispatcher.current 是一个全局可变指针,由 React 在进入函数组件渲染前设置成"渲染期 dispatcher",渲染结束后再切回"非法 dispatcher"(调用即报错)。

        ┌──────────────────────────────────────────┐
渲染前  │ dispatcher = InvalidDispatcher(抛错)    │
        └──────────────────────────────────────────┘
                          ↓ 进入 FiberNode 渲染
        ┌──────────────────────────────────────────┐
渲染中  │ dispatcher = HooksDispatcherOnMount/Update│  ← 此时调 Hook 才合法
        └──────────────────────────────────────────┘
                          ↓ 渲染结束
        ┌──────────────────────────────────────────┐
渲染后  │ dispatcher = InvalidDispatcher(抛错)    │
        └──────────────────────────────────────────┘

3.2 哪些场所合法 / 不合法

场所合法?原因
函数组件函数体顶层渲染期 dispatcher 已挂载 + Fiber 上下文存在
自定义 Hook(useXxx函数体顶层由调用它的组件继承 dispatcher 与 Fiber
类组件的 render / 生命周期类组件走另一套 instance 机制,没有 hooks 链表
普通工具函数(非 useXxx 命名)调用时机不可控、ESLint 也无法静态校验
事件回调(onClick={() => useState(...)}渲染已结束,dispatcher 已被切回非法值 → 直接抛错
setTimeout / Promise.then 异步回调同上,渲染上下文早已退出
服务端工具函数、Node 脚本根本没有 React 渲染流程

报错信息典型为:

Invalid hook call. Hooks can only be called inside of the body of a function component.

3.3 为什么自定义 Hook 必须以 use 开头

并非运行时强制,而是ESLint 插件 eslint-plugin-react-hooks 的静态识别约定

  • use 开头 → ESLint 把它视为 Hook,应用所有 Hook 规则进行检查。
  • 不以 use 开头 → ESLint 视作普通函数,内部如调用 Hook 不会被警告,运行时才会出错。

所以这是「让静态检查工具帮你兜底」的命名约定,建议严格遵守


4. 完整规则总结表

维度规则底层原因
顺序顶层调用,不在循环/条件/嵌套函数中React 用调用顺序作为 Hook 状态链表的索引;顺序变了 → 槽位错位
场所仅在函数组件 / 自定义 Hook 顶层调用React 仅在渲染函数组件期间挂载合法 dispatcher;其他时刻调用会抛 Invalid hook call
命名自定义 Hook 必须 useXxxESLint 据此静态校验 Hook 规则,避免运行时才发现
早返回early return 之后不能再调 Hook早返回等价于条件分支,会导致后续渲染调用数量不一致

5. 三个常见反例 & 修正

反例 1:条件调用

// ❌ 错
const Demo = ({ enabled }: { enabled: boolean }) => {
  if (enabled) {
    const [v, setV] = useState(0);
  }
};

// ✅ 改:把条件放进 Hook 里,或抽到子组件
const Demo = ({ enabled }: { enabled: boolean }) => {
  const [v, setV] = useState(0);
  if (!enabled) return null;
  return <div>{v}</div>;
};

反例 2:循环调用

// ❌ 错
const List = ({ items }: { items: string[] }) => {
  for (const item of items) {
    const [v] = useState(item); // 每次 items 长度不同,状态错位
  }
};

// ✅ 改:把每个 item 抽成子组件
const Item = ({ initial }: { initial: string }) => {
  const [v] = useState(initial);
  return <li>{v}</li>;
};

const List = ({ items }: { items: string[] }) => (
  <ul>{items.map((it) => <Item key={it} initial={it} />)}</ul>
);

反例 3:早返回后调用

// ❌ 错
const Demo = ({ data }: { data: Data | null }) => {
  if (!data) return null;
  const [v, setV] = useState(0); // 早返回路径上不会执行 → 槽位错位
};

// ✅ 改:所有 Hook 调用先放在顶层
const Demo = ({ data }: { data: Data | null }) => {
  const [v, setV] = useState(0);
  if (!data) return null;
  return <div>{v}</div>;
};

一句话总结

顶层调用 是因为 React 用调用顺序而非变量名识别状态槽;调用场所限制 是因为 Hook 依赖 React 在渲染期临时挂载的全局 dispatcher——离开函数组件 / 自定义 Hook 的渲染流程,dispatcher 不可用,必然报错。

八、案例展示 - B站评论 - 普通版

  • 效果展示: 111.gif
  • 功能需求:
    • 渲染评论列表;
    • 实现删除评论;
      • 只有自己的评论才显示删除按钮;
      • 点击删除按钮,删除当前评论,列表中不再显示;
    • 渲染导航Tab和高亮实现;
    • 评论列表排序功能实现;
      • 最新:评论列表按照创建时间排序(新的在前);
      • 最热:点赞数排序(点赞数多的在前);
    • 实现评论功能;
      • 点击发布之后,需要清空输入框的内容,并且自动获取焦点;
      • 回车也可以发送评论;
  • 核心思路:
    • 使用 useState 维护评论列表;
    • 使用 map 对列表进行遍历渲染(一定要添加key);
  • 代码展示:
import { useRef, useState } from 'react';
// 需要安装 lodash
import _ from 'lodash';
// 导入所需样式 - 放在本文的末尾
import './bilibili-review.scss';
// 大家自己在本地虽败找一张图片
import avatar from './images/bozai.png';

// 评论列表数据
const defaultList = [
  {
    // 评论id
    rpid: 3,
    // 用户信息
    user: {
      uid: '13258165',
      avatar: 'https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/comment/zhoujielun.jpeg',
      uname: '周杰伦'
    },
    // 评论内容
    content: '哎哟,不错哦',
    // 评论时间
    ctime: '10-18 08:15',
    // 喜欢数量
    like: 98,
    // 0:未表态 1: 喜欢 2: 不喜欢
    action: 0
  },
  {
    rpid: 2,
    user: {
      uid: '36080105',
      avatar: 'https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/comment/xusong.jpeg',
      uname: '许嵩'
    },
    content: '我寻你千百度 日出到迟暮',
    ctime: '11-13 11:29',
    like: 88,
    action: 2
  },
  {
    rpid: 1,
    user: {
      uid: '30009257',
      avatar,
      uname: '黑马前端'
    },
    content: '学前端就来黑马',
    ctime: '10-19 09:00',
    like: 66,
    action: 1
  }
];

// 当前登录用户信息
const user = {
  // 用户id
  uid: '30009257',
  // 用户头像
  avatar,
  // 用户昵称
  uname: '黑马前端'
};

// 头部Tab配置项
const tabOptions = [
  { type: 'latest', text: '最新' },
  { type: 'hottest', text: '最热' }
];

const App = () => {
  // 评论列表数据
  const newList = _.orderBy(defaultList, ['ctime'], ['desc']);
  const [list, setList] = useState(newList);
  // 记录活跃的Tab状态
  const [activeTab, setActiveTab] = useState('latest');
  /** 更新评论列表 - 删除 + 排序 */
  const updateList = (type, id) => {
    let newList = [];
    if (type && !['del'].includes(type)) setActiveTab(type);
    switch (type) {
      case 'del':
        newList = list.filter((item) => item.rpid !== id);
        break;
      case 'latest':
        newList = _.orderBy(list, ['ctime'], ['desc']);
        break;
      case 'hottest':
        newList = _.orderBy(list, ['like'], ['desc']);
        break;
      default:
        newList = list;
        break;
    }
    setList(newList);
  };
  /** 保存输入框内容 */
  const [value, setValue] = useState('');
  /** 输入框ref */
  const inputRef = useRef(null);
  /** 发布评论 */
  const addReview = (e) => {
    if (e.key === 'Enter') e.preventDefault();
    if (!value || (e.type === 'keydown' && e.key !== 'Enter')) return;
    const item = {
      rpid: new Date().getTime(),
      user,
      content: value,
      ctime: new Date().toLocaleDateString(),
      like: 0,
      action: 0
    };
    setList([item, ...list]);
    setValue('');
    inputRef.current.focus();
  };

  return (
    <div className="app">
      {/* 头部 */}
      <div className="header align-center">
        <div className="title align-center">
          评论<em>{list.length}</em>
        </div>
        {/* tab栏 */}
        <ul className="tab align-center">
          {tabOptions.map((item, index) => {
            return (
              // className={['item', 'align-center', activeTab === item.type ? 'active' : null].join(' ')}
              // className={['item', 'align-center', activeTab === item.type && 'active'].join(' ')}
              // className={`item align-center ${activeTab === item.type ? 'active' : null}`}
              // className={`item align-center ${activeTab === item.type && 'active'}`}
              <li
                className={`item align-center ${activeTab === item.type && 'active'}`}
                onClick={() => updateList(item.type)}
                key={item.type}>
                <span>{item.text}</span>
                {tabOptions.length - 1 !== index && <span className="split-line"></span>}
              </li>
            );
          })}
        </ul>
      </div>

      <div className="review-box">
        {/* 评论框 */}
        <div className="post-review align-center">
          <img src={user.avatar} alt={user.uname} className="avatar" />
          <div className="input align-center">
            <textarea
              type="text"
              ref={inputRef}
              value={value}
              onChange={(e) => setValue(e.target.value)}
              onKeyDown={addReview}
              placeholder="发一条友善的评论"
            />
            <button onClick={addReview}>发布</button>
          </div>
        </div>

        {/* 评论列表 */}
        <div className="review-list">
          {list.map(({ user: { avatar, uname, uid }, content, ctime, like, rpid }) => {
            return (
              <div className="review-item" key={rpid}>
                <div className="left">
                  <img src={avatar} alt={uname} />
                </div>
                <div className="right">
                  <div className="user-name">{uname}</div>
                  <div className="content">{content}</div>
                  <div className="bottom">
                    <div className="time">{ctime}</div>
                    <div className="like-num">点赞数:{like}</div>
                    <ul className="controls">
                      {/* 只有自己的评论才展示删除按钮 */}
                      {uid === user.uid && (
                        <li className="del" onClick={() => updateList('del', rpid)}>
                          删除
                        </li>
                      )}
                    </ul>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default App;
  • bilibili-review.scss
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    body {
      background-color: #c9ccd0;
    }
    
    li {
      list-style: none;
    }
    
    em {
      font-style: normal;
    }
    
    .align-center {
      display: flex;
      align-items: center;
    }
    
    .app {
      padding: 100px;
      background-color: #fff;
    
      .header {
        justify-content: flex-start;
        height: 24px;
    
        .title {
          font-weight: bold;
          font-size: 18px;
          color: #333;
    
          em {
            margin-left: 10px;
            color: #666;
            font-size: 12px;
            font-weight: 400;
          }
        }
    
        .tab {
          justify-content: flex-start;
          height: 100%;
          margin-left: 30px;
          color: #666;
          font-size: 12px;
    
          li.item {
            cursor: pointer;
    
            &:hover {
              color: #000;
            }
    
            .split-line {
              height: 11px;
              margin: 0 12px;
              border-right: 1px solid #9499a0;
            }
          }
    
          .active {
            color: #00aeec;
          }
        }
      }
    
      .review-box {
        width: 100%;
        padding-left: 20px;
    
        .post-review {
          align-items: flex-start;
          width: 100%;
          height: 70px;
          margin-top: 16px;
    
          img {
            width: 40px;
            height: 40px;
            border-radius: 50%;
            margin-right: 16px;
          }
    
          .input {
            width: 100%;
            align-items: flex-start;
    
            textarea {
              width: calc(100% - 100px - 10px) !important;
              height: 50px;
              margin-right: 10px;
              padding-left: 10px;
              border: 1px solid #f1f2f3;
              background-color: #f1f2f3;
              border-radius: 6px;
              font-size: 16px;
              line-height: 50px;
              outline: none;
              resize: none;
              transition: height 0.2s;
              appearance: none;
              -webkit-appearance: none;
    
              &:hover,
              &:focus {
                border-color: #c9ccd0;
                background-color: #fff;
              }
    
              &:focus {
                height: 70px;
              }
    
              &::placeholder {
                font-size: 12px;
              }
    
              &::-webkit-scrollbar {
                display: none;
              }
            }
    
            button {
              width: 100px;
              height: 50px;
              background-color: #00aeec;
              border: none;
              border-radius: 6px;
              color: #fff;
              font-size: 15px;
              cursor: pointer;
              opacity: 0.5;
    
              &:hover {
                opacity: 1;
              }
            }
          }
        }
    
        .review-list {
          .review-item {
            display: flex;
            align-items: flex-start;
            width: 100%;
            margin-top: 20px;
    
            .left {
              width: 56px;
              height: 100%;
    
              img {
                width: 40px;
                height: 40px;
                border-radius: 50%;
                cursor: pointer;
              }
            }
    
            .right {
              width: calc(100% - 56px);
              height: 100%;
              padding-bottom: 20px;
              border-bottom: 2px solid #eee;
    
              .user-name {
                color: #61666d;
                font-size: 13px;
                cursor: pointer;
              }
    
              .content {
                margin: 16px 0 10px;
                font-size: 15px;
              }
    
              .bottom {
                display: flex;
                align-items: center;
                justify-content: flex-start;
                color: #9499a0;
                font-size: 13px;
    
                .like-num {
                  margin: 0 20px;
                }
    
                .controls {
                  li {
                    cursor: pointer;
                  }
    
                  .del:hover {
                    color: #000;
                  }
                }
              }
            }
          }
        }
      }
    }