目 录CONTENT

文章目录

React(八)列表渲染

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     │ │
│                                    └────────┬────────┘ │
│                                             │          │
│                                             ▼          │
│                                    ┌─────────────────┐ │
│                                    │  页面可见的列表  │ │
│                                    │  · 张三          │ │
│                                    │  · 李四          │ │
│                                    │  · 王五          │ │
│                                    └─────────────────┘ │
│                                                         │
└─────────────────────────────────────────────────────────┘

流程四步走

  1. 数组数据:你的数据源,可以来自变量、props、state 或 API 请求
  2. map 映射:用 Array.map() 把每条数据"翻译"成一个 JSX 元素,并配上唯一的 key 属性
  3. React 渲染引擎:React 拿到 JSX 元素数组后,通过 key 对比新旧列表,精确判断哪些项需要新增、删除、更新
  4. 页面可见列表:最终呈现在浏览器中的真实 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>
  );
}

这个示例展示了三个关键能力:

  1. 多字段提取:从每个对象中取出 namepricecategoryinStock,组合成丰富的 UI
  2. 条件样式:根据 inStock 的值动态改变文字颜色(有货绿色、缺货红色)
  3. 复杂布局:每项不只是简单的 <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:

  1. 列表是静态的,永远不会增、删、排序
  2. 列表项内部没有"有状态的子组件"(如输入框、复选框)
  3. 列表项不会被重新排序

实战铁律只要列表涉及增删改、排序、筛选中的任意一种,就必须用稳定的唯一 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-windowreact-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 练习一:静态商品列表

任务要求:

  1. 准备一个包含 4 个商品的数组,每个商品有:idname(商品名)、price(价格)、img(图片链接)
  2. map() 渲染成卡片列表,每张卡片展示:商品图片、名称、价格
  3. 价格超过 50 元的商品,价格文字用红色加粗显示
  4. 使用商品的 id 作为 key,不能用 index

提示:

  • 图片链接可用 https://via.placeholder.com/150 占位
  • 条件样式参考本文 2.3 节的商品列表示例

4.2 练习二:动态 Todo 列表

任务要求:

  1. useState 管理一个 todo 列表,初始有至少 3 条数据
  2. 每条 todo 包含:idtext(任务名)、completed(布尔值,是否完成)
  3. 实现新增任务:顶部的输入框 + 添加按钮(或按 Enter 添加)
  4. 实现删除任务:每行一个删除按钮
  5. 实现切换完成状态:每行一个复选框,勾选后文字显示中划线并变灰色
  6. 底部显示统计:共 X 项,已完成 Y 项
  7. 列表为空时显示:"暂无任务,去添加一个吧~"

提示:

  • 新增使用 [...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 开发中最常用的技能。


上一篇:React(七)useState 全方位指南

0

评论区