Skip to content

DOM 节点操作与列表渲染 ​

上一节已经能修改页面中已有的元素。现在要显示学员列表时,数据可能来自数组或 API,页面事先不知道有多少条,不能把每张卡片都写死在 HTML 中。

本节课解决三个问题:

  1. 怎样创建、添加、删除、替换和复制页面节点。
  2. 怎样把学员数组渲染成页面列表。
  3. 怎样处理重复渲染、空数据和接口文字安全。

本节的主线很简单:先把一条学员数据创建成 DOM 节点,再把全部节点放进列表容器。后面数据改为来自 API,只要数据结构一致,渲染函数不需要重写。

首轮重点掌握 createElement()、textContent、append() 和 replaceChildren(),它们会贯穿列表案例。兄弟节点插入、移动和复制用于补充局部调整能力,可在完成列表后对照速查表复习。

前两部分的短示例分别演示一种操作,不要把不同小节里重复声明的 const studentList 连续复制到同一个脚本。列表渲染部分会逐步改进同一段代码,后一版替换前一版。

节点创建与插入 ​

document.createElement() 用来创建新的 HTML 元素。

html
<ul id="student-list"></ul>
js
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);

代码按下面的顺序执行:

  1. 找到列表容器。
  2. 在内存中创建一个新的 li。
  3. 设置文字、class 和 data-id。
  4. 把新元素添加到列表中。

只调用 createElement() 时,节点还在内存中,页面不会变化。添加到 DOM 后,浏览器才会显示。

js
const item = document.createElement("li");
item.textContent = "李四";

console.log(item); // 已经创建
// 页面还看不到,因为没有添加到 DOM

子节点插入 ​

append() 把内容添加到父元素内部的最后面,prepend() 添加到最前面。

html
<ul id="student-list">
  <li>张三</li>
</ul>
js
const studentList = document.querySelector("#student-list");
const item = document.createElement("li");
item.textContent = "李四";

studentList.append(item);

运行后,李四会出现在张三后面:

html
<ul id="student-list">
  <li>张三</li>
  <li>李四</li>
</ul>

append() 可以一次添加多个节点,也可以添加普通文字:

js
const name = document.createElement("strong");
name.textContent = "王五";

const className = document.createElement("span");
className.textContent = "Python 2 班";

item.append(name, " - ", className);
js
const firstItem = document.createElement("li");
firstItem.textContent = "置顶学员";

studentList.prepend(firstItem);

append() 和 prepend() 都会形成父子关系。新增一条数据时常用 append();需要把通知或置顶内容放在开头时,再使用 prepend()。

兄弟节点插入 ​

before() 和 after() 不会把内容放进当前元素内部,而是在它的前后插入兄弟元素。

html
<section id="student-panel">
  <ul id="student-list"></ul>
</section>
js
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,彼此是兄弟节点:

html
<section id="student-panel">
  <h2>学员列表</h2>
  <ul id="student-list"></ul>
  <p>共 3 名学员</p>
</section>

节点移动与复制 ​

一个节点同时只能出现在 DOM 的一个位置。把已存在的节点添加到另一个容器,浏览器会移动它,不会复制一份。

html
<div id="left-panel">
  <p id="message">课程通知</p>
</div>
<div id="right-panel"></div>
js
const message = document.querySelector("#message");
const rightPanel = document.querySelector("#right-panel");

rightPanel.append(message);

执行后,message 会从左侧移动到右侧。确实需要两份相同内容时,使用 cloneNode():

js
const copy = message.cloneNode(true);
copy.removeAttribute("id");
rightPanel.append(copy);

true 表示同时复制内部子节点。原节点已有 id="message",因此插入副本前要移除副本的 id,避免页面出现重复 ID。cloneNode(true) 也不会复制通过 addEventListener() 绑定的事件监听器;动态按钮的事件绑定会在后面的事件章节继续学习。

节点删除与替换 ​

删除与清空 ​

删除一条学员时,直接对这一条节点调用 remove()。

html
<li id="student-2">李四</li>
js
const studentItem = document.querySelector("#student-2");
studentItem.remove();

调用后,元素和它内部的全部内容都会从页面中删除。页面元素可能不存在时先判断:

js
const studentItem = document.querySelector("#student-2");

if (studentItem) {
  studentItem.remove();
}

重新渲染整个列表前,需要清除旧内容。replaceChildren() 不传参数时,会删除容器中的全部子节点,但会保留容器本身。

js
const studentList = document.querySelector("#student-list");
studentList.replaceChildren();

也可以写:

js
studentList.textContent = "";

