DOM 节点操作与列表渲染
上一节已经能修改页面中已有的元素。现在要显示学员列表时,数据可能来自数组或 API,页面事先不知道有多少条,不能把每张卡片都写死在 HTML 中。
本节课解决三个问题:
- 怎样创建、添加、删除、替换和复制页面节点。
- 怎样把学员数组渲染成页面列表。
- 怎样处理重复渲染、空数据和接口文字安全。
本节的主线很简单:先把一条学员数据创建成 DOM 节点,再把全部节点放进列表容器。后面数据改为来自 API,只要数据结构一致,渲染函数不需要重写。
首轮重点掌握 createElement()、textContent、append() 和 replaceChildren(),它们会贯穿列表案例。兄弟节点插入、移动和复制用于补充局部调整能力,可在完成列表后对照速查表复习。
前两部分的短示例分别演示一种操作,不要把不同小节里重复声明的 const studentList 连续复制到同一个脚本。列表渲染部分会逐步改进同一段代码,后一版替换前一版。
节点创建与插入
document.createElement() 用来创建新的 HTML 元素。
<ul id="student-list"></ul>const studentList = document.querySelector("#student-list");
const studentItem = document.createElement("li");
studentItem.textContent = "张三 - Python 1 班";
studentItem.classList.add("student-item");
studentItem.dataset.id = "1";
studentList.append(studentItem);代码按下面的顺序执行:
- 找到列表容器。
- 在内存中创建一个新的
li。 - 设置文字、class 和
data-id。 - 把新元素添加到列表中。
只调用 createElement() 时,节点还在内存中,页面不会变化。添加到 DOM 后,浏览器才会显示。
const item = document.createElement("li");
item.textContent = "李四";
console.log(item); // 已经创建
// 页面还看不到,因为没有添加到 DOM子节点插入
append() 把内容添加到父元素内部的最后面,prepend() 添加到最前面。
<ul id="student-list">
<li>张三</li>
</ul>const studentList = document.querySelector("#student-list");
const item = document.createElement("li");
item.textContent = "李四";
studentList.append(item);运行后,李四会出现在张三后面:
<ul id="student-list">
<li>张三</li>
<li>李四</li>
</ul>append() 可以一次添加多个节点,也可以添加普通文字:
const name = document.createElement("strong");
name.textContent = "王五";
const className = document.createElement("span");
className.textContent = "Python 2 班";
item.append(name, " - ", className);const firstItem = document.createElement("li");
firstItem.textContent = "置顶学员";
studentList.prepend(firstItem);append() 和 prepend() 都会形成父子关系。新增一条数据时常用 append();需要把通知或置顶内容放在开头时,再使用 prepend()。
兄弟节点插入
before() 和 after() 不会把内容放进当前元素内部,而是在它的前后插入兄弟元素。
<section id="student-panel">
<ul id="student-list"></ul>
</section>const studentList = document.querySelector("#student-list");
const title = document.createElement("h2");
title.textContent = "学员列表";
studentList.before(title);
const summary = document.createElement("p");
summary.textContent = "共 3 名学员";
studentList.after(summary);运行后的结构如下。h2、ul、p 都属于 section,彼此是兄弟节点:
<section id="student-panel">
<h2>学员列表</h2>
<ul id="student-list"></ul>
<p>共 3 名学员</p>
</section>节点移动与复制
一个节点同时只能出现在 DOM 的一个位置。把已存在的节点添加到另一个容器,浏览器会移动它,不会复制一份。
<div id="left-panel">
<p id="message">课程通知</p>
</div>
<div id="right-panel"></div>const message = document.querySelector("#message");
const rightPanel = document.querySelector("#right-panel");
rightPanel.append(message);执行后,message 会从左侧移动到右侧。确实需要两份相同内容时,使用 cloneNode():
const copy = message.cloneNode(true);
copy.removeAttribute("id");
rightPanel.append(copy);true 表示同时复制内部子节点。原节点已有 id="message",因此插入副本前要移除副本的 id,避免页面出现重复 ID。cloneNode(true) 也不会复制通过 addEventListener() 绑定的事件监听器;动态按钮的事件绑定会在后面的事件章节继续学习。
节点删除与替换
删除与清空
删除一条学员时,直接对这一条节点调用 remove()。
<li id="student-2">李四</li>const studentItem = document.querySelector("#student-2");
studentItem.remove();调用后,元素和它内部的全部内容都会从页面中删除。页面元素可能不存在时先判断:
const studentItem = document.querySelector("#student-2");
if (studentItem) {
studentItem.remove();
}重新渲染整个列表前,需要清除旧内容。replaceChildren() 不传参数时,会删除容器中的全部子节点,但会保留容器本身。
const studentList = document.querySelector("#student-list");
studentList.replaceChildren();也可以写:
studentList.textContent = "";两种写法都能清空内容。项目中优先使用 replaceChildren(),因为它直接表达了“替换容器子节点”的意图。删除单条数据用 remove();重新展示一组数据用 replaceChildren()。
替换节点
页面加载完成后,常常需要把“正在加载”替换为结果提示。这时使用 replaceWith()。
<p id="loading-message">正在加载...</p>const loadingMessage = document.querySelector("#loading-message");
const successMessage = document.createElement("p");
successMessage.classList.add("success-message");
successMessage.textContent = "加载完成";
loadingMessage.replaceWith(successMessage);replaceWith() 替换的是当前元素本身。列表从“加载中”切换到“暂无数据”或“正常数据”时,通常需要替换的是容器内部全部内容,更适合使用 replaceChildren():
const emptyMessage = document.createElement("li");
emptyMessage.textContent = "暂无学员数据";
studentList.replaceChildren(emptyMessage);这里的 studentList 是 <ul>,提示节点也要用 <li>。选择原则:只换一个提示元素用 replaceWith();整体更新列表内容用 replaceChildren()。
列表渲染
先准备学员数组:
const students = [
{ id: 1, name: "张三", className: "Python 1 班" },
{ id: 2, name: "李四", className: "Python 2 班" },
{ id: 3, name: "王五", className: "Web 全栈班" },
];列表项创建
<ul id="student-list"></ul>const studentList = document.querySelector("#student-list");
for (const student of students) {
const item = document.createElement("li");
item.dataset.id = String(student.id);
item.textContent = `${student.name} - ${student.className}`;
studentList.append(item);
}循环每执行一次,就创建一个 li,填写当前学员的数据,再添加到列表。数组有 3 条数据,页面就出现 3 个列表项。
这个版本能运行,但卡片结构变复杂后,循环内部会越来越长。接下来把“创建一条节点”的工作单独交给函数。
封装列表项
function createStudentItem(student) {
const item = document.createElement("li");
const name = document.createElement("strong");
const className = document.createElement("span");
item.classList.add("student-item");
item.dataset.id = String(student.id);
name.textContent = student.name;
className.textContent = student.className;
item.append(name, " - ", className);
return item;
}
for (const student of students) {
studentList.append(createStudentItem(student));
}createStudentItem() 只接收一条学员数据,只返回一个 li 节点,不负责查询容器,也不负责循环。以后要改姓名、班级的显示结构,只改这个函数。
列表渲染函数
function renderStudentList(students) {
studentList.replaceChildren();
for (const student of students) {
studentList.append(createStudentItem(student));
}
}
renderStudentList(students);renderStudentList() 负责整个列表:先清除旧节点,再根据数组创建新节点。这样重复调用也不会产生重复卡片:
renderStudentList(students);
renderStudentList(students);如果没有 replaceChildren(),上面两次调用会把同一组数据追加两遍。这里的函数职责要分清:createStudentItem() 处理一条数据,renderStudentList() 处理一组数据和页面状态。
批量准备节点
数据较多时,可以先把节点添加到 DocumentFragment,最后一次放入页面。
function renderStudentList(students) {
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.append(createStudentItem(student));
}
studentList.replaceChildren(fragment);
}DocumentFragment 可以理解为临时节点容器。它不会显示在页面中;放入 studentList 后,内部节点会进入列表。这里使用它,是为了明确区分“先准备全部节点”和“统一更新页面”两个步骤,不必把它当成本节的性能重点。
文本安全
也可以用 insertAdjacentHTML() 或 innerHTML 拼接 HTML 字符串,但学员姓名、班级、手机号等数据可能来自用户输入或接口返回。字符串一旦被当成 HTML 解析,就可能把不可信内容插入页面。
本节统一用 createElement() 创建结构,用 textContent 填写普通文字。这样每个节点的来源和内容都更清楚,也延续上一节的安全写法。
节点操作速查
| API | 作用 | 常见场景 |
|---|---|---|
document.createElement(tag) | 创建元素 | 根据一条数据创建卡片或列表项 |
parent.append(...items) | 添加到内部最后面 | 新增一条数据 |
parent.prepend(...items) | 添加到内部最前面 | 放置置顶内容 |
element.before(...items) | 添加到当前元素前面 | 列表前插入标题 |
element.after(...items) | 添加到当前元素后面 | 列表后插入统计信息 |
element.remove() | 删除元素本身 | 删除一条卡片 |
element.replaceWith(newNode) | 替换当前元素 | 替换加载提示 |
parent.replaceChildren(...nodes) | 替换全部子节点 | 重新渲染列表 |
element.cloneNode(true) | 复制元素及内部节点 | 复制静态展示结构 |
document.createDocumentFragment() | 创建临时节点容器 | 批量准备列表节点 |
element.insertAdjacentHTML(position, html) | 插入项目自己编写的可信 HTML 字符串 | 固定模板 |
综合案例:学员卡片列表
根据学员数组生成卡片列表:
- 每张卡片显示头像、姓名、班级、手机号和状态。
- 停用学员显示灰色状态。
- 学员编号保存到卡片的
data-id。 - 页面顶部显示总人数和停用人数。
- 数组为空时显示空数据提示。
页面与数据
页面先准备标题、统计文字和卡片容器。容器使用 <section>,既能放学员卡片,也能在没有数据时放提示段落:
<main>
<h1>学员列表</h1>
<p id="student-summary">正在准备数据...</p>
<section id="student-list"></section>
</main>学员数据
const students = [
{
id: 1,
name: "张三",
className: "Python 1 班",
mobile: "13800000001",
disabled: false,
},
{
id: 2,
name: "李四",
className: "Python 2 班",
mobile: "13800000002",
disabled: true,
},
{
id: 3,
name: "王五",
className: "Web 全栈班",
mobile: "13800000003",
disabled: false,
},
];卡片创建
班级、手机号都由“标签 + 值”组成。先把这段重复结构封装起来:
function createInfoRow(label, value) {
const row = document.createElement("p");
const labelElement = document.createElement("span");
const valueElement = document.createElement("span");
labelElement.classList.add("info-label");
labelElement.textContent = `${label}:`;
valueElement.textContent = value;
row.append(labelElement, valueElement);
return row;
}调用 createInfoRow("班级", student.className) 会得到一行“班级:Python 1 班”。以后增加同类字段时,可以继续复用这个函数。
学员卡片
function createStudentCard(student) {
const card = document.createElement("article");
const avatar = document.createElement("img");
const content = document.createElement("div");
const heading = document.createElement("div");
const name = document.createElement("h2");
const status = document.createElement("span");
card.classList.add("student-card");
card.classList.toggle("is-disabled", student.disabled);
card.dataset.id = String(student.id);
avatar.classList.add("student-avatar");
avatar.src = "../../api_server/uploads/default.svg";
avatar.alt = `${student.name}的头像`;
content.classList.add("student-content");
heading.classList.add("student-heading");
name.textContent = student.name;
status.classList.add("status-badge");
status.classList.toggle("is-disabled", student.disabled);
status.textContent = student.disabled ? "已停用" : "正常";
heading.append(name, status);
content.append(
heading,
createInfoRow("班级", student.className),
createInfoRow("手机号", student.mobile),
);
card.append(avatar, content);
return card;
}这段函数把一条学员对象转换成一个完整的 article。classList.toggle("is-disabled", student.disabled) 会根据布尔值决定是否添加停用样式;dataset.id 保留编号,后面编辑、删除时可以继续使用。
列表渲染与统计
function renderStudentList(students) {
const studentList = document.querySelector("#student-list");
const summary = document.querySelector("#student-summary");
if (students.length === 0) {
const emptyMessage = document.createElement("p");
emptyMessage.classList.add("empty-message");
emptyMessage.textContent = "暂无学员数据";
studentList.replaceChildren(emptyMessage);
summary.textContent = "共 0 名学员,0 名已停用";
return;
}
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.append(createStudentCard(student));
}
studentList.replaceChildren(fragment);
const disabledCount = students.filter((student) => student.disabled).length;
summary.textContent =
`共 ${students.length} 名学员,${disabledCount} 名已停用`;
}
renderStudentList(students);渲染函数先处理空数组,避免页面只留下旧卡片。正常数据先逐条创建卡片,再统一替换列表内容,最后更新顶部统计。return 表示空数据已经处理完成,不再继续创建卡片。
函数职责
| 函数 | 职责 |
|---|---|
createInfoRow() | 创建一行“名称:值”结构 |
createStudentCard() | 把一条学员数据转换成卡片节点 |
renderStudentList() | 处理空数据、批量创建和页面统计 |
把创建和渲染拆开后,后面无论数据来自本地数组还是 API,都可以继续使用同一组函数。
运行与验收
打开学员卡片列表(源码文件:./.code/p04/c04-elements-render/04-student-cards.html),默认应看到 3 张学员卡片,其中李四是停用状态,顶部显示“共 3 名学员,1 名已停用”。
可以按下面步骤验证:
- 把
students改为[],刷新页面,应显示“暂无学员数据”和“共 0 名学员,0 名已停用”。 - 在脚本末尾再次调用一次
renderStudentList(students),页面仍应只显示一组卡片,不会重复。 - 把某条数据的
disabled改为true,刷新页面,卡片和状态标签都应变为停用样式。
完整附件包含页面样式和上述三个函数,还依赖测试设施中的默认头像 .code/api_server/uploads/default.svg。复制时保留导入路径中的目录结构:
学员卡片列表源码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DOM 节点操作与列表渲染</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f7fa;
color: #303133;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
.page-container {
width: min(980px, calc(100% - 32px));
margin: 48px auto;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
margin: 0 0 8px;
font-size: 28px;
}
#student-summary {
margin: 0;
color: #606266;
}
.student-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.student-card {
display: flex;
gap: 18px;
min-height: 170px;
padding: 22px;
border: 2px solid transparent;
border-radius: 10px;
background: #fff;
box-shadow: 0 6px 18px rgb(0 0 0 / 6%);
}
.student-card.is-disabled {
border-color: #dcdfe6;
background: #f2f3f5;
color: #909399;
}
.student-avatar {
width: 92px;
height: 92px;
flex: 0 0 auto;
border-radius: 50%;
background: #ebeef5;
object-fit: cover;
}
.student-content {
min-width: 0;
flex: 1;
}
.student-heading {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.student-heading h2 {
margin: 0;
font-size: 20px;
}
.student-content p {
margin: 8px 0;
font-size: 14px;
}
.info-label {
color: #606266;
}
.status-badge {
padding: 3px 9px;
border-radius: 10px;
background: #e8f8f5;
color: #16a091;
font-size: 12px;
}
.status-badge.is-disabled {
background: #dcdfe6;
color: #606266;
}
.empty-message {
grid-column: 1 / -1;
padding: 70px 20px;
border-radius: 10px;
background: #fff;
color: #909399;
text-align: center;
}
@media (max-width: 760px) {
.student-list {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="page-container">
<header class="page-header">
<div>
<h1 class="page-title">学员列表</h1>
<p id="student-summary">正在准备数据...</p>
</div>
</header>
<section id="student-list" class="student-list"></section>
</main>
<script>
const students = [
{
id: 1,
name: "张三",
className: "Python 1 班",
mobile: "13800000001",
disabled: false,
},
{
id: 2,
name: "李四",
className: "Python 2 班",
mobile: "13800000002",
disabled: true,
},
{
id: 3,
name: "王五",
className: "Web 全栈班",
mobile: "13800000003",
disabled: false,
},
];
function createInfoRow(label, value) {
const row = document.createElement("p");
const labelElement = document.createElement("span");
const valueElement = document.createElement("span");
labelElement.classList.add("info-label");
labelElement.textContent = `${label}:`;
valueElement.textContent = value;
row.append(labelElement, valueElement);
return row;
}
function createStudentCard(student) {
const card = document.createElement("article");
const avatar = document.createElement("img");
const content = document.createElement("div");
const heading = document.createElement("div");
const name = document.createElement("h2");
const status = document.createElement("span");
card.classList.add("student-card");
card.classList.toggle("is-disabled", student.disabled);
card.dataset.id = String(student.id);
avatar.classList.add("student-avatar");
avatar.src = "../../api_server/uploads/default.svg";
avatar.alt = `${student.name}的头像`;
content.classList.add("student-content");
heading.classList.add("student-heading");
name.textContent = student.name;
status.classList.add("status-badge");
status.classList.toggle("is-disabled", student.disabled);
status.textContent = student.disabled ? "已停用" : "正常";
heading.append(name, status);
content.append(
heading,
createInfoRow("班级", student.className),
createInfoRow("手机号", student.mobile),
);
card.append(avatar, content);
return card;
}
function renderStudentList(students) {
const studentList = document.querySelector("#student-list");
const summary = document.querySelector("#student-summary");
if (students.length === 0) {
const emptyMessage = document.createElement("p");
emptyMessage.classList.add("empty-message");
emptyMessage.textContent = "暂无学员数据";
studentList.replaceChildren(emptyMessage);
summary.textContent = "共 0 名学员,0 名已停用";
return;
}
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.append(createStudentCard(student));
}
studentList.replaceChildren(fragment);
const disabledCount = students.filter((student) => student.disabled).length;
summary.textContent =
`共 ${students.length} 名学员,${disabledCount} 名已停用`;
}
renderStudentList(students);
</script>
</body>
</html><svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="16" fill="#f2f3f5"/>
<circle cx="64" cy="46" r="23" fill="#c9cdd4"/>
<path d="M24 112c4-25 19-38 40-38s36 13 40 38" fill="#c9cdd4"/>
</svg>小结
本节课最核心的是掌握五件事:
createElement()只负责创建,节点添加到 DOM 后才会显示。- 新增局部内容常用
append(),整体重新展示列表常用replaceChildren()。 remove()删除元素,replaceWith()替换当前元素,已存在的节点再次append()会被移动。- 将“一条数据转成节点”和“渲染全部数据”拆成不同函数。
- 接口普通文字继续使用
textContent,并处理空数组和重复渲染。