Skip to content

综合案例:图书管理 ​

本页对应 1-4综合案例-图书管理,共 8 段视频,约 1 小时 30 分。

学习目标 ​

  1. 能使用 axios 按接口文档携带 creator 参数获取图书列表数据,并通过 map、join、innerHTML 将数据渲染到表格中,同时将图书 ID 存入自定义属性供后续操作使用。
  2. 能使用 Bootstrap 弹框的属性控制与 JavaScript 控制两种方式,分别实现新增、编辑弹框的显示与隐藏,并说明两种方式各自的适用场景。
  3. 能独立完成新增图书功能:使用表单序列化插件收集书名、作者、出版社,结合展开运算符合并 creator 后提交到服务器,并在成功后重置表单、关闭弹框、重新获取并渲染最新列表。
  4. 能使用事件委托为动态生成的删除按钮绑定点击事件,通过类名判断和 return 阻断非目标点击,获取父元素上的 data-id 后调用 DELETE 路径参数接口删除数据,并刷新列表。
  5. 能完成编辑图书的数据回填:调用图书详情接口获取数据,使用 JavaScript 控制编辑弹框显示,并通过 Object.keys 与 forEach 动态生成选择器、结合中括号语法将数据写入对应表单元素,包括使用隐藏表单元素保存 ID。
  6. 能完成编辑图书的保存修改:序列化编辑表单并解构所需字段,按 PUT 接口要求将 ID 拼接到 URL、将书名、作者、出版社和 creator 放入请求体提交,成功后关闭弹框并调用列表函数刷新页面。

知识点 ​

本节要完成什么 ​

本节是“图书管理”案例的介绍课,不急着写完整代码,而是先确认案例最终效果、业务功能和实现顺序。为什么需要先介绍?因为增删改查涉及弹框、接口、列表渲染和多次数据刷新,如果一上来就写代码,很容易不知道每一步在整体中的位置。怎么想:先看成品,再拆功能,最后排顺序。怎么做:打开备课代码运行案例,观察默认渲染的三条图书数据——西游记、三国演义、水浒传;点击右上角“添加”,输入书名、作者、出版社后提交;再点击某行末尾“删除”;最后点击“编辑”,修改出版社并保存。易错点:不要把“介绍课”当成“代码课”,本节重点是建立业务地图。

四个功能与业务本质 ​

案例要完成四个功能:渲染图书列表、新增图书、删除图书、编辑图书。为什么需要这四个功能?因为多数项目都会涉及数据管理,虽然数据内容不同,但基本都有“渲染数据、新增数据、删除数据、修改数据”。怎么想:图书数据不在页面里,而在服务器里;前端通过 axios 调接口操作服务器数据,页面只是渲染结果。转写稿中反复强调“数据在服务器”,接口文档里的 creator 是数据所有者外号,通过 query 参数传给服务器,服务器返回属于这个外号的三本书。怎么做:获取数据后渲染列表;新增时收集表单数据提交服务器;删除时调删除接口;编辑时先获取详情,再填表单,最后提交修改。易错点:服务器数据变了,页面不会自动变,必须重新获取数据并渲染;编辑时不要只从页面拿数据,要向服务器获取详情,保证数据准确。

为什么先学弹框、先做列表 ​

推荐顺序是:先学习 Bootstrap 弹框,再做渲染图书列表,然后做新增、删除、编辑。为什么需要先学弹框?因为添加图书和编辑图书都需要弹窗承载表单;不会控制弹框显示隐藏,新增和编辑就无法完整演示。怎么想:先做列表,是因为列表是后续功能的“观察窗口”;新增、删除、编辑后都要重新渲染列表,才能看到数据变化。怎么做:Bootstrap 弹框可用属性控制,如 data-bs-toggle="modal"、data-bs-target="选择器" 显示,data-bs-dismiss="modal" 隐藏;也可用 JS 实例化后调用 show()、hide()。属性控制适合点击后立刻显示或隐藏;JS 控制适合显示或隐藏前还要做别的事,例如新增提交成功后再关闭弹框。易错点:data-bs-target 要写 CSS 选择器;隐藏属性要放在弹框内部元素上;不要把新增弹框和编辑弹框混用——本节明确是两套弹框,后续项目再考虑复用同一个。

推荐实现顺序与调试思路 ​

为什么需要排顺序?因为四个功能互相依赖。怎么想:先做“渲染列表”,让页面有数据;再做“新增”,提交后调用获取列表函数刷新;再做“删除”,通过事件委托绑定动态生成的删除按钮,拿到 ID 后调 delete 接口,再刷新列表;最后做“编辑”,点击编辑先获取该图书详情,弹出编辑框并填表,保存时调 put 接口,再刷新列表并关闭弹框。怎么做:转写稿中的调试过程可作为验证方法:打开浏览器网络面板,查看请求是否发出、预览返回数据、状态码是否成功;例如删除接口成功时可能返回 204,页面不自动更新时要手动调用获取列表函数。易错点:动态生成的删除、编辑按钮要用事件委托;ID 可存在自定义属性或隐藏表单元素中,编辑时常用 type="hidden" 保存 ID,避免用户修改;接口方法 get、post、delete、put 要按文档设置,参数位置要区分 query、path、body。

小结 ​

做完这个案例,收获是掌握前端经典增删改查的业务和思路,对以后开发很有帮助。为什么需要总结?因为本节不是孤立案例,而是可迁移的管理业务模板。怎么想:把“获取数据—渲染数据—提交变更—重新获取—更新页面”作为主线。怎么做:后续遇到类似管理业务,可参考本案例顺序:先列表,再新增、删除、编辑;弹框按场景选择属性控制或 JS 控制。易错点:不要忘记变更后重新获取数据;不要忽略 ID 的保存与传递;不要混淆新增和编辑弹框的职责。

本节要解决的问题 ​

Bootstrap 弹框(Modal)的作用是:不离开当前页面,显示单独内容,供用户操作。例如图书管理里点击“添加”后弹出新增表单,用户填写完再关闭。
这一节的目标很明确:使用属性方式控制 Bootstrap 弹框的显示和隐藏。也就是说,先不写 JS 逻辑,只靠 HTML 自定义属性完成开关弹框。

为什么需要属性控制?因为很多场景只是“点按钮→弹框出现”“点取消/小叉→弹框关闭”,没有必要额外写 JS。用属性控制更直接,结构清楚,调试也快。

怎么想?先确认依赖和弹框结构,再给“触发按钮”加显示属性,给“弹框内部关闭按钮”加隐藏属性。怎么做?按下面步骤实现。

准备依赖与弹框结构 ​

为什么需要先准备依赖?Bootstrap 的弹框样式来自 bootstrap.css,交互行为来自 bootstrap.js。只引 CSS 可能样式对,但点击不会正常开关;只引 JS 又没有样式。