两种写法都能清空内容。项目中优先使用 replaceChildren(),因为它直接表达了“替换容器子节点”的意图。删除单条数据用 remove();重新展示一组数据用 replaceChildren()。

替换节点 ​

页面加载完成后,常常需要把“正在加载”替换为结果提示。这时使用 replaceWith()。

html
<p id="loading-message">正在加载...</p>
js
const loadingMessage = document.querySelector("#loading-message");
const successMessage = document.createElement("p");

successMessage.classList.add("success-message");
successMessage.textContent = "加载完成";

loadingMessage.replaceWith(successMessage);

replaceWith() 替换的是当前元素本身。列表从“加载中”切换到“暂无数据”或“正常数据”时,通常需要替换的是容器内部全部内容,更适合使用 replaceChildren():

js
const emptyMessage = document.createElement("li");
emptyMessage.textContent = "暂无学员数据";

studentList.replaceChildren(emptyMessage);

这里的 studentList 是 <ul>,提示节点也要用 <li>。选择原则:只换一个提示元素用 replaceWith();整体更新列表内容用 replaceChildren()。

列表渲染 ​

先准备学员数组:

js
const students = [
  { id: 1, name: "张三", className: "Python 1 班" },
  { id: 2, name: "李四", className: "Python 2 班" },
  { id: 3, name: "王五", className: "Web 全栈班" },
];

列表项创建 ​

html
<ul id="student-list"></ul>
js
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 个列表项。

这个版本能运行,但卡片结构变复杂后,循环内部会越来越长。接下来把“创建一条节点”的工作单独交给函数。

封装列表项

js
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 节点,不负责查询容器,也不负责循环。以后要改姓名、班级的显示结构,只改这个函数。

列表渲染函数 ​

js
function renderStudentList(students) {
  studentList.replaceChildren();

  for (const student of students) {
    studentList.append(createStudentItem(student));
  }
}

renderStudentList(students);

renderStudentList() 负责整个列表:先清除旧节点,再根据数组创建新节点。这样重复调用也不会产生重复卡片:

js
renderStudentList(students);
renderStudentList(students);

如果没有 replaceChildren(),上面两次调用会把同一组数据追加两遍。这里的函数职责要分清:createStudentItem() 处理一条数据,renderStudentList() 处理一组数据和页面状态。

批量准备节点

数据较多时,可以先把节点添加到 DocumentFragment,最后一次放入页面。

js
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 字符串固定模板

综合案例:学员卡片列表 ​

根据学员数组生成卡片列表:

  1. 每张卡片显示头像、姓名、班级、手机号和状态。
  2. 停用学员显示灰色状态。
  3. 学员编号保存到卡片的 data-id。
  4. 页面顶部显示总人数和停用人数。
  5. 数组为空时显示空数据提示。

页面与数据 ​

页面先准备标题、统计文字和卡片容器。容器使用 <section>,既能放学员卡片,也能在没有数据时放提示段落:

html
<main>
  <h1>学员列表</h1>
  <p id="student-summary">正在准备数据...</p>
  <section id="student-list"></section>
</main>

学员数据

js
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,
  },
];

卡片创建 ​

班级、手机号都由“标签 + 值”组成。先把这段重复结构封装起来:

js
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 班”。以后增加同类字段时,可以继续复用这个函数。

学员卡片

js
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 保留编号,后面编辑、删除时可以继续使用。

列表渲染与统计 ​

js
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 名已停用”。

可以按下面步骤验证:

  1. 把 students 改为 [],刷新页面,应显示“暂无学员数据”和“共 0 名学员,0 名已停用”。
  2. 在脚本末尾再次调用一次 renderStudentList(students),页面仍应只显示一组卡片,不会重复。
  3. 把某条数据的 disabled 改为 true,刷新页面,卡片和状态标签都应变为停用样式。

完整附件包含页面样式和上述三个函数,还依赖测试设施中的默认头像 .code/api_server/uploads/default.svg。复制时保留导入路径中的目录结构:

学员卡片列表源码
html
<!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
<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>

小结 ​

本节课最核心的是掌握五件事:

  1. createElement() 只负责创建,节点添加到 DOM 后才会显示。
  2. 新增局部内容常用 append(),整体重新展示列表常用 replaceChildren()。
  3. remove() 删除元素,replaceWith() 替换当前元素,已存在的节点再次 append() 会被移动。
  4. 将“一条数据转成节点”和“渲染全部数据”拆成不同函数。
  5. 接口普通文字继续使用 textContent,并处理空数组和重复渲染。