前言
什么是组件?
- 组件是一个广泛的概念,现在流行的框架中都有组件;
- 一个组件就是用户界面的一部分,它可以有自己 的 逻辑 和 外观,组件之间 可以 相互嵌套,也可以 复用 多次;
一、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;
2.2 语法
- ❗ 注意:
- 使用之前需要先导入;
- React Hooks 必须在 React函数组件 或 自定义Hook函数 的最顶层 调用;
import { useState } from 'react';
const [状态变量, 修改状态变量的函数] = useState(初始值);
// const [状态变量, set状态变量] = useState(初始值);
- 作用:
- 向组件添加一个状态变量;
- 语法:
- 在 组件顶层 调用
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 中,状态 被认为是 只读 的,我们应该始终 替换 而不是 修改 它,直接修改 状态 不能引起视图更新;
- ❗ 注意:
- 直接修改 状态变量 的 值,状态变量 能被 修改,但是 不会引起 视图 的 更新;
- 既要 修改状态 变量的值,还想要 视图同时更新,只能通过
useState的set函数去修改状态变量的值;
- 代码展示:
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; - 演示效果:
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; - 演示效果:
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类名控制(单独写样式,导入到组件文件中);
- ❌ 行内样式(极不推荐);
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 等全局污染问题。生产项目中应根据场景选择合适的样式方案:
| 方案 | 代表 | 作用域 | 主要优点 | 主要弊端 |
|---|---|---|---|---|
| 全局 CSS | import './index.css' | ❌ 全局 | 简单直接、零配置 | 命名冲突、样式互相覆盖、复用差、难维护 |
| CSS Modules | xxx.module.css | ✅ 文件级 | 自动哈希类名、零运行时、与原生 CSS 一致 | 跨组件共享变量需借助 :global / CSS 变量 |
| CSS-in-JS | styled-components、emotion | ✅ 组件级 | 动态样式、props 驱动、TS 友好 | 运行时开销、SSR 配置较重、调试样式不直观 |
| 原子化 CSS | TailwindCSS、UnoCSS | ✅ 类名级 | 无命名负担、HTML 即样式、构建产物小、设计一致性强 | 长 className 可读性差、需团队约定抽离规范 |
| Zero-runtime CSS-in-JS | vanilla-extract、Linaria | ✅ 文件级 | 类型安全 + 零运行时 | 生态较新、学习成本 |
- 全局 CSS 的弊端示例:
/* a.css */
.title { color: red; }
/* b.css,另一个组件作者写 */
.title { color: blue; } /* 不知不觉覆盖了 a.css */
-
打包后两份样式合并到同一份 stylesheet,后加载者覆盖前者,且 IDE/编译器无任何告警。
-
推荐选型:
- 小型 demo / 一次性页面 → 全局 CSS 也无妨。
- 中大型 React 项目 → CSS Modules 或 TailwindCSS。
- 强动态样式(主题切换、props 驱动颜色)→ CSS-in-JS 或 CSS 变量 + Tailwind
arbitrary value。 - 极致性能 + 类型安全 → vanilla-extract。
一句话:任何 ≥ 3 人协作的 React 项目都不应再以全局
.css作为主样式方案,至少应使用 CSS Modules 或原子化 CSS 隔离作用域,避免不可见的样式污染。
四、受控表单组件绑定
4.1 概念
- 使用 React 组件状态的状态(
useState)控制表单的状态;
4.2 使用步骤
- 准备一个React状态值
- 使用
useState声明状态;
const [value, setValue] = useState(''); - 使用
- 通过
value属性绑定状态,通过onChange事件绑定状态同步的函数,通过事件对象e拿到输入框最新的值,反向修改react的状态;<input type="text" value={value} onChange={(e) => setValue(e.target.value)}/>
- ❗ 注意:
- 「只设置 value 而没有 onChange」时 React 会发警告并使其只读;二者同设才是标准受控组件。
- 如果该字段应该是可变的,则使用
defaultValue;- 否则,设置
onChange或readOnly;
五、useRef
- 在 React 中 获取 / 操作 DOM,需要使用
useRef钩子函数,分为两步:- 使用
useRef创建ref对象,并于 JSX 绑定;- 在组建内部调用;
const inputRef = useRef(null); <input type="text" ref={inputRef} />
- 使用
- 在DOM可用时,通过
ref名称.current拿到DOM对象;console.log(inputRef.current);
- ❗ 什么是DOM可用?
- 组件渲染完毕之后才可用的;
- 渲染之前获取DOM得到的是
null;
- useRef 除了用来获取DOM之外,还有以下用途:
- 保存任意可变值且不触发重渲染(定时器ID、上一次值、首次渲染标记等)
六、React 中 ref 与 state 的对比
1. 一句话总结
state | ref | |
|---|---|---|
| 本质 | 触发渲染的"快照数据" | 不触发渲染的"可变盒子" |
| 更新方式 | setState / setXxx | 直接赋值 ref.current = ... |
| 变化是否引起 re-render | ✅ 是 | ❌ 否 |
| 读到的值 | 当前渲染时的快照(可能旧) | 永远是最新值 |
| 典型 API | useState、this.state | useRef、createRef |
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:
-
访问 DOM 元素
const inputRef = useRef<HTMLInputElement>(null); const handleFocus = () => inputRef.current?.focus(); return <input ref={inputRef} aria-label="输入框" />; -
保存"实例变量"——跨渲染保持但不影响渲染
const timerRef = useRef<NodeJS.Timeout | null>(null); const prevValueRef = useRef<string>(''); -
解决闭包陷阱(在 effect/callback 里读最新值)
const latestPropsRef = useRef(props); useEffect(() => { latestPropsRef.current = props; }); useEffect(() => { const id = setInterval(() => { // 永远是最新 props,不会因为闭包而拿到旧值 console.log(latestPropsRef.current); }, 1000); return () => clearInterval(id); }, []); -
暴露子组件方法(配合
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 的两条核心规则(是什么)
- 只在最顶层调用 Hook:不要在循环、条件、嵌套函数中调用。
- 只在 React 函数中调用 Hook:只能在函数组件或自定义 Hook中调用,不能在普通 JS 函数、类组件、事件回调中调用。
2. 为什么必须"顶层调用"——React 靠调用顺序识别状态槽
2.1 React 内部如何存储 Hook 状态
-
React 不会用变量名来识别
useState、useEffect,因为函数组件每次渲染都是一次普通函数调用,闭包里的变量名对 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=true | useState('Tom')、useState(18) | ①='Tom' ②=18 |
| 第 2 次 | showName=false | useState(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 必须 useXxx | ESLint 据此静态校验 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站评论 - 普通版
- 效果展示:
- 功能需求:
- 渲染评论列表;
- 实现删除评论;
- 只有自己的评论才显示删除按钮;
- 点击删除按钮,删除当前评论,列表中不再显示;
- 渲染导航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; } } } } } } } }