怎么想?先保证页面里已经引入 Bootstrap 的 CSS 和 JS,然后准备弹框标签。笔记中说明:可以从文档的 Modal 里复制基础例子,确认结构。案例里新增图书弹框已经准备好了,所以本节重点不是从零搭结构,而是学会控制它。

怎么做?检查页面是否引入 bootstrap.css 和 bootstrap.js,并找到弹框容器和触发按钮。弹框一般包含标题、内容区、关闭按钮、取消/确认按钮等。

易错点:不要只引一个文件;弹框结构不要自己随意删到缺少 Bootstrap 需要的层级;先确认目标弹框的选择器,再写属性。

用属性控制弹框显示 ​

为什么需要?用户点击“添加”按钮时,需要让指定弹框出现。属性控制的核心是:告诉 Bootstrap“这是一个弹框触发器”,再告诉它“要打开哪个弹框”。

怎么想?显示弹框需要两个属性配合:

  • data-bs-toggle="modal":表示触发弹框;
  • data-bs-target="CSS选择器":指定要显示哪个弹框。

笔记中的语法图如下:

Bootstrap 弹框属性控制语法图

怎么做?例如给“添加”按钮加上属性,目标选择器要和弹框自身的选择器对应。转写稿中演示时用的是类选择器,类似:

html
<button
  class="btn btn-primary"
  data-bs-toggle="modal"
  data-bs-target=".add-modal"
>
  添加
</button>

对应弹框容器需要有 .add-modal 这个类。注意:data-bs-target 接收的是 CSS 选择器,不是随便写一段文字。目标用 id 就写 #addModal,目标用 class 就写 .add-modal,点不能漏。

易错点:data-bs-toggle 的值是 modal;data-bs-target 必须能选中目标弹框;属性名拼写要准确,如 data-bs-toggle、data-bs-target。

用属性控制弹框隐藏 ​

为什么需要?弹框弹出后,用户点小叉或“取消”应该能关闭。隐藏属性解决的是“关闭当前所在弹框”。

怎么想?关闭按钮通常位于弹框内部,所以只需要一个属性:data-bs-dismiss="modal"。它关闭的是标签所在的当前提示框,不需要再写 target。

怎么做?给弹框里的小叉、取消按钮加属性:

html
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
  取消
</button>

转写稿的调试过程很清楚:先只给“添加”按钮加显示属性,点击后弹框能出现;此时点小叉、点取消没有反应。接着给小叉加 data-bs-dismiss="modal",再点小叉,弹框关闭;再给“取消”按钮补同样属性,点取消也能关闭。这说明显示和隐藏是两组属性,不能只加显示不加关闭。

易错点:data-bs-dismiss="modal" 要加在弹框内部的关闭元素上;关闭按钮如果放到弹框外,就不是“关闭所在弹框”的效果;属性值仍是 modal。

调试与小结 ​

调试时建议按顺序来:先确认 CSS/JS 已引入;再确认弹框结构存在;然后只加显示属性,测试能否弹出;最后给关闭按钮补 data-bs-dismiss="modal",测试小叉和取消。这样出错时容易定位是“目标选择器错”还是“关闭属性没加”。

本节两个小结问题:

  1. 用哪个属性绑定来控制弹框显示?
    答案:data-bs-toggle 和 data-bs-target。

  2. 用哪个属性来控制隐藏弹框?
    答案:data-bs-dismiss,关闭的是标签所在的当前提示框。

记住一句话:显示靠 toggle + target 找目标,隐藏靠 dismiss 关所在弹框。

目标与场景:为什么要用 JS 控制弹框 ​

本节目标是:使用 JS 方式控制 Bootstrap 弹框的显示和隐藏。

属性方式也能控制弹框:显示时给按钮加 data-bs-toggle="modal" 和 data-bs-target="弹框选择器",隐藏时在弹框内部加 data-bs-dismiss="modal"。它适合“点了就立刻显示/隐藏”的场景。

但图书管理案例里,点击“增加”后并不是立刻关闭弹框:要先收集用户输入的书名、作者、出版社,提交到服务器,成功后再关闭。也就是说,在显示或隐藏之前还要执行一段 JS 逻辑,这时就适合用 JS 方式控制。笔记小结也强调:直接出现/隐藏用属性方式;如果需要先执行一段 JS 逻辑再显示/隐藏,就用 JS 方式。

语法:怎么想与怎么做 ​

JS 控制的核心思路是:先找到弹框的 DOM 元素,把它传给 Bootstrap 提供的 Modal 构造函数,得到实例对象;再通过实例调用 show() 显示、hide() 隐藏。

Bootstrap 弹框 JS 控制语法示意图

以案例中的新增弹框为例,代码可以写成下面这样(选择器以你页面中的实际类名为准):

js
// 1. 找到弹框 DOM,并实例化
const addModal = new bootstrap.Modal(document.querySelector('.add-modal'));

// 2. 显示弹框
document.querySelector('.btn-show').addEventListener('click', () => {
  addModal.show();
});

// 3. 隐藏弹框
document.querySelector('.add-btn').addEventListener('click', () => {
  // 隐藏前还要做别的事:收集用户数据并提交到服务器
  // todo...
  addModal.hide();
});

转写稿中的调试过程是:先在页面上加一个“JS 显示弹框”的测试按钮,绑定点击事件后调用 addModal.show(),确认弹框能弹出;再给“增加”按钮绑定点击事件,调用 addModal.hide(),确认弹框能关闭。最后在 hide() 前留一个 todo:收集用户数据并提交到服务器。这样就把“JS 控制弹框”与后续新增业务衔接起来了。

易错点 ​

  1. new bootstrap.Modal(...) 传入的是 DOM 元素,不是选择器字符串。要先 document.querySelector(...) 取到元素再传进去。
  2. Modal 的 M 是大写,show()、hide() 是实例方法,不要写成属性方式里的 data-bs-*。
  3. 隐藏前如果有业务逻辑,要把逻辑放在 hide() 之前;否则弹框关了但数据还没提交。
  4. 属性方式适合立即显示/隐藏;JS 方式适合显示/隐藏前还要执行逻辑。不要为了简单场景强行写复杂 JS,也不要在需要先提交数据时只用属性关闭。
  5. 隐藏按钮如果使用属性方式,data-bs-dismiss="modal" 要写在弹框内部的标签上;JS 方式则要先拿到弹框实例再调用 hide()。

小结 ​

什么时候用属性控制,什么时候用 JS 控制 Bootstrap 弹框的显示/隐藏?

  • 直接出现/隐藏:用属性方式控制。
  • 如果需要先执行一段 JS 逻辑再显示/隐藏:用 JS 方式控制。

