React(八):列表渲染 —— 从数组到 UI 的"魔法映射"
目录
第一部分:定义 —— 什么是列表渲染?
1.1 前端开发中无处不在的列表
打开任何一个网页或 App,你十秒内就能看到列表的身影:
| 场景 | 列表的表现形式 |
|---|---|
| 淘宝 / 京东 | 商品搜索结果的卡片列表 |
| 微信 / QQ | 聊天联系人列表 |
| 微博 / 抖音 | 信息流中的帖子/视频列表 |
| GitHub | 仓库文件列表、Issue 列表 |
| VSCode | 左侧文件目录树 |
| 音乐播放器 | 歌单中的歌曲列表 |
可以毫不夸张地说:前端开发 70% 的工作,本质上就是在和各种"列表"打交道。
一句话定义:列表渲染,就是把一份"数据清单"(数组)转化成屏幕上一条条"可见的内容"(UI 元素)的过程。
1.2 React 列表渲染的核心本质
在 React 中,列表渲染的核心武器是 JavaScript 原生的 Array.map() 方法。
如果你学过 JavaScript,应该知道 map() 的作用:遍历数组的每一项,经过一个"转换函数",返回一个全新的数组。
// 原生 JS 中 map 的经典用法:把数字数组变成两倍数组
const nums = [1, 2, 3];
const doubled = nums.map((n) => n * 2);
// doubled = [2, 4, 6]
React 列表渲染的魔法在于:把 map 的"转换函数"里的返回值,从普通值换成 JSX 元素。
// React 中:把字符串数组变成 <li> 元素数组
const names = ["张三", "李四", "王五"];
const listItems = names.map((name) => <li>{name}</li>);
// listItems = [<li>张三</li>, <li>李四</li>, <li>王五</li>]
用表格来直观对比:
| 环节 | 普通 JS 的 map | React 列表渲染的 map |
|---|---|---|
| 输入 | 数据数组 [1, 2, 3] |
数据数组 ["张三", "李四", "王五"] |
| 转换函数 | (n) => n * 2 |
(name) => <li>{name}</li> |
| 输出 | 新数据数组 [2, 4, 6] |
JSX 元素数组 [<li>...</li>, ...] |
| 最终效果 | 拿到处理后的数据 | React 自动渲染成一列 <li> 到页面 |
1.3 原生 JS vs React:两种截然不同的思路
为了让你更深刻地理解 React 的优势,我们先看看不用 React 是怎么做列表渲染的。
原生 JS 方式:手动操作 DOM
<!-- HTML:先准备一个空容器 -->
<ul id="name-list"></ul>
<script>
// 数据
const names = ["张三", "李四", "王五"];
// 步骤 1:找到容器
const ul = document.getElementById("name-list");
// 步骤 2:遍历数据,逐条创建 DOM 节点
for (let i = 0; i < names.length; i++) {
// 步骤 3:手动创建 <li>
const li = document.createElement("li");
// 步骤 4:手动设置文字内容
li.textContent = names[i];
// 步骤 5:手动插入到 DOM 中
ul.appendChild(li);
}
// 如果数据变了怎么办?比如新增一个 "赵六":
// 你得再写一套逻辑:找到变化的位置,插入新节点...
// 如果删除了 "李四":
// 你还得找到对应节点,removeChild...
// 数据一复杂,代码就变成"意大利面条"
</script>
原生 JS 方式的痛点:
- 你需要亲自指挥每一步 DOM 操作:创建节点、设置内容、插入位置
- 数据变化时,你需要手动追踪哪些 DOM 需要更新、删除、新增
- 一旦列表复杂,代码就像走迷宫——维护起来苦不堪言
React 方式:数据驱动视图
function NameList() {
// 数据
const names = ["张三", "李四", "王五"];
return (
<ul>
{/* 一行代码搞定! */}
{names.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
);
}
React 方式的哲学:
你只需要告诉 React:数据长什么样 → UI 应该长什么样
React 自己搞定:数据变了 → 自动算出哪里需要更新 DOM → 精确更新
这不是"手动操作 DOM"
而是"声明 UI 长什么样 —— React 帮你去实现"
| 维度 | 原生 JS(命令式) | React(声明式) |
|---|---|---|
| 你关心什么 | 每一步怎么操作 DOM(创建、插入、删除) | 数据是什么,UI 应该是什么样 |
| 数据变化后 | 你手动更新 DOM | React 自动对比差异、自动更新 DOM |
| 代码量 | 随复杂度暴涨 | 始终简洁 |
| 出 BUG 概率 | 高(容易漏更新、更新错位置) | 低(框架保证一致性) |
| 心智负担 | 需要在大脑中模拟 DOM 的当前状态 | 只需要思考数据和 UI 的对应关系 |
一句话总结:React 的列表渲染 = 用 JavaScript 的
map()把数据"翻译"成 UI。你只管数据和 UI 的映射规则,DOM 的增删改全部交给 React。
1.4 React 19 底层适配:Fiber 与并发渲染
本课程不要求你深入理解底层原理,但建立基础认知有助于后续学习。
React 19 有两个重要的底层升级,与列表渲染直接相关:
1. Fiber 架构
React 16 开始引入的 Fiber 架构,把渲染工作拆成一个个可中断的"小任务"。列表渲染天然受益于这一点:
- 一个包含 1000 条数据的列表,React 不会一次性渲染完所有 DOM
- 它会拆成多个小批次,边渲染边"休息",把控制权交还给浏览器
- 这样即使列表很大,页面也不会卡顿,用户点击、滚动依旧流畅
2. 并发渲染(Concurrent Rendering)
React 19 正式启用的并发渲染,让 React 可以在渲染列表的"间隙"中,优先处理用户交互(如点击、输入):
传统渲染: [===== 渲染 1000 条列表 =====] → [响应用户点击]
↑ 这期间页面卡住,用户点了没反应
并发渲染: [渲染50条] [用户点击!优先!] [渲染50条] [渲染50条] ...
↑ React 可以随时"暂停"渲染,先去响应用户操作
你只需要记住:React 19 里渲染大列表更流畅了,不需要你做额外配置,框架底层已经优化好了。
1.5 一张图理解列表渲染流程
用文字描述这个流程图,你可以自己在纸上或白板上画出来:
┌─────────────────────────────────────────────────────────┐
│ 列表渲染流程 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ 数组数据 │ ───→ │ Array.map() │ ───→ │ JSX 元素 │ │
│ │ │ │ 转换函数 │ │ 数组 │ │
│ │[ │ │ │ │[ │ │
│ │ {id:1, │ │ item => │ │ <li key=1 │ │
│ │ name:"…"}│ │ <li key= │ │ >张三 │ │
│ │ {id:2, │ │ {item.id}> │ │ </li>, │ │
│ │ name:"…"}│ │ {item.name} │ │ <li key=2 │ │
│ │] │ │ </li> │ │ >李四 │ │
│ └──────────┘ └──────────────┘ │ </li> │ │
│ └─────┬─────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ React 渲染引擎 │ │
│ │ │ │
│ │ key 比对 → 判断 │ │
│ │ 增/删/改 → 精确 │ │
│ │ 更新真实 DOM │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 页面可见的列表 │ │
│ │ · 张三 │ │
│ │ · 李四 │ │
│ │ · 王五 │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
流程四步走:
- 数组数据:你的数据源,可以来自变量、props、state 或 API 请求
- map 映射:用
Array.map()把每条数据"翻译"成一个 JSX 元素,并配上唯一的key属性 - React 渲染引擎:React 拿到 JSX 元素数组后,通过 key 对比新旧列表,精确判断哪些项需要新增、删除、更新
- 页面可见列表:最终呈现在浏览器中的真实 DOM 列表
第二部分:使用 —— 从基础到进阶
2.1 基础用法:Array.map() 配合 JSX
示例一:渲染一个简单的无序列表
function FruitList() {
// 1. 准备数据:一个水果名称的数组
const fruits = ["苹果", "香蕉", "橘子", "葡萄"];
return (
<div>
<h2>水果清单</h2>
<ul>
{/* 2. 用 map 把每个水果名映射成一个 <li> 元素 */}
{fruits.map((fruit) => (
// 3. key 先简单用 fruit 本身(仅当数据不重复时可用)
<li key={fruit}>{fruit}</li>
))}
</ul>
</div>
);
}
逐行讲解:
| 行 | 代码 | 解释 |
|---|---|---|
| 1 | const fruits = [...] |
定义数据源,一个包含 4 个字符串的数组 |
| 2 | fruits.map(...) |
调用 map 方法遍历数组每一项 |
| 3 | (fruit) => (...) |
fruit 是当前遍历到的元素(第一次是"苹果",第二次是"香蕉"...) |
| 4 | <li key={fruit}>{fruit}</li> |
返回一个 JSX 的 <li> 元素,内容就是当前水果名 |
渲染结果(浏览器中看到的):
<ul>
<li>苹果</li>
<li>香蕉</li>
<li>橘子</li>
<li>葡萄</li>
</ul>
示例二:渲染表格行
function ScoreTable() {
// 准备数据:一个包含多行数据的二维结构
const scores = [
{ subject: "语文", score: 92 },
{ subject: "数学", score: 88 },
{ subject: "英语", score: 95 },
];
return (
<table border="1" cellPadding="8">
<thead>
<tr>
<th>科目</th>
<th>成绩</th>
</tr>
</thead>
<tbody>
{/* 遍历 scores 数组,每条数据生成一行 <tr> */}
{scores.map((item, index) => (
<tr key={item.subject}>
<td>{item.subject}</td>
<td>{item.score}</td>
</tr>
))}
</tbody>
</table>
);
}
map 回调函数的两个参数:
array.map((当前元素, 当前索引) => {
// 当前元素:本次遍历到的数组项
// 当前索引:从 0 开始的位置编号
});
2.2 核心依赖:key 属性
key 是什么?
key 是 React 列表渲染中一个特殊的字符串属性,写在每个由 map 返回的最外层 JSX 元素上。它就像每个人的"身份证号"——帮助 React 区分列表中的不同项。
key 的作用
场景:列表中有三个 <li>:张三、李四、王五
没有 key 时:
React 分不清谁是谁,只能靠位置猜。
如果删掉"李四",React 可能把"王五"的内容更新到"李四"的位置,
而不是简单地移除中间那个 DOM 节点。这既慢又容易出 BUG。
有 key 时:
张三(key=1)、李四(key=2)、王五(key=3)
删除李四后,React 一看:key=2 没了!精确移除 key=2 对应的 DOM。
其他节点完全不受影响。又快又稳。
一句话:key 帮 React 给列表项"贴标签",让 React 在数据变化时能精确识别谁是谁,从而实现高效的 DOM 更新。
基础写法
// ✅ 如果有唯一 id,用 id 作为 key(最推荐)
const users = [
{ id: 101, name: "张三" },
{ id: 102, name: "李四" },
{ id: 103, name: "王五" },
];
function UserList() {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li> // key 用每条数据的唯一 id
))}
</ul>
);
}
key 写在哪个位置?
// ✅ 正确:key 写在 map 返回的最外层元素上
users.map((user) => <li key={user.id}>{user.name}</li>);
// ❌ 错误:key 写在 <li> 内部的子元素上
users.map((user) => (
<li>
<span key={user.id}>{user.name}</span> {/* 不应该写在这里 */}
</li>
));
// ❌ 错误:key 写在自定义组件上,但组件内部没有接收 key
users.map((user) => <UserItem key={user.id} user={user} />);
// 需要确保 UserItem 组件内部把 key 绑定在最外层 JSX 上
规则:key 必须写在
map()回调函数中直接返回的那个最外层元素上。
2.3 复杂列表渲染:对象数组
实际开发中,列表数据几乎都是对象数组。下面展示一个完整的商品列表示例。
function ProductList() {
// 数据:每个商品有多个字段
const products = [
{
id: 1,
name: "React 入门实战",
price: 59.9,
category: "编程",
inStock: true,
},
{
id: 2,
name: "TypeScript 高级指南",
price: 79.9,
category: "编程",
inStock: false,
},
{
id: 3,
name: "CSS 艺术设计",
price: 49.9,
category: "设计",
inStock: true,
},
];
return (
<div>
<h2>商品列表</h2>
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
{products.map((product) => (
// 每件商品渲染成一张卡片
<div
key={product.id}
style={{
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
width: 220,
}}
>
{/* 提取对象的多个字段 */}
<h3>{product.name}</h3>
<p>
价格:
<span
style={{ color: "#e74c3c", fontSize: 20, fontWeight: "bold" }}
>
¥{product.price}
</span>
</p>
<p>分类:{product.category}</p>
<p>
状态:
<span style={{ color: product.inStock ? "green" : "red" }}>
{product.inStock ? "有货" : "缺货"}
</span>
</p>
</div>
))}
</div>
</div>
);
}
这个示例展示了三个关键能力:
- 多字段提取:从每个对象中取出
name、price、category、inStock,组合成丰富的 UI - 条件样式:根据
inStock的值动态改变文字颜色(有货绿色、缺货红色) - 复杂布局:每项不只是简单的
<li>,而是包含多个标签的卡片结构
2.4 列表项绑定交互事件
列表不光是"看"的,还有"操作"的。最常见的需求:点击某一行、删除某一项。
示例:可删除的学生名单
import { useState } from "react";
function StudentManager() {
// 用 useState 管理列表数据
const [students, setStudents] = useState([
{ id: 1, name: "张三", grade: "大一" },
{ id: 2, name: "李四", grade: "大二" },
{ id: 3, name: "王五", grade: "大三" },
]);
// 删除学生:根据 id 过滤掉对应项
const handleDelete = (id) => {
setStudents(students.filter((student) => student.id !== id));
};
// 查看学生详情(演示点击事件)
const handleView = (student) => {
alert(`姓名:${student.name}\n年级:${student.grade}`);
};
return (
<div>
<h2>学生名单</h2>
<table border="1" cellPadding="8">
<thead>
<tr>
<th>姓名</th>
<th>年级</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{students.map((student) => (
<tr key={student.id}>
<td>{student.name}</td>
<td>{student.grade}</td>
<td>
{/* 查看按钮:传递整个 student 对象 */}
<button onClick={() => handleView(student)}>查看</button>
{/* 删除按钮:传递 student.id */}
<button onClick={() => handleDelete(student.id)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
事件绑定中的关键技巧
// ❌ 错误:直接调用函数,会在渲染时就执行!
<button onClick={handleDelete(student.id)}>删除</button>
// ✅ 正确:用箭头函数包裹,点击时才执行
<button onClick={() => handleDelete(student.id)}>删除</button>
为什么需要箭头函数包裹?
onClick={handleDelete(student.id)}→ 渲染时立即调用handleDelete,把返回值(undefined)赋给onClick,按钮点击没反应onClick={() => handleDelete(student.id)}→ 渲染时不调用,只是把箭头函数赋给onClick,按钮点击时箭头函数执行,进而调用handleDelete
关于 this 的特别说明
在函数组件中,你不需要关心 this!函数组件没有 this,直接使用变量和函数即可。上面的示例就是标准的函数组件写法,事件处理函数通过闭包自然访问组件内部的变量。
// 函数组件:没有 this,直接用变量
function MyComponent() {
const handleClick = () => {
// 直接使用 count,不需要 this.count
console.log(count);
};
}
// 类组件(旧写法):需要注意 this 绑定
class MyComponent extends React.Component {
handleClick() {
console.log(this.state.count); // 需要 bind 或箭头函数
}
}
好消息:本课程全部使用函数组件,你永远不用担心
this指向问题。
2.5 带条件渲染的列表
场景一:列表为空时显示占位提示
function EmptyListDemo() {
const [items, setItems] = useState([]); // 空数组
return (
<div>
<h2>我的收藏</h2>
{/* 条件渲染:如果数组为空,显示提示 */}
{items.length === 0 ? (
<p style={{ color: "#999" }}>还没有收藏任何内容,快去逛逛吧~</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)}
</div>
);
}
场景二:部分列表项隐藏
function FilteredList() {
const [showAll, setShowAll] = useState(true);
const todos = [
{ id: 1, text: "学习 React", done: true },
{ id: 2, text: "写作业", done: false },
{ id: 3, text: "跑步", done: true },
];
// 根据 showAll 决定显示全部还是只显示未完成的
const filteredTodos = showAll ? todos : todos.filter((t) => !t.done);
return (
<div>
<h2>任务列表</h2>
{/* 切换按钮 */}
<button onClick={() => setShowAll(!showAll)}>
{showAll ? "只显示未完成" : "显示全部"}
</button>
<ul>
{filteredTodos.length === 0 ? (
<li>没有任务</li>
) : (
filteredTodos.map((todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.done ? "line-through" : "none",
color: todo.done ? "#999" : "#333",
}}
>
{todo.text}
</li>
))
)}
</ul>
</div>
);
}
场景三:先过滤再渲染(两种写法等价)
// 写法一:先把过滤后的结果存到变量
const cheapProducts = products.filter((p) => p.price < 50);
return cheapProducts.map((p) => <li key={p.id}>{p.name}</li>);
// 写法二:链式写法
return products
.filter((p) => p.price < 50)
.map((p) => <li key={p.id}>{p.name}</li>);
// 写法三:在 map 内部做判断,不满足条件返回 null(React 不渲染 null)
return products.map((p) =>
p.price < 50 ? <li key={p.id}>{p.name}</li> : null,
);
第三部分:注意事项 —— 六大踩坑指南
3.1 禁止用 index 作为 key
这是 React 列表渲染中排名第一的坑,几乎每个新手都会踩。
错误写法
// ❌ 用数组索引作为 key
{
students.map((student, index) => <li key={index}>{student.name}</li>);
}
为什么不能?—— 实操演示
function IndexKeyBug() {
// 用 index 作为 key 的列表
const [items, setItems] = useState([
{ id: "a", text: "苹果" },
{ id: "b", text: "香蕉" },
{ id: "c", text: "橘子" },
]);
// 每项旁边有一个输入框(用于展示 BUG)
return (
<div>
{/* ❌ 用 index 作为 key */}
{items.map((item, index) => (
<div key={index}>
<span>{item.text}</span>
<input placeholder={`输入关于${item.text}的备注`} />
<button
onClick={() => {
// 删除第一项
setItems(items.slice(1));
}}
>
删除第一项
</button>
</div>
))}
</div>
);
}
操作演示:
初始状态(三行,每行各有一个输入框):
index=0 苹果 [输入框:我在苹果行写了"好吃"]
index=1 香蕉 [输入框:我在香蕉行写了"一般"]
index=2 橘子 [输入框:我在橘子行写了"酸"]
点击"删除第一项"(删除"苹果"),期望结果:
香蕉 [输入框:一般]
橘子 [输入框:酸]
实际结果(使用 index 作为 key):
香蕉 [输入框:好吃] ← 输入框内容还是原来苹果的!
橘子 [输入框:一般] ← 输入框内容还是原来香蕉的!
为什么?
删除前:key=0→苹果+输入框A、key=1→香蕉+输入框B、key=2→橘子+输入框C
删除后:key=0→香蕉、key=1→橘子
React 看到 key=0 还在,认为"这个元素只是内容变了",不会销毁重建
所以【输入框 DOM 节点被复用】,输入框里的文字保留原样
正确写法
// ✅ 用唯一 id 作为 key
{
items.map((item) => (
<div key={item.id}>
{" "}
{/* 用稳定的唯一标识 */}
<span>{item.text}</span>
<input placeholder={`输入关于${item.text}的备注`} />
</div>
));
}
唯一例外: 当列表满足以下所有条件时,可以临时使用 index:
- 列表是静态的,永远不会增、删、排序
- 列表项内部没有"有状态的子组件"(如输入框、复选框)
- 列表项不会被重新排序
实战铁律:只要列表涉及增删改、排序、筛选中的任意一种,就必须用稳定的唯一 id 作为 key。 99% 的真实项目都满足这个条件,所以养成用 id 的习惯就好。
3.2 key 值必须保证同级唯一
错误一:同一层级出现重复 key
// ❌ 同一层级的 key 重复了
const items = [
{ id: 1, name: "A" },
{ id: 1, name: "B" }, // id 重复!
];
items.map((item) => <li key={item.id}>{item.name}</li>);
// React 会在控制台报错:Encountered two children with the same key
不同层级互不影响
// ✅ 不同层级的 key 可以相同(互不影响)
function NestedList() {
const categories = [
{ id: 1, name: "水果" },
{ id: 2, name: "蔬菜" },
];
return (
<div>
{categories.map((cat) => (
// 外层 key = cat.id
<div key={cat.id}>
<h3>{cat.name}</h3>
{/* 内层子列表的 key 和大层无关 */}
<ul>
{[1, 2, 3].map((n) => (
<li key={n}>项目 {n}</li> // 内层 key=1,2,3,和外层的 1,2 不冲突
))}
</ul>
</div>
))}
</div>
);
}
规则:key 的唯一性范围是当前 map 产生的同级兄弟元素之间。不同 map() 产生的列表,key 互不干扰。
3.3 map 函数必须有 return 值
这是新手用箭头函数最常犯的语法错误。
箭头函数的两种写法
// 写法一:不带花括号,隐式返回(推荐用于简单 JSX)
const items = names.map((name) => <li key={name}>{name}</li>);
// ↑ 没有花括号,自动返回
// 写法二:带花括号,需要显式 return(用于包含多行逻辑时)
const items = names.map((name) => {
return <li key={name}>{name}</li>; // ← 必须写 return!
});
常见错误
// ❌ 错误:花括号 + 忘了 return
const items = names.map((name) => {
<li key={name}>{name}</li>; // 没有 return!什么都不会渲染
});
// ❌ 错误(更容易中招):看起来像写了 return,其实是花括号
const items = names.map((name) => {
return;
<li key={name}>{name}</li>; // return 后面自动加了分号,后面的代码不执行
});
// ✅ 正确:要么省花括号,要么写 return
// 方式一:
const items = names.map((name) => <li key={name}>{name}</li>);
// 方式二:
const items = names.map((name) => {
const upper = name.toUpperCase(); // 多行逻辑
return <li key={name}>{upper}</li>; // 显式 return
});
// 方式三:用圆括号,既有多行又不漏 return
const items = names.map((name) => (
<li key={name}>
<span>{name}</span>
</li>
));
// 圆括号 () 内的内容作为整体自动返回
记忆口诀:箭头函数用花括号
{}→ 必须写return;用圆括号()→ 不用写return。JSX 写在一行可以什么都不加。
3.4 列表项必须包裹在根节点中
问题场景
// ❌ 错误:map 返回两个并列的 <li> 元素
{items.map((item) => (
<li key={item.id}>{item.name}</li>
<li key={`${item.id}-desc`}>{item.desc}</li>
// JSX 要求每个表达式只能有一个根元素!
))}
React 会报错:"JSX expressions must have one parent element."
解决方案
// 方案一:用 <Fragment> 或短语法 <>...</>(推荐,不产生额外 DOM)
import { Fragment } from "react";
{
items.map((item) => (
<Fragment key={item.id}>
<li>{item.name}</li>
<li>{item.desc}</li>
</Fragment>
));
}
// 短语法版本(更简洁,但不支持写 key)
{
items.map((item) => (
<>
<li key={`${item.id}-name`}>{item.name}</li>
<li key={`${item.id}-desc`}>{item.desc}</li>
</>
));
}
// 方案二:用 <div> 包裹(会产生额外的 DOM 节点)
{
items.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<span>{item.desc}</span>
</div>
));
}
注意:使用
<>...</>短语法时,如果需要写key,必须用完整的<Fragment key={...}>。
3.5 大数据列表的渲染优化
当列表数据达到千条以上时,直接全量渲染会导致页面卡顿。
识别问题的信号
- 页面加载后有明显延迟才显示出列表
- 滚动列表时感觉不流畅、掉帧
- 输入框有输入延迟
React 19 优化方案:useTransition
useTransition 可以把列表渲染标记为"低优先级",把用户交互标记为"高优先级"。这样输入框打字不会被大列表渲染阻塞。
import { useState, useTransition } from "react";
function SearchList() {
const [keyword, setKeyword] = useState("");
const [filteredData, setFilteredData] = useState([]);
const [isPending, startTransition] = useTransition();
// 生成 10000 条模拟数据
const allData = Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `项目 ${i}`,
}));
const handleSearch = (e) => {
const value = e.target.value;
setKeyword(value); // 输入框立即显示(高优先级)
// 列表过滤标记为低优先级
startTransition(() => {
setFilteredData(allData.filter((item) => item.text.includes(value)));
});
};
return (
<div>
<input
value={keyword}
onChange={handleSearch}
placeholder="搜索(10000 条数据)"
/>
{/* 如果过滤还没完成,显示一个轻量提示 */}
{isPending && <span>过滤中...</span>}
{/* 渲染过滤后的列表 */}
<ul>
{filteredData.slice(0, 100).map(
(
item, // 先只显示前100条
) => (
<li key={item.id}>{item.text}</li>
),
)}
</ul>
{filteredData.length > 100 && (
<p>还有 {filteredData.length - 100} 条结果...</p>
)}
</div>
);
}
进阶方向:虚拟列表(Virtual List)
当列表数据达到万级以上时,即使是 React 19 的优化也不够。这时需要用"虚拟列表"技术。
思路:只渲染可视区域内的列表项。
完整列表(10000条):
┌────────────────────┐
│ item-1 │ ← 可视区域(只渲染这 10~20 条)
│ item-2 │
│ item-3 │
├────────────────────┤
│ item-4 │ ← 虽然数据存在,但不创建 DOM
│ item-5 │
│ ... │
│ item-10000 │
└────────────────────┘
技术本质:动态计算滚动位置,只渲染"看得见"的那批元素。
拓展学习方向:常用的虚拟列表库有
react-window、react-virtuoso、@tanstack/react-virtual。本课程不做深入,有兴趣可课后搜索学习。
3.6 异步加载列表的状态处理
真实项目中,列表数据往往是从后端 API 拉取的。你需要妥善处理三种状态。
完整示例:带三种状态的异步列表
import { useState, useEffect } from "react";
function AsyncUserList() {
const [users, setUsers] = useState([]); // 数据
const [loading, setLoading] = useState(true); // 加载状态
const [error, setError] = useState(null); // 错误信息
useEffect(() => {
// 模拟 API 请求
const fetchUsers = async () => {
try {
setLoading(true);
setError(null);
// 模拟网络请求延迟
await new Promise((resolve) => setTimeout(resolve, 1500));
// 模拟接口返回数据
const response = [
{ id: 1, name: "赵六", email: "zhao@example.com" },
{ id: 2, name: "钱七", email: "qian@example.com" },
{ id: 3, name: "孙八", email: "sun@example.com" },
];
setUsers(response);
} catch (err) {
setError("数据加载失败,请稍后重试");
} finally {
setLoading(false);
}
};
fetchUsers();
}, []); // 空依赖 → 只在组件挂载时请求一次
// ====== 三种状态的渲染 ======
// 状态一:加载中
if (loading) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<p>正在加载用户列表...</p>
{/* 实际项目中这里可以放一个 loading 动画 */}
</div>
);
}
// 状态二:加载失败
if (error) {
return (
<div style={{ textAlign: "center", padding: 40, color: "red" }}>
<p>{error}</p>
<button onClick={() => window.location.reload()}>点击重试</button>
</div>
);
}
// 状态三:加载成功但数据为空
if (users.length === 0) {
return (
<div style={{ textAlign: "center", padding: 40, color: "#999" }}>
<p>暂无用户数据</p>
</div>
);
}
// 正常渲染列表
return (
<div>
<h2>用户列表</h2>
<table border="1" cellPadding="8">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
三种状态对照表
| 状态 | 触发条件 | 用户看到什么 | 常见处理方式 |
|---|---|---|---|
| 加载中 | 正在请求 API | "加载中..." 或 loading 动画 | loading === true 时展示 |
| 加载失败 | 网络异常/接口报错 | 错误提示 + 重试按钮 | 展示错误信息,提供重试入口 |
| 空数据 | 接口返回空数组 | "暂无数据" 占位提示 | 引导用户去创建第一条数据 |
教学提醒:初学者最容易漏掉"加载中"状态——这会导致页面先显示"暂无数据",过一秒才刷出真实数据,体验很差。务必三个状态都覆盖。
第四部分:课堂实操练习
4.1 练习一:静态商品列表
任务要求:
- 准备一个包含 4 个商品的数组,每个商品有:
id、name(商品名)、price(价格)、img(图片链接) - 用
map()渲染成卡片列表,每张卡片展示:商品图片、名称、价格 - 价格超过 50 元的商品,价格文字用红色加粗显示
- 使用商品的
id作为key,不能用index
提示:
- 图片链接可用
https://via.placeholder.com/150占位 - 条件样式参考本文 2.3 节的商品列表示例
4.2 练习二:动态 Todo 列表
任务要求:
- 用
useState管理一个 todo 列表,初始有至少 3 条数据 - 每条 todo 包含:
id、text(任务名)、completed(布尔值,是否完成) - 实现新增任务:顶部的输入框 + 添加按钮(或按 Enter 添加)
- 实现删除任务:每行一个删除按钮
- 实现切换完成状态:每行一个复选框,勾选后文字显示中划线并变灰色
- 底部显示统计:
共 X 项,已完成 Y 项 - 列表为空时显示:"暂无任务,去添加一个吧~"
提示:
- 新增使用
[...prev, newItem] - 删除使用
filter() - 切换使用
map()+ 展开运算符{ ...item, completed: !item.completed } - 统计信息是派生值,不需要 useState
4.3 参考答案
练习一参考代码
function ProductList() {
const products = [
{
id: 1,
name: "React 入门实战",
price: 59.9,
img: "https://via.placeholder.com/150",
},
{
id: 2,
name: "CSS 艺术设计",
price: 39.9,
img: "https://via.placeholder.com/150",
},
{
id: 3,
name: "TypeScript 指南",
price: 79.9,
img: "https://via.placeholder.com/150",
},
{
id: 4,
name: "JavaScript 基础",
price: 29.9,
img: "https://via.placeholder.com/150",
},
];
return (
<div>
<h2>商品列表</h2>
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
{products.map((product) => (
<div
key={product.id} // ✅ 用 id,不用 index
style={{
border: "1px solid #ddd",
borderRadius: 8,
padding: 12,
width: 190,
}}
>
<img
src={product.img}
alt={product.name}
style={{
width: "100%",
height: 150,
objectFit: "cover",
borderRadius: 4,
}}
/>
<h4>{product.name}</h4>
{/* 价格超过 50 红色加粗 */}
<p
style={{
fontWeight: product.price > 50 ? "bold" : "normal",
color: product.price > 50 ? "red" : "#333",
}}
>
¥{product.price}
</p>
</div>
))}
</div>
</div>
);
}
练习二参考代码
import { useState } from "react";
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: "完成 React 列表渲染课程", completed: false },
{ id: 2, text: "完成课后练习", completed: false },
{ id: 3, text: "复习本周知识点", completed: true },
]);
const [inputValue, setInputValue] = useState("");
const [nextId, setNextId] = useState(4);
// 新增
const addTodo = () => {
const text = inputValue.trim();
if (text === "") return;
setTodos([...todos, { id: nextId, text, completed: false }]);
setNextId(nextId + 1);
setInputValue("");
};
// 删除
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
// 切换完成状态
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
};
// 键盘事件
const handleKeyDown = (e) => {
if (e.key === "Enter") addTodo();
};
// 派生统计
const totalCount = todos.length;
const doneCount = todos.filter((t) => t.completed).length;
return (
<div style={{ maxWidth: 500, margin: "0 auto" }}>
<h2>Todo 列表</h2>
{/* 输入区域 */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入新任务,按 Enter 添加"
style={{ flex: 1, padding: 8 }}
/>
<button onClick={addTodo}>添加</button>
</div>
{/* 列表为空 */}
{todos.length === 0 ? (
<p style={{ color: "#999" }}>暂无任务,去添加一个吧~</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{todos.map((todo) => (
<li
key={todo.id} // ✅ 用唯一 id
style={{
display: "flex",
alignItems: "center",
padding: "8px 0",
borderBottom: "1px solid #eee",
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
style={{ marginRight: 8 }}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? "#999" : "#333",
}}
>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>删除</button>
</li>
))}
</ul>
)}
{/* 统计 */}
<div style={{ marginTop: 16, color: "#666", fontSize: 14 }}>
共 {totalCount} 项,已完成 {doneCount} 项
</div>
</div>
);
}
全文总结:React 列表渲染的本质就是用
Array.map()把数据数组"翻译"成 JSX 元素数组。核心牢记三点:每条数据配唯一key、用不可变方式更新列表数据、处理好加载/空/错误三种状态。 掌握了列表渲染,你就掌握了 React 开发中最常用的技能。
评论区