本节要记住的 JS 控制流程是:找到弹框 DOM → new bootstrap.Modal(dom元素) 创建实例 → 用 show() 显示、hide() 隐藏。

本节目标与两步主线 ​

为什么需要:图书管理案例的第一个功能是“渲染图书列表”。页面打开后要能看到属于自己的三本图书,后续新增、删除、编辑才有操作对象。

怎么想:转写稿把核心拆成两步:先获取数据,再分析数据结构并渲染到页面。数据不在本地,而在服务器,所以不能手写死三条数据。

怎么做:先按接口文档调用图书列表接口,拿到数组;再对照页面结构,用模板字符串生成若干 <tr>,拼接后写入 tbody。

易错点:不要一上来就写渲染。先确认接口返回什么、数组里字段叫什么,再写模板。否则容易出现字段名拼错、页面空白。

为什么需要设置“外号” ​

为什么需要:所有同学的数据都在同一个服务器上,服务器必须知道“这份图书数据属于谁”,否则无法返回你的三本书。

怎么想:转写稿里的“外号”就是数据所有者。接口参数名应纠正为 creator,不是口音里的 create/creta。请求时通过查询参数告诉服务器。

怎么做:把外号提成全局常量,避免多处写死:

js
const creator = '你的外号'

易错点:外号不要直接写死在多个请求里;后续新增、编辑也会用到,统一用常量最稳。

获取图书列表数据 ​

为什么需要:渲染列表的前提是拿到数组数据。转写稿中先通过 axios 发 GET 请求,参数放在 params 中。

怎么想:先 console.log(res) 看返回结构,再右键复制属性路径,确认列表在 res.data.data。

怎么做:

js
async function getBooks() {
  const res = await axios({
    url: '接口文档中的图书列表地址',
    params: { creator }
  })
  const list = res.data.data
  console.log(list) // 调试:确认是数组,且有三条图书
}

易错点:忘记 await 会拿到 Promise;参数写成 create 而不是 creator 会拿不到对应数据;不要把 res.data 和 res.data.data 混用。

分析结构并渲染列表 ​

为什么需要:有数据后还要把数组转成页面标签。转写稿中先审查元素,确认每条数据对应一个 <tr>,序号、书名、作者、出版社分别对应 <td>。

怎么想:数组转 HTML 数组用 map,再 join('') 拼成一个字符串,最后用 innerHTML 写入 tbody。序号用 index + 1,因为索引从 0 开始。

怎么做:

js
const htmlArr = list.map((item, index) => `
  <tr>
    <td>${index + 1}</td>
    <td>${item.bookname}</td>
    <td>${item.author}</td>
    <td>${item.publisher}</td>
    <td>
      <button class="del">删除</button>
      <button class="edit">编辑</button>
    </td>
  </tr>
`)
const htmlStr = htmlArr.join('')
document.querySelector('.list').innerHTML = htmlStr

易错点:字段名要按接口返回写,常见是 bookname/author/publisher;join 要传空字符串,避免标签间出现逗号;innerHTML 会覆盖原有内容;选择器要选到 tbody。

封装 getBooks 与默认调用 ​

为什么需要:转写稿强调,图书列表逻辑后续新增、删除、编辑后都要重新调用,所以不能只写一次散代码。

怎么想:把“获取数据 + 渲染数据”抽到 getBooks 函数中,页面初始化时默认调用一次。

怎么做:在函数内完成 axios 请求、map/join、innerHTML,最后写 getBooks()。

易错点:封装后忘记默认调用,页面不会显示;后续操作后也要记得再次调用,页面才会更新。

调试过程与易错点 ​

为什么需要:转写稿的调试顺序很值得照做:先看 Network 请求是否发出,再看 Preview 是否返回三条数据,再在控制台看 list 是否数组,再看 map 后是否 HTML 数组,最后看 join 后字符串和页面 <tr> 数量。

怎么想:每写一步就验证一步,不要等全部写完才查错。

怎么做:先确认三条默认数据是西游记、三国演义、水浒传;再逐格替换模板内容,序号、书名、作者、出版社依次对上。

易错点:外号写错、字段名拼错、join 漏写空串、选择器写错、索引忘记 +1,都会导致页面显示异常。

目标与整体思路 ​

本节要完成“图书管理—新增图书”。转写稿反复强调:图书数据存在服务器,新增不是只改页面,而是“准备输入区域 → 收集表单数据提交 → 重新获取并渲染列表”。因此新增图书可以拆成三步:先控制新增弹框的显示与隐藏;再在点击添加确认时收集表单数据并提交;最后调用之前封装的 getBooks() 刷新图书列表。

为什么需要:只把数据插到页面,刷新后就会丢;只提交服务器,不重新获取列表,页面又不会自动更新。
怎么想:把“弹框控制”和“数据提交”分开处理,提交成功后再刷新列表。
怎么做:Bootstrap 弹框负责显示隐藏,axios 负责提交,getBooks() 负责刷新。
易错点:转写稿里的“弹簧”是口误,实际是“弹框(modal)”;“date”应理解为 data;“mus/mers”应理解为 method。

弹框控制:属性方式 ​

为什么需要:新增表单默认不显示,点击“添加”才弹出;点“取消”或小叉要能关闭。
怎么想:如果只是点了立刻显示/隐藏,用 Bootstrap 属性最直接。
怎么做:显示弹框要两个属性:data-bs-toggle="modal" 表示弹框,data-bs-target 指定弹框的 CSS 选择器;关闭用 data-bs-dismiss="modal",且关闭按钮要放在弹框内部。

html
<button data-bs-toggle="modal" data-bs-target=".add-modal">添加</button>
<button data-bs-dismiss="modal">取消</button>

易错点:data-bs-target 的值是选择器,类选择器不要漏点;取消、小叉必须位于对应弹框内部,否则 data-bs-dismiss 不知道关闭哪个弹框。

JS 控制弹框:为提交做准备 ​

为什么需要:点击“添加确认”时不能立刻关闭弹框,而要先把数据提交到服务器,成功后再关闭。
怎么想:转写稿明确,JS 控制适合“显示或隐藏之前还要做一些别的事情”的场景。
怎么做:先拿到弹框 DOM,实例化 bootstrap.Modal,再用 show()、hide() 控制。

js
const addModal = new bootstrap.Modal(document.querySelector('.add-modal'))
// 提交成功后关闭
addModal.hide()

易错点:bootstrap.Modal 的 M 是大写;构造函数接收的是 DOM 元素;隐藏方法是 hide(),不是 hiigh。

收集表单数据并提交 ​

为什么需要:新增接口需要书名、作者、出版社和创建者外号。转写稿中表单的 name 已与接口字段对应,可以直接用表单插件收集。
怎么想:serialize 能拿到表单内三个字段,但接口还需要 creator,所以用展开运算符合并。
怎么做:转写稿里的“s yourize”实际是 serialize,第二个参数为 { hash: true, empty: true }。

js
// 点击“添加”确认按钮后
const data = serialize(addForm, { hash: true, empty: true })
await axios({
  url: '新增图书接口地址',
  method: 'post',
  data: { creator, ...data }
})

调试时可在网络面板查看请求,预览中服务器返回“添加图书成功”,提交数据应有 4 个字段。
易错点:漏掉 creator 会少一个参数;接口方法是 post;serialize 得到的只是表单三字段,不要忘记展开合并。

刷新列表与清空表单 ​

为什么需要:服务器数据变了,页面不会自动变;表单不清空,下次打开还会残留上次输入。
怎么想:复用图书列表阶段封装好的 getBooks(),提交成功后重新获取并渲染;表单用 reset() 清空。
怎么做:提交成功后依次关闭弹框、刷新列表、重置表单。

js
addModal.hide()
getBooks()
addForm.reset()

转写稿的调试过程很关键:只提交时,网络请求成功但页面不变;刷新浏览器后才看到新书,说明服务器已变、本地未重新获取;调用 getBooks() 后页面立即更新。
易错点:忘记 getBooks() 页面不更新;reset() 是表单元素的方法;不要只清空某个输入框。

小结 ​

新增数据可记为三步:准备好数据标签和样式(本节是 Bootstrap 弹框与表单);收集表单数据提交保存;刷新列表。核心代码就是属性控制弹框、serialize 收集、axios 提交、getBooks() 刷新、reset() 清空。

先想清楚:删除按钮是“后来才有”的 ​

删除图书的目标很直接:点某一行末尾的删除标签,把这一行的数据从服务器删掉,再让页面显示最新的列表。但动手前要先想明白一件事——页面上的删除标签不是写在 HTML 里的,而是渲染图书列表时用 map 循环拼出来的。也就是说,页面加载完成那一刻,这些标签还不存在。

为什么这件事重要?因为如果你直接 document.querySelector('.del').addEventListener(...),在脚本执行时根本选不到元素,会报错或者绑定无效。怎么想?元素是动态生成的,就要用事件委托:把点击事件绑在一直存在的父元素 tbody 上,利用事件冒泡,点内部任何一个元素都会冒泡到 tbody,再通过事件对象 e.target 判断到底点的是谁。怎么做?先审查元素确认 tbody 的类名是 list,然后绑定:

js
document.querySelector('.list').addEventListener('click', (e) => {
  // 后续逻辑
})

易错点:委托的父元素必须是页面一开始就存在的元素,绑在 tr 或删除标签上都会失效。

判断点的是不是删除标签:取反 + return ​

tbody 里有很多元素,点序号、点书名都会触发这个事件,所以必须过滤。删除标签的类名是 del,用 classList.contains('del') 判断。

这里有个写法上的推导:如果写成 if (e.target.classList.contains('del')) { ...一大段逻辑... },后面还要嵌套 then 回调,层级会越来越深,可读性差。转写稿里把判断取反,先 return:

js
if (!e.target.classList.contains('del')) return

为什么可行?点的不是删除标签时,条件成立,直接 return 阻断后续代码;能执行到下面的,就一定点的是删除标签。这就是“卫语句”思路,减少嵌套。易错点:contains 里的类名不要带点(写 del 不是 .del),取反符号别漏。

第二步:拿到要删除的那条数据的 id ​

删除接口需要知道删哪一条,所以必须拿到 id。可是渲染出来的界面上并没有 id。怎么想?id 在 getBooks 里请求回来的数据中就有,那就在渲染时“顺手”存下来。存在哪里最方便?删除和以后的编辑都要用,所以存在删除标签的父元素 tr 上,用自定义属性 data-id:

js
`<tr data-id="${v.id}">`

取值时,从触发事件的元素往上找父元素,再读 dataset:

js
const { id } = e.target.parentNode.dataset

调试过程可以这样验证:右键检查元素,看 tr 上有没有 data-id;再在控制台打印 e.target.parentNode.dataset,确认拿到 { id: '...' }。易错点:dataset 取值是驼峰形式,data-id 对应 dataset.id;如果存的是 data-book-id,就要写 dataset.bookId。另外父元素别找错,删除标签的直接父元素是 tr。

第三步:调用删除接口 ​

打开接口文档会发现,删除和之前的列表、新增有两处不同:请求方法是 delete;参数 id 的位置是 path(路径参数),要直接拼在 URL 末尾的 {id} 处,而不是放在 params 里。

js
axios({
  url: `http://.../books/${id}`,
  method: 'delete'
})

为什么用模板字符串?因为要把变量拼进 URL。调试时打开网络面板,能看到多了一次请求;点开响应,状态码是 204。这里要特别注意:204 表示成功,但服务器不返回响应体,所以预览里是空的,不要误以为失败。易错点:方法写成 delete(大小写不敏感,但别写成 del);id 忘了拼进 URL。

第四步:重新获取列表 ​

接口调用成功后,服务器数据变了,但页面不会自动变——页面上的数据只是渲染结果。所以要在 then 里调用之前封装好的 getBooks():

js
.then(() => {
  getBooks()
})

getBooks 内部既请求数据又渲染,调用一次页面就更新了。易错点:只写 axios 不刷新,会看到“删了但还在”;反过来,也别在请求还没成功时就去刷新。

小结 ​

删除数据的步骤是:告知服务器要删除的数据 id,服务器删除后,重新获取并刷新列表。落到代码上:事件委托绑 tbody → 判断 del 类名(取反 + return)→ 从父元素 dataset 取 id → axios 用 delete 方法和路径参数调接口 → getBooks() 刷新。

目标 ​

完成图书管理案例中的“编辑图书”需求:点击某一行的“编辑”,先获取该图书详情并回显到编辑弹框,用户修改后提交到服务器,最后重新获取列表刷新页面。

本节笔记把编辑图书拆成三步:获取正在编辑的数据并回显、收集编辑表单数据提交保存、重新获取并刷新列表。转写稿中编辑图书分了多个视频讲解,下面按核心步骤展开。

编辑数据的核心思路 ​

为什么需要:页面上的数据只是渲染结果,真正准确的数据在服务器。删除、新增、修改最终改变的都是服务器数据,所以编辑时不能直接从页面文本里取旧数据,而要先按 id 查询详情。

怎么想:点击编辑时先拿到该行图书 id;用 id 调“查询图书详情”接口;把返回数据回显到编辑表单;用户点“修改”后收集表单数据,用 put 提交;服务器成功后调用 getBooks() 重新获取列表,并关闭弹框。

怎么做:编辑按钮是动态生成的,所以用事件委托绑定到 tbody,并判断点击的是不是编辑标签。转写稿里用“取反 + return”减少嵌套:

js
document.querySelector('.list').addEventListener('click', (e) => {
  if (!e.target.classList.contains('editor')) return
  // 能走到这里,说明点的是编辑
})

易错点:不要把判断逻辑全写在 if 里层层嵌套;编辑标签类名要核对准确;事件委托的父元素要选一直存在的 tbody。

获取正在编辑的数据 ​

为什么需要:编辑回显必须用服务器最新数据,保证准确性。转写稿中调试时也是先点编辑,再打开 Network 看“查询图书详情”的响应,确认 data 里有 id、bookname、author、publisher。

怎么想:id 在删除功能里已经存到编辑/删除按钮的父元素自定义属性 data-id 上,编辑时直接通过 e.target.parentNode.dataset.id 取,不必重复存。

怎么做:

js
const { id } = e.target.parentNode.dataset
axios({
  url: `接口地址/${id}`,
  method: 'get'
}).then(res => {
  const book = res.data.data
  // 回显 book
})

易错点:dataset 取到的是对象,要解构 id;如果列表渲染时漏存 data-id,这里就拿不到 id;详情接口参数在路径里,不是 params。

回显到编辑弹框 ​

为什么需要:编辑弹框要先拿到数据再显示,所以适合用 JS 控制 Bootstrap 弹框,而不是纯属性控制。转写稿中强调:点击编辑不是立刻弹框,而是“先拿数据,再弹框”。

怎么想:创建编辑弹框实例;把 book 的字段填到表单元素;ID 不能让用户改,放进 type="hidden" 的隐藏域。转写稿先逐个写,后来发现“表单元素类名”和“对象属性名”一致,于是用 Object.keys 遍历优化。

怎么做:

js
const editModel = new bootstrap.Modal(document.querySelector('.edit-modal'))

Object.keys(book).forEach(key => {
  document.querySelector(`.edit-modal .${key}`).value = book[key]
})
editModel.show()

易错点:选择器要限定在 .edit-modal 内,避免选到新增弹框;隐藏域用于存 id,不能让用户看到或修改;先填充再 show()。

保存修改并刷新列表 ​

为什么需要:点“修改”后要把新数据提交服务器;服务器变了页面不会自动变,所以要重新调用列表函数;弹框也要关闭。

怎么想:用 serialize 收集编辑表单数据,解构出 id、bookname、author、publisher;id 拼到 URL 路径,其他字段放 data,并补上外号变量 creator;请求方法用 put。

怎么做:

js
document.querySelector('.edit-btn').addEventListener('click', () => {
  const form = document.querySelector('.edit-modal form')
  const data = serialize(form, { hash: true, empty: true })
  const { id, bookname, author, publisher } = data

  axios({
    url: `接口地址/${id}`,
    method: 'put',
    data: { bookname, author, publisher, creator }
  }).then(() => {
    getBooks()
    editModel.hide()
  })
})

易错点:id 要拼 URL,不要只放 data;creator 是获取列表时定义的外号变量;提交成功后别忘 getBooks() 和 editModel.hide();调试时先看 Network 的请求体是否正好四个字段,再看响应是否“修改图书成功”。

小结 ​

编辑数据的步骤是:获取正在编辑数据并回显,收集编辑表单的数据提交保存,重新获取并刷新列表。转写稿中的 bushop/brap 均为 Bootstrap,ex/axx 均为 axios,s yourize/seize 为 serialize,写代码时注意纠正。

一、为什么先总结:管理类业务有共同模型 ​

学完图书管理案例,真正要收获的不是“只会改图书”,而是理解一类管理业务的共同模型:数据存在服务器,页面通过 axios 调接口完成增删改查,操作成功后重新取数并渲染。
为什么需要:实际项目里数据字段会变,但“列表、新增、删除、修改”的流程高度相似。
怎么想:先判断当前操作改的是哪里的数据——服务器;再判断页面为什么不自动变——页面只是渲染结果,必须重新获取。
怎么做:把列表逻辑抽成 getBooks,新增、删除、修改成功后都调用它。
易错点:只提交成功却不刷新页面;把“页面数据”当成“服务器数据”。

二、渲染列表:先取数,再渲染 ​

为什么需要:列表是后续删除、编辑的基础,ID 也应在渲染时顺手保存。
怎么想:第一步通过 creator 外号告诉服务器“我是谁”,拿到自己的图书;第二步把数组映射成 HTML 字符串并渲染。
怎么做:核心片段按转写稿整理如下:

js
async function getBooks() {
  const res = await axios({
    url: '图书列表接口',
    params: { creator }
  });
  const list = res.data.data;
  const html = list.map((book, index) => `
    <tr data-id="${book.id}">
      <td>${index + 1}</td>
      <td>${book.bookname}</td>
      <td>${book.author}</td>
      <td>${book.publisher}</td>
    </tr>
  `).join('');
  document.querySelector('.list').innerHTML = html;
}

易错点:忘记 join('');模板里字段名写错;ID 没有在渲染时存到 data-id,导致删除、编辑拿不到 ID。

三、新增:先准备输入区,再提交并刷新 ​

为什么需要:用户要输入书名、作者、出版社,必须有一个输入区域。案例使用 Bootstrap 弹框。
怎么想:弹框显示可用属性控制,也可用 JS 控制。点击“添加”立即显示,适合属性控制;提交成功后还要先收集数据再关闭,适合 JS 控制。
怎么做:属性控制用 data-bs-toggle="modal" 和 data-bs-target="选择器";隐藏用 data-bs-dismiss="modal"。JS 控制用 new bootstrap.Modal(dom),再调用 show()、hide()。提交时用 serialize 收集表单数据,再与 creator 合并:

js
const data = { creator, ...serialize(form, { hash: true, empty: true }) };
await axios({ url: '新增接口', method: 'post', data });
getBooks();
form.reset();
editModal.hide();

易错点:serialize 只拿到三个字段,忘记合并 creator;新增成功后忘记 getBooks();忘记 reset() 清空表单。

四、删除:事件委托、路径参数、重新取数 ​

为什么需要:删除按钮是动态生成的,直接绑事件绑不到。
怎么想:把点击事件委托给一直存在的 tbody,再判断点击的是不是删除按钮。
怎么做:用 e.target.classList.contains('del') 判断;为减少嵌套,可反向判断并 return。ID 从父元素的 data-id 取,拼到 URL 末尾:

js
tbody.addEventListener('click', async (e) => {
  if (!e.target.classList.contains('del')) return;
  const { id } = e.target.parentNode.dataset;
  await axios({ url: `删除接口/${id}`, method: 'delete' });
  getBooks();
});

易错点:把事件绑在动态按钮上;忘记 return 导致非删除区域也执行;删除接口参数是路径参数,不是 query 参数。

五、编辑:取数、回填、提交并刷新 ​

为什么需要:编辑要先拿到准确数据,再回填弹框。
怎么想:不要从页面文本取数,应问服务器要详情;回填时表单元素类名与对象属性名一致,可用 Object.keys 循环简化。
怎么做:

js
const res = await axios({ url: `详情接口/${id}` });
const book = res.data.data;
Object.keys(book).forEach(key => {
  document.querySelector(`.edit-modal .${key}`).value = book[key];
});
editModal.show();

ID 通常放在 type="hidden" 的表单元素里,用户看不到但不能改。保存时用 serialize 取表单值,解构出 id、bookname、author、publisher,再提交:

js
await axios({
  url: `修改接口/${id}`,
  method: 'put',
  data: { bookname, author, publisher, creator }
});
getBooks();
editModal.hide();

易错点:ID 让用户可见可改;回填时选择器写死;修改成功后忘记关闭弹框和刷新列表。

六、总结:四步流程可套用 ​

渲染数据:获取数据 → 渲染;新增数据:准备输入区 → 提交 → 刷新;删除数据:触发删除 → 调删除接口 → 刷新;修改数据:获取详情 → 回填 → 提交 → 刷新并关弹框。
易错点总清单:混淆 query 参数与路径参数;忘记 creator;忘记 getBooks();事件委托判断不严;动态元素直接绑事件;表单提交后不清空、不关弹框。掌握这些,再遇到类似管理业务就能按同一思路下手。

课件图示 ​

课件第6页:新增图书 bootstrap弹框 增加图书 <button data-bs-to

课件第11页:1. 图书列表 2. 新增图书 3. 删除图书 4. 编辑图书 需求 (渲染数据

课件第13页:核心步骤 获取数据 渲染数据 渲染数据 收集待新增数据 新增数据 新增数据 删除

课件第28页:修改信息 收集表单数据 调用修改信息接口 提示用户 <div class="to

课件第29页:总结 总结 1. 信息渲染 获取-> 渲染 2. 修改头像 调用接口-> 更新头

课件第8页:删除图书 绑定点击事件 删除图书 axios({ url: 'http://hm

课件第14页:核心步骤 获取数据 渲染数据 渲染数据 收集待新增数据 新增数据 新增数据 修改

代码示例 ​

代码示例 1:Bootstrap 弹框的两种控制方式(属性 + JS) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Bootstrap 弹框 - 属性控制 & JS 控制</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body>
  <div class="container mt-3">
    <!-- 属性方式:显示弹框(data-bs-toggle + data-bs-target 指定选择器) -->
    <button class="btn btn-primary"
            data-bs-toggle="modal"
            data-bs-target=".add-modal">属性方式-显示弹框</button>

    <!-- JS 方式:显示弹框 -->
    <button class="btn btn-success btn-show">JS 方式-显示弹框</button>
  </div>

  <!-- 弹框结构:从 Bootstrap 文档 Modal 里复制基础例子 -->
  <div class="modal add-modal">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">新增图书</h5>
          <!-- 属性方式:隐藏弹框(关闭的是标签所在的当前弹框) -->
          <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
        </div>
        <div class="modal-body">
          <form class="add-form">
            <input type="text" class="form-control mb-2" name="bookname" placeholder="书名">
          </form>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
          <button type="button" class="btn btn-primary btn-add">添加</button>
        </div>
      </div>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    // 1. 创建弹框实例:构造函数里传的是 DOM 元素,不是选择器字符串
    const addModal = new bootstrap.Modal(document.querySelector('.add-modal'))

    // 2. JS 方式显示弹框
    document.querySelector('.btn-show').addEventListener('click', () => {
      addModal.show()
    })

    // 3. JS 方式隐藏弹框:隐藏之前还要做别的事(收集数据提交服务器)
    document.querySelector('.btn-add').addEventListener('click', () => {
      // to do:收集用户数据,并提交到服务器
      console.log('提交数据……')
      addModal.hide()
    })
  </script>
</body>
</html>

关键行与易错点

  • data-bs-toggle="modal" 只表示“我要弹框”,必须再配 data-bs-target="选择器" 告诉它弹哪一个,选择器前要带 .(类选择器)。
  • data-bs-dismiss="modal" 关闭的是标签所在的当前弹框,所以小叉和“取消”只要写在弹框内部就能生效,不用写 JS。
  • new bootstrap.Modal(dom元素) 里必须是 DOM 元素;直接传字符串不会报错但拿不到实例。
  • 什么时候用属性、什么时候用 JS:点击立刻显示/隐藏用属性;显示或隐藏之前要先执行一段 JS 逻辑(比如先拿数据、先提交表单)就必须用 JS 的 show() / hide()。

代码示例 2:渲染图书列表 + 新增图书 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>图书管理 - 渲染列表 & 新增</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
  <style>
    .list { text-align: center; }
    .list a { margin: 0 4px; }
  </style>
</head>
<body>
  <div class="container mt-3">
    <h3>
      图书管理
      <button class="btn btn-primary btn-sm"
              data-bs-toggle="modal"
              data-bs-target=".add-modal">添加</button>
    </h3>
    <table class="table table-bordered list">
      <thead>
        <tr>
          <th>序号</th><th>书名</th><th>作者</th><th>出版社</th><th>操作</th>
        </tr>
      </thead>
      <tbody>
        <!-- 数据由 JS 动态渲染 -->
      </tbody>
    </table>
  </div>

  <!-- 新增弹框 -->
  <div class="modal add-modal">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">新增图书</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
        </div>
        <div class="modal-body">
          <form class="add-form">
            <input type="text" class="form-control mb-2" name="bookname" placeholder="书名">
            <input type="text" class="form-control mb-2" name="author" placeholder="作者">
            <input type="text" class="form-control mb-2" name="publisher" placeholder="出版社">
          </form>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
          <button type="button" class="btn btn-primary btn-add">添加</button>
        </div>
      </div>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
  <script>
    // 外号(数据所有者),换成自己的,别用我的
    const creator = '西兰花'
    // 接口地址(以接口文档为准)
    const BASE_URL = 'http://hmajax.itheima.net/api/books'

    // ========== 一、渲染图书列表(抽成函数,后面增删改都要复用) ==========
    const getBooks = async () => {
      const res = await axios({ url: BASE_URL, params: { creator } })
      const list = res.data.data          // 数组:每项是一本书
      const html = list.map((item, index) => `
        <tr>
          <td data-id="${item.id}">${index + 1}</td>
          <td>${item.bookname}</td>
          <td>${item.author}</td>
          <td>${item.publisher}</td>
          <td data-id="${item.id}">
            <a href="javascript:;" class="del">删除</a>
            <a href="javascript:;" class="edit">编辑</a>
          </td>
        </tr>`).join('')                 // join('') 之间不能有任何连接符
      document.querySelector('.list tbody').innerHTML = html
    }
    getBooks()                            // 默认调用一次

    // ========== 二、新增图书 ==========
    const addModal = new bootstrap.Modal(document.querySelector('.add-modal'))
    const addForm = document.querySelector('.add-form')

    document.querySelector('.btn-add').addEventListener('click', async () => {
      // 1. 收集表单数据(课程用 serialize 插件,这里用 FormData 等价实现)
      const data = {}
      new FormData(addForm).forEach((value, key) => { data[key] = value })

      // 2. 提交到服务器:表单只有 3 个字段,接口还要 creator,用展开运算符合并
      await axios({ url: BASE_URL, method: 'post', data: { ...data, creator } })

      // 3. 服务器数据变了,页面不会自动变 —— 必须重新获取并渲染
      getBooks()
      // 4. 清空表单 + 关闭弹框
      addForm.reset()
      addModal.hide()
    })
  </script>
</body>
</html>

关键行与易错点

  • index + 1:map 的索引从 0 开始,直接渲染会出现序号 0。
  • join('') 的参数必须是空字符串,否则表格行之间会多出逗号等连接符。
  • 列表逻辑一定要抽成 getBooks():新增、删除、修改后服务器数据变了,页面不会自己变,都得再调一次它。
  • creator 提升为全局常量:获取列表和新增/修改都要用到,写死在函数里后面没法复用。
  • { ...data, creator }:serialize/FormData 拿到的只有 bookname/author/publisher,接口还要求 creator,用展开运算符拼成一个完整对象再提交。
  • 提交成功后的顺序:先刷新列表 → 再 reset() 清空表单 → 最后 hide() 关弹框;漏了 reset(),下次点“添加”还会看到上次的旧内容。
  • form.reset() 是原生表单方法,必须用 form 元素调,用 input 调没有效果。

代码示例 3:删除图书 + 编辑图书(回显 + 保存修改) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>图书管理 - 删除 & 编辑</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
  <style>
    .list { text-align: center; }
    .list a { margin: 0 4px; }
  </style>
</head>
<body>
  <div class="container mt-3">
    <h3>图书管理</h3>
    <table class="table table-bordered list">
      <thead>
        <tr>
          <th>序号</th><th>书名</th><th>作者</th><th>出版社</th><th>操作</th>
        </tr>
      </thead>
      <tbody><!-- 动态渲染 --></tbody>
    </table>
  </div>

  <!-- 编辑弹框(和新增是两套弹框) -->
  <div class="modal edit-modal">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">编辑图书</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
        </div>
        <div class="modal-body">
          <form class="edit-form">
            <!-- 隐藏域存 ID:用户看不到,但数据提交时会带上 -->
            <input type="hidden" name="id">
            <input type="text" class="form-control mb-2" name="bookname" placeholder="书名">
            <input type="text" class="form-control mb-2" name="author" placeholder="作者">
            <input type="text" class="form-control mb-2" name="publisher" placeholder="出版社">
          </form>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
          <button type="button" class="btn btn-primary btn-edit">修改</button>
        </div>
      </div>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
  <script>
    const creator = '西兰花'
    const BASE_URL = 'http://hmajax.itheima.net/api/books'

    // ========== 渲染列表(把 id 顺手存到父元素 td 的自定义属性上) ==========
    const getBooks = async () => {
      const res = await axios({ url: BASE_URL, params: { creator } })
      const list = res.data.data
      const html = list.map((item, index) => `
        <tr>
          <td>${index + 1}</td>
          <td>${item.bookname}</td>
          <td>${item.author}</td>
          <td>${item.publisher}</td>
          <td data-id="${item.id}">
            <a href="javascript:;" class="del">删除</a>
            <a href="javascript:;" class="edit">编辑</a>
          </td>
        </tr>`).join('')
      document.querySelector('.list tbody').innerHTML = html
    }
    getBooks()

    // ========== 一、删除图书(事件委托) ==========
    document.querySelector('.list tbody').addEventListener('click', async (e) => {
      // 点的不是删除标签,直接阻断后续代码(比套 if 可读性更好)
      if (!e.target.classList.contains('del')) return

      const id = e.target.parentNode.dataset.id      // 从父元素 td 上取自定义属性
      await axios({ url: `${BASE_URL}/${id}`, method: 'delete' })

      getBooks()                                     // 重新获取并渲染最新列表
    })

    // ========== 二、编辑图书:获取详情 + 回显 ==========
    const editModal = new bootstrap.Modal(document.querySelector('.edit-modal'))
    const editForm = document.querySelector('.edit-form')

    document.querySelector('.list tbody').addEventListener('click', async (e) => {
      if (!e.target.classList.contains('edit')) return

      const id = e.target.parentNode.dataset.id
      // 页面上看到的数据不够准,查看数据一律以服务器为准
      const res = await axios({ url: `${BASE_URL}/${id}` })
      const book = res.data.data

      // 表单元素的 name 与对象属性名一一对应,循环设置值
      Object.keys(book).forEach(key => {
        const input = editForm.querySelector(`[name="${key}"]`)
        if (input) input.value = book[key]
      })

      editModal.show()                               // 先拿数据,再弹框
    })

    // ========== 三、编辑图书:保存修改 ==========
    document.querySelector('.btn-edit').addEventListener('click', async () => {
      const data = {}
      new FormData(editForm).forEach((value, key) => { data[key] = value })

      // 解构:id 要拼在 URL 上,其余三个放 data 里
      const { id, bookname, author, publisher } = data

      await axios({
        url: `${BASE_URL}/${id}`,
        method: 'put',
        data: { bookname, author, publisher, creator }
      })

      getBooks()        // 刷新列表
      editModal.hide()  // 关闭弹框
    })
  </script>
</body>
</html>

关键行与易错点

  • 事件委托:列表是动态生成的,事件必须绑在一直存在的 tbody 上;再用 e.target.classList.contains('del') 判断点的是不是目标按钮。
  • if (!xxx) return 的取反写法:点错元素直接阻断,后面的代码不用层层嵌套,可读性更好。
  • data-id 存在 a 的父元素 td 上,所以取值是 e.target.parentNode.dataset.id;如果存在 tr 上就要 parentNode.parentNode,层级写错会拿到 undefined。
  • 删除接口是 method: 'delete',参数是路径参数(直接拼在 URL 末尾 ${BASE_URL}/${id}),不是 params;删除成功服务器通常不返回内容(204),别以为请求失败了。
  • 编辑时先获取数据再 show(),所以只能用 JS 控制弹框,不能只靠 data-bs-toggle。
  • 隐藏域 <input type="hidden" name="id"> 用来存 ID:ID 不能让用户改,但提交时又要带上;如果写成普通输入框,用户改掉 ID 服务器就找不到要改哪条数据了。
  • Object.keys(book).forEach(...) 依赖“表单 name 与接口字段名一致”这个规律,一次循环搞定所有字段;用中括号语法 book[key] 动态取值,别写成 book.key。
  • 保存修改用 method: 'put',ID 拼在 URL,其余字段放 data,且必须补上 creator(它是全局常量,直接取用即可)。
  • 提交成功后两步缺一不可:getBooks() 刷新列表 + editModal.hide() 关闭弹框。

常见错误 ​

  1. 点“添加”按钮弹框不出来 现象:按钮点击无任何反应,控制台无报错。 原因:只引入了 bootstrap.css 没引入 bootstrap.js;或 data-bs-target 写成了 #add-modal 而标签上根本没有 id(案例里用的是类名,要写 .add-modal);或漏写 data-bs-toggle="modal"。 修法:确认两个文件都引入且顺序在自定义脚本之前;打开审查元素核对 target 选择器与实际弹框类名/id 完全一致,点号/井号不能漏。

  2. 新增提交成功后弹框关不掉 现象:数据提交成功、列表也刷新了,但弹框一直挂着。 原因:显示/隐藏只用了属性方式(data-bs-dismiss),而提交按钮上的逻辑是 JS 执行的,属性方式无法在异步回调里关框。 修法:用 JS 方式 const addModal = new bootstrap.Modal(document.querySelector('.add-modal')),在 then 里调 addModal.hide();同时注意方法名是 hide 不是 hidden、close。

  3. 新增后列表不刷新,必须手动刷新页面才看到新数据 现象:接口返回“添加成功”,页面数据不变。 原因:只提交了数据,忘了服务器数据变了不等于页面变了;或者获取列表的逻辑写在默认执行处,没有抽成可复用函数。 修法:把“获取+渲染”抽成 getBooks() 并在页面加载时调用一次;任何增删改成功后都再调一次 getBooks()。

  4. 删除/编辑时拿不到 id,请求 URL 里出现 undefined 现象:请求发出去是 /books/undefined,或 dataset.id 打印为 undefined。 原因:渲染列表时忘了在 tr 上写 data-id="${v.id}",或写了但取的是 e.target.dataset.id(删除按钮本身没有该属性,属性在父级 tr 上)。 修法:渲染模板时补上 data-id;取值统一走 e.target.parentNode.dataset.id,并先 console.log 验证再拼接口。

  5. 点表格任意位置都触发删除/编辑 现象:点空白处、点书名都会发请求。 原因:事件委托绑在 tbody 上,只写了点击回调,没有过滤真正触发的元素。 修法:if (!e.target.classList.contains('del')) return(用取反 + return 阻断,减少嵌套);同时确认绑的是 tbody 这种始终存在的祖先,而不是动态生成的 tr。

  6. 编辑弹框里回显的数据错乱或为空 现象:点编辑后弹框弹出,但输入框是空的,或填到了新增弹框的输入框里。 原因:两个弹框里存在同名类名(如 .book-name),querySelector 命中了页面上第一个匹配的元素(新增框里的);或先 show() 再异步拿数据,时序上看起来“没回显”。 修法:选择器加祖先限定 .edit-modal .book-name;回显逻辑放在获取详情成功的回调里,赋值完成后再 editModal.show()。

  7. serialize 拿到的不是对象,展开合并后参数不对 现象:data 是 bookName=xx&author=yy 这样的字符串,或提交后接口报缺少 creator。 原因:serialize 第二个参数忘了传 { hash: true, empty: true };或只提交了表单数据,没把外号 creator 合并进去。 修法:固定写 serialize(form, { hash: true, empty: true });提交时用 { creator, ...data } 合并,并注意展开顺序(后面的会覆盖前面的同名属性)。

练习 ​

  1. 给新增加校验:书名输入为空时点击“添加”不发请求,弹框保持显示并给出提示。要求说明为什么这里必须用 JS 方式控制弹框,而不能靠 data-bs-dismiss。

  2. 删除加二次确认:点击删除先弹 confirm('确定删除吗?'),取消则不调接口。写出事件委托里的完整判断链路(过滤元素 → 取 id → 确认 → 调接口 → 刷新)。

  3. 合并弹框改造:把新增和编辑合并成同一个弹框(后续项目常见做法)。列出需要改动的位置,并说明如何用一个变量区分当前是“新增态”还是“编辑态”,以及点击“保存”时如何走不同分支。

  4. 扩展字段:给图书增加“价格”列,要求列表能展示、新增能录入、编辑能回显和修改。写出最少改动点,并说明为什么编辑回显那段 Object.keys(book).forEach(...) 的写法可以几乎不改代码。

  5. 回显健壮性:若详情接口返回的字段比表单元素多(比如多了 createTime),Object.keys 循环回显会出什么问题?给出两种修法(提示:判断元素是否存在 / 只遍历表单元素)。

原视频对照 ​

#视频时长
1图书管理-图书列表12:45
2图书管理-新增图书0112:26
3图书管理-新增图书0210:43
4图书管理-删除图书14:08
5图书管理-编辑图书-获取数据09:35
6图书管理-编辑图书-编辑弹框14:01
7图书管理-编辑图书-保存修改09:59
8图书管理-总结06:23

小结 ​

  1. 图书管理四个功能(渲染、新增、删除、编辑)本质是同一套模板:拿数据 → 改数据 → 重新拿数据刷新页面,只有“改”的那一步接口不同。
  2. 凡是服务器上的数据发生变化,本地页面都必须重新调用一次抽好的 getBooks(),不要指望刷新页面提示用户。
  3. 弹框控制记住选择标准:立刻显示/隐藏用属性(data-bs-toggle/data-bs-target/data-bs-dismiss),显示或隐藏之前还要执行 JS 逻辑(校验、提交、回显)就必须用 new bootstrap.Modal(el) + show()/hide()。
  4. 动态生成的元素一律用事件委托绑在始终存在的祖先上,并用“取反 + return”的方式过滤目标元素,代码嵌套更浅、可读性更好。
  5. id 要在渲染列表时顺手写进 tr 的 data-id,删除和编辑都从这里取;后期做任何行内操作都靠它,别等到用时再回来补。
  6. 编辑场景必须从服务器重新查详情再回显,不能从页面上抠文本;id 用 type="hidden" 的输入框承载,既不污染用户视野又能被 serialize 一并收集。
  7. 请求方法(get/post/put/delete)统一写进 method,路径参数直接拼到 URL 末尾,query 参数放 params——三者位置别混。
  8. 编辑回显用 Object.keys(book).forEach + 模板字符串选择器 + 中括号取值,能让“字段数量变化”变成零成本,这个套路在后续表单类业务里会反复用到。