Skip to content

黑马就业数据平台(案例实战)(二) ​

本页对应 黑马就业数据平台(案例实战),共 8 段视频,约 1 小时 6 分。

学习目标 ​

  • 能使用 Axios 的 GET 请求别名获取学员列表数据,并将返回数据通过模板字符串渲染到页面指定容器中。
  • 能封装可复用的渲染函数,并在新增、删除等业务完成后调用该函数刷新页面数据。
  • 能初始化并控制 Bootstrap 模态框,完成点击新增按钮弹出模态框、提交后关闭模态框及重置表单数据的操作。
  • 能实现省、市、区三级联动:默认渲染省份,省份改变后请求并渲染城市,城市改变后请求并渲染地区,同时正确清空下级列表。
  • 能通过表单收集新增学员数据,按接口要求转换字段类型,使用 POST 请求提交数据,并通过 try/catch 处理成功与失败提示。
  • 能使用事件委托监听动态生成的删除按钮,获取对应学员 ID 并调用删除逻辑,同时能将本地提交推送到远程仓库。

知识点 ​

为什么需要把公共逻辑抽到 common.js ​

学生信息管理页面并不是孤立的一页。除了它,项目里还有别的页面,而「进页面先校验登录」「把当前用户名渲染到右上角」「点退出登录能退回登录页」这三件事,几乎每个页面都要做一遍。如果每个页面的 js 里都抄一份,改一处就得改十处,漏一处就是线上 bug。

这个思路在转写稿里其实反复出现过:在写渲染业务时强调,渲染函数一定要抽出来,原因是「我新增了要不要渲染?我编辑了是不是也要渲染,删除是不是也要渲染」——凡是多处复用、且逻辑固定的代码,就应该抽成一个函数。render 是这样,showToast 也是这样(转写稿里提到「我们是不是封装过一个函数,叫做 showToast,在我们 common.js 里边」),而登录校验、渲染用户名、退出登录,同样是这个性质,所以它们被抽到了 common.js,本节要做的就是让 student.js 去用它们。

怎么想:整合不是重写,而是「调用」 ​

要先分清两件事:

  • common.js 负责「做什么」:登录校验怎么判断、用户名从哪取、退出时清什么,这些实现已经写好了,不用再动。
  • student.js 负责「什么时候做」:页面一加载就要校验、就要渲染用户名、就要把退出按钮的事件挂上。

所以整合的核心动作只有三个字:去调用。不需要把 common.js 里的代码复制过来,复制反而是倒退——那就又变成多份副本了。

怎么做:在 student.js 中调用三个公共函数 ​

按照笔记里的关键代码,整合后的 student.js 中会出现这样三处调用:

javascript
// 整合函数-登录校验
checkLogin()

// 整合函数-渲染用户名
renderUsername()

// 整合函数-退出登录逻辑
registerLogout()

这三行放在 student.js 的入口位置即可,也就是页面脚本一开始执行、业务函数(如渲染列表的 render)被调用之前的位置。原因和转写稿里「render 函数不调用就看不到数据」是同一个道理:公共函数写好了不调用,页面上就等于没有——不调 checkLogin(),未登录也能进来;不调 renderUsername(),右上角用户名的位置就是空的;不调 registerLogout(),点退出按钮毫无反应。

调用顺序为什么重要 ​

三行代码不是随便排的,建议保持「先校验 → 再渲染 → 再注册事件」的顺序:

  1. checkLogin() 放在最前面。它是拦截性质的,如果校验不通过,后面的业务就不该继续跑。
  2. renderUsername() 放在校验之后。能走到这一步,说明用户身份是有效的,渲染出来的名字才有意义。
  3. registerLogout() 放在最后,它只是绑定事件,不依赖前面的结果,但放在校验之后可以避免「没登录也把退出按钮挂上」这种别扭情况。

这一点和转写稿里新增业务的处理是相通的:新增失败时,特意强调要先关闭模态框再弹提示,因为提示框层级比模态框低,顺序错了用户根本看不到提示。顺序问题从来不是小事。

易错点 ​

第一,把公共函数又抄了一份到 student.js。 整合的目标是「少写代码」,如果发现自己在 student.js 里重新写了一遍校验逻辑,说明方向就错了,应该删掉,只留调用。

第二,只调用不检查页面元素。 renderUsername() 需要页面上有承载用户名的节点,如果学生端页面的结构里没有这个位置,函数执行了也看不到效果,这时要回去确认页面结构,而不是怀疑函数写错。

第三,registerLogout() 重复调用。 它内部是绑定点击事件,同一个按钮绑定两次,点一下就会触发两次逻辑。整合时确认它只被调用一次即可。

第四,校验函数被放在了业务渲染之后。 如果先发请求拿学生列表、再校验登录,那么未登录状态下请求已经发出去了,拦截就失去了意义。

提交这次改动 ​

整合完成后,按笔记里的 git 记录提交:

bash
git add .
git commit -m"信息管理-公共逻辑整合"

提交前可以先看一眼 git status,确认改动的确实是 student.js 这一个文件——如果发现 common.js 也被改动,说明你可能在整合时顺手改了公共逻辑,那就不是「整合」而是「重构」了,应该拆成两次提交,让每次提交只做一件事。

需求:页面打开后获取并渲染学员数据 ​

为什么需要:学员信息渲染是信息管理的第一步。页面打开后如果没有数据,表格就是空的;只有先拿到学员列表并渲染出来,后面的新增、编辑、删除才有操作对象。

怎么想:转写稿里先问“你现在有数据吗?没有,所以要先获取数据;获取到数据之后再去渲染页面”。因此整体顺序是:抽取渲染函数 → 发请求获取数据 → 拼接 HTML 渲染页面。

学员信息渲染效果

怎么做:把渲染逻辑抽成 getData 函数,页面加载时调用一次。函数内部先请求 /students,再用 res.data 渲染表格,最后更新总人数。

易错点:不要页面一打开就只写请求不渲染;也不要忘记首次调用函数,否则页面不会显示数据。

抽取渲染函数 ​

为什么需要:转写稿明确说“新增了要不要渲染?编辑了是不是也要渲染,删除是不是也要渲染”,所以渲染逻辑要抽成公共函数,后续复用。

怎么想:先定义一个 async function getData(),把“获取数据”和“渲染数据”都放进去。页面加载时先调用一次,验证函数能执行。

怎么做:

javascript
// 抽取函数-获取学员数据
async function getData() {
  // 获取数据
  const res = await axios.get('/students')
  // console.log(res)
  // 渲染数据
  const html = res.data.map(v => {
    const { name, age, gender, group, hope_salary, salary, province, city, area, id } = v
    return `
    <tr>
        <td>${name}</td>
        <td>${age}</td>
        <td>${gender === 0 ? '男' : '女'}</td>
        <td>第${group}组</td>
        <td>${hope_salary}</td>
        <td>${salary}</td>
        <td>${province + city + area}</td>
        <td data-id="${id}">
          <a href="javascript:;" class="text-success mr-3"><i class="bi bi-pen"></i></a>
          <a href="javascript:;" class="text-danger"><i class="bi bi-trash"></i></a>
        </td>
      </tr>
    `
  }).join('')

  document.querySelector('.list').innerHTML = html

  document.querySelector('.total').innerText = res.data.length
}

getData()

易错点:await 必须写在 async 函数里;函数定义后要调用;后续新增、编辑、删除完成后也应重新调用它刷新页面。

用 axios.get 别名获取数据 ​

为什么需要:渲染前必须有数据。转写稿里说“发请求获取数据”,这里用的是 axios 的 GET 别名方法。

怎么想:GET 请求的查询参数不能像 POST 那样直接传数据,而要放在 params 对象里。转写稿里的“盖的请求”就是 get 请求,“parmers”应纠正为 params。

怎么做:

javascript
// get请求
// 参数1 url地址
// 参数2 以对象的形式调整设置
axios.get(url, {
  // 查询参数
  params: {}
})

本节请求学生列表时没有查询参数,所以直接写 axios.get('/students') 即可。

易错点:GET 参数要写在 params 里;不要忘记 await;真正要渲染的是 res.data,不是整个 res。

渲染表格并保存学员 id ​

为什么需要:表格需要把接口字段转换成页面结构;同时笔记要求“保存学员 id 方便后续删除和编辑使用”。

怎么想:用 map 把每个学员对象转成一行 <tr> 字符串,再用 join('') 拼成一个完整字符串,最后赋给 .list 的 innerHTML。性别接口给的是 0/1,要转成“男/女”;省市区要拼接;id 放在 td 的 data-id 上。

怎么做:见上面关键代码。转写稿中的调试过程是:先 console.log(res) 确认请求成功,再打印 res.data 确认是数组;然后对照页面字段取 name、age、gender、group、hope_salary、salary、province、city、area、id;性别先直接渲染会发现显示 0/1,再用 gender === 0 ? '男' : '女' 处理;省市区不能写成 ${province city area},要写成 ${province + city + area}。

易错点:map 后必须 join(''),否则数组转字符串会带逗号;模板字符串要用反引号;gender 判断别写反;data-id="${id}" 不要漏;.total 用 res.data.length 更新总人数。

调试与提交记录 ​

转写稿里还强调:先写函数并 log 验证有没有执行,再发请求看有没有拿到数据,最后赋值看页面有没有渲染。若页面没数据,按“函数是否调用 → 请求是否成功 → res.data 是否有值 → 拼接字符串是否赋值到 .list”顺序排查。

易错点:不要把 res 直接 map;不要忘记 document.querySelector('.list') 的选择器;后续删除、编辑可通过 data-id 取学员 id,因此渲染时就要保存好。

bash
git add .
git commit -m"信息管理-学员信息渲染"

先明确这一节要完成什么 ​

信息管理页面的“增删改查”里,这一节完成“新增学员”。页面上的“加号”按钮点开后要出现模态框,里面要填写姓名、年龄、性别、组号、期望薪资、就业薪资和籍贯。籍贯不是普通输入框,而是省、市、区三级联动下拉框;最后点“确认”把数据提交给后端。

这一节可以拆成三件事:点击按钮弹出模态框、省市区联动、收集并提交新增数据。转写稿里反复强调“先做弹框,再做联动,最后做提交”,因为弹框不出来,后面的表单操作没有载体;联动不完成,提交的数据不完整;提交时不转换类型,后端会校验失败。

第一步:点击按钮弹出模态框 ​

为什么需要先做弹框?新增学员的表单在模态框里,用户必须先看到表单才能填写。怎么想?Bootstrap 的 Modal 组件需要先拿到 DOM 元素并实例化,之后调用实例的 show() 方法显示。不要把 new bootstrap.Modal() 写在点击事件里,否则每次点击都重复实例化。怎么做?笔记代码是:

javascript
// 显示弹框
const modalDom = document.querySelector('#modal')
const modal = new bootstrap.Modal(modalDom)

document.querySelector('#openModal').addEventListener('click', () => {
  modal.show()
})

易错点:选择器要对应页面里的 id="modal" 和 id="openModal";modal.show() 是 Bootstrap 实例方法,不是给 DOM 设置 display。转写稿里先单独调用一次 show() 验证弹框能不能出来,再绑定点击事件,这个调试顺序值得保留。

第二步:省市区三级联动 ​

为什么需要联动?省份决定城市列表,城市决定地区列表,不能让用户手填,也不能让三个下拉框一开始就写死。怎么想?页面加载时先请求省份并渲染;省份 change 时按当前省份请求城市;城市 change 时按当前省和市请求地区。每次上级变化,都要把下级下拉框重置成占位项,否则会出现“选了山西,城市里还留着衡水”的残留数据。转写稿里就抓到了这个 Bug:地区没有清空,上一次的选项还在。

怎么做?笔记把联动抽成 initSelect(),里面完成省数据获取和渲染,并给省、市绑定 change:

javascript
// 省市区联动
const proSelect = document.querySelector('[name=province]')
const citySelect = document.querySelector('[name=city]')
const areaSelect = document.querySelector('[name=area]')

async function initSelect() {
  // 省数据获取+渲染
  const proRes = await axios.get('/api/province')
  const proHtml = proRes.list.map(v => {
    return `<option value="${v}">${v}</option>`
  }).join('')
  proSelect.innerHTML = `<option value="">--省份--</option>${proHtml}`

  // 市数据获取+渲染
  proSelect.addEventListener('change', async () => {
    const cityRes = await axios.get('/api/city', {
      params: {
        pname: proSelect.value
      }
    })
    const cityHtml = cityRes.list.map(v => {
      return `<option value="${v}">${v}</option>`
    }).join('')
    citySelect.innerHTML = `<option value="">--城市--</option>${cityHtml}`

    // 一会需要清空地区标签的内容,否则会有Bug(停留在上一次的状态)
    areaSelect.innerHTML = `<option value="">--地区--</option>`
  })

  // 区数据获取+渲染
  citySelect.addEventListener('change', async () => {
    const areaRes = await axios.get('/api/area', {
      params: {
        pname: proSelect.value,
        cname: citySelect.value
      }
    })
    const areaHtml = areaRes.list.map(v => {
      return `<option value="${v}">${v}</option>`
    }).join('')
    areaSelect.innerHTML = `<option value="">--地区--</option>${areaHtml}`
  })
}

initSelect()

易错点:change 回调里要读当前下拉框的 value,所以 <option> 必须写 value;axios.get 的查询参数放在 params 对象里,不是直接跟在 URL 后面;async 回调里才能用 await。转写稿里还演示了先打印 res 看 list,再 map + join 拼字符串,最后赋给 innerHTML,这个顺序能快速定位“数据有没有拿到”和“页面有没有渲染”。

第三步:收集并提交新增数据 ​

为什么要把新增逻辑抽成函数?因为“确认”按钮不只新增会用,后续编辑也会用同一个按钮,抽成 addStudent() 后,点击事件里只负责调用。怎么想?表单字段多,用 serialize 一次性收集成对象;后端文档要求年龄、性别、组号、期望薪资、就业薪资是数字,而表单收集出来都是字符串,所以要手动转换;提交用 try/catch 区分成功和失败。

怎么做?笔记代码:

javascript
// 数据新增
document.querySelector('#submit').addEventListener('click', () => {
    // 调用新增学员函数
    addStudent()
})

async function addStudent() {
  // 数据收集+转换+提交
  const form = document.querySelector('#form')
  const data = serialize(form, { hash: true, empty: true })

  data.age = +data.age
  data.gender = +data.gender
  data.hope_salary = +data.hope_salary
  data.salary = +data.salary
  data.group = +data.group

  try {
    // 新增成功
    const res = await axios.post('/students', data)
    showToast(res.message)
    getData()
  } catch (error) {
    // 新增失败
    showToast(error.response.data.message)
  }
  modal.hide()
}

易错点:serialize 的 hash: true 才是对象,empty: true 才会保留空值;五个数字字段都要用 + 转成数字,转写稿里第一次提交报“校验失败,请检查字段 age”,就是请求体里全是字符串;modal.hide() 放在 try/catch 后面,保证成功和失败都关闭弹框,否则提示框可能被模态框挡住;成功后要调用 getData() 重新获取列表,页面才会出现新学员。

调试与提交 ​

转写稿里的调试路径可以复用:先点加号确认弹框出现;再选省份看城市是否更新,选城市看地区是否更新,并检查换省后地区是否清空;提交时打开 Network 看请求体,确认数字字段不是字符串;成功后看列表是否刷新,失败时看错误提示是否正常显示。最后按笔记提交:

bash
git add .
git commit -m"信息管理-新增学员"

需求与整体思路 ​

为什么需要:列表里的学生数据被删除后,如果页面不重新获取数据,用户仍会看到已删除的记录,造成“删了但没删”的错觉。
怎么想:点击某一行删除按钮 → 拿到这一行学生的 id → 调用删除接口 → 删除成功后重新获取最新数据并渲染。
怎么做:先看需求示意图,再按“绑定事件(事件委托)→ 抽取函数 → 获取 id + 调用接口 → 重新渲染”推进。
删除数据操作示意图

易错点:删除按钮是渲染后追加的,页面加载时直接 querySelector 往往拿不到;列表里还有编辑按钮,判断点击目标时不能只看标签名,要区分类名。

用事件委托绑定删除点击 ​

为什么需要:删除按钮由渲染模板动态生成,直接给每个删除按钮绑定事件既麻烦又容易绑不到;而 .list 容器一开始就存在。
怎么想:把点击事件绑在 .list 上,利用事件冒泡,等点击发生后判断 e.target 是不是删除图标。转写中也是先验证:点删除时控制台能输出“删除”,点前面的编辑区域没有反应,说明类名判断有效。
怎么做:判断 e.target.classList.contains('bi-trash'),成立就取 id 并调用删除函数。
易错点:不要用标签名判断,因为编辑和删除都可能是 i;要用 classList.contains 判断类名是否存在;点击目标可能不是图标本身,取 id 时要按实际 DOM 层级向上找。

抽取删除函数 ​

为什么需要:删除逻辑独立出来后,事件回调只负责“识别点击 + 取 id”,后续修改请求地址、增加提示等只改一处,便于管理。
怎么想:事件里调用 delStudent(id),函数内部专门做删除请求和重新渲染。转写中强调删除也要抽函数,因为后续编辑等逻辑也会复用类似思路。
怎么做:声明 async function delStudent(id),内部 await axios.delete(...),请求完成后调用渲染函数。
易错点:函数要接收 id 参数,不能写死;await 要放在 async 函数里;事件里别忘记真正调用 delStudent(id)。

获取 id 并调用删除接口 ​

为什么需要:删除接口需要知道删哪一条,接口文档里删除使用路径参数 id,所以请求地址要拼成 /students/${id}。
怎么想:id 在渲染页面时最清楚,因此渲染时把 id 保存到该行元素上;点击时从 e.target 向上找到保存 id 的 td,读取 dataset.id。转写中先把 id 绑到 td 上,再打印验证,点第一行、第二行都能拿到对应 id。
怎么做:笔记关键代码如下,注意 axios.delete(url) 的别名写法:请求方法为 delete,参数 1 是 url 地址。

javascript
// 绑定事件
document.querySelector('.list').addEventListener('click', (e) => {
  // 点了删除标签-调用对应的函数
  if (e.target.classList.contains('bi-trash')) {
    // console.log(e.target.parentNode.parentNode.dataset.id)
    const id = e.target.parentNode.parentNode.dataset.id
    delStudent(id)
  }
})

// 抽取函数-删除数据
async function delStudent(id) {
  // console.log('点了删除')
  // console.log(id)
  // 获取id+调用接口
  await axios.delete(`/students/${id}`)

  // 重新渲染
  getData()
}

易错点:如果 id 绑在 td 上,而点击的是 i,通常需要 parentNode.parentNode 才能到 td;dataset.id 对应 data-id;路径参数要用模板字符串拼接;delete 请求这里只需要 url,不要误加 body 参数。转写中还提到可以把 target、classList、dataset 解构出来,减少重复写 e.target,但解构后要保证层级仍然正确。

重新渲染与提交记录 ​

为什么需要:删除成功后页面必须显示最新列表;提交 git 记录便于回退和追踪本节完成内容。
怎么想:删除请求 await 完成后再调用渲染函数。笔记关键代码里调用的是 getData();如果当前工程渲染函数叫 render,就调用对应函数,核心是“删除成功后重新获取并渲染最新数据”。
怎么做:执行 git 记录:

bash
git add .
git commit -m"信息管理-删除数据"

易错点:不要在请求还没完成时就渲染;重新渲染函数名要与工程一致;提交前确认改动文件正确。

课件图示 ​

课件第5页:信息管理-公共逻辑整合 需求: 整合抽取的公共逻辑 渲染用户名 登录校验 退出登

课件第6页:核心步骤: 信息管理-学员信息渲染 axios.get(url, { param

课件第7页:核心步骤: 信息管理-新增学员 省市区联动 点击弹框 数据新增 实例化Modal

课件第8页:核心步骤: 信息管理-新增学员 省市区联动 点击弹框 数据新增 省数据获取+渲染

课件第9页:核心步骤: 信息管理-新增学员 省市区联动 点击弹框 数据新增 函数抽取 数据收

课件第10页:核心步骤: 信息管理-删除数据 抽取函数 绑定事件 获取id+调用接口 axio

课件第13页:核心步骤: 信息管理-编辑数据 保存修改 弹出编辑框 调整弹框逻辑 修改标题 表

课件第11页:核心步骤: 信息管理-编辑数据 保存修改 弹出编辑框 调整弹框逻辑 函数抽取 根

代码示例 ​

1. student.js —— 公共逻辑整合 + 学员信息渲染 + 删除 ​

javascript
/* ================= 1. 公共逻辑整合(原 common.js) ================= */
checkLogin()        // 登录校验
renderUsername()    // 渲染用户名
registerLogout()    // 退出登录

/* ================= 2. 学员信息渲染 ================= */
async function getData() {
  // GET 请求:参数1 是 url,参数2 用 params 传查询参数
  const res = await axios.get('/students')

  const html = res.data.map(v => {
    const { name, age, gender, group, hope_salary, salary, province, city, area, id } = v
    return `
    <tr>
        <td>${name}</td>
        <td>${age}</td>
        <td>${gender === 0 ? '男' : '女'}</td>
        <td>第${group}组</td>
        <td>${hope_salary}</td>
        <td>${salary}</td>
        <td>${province + city + area}</td>
        <td data-id="${id}">
          <a href="javascript:;" class="text-success mr-3"><i class="bi bi-pen"></i></a>
          <a href="javascript:;" class="text-danger"><i class="bi bi-trash"></i></a>
        </td>
      </tr>
    `
  }).join('')

  document.querySelector('.list').innerHTML = html
  document.querySelector('.total').innerText = res.data.length
}

// 页面加载首次调用
getData()

/* ================= 3. 删除数据(事件委托) ================= */
document.querySelector('.list').addEventListener('click', (e) => {
  // 点了删除图标
  if (e.target.classList.contains('bi-trash')) {
    const id = e.target.parentNode.parentNode.dataset.id
    delStudent(id)
  }
})

// 抽取函数 - 删除数据
async function delStudent(id) {
  await axios.delete(`/students/${id}`)   // DELETE 别名方法
  getData()                               // 删完重新渲染
}

要点解释

  • checkLogin / renderUsername / registerLogout 直接来自 common.js,整合进 student.js 后文件顶部一次性调用,顺序不要乱(先校验登录,再渲染用户名)。
  • getData() 必须在页面加载时首次调用,否则表格永远是空的;后续新增、删除、编辑都复用它。
  • 渲染用 map(...).join('') 拼成一整段 HTML 字符串,join 不能漏,否则数组转字符串会带逗号。
  • 性别字段 gender 后端给的是 0/1,要用三元判断转成「男/女」,不能直接输出。
  • data-id="${id}" 是后续删除和编辑的关键,必须解构出 id 并绑到 td 上。
  • 删除按钮是动态渲染出来的,直接 querySelector 拿不到,必须用事件委托绑在 .list 上;通过 e.target.parentNode.parentNode.dataset.id 取 id(图标 → a → td)。

2. student.js —— 新增学员(弹框 + 省市区联动 + 提交) ​

javascript
/* ================= 1. 弹出模态框 ================= */
const modalDom = document.querySelector('#modal')
const modal = new bootstrap.Modal(modalDom)

document.querySelector('#openModal').addEventListener('click', () => {
  modal.show()
})

/* ================= 2. 省市区三级联动 ================= */
const proSelect = document.querySelector('[name=province]')
const citySelect = document.querySelector('[name=city]')
const areaSelect = document.querySelector('[name=area]')

async function initSelect() {
  // 省数据获取 + 渲染
  const proRes = await axios.get('/api/province')
  const proHtml = proRes.list.map(v => {
    return `<option value="${v}">${v}</option>`
  }).join('')
  proSelect.innerHTML = `<option value="">--省份--</option>${proHtml}`

  // 省 change → 获取城市
  proSelect.addEventListener('change', async () => {
    const cityRes = await axios.get('/api/city', {
      params: { pname: proSelect.value }
    })
    const cityHtml = cityRes.list.map(v => {
      return `<option value="${v}">${v}</option>`
    }).join('')
    citySelect.innerHTML = `<option value="">--城市--</option>${cityHtml}`

    // 关键:清空地区,否则会停留在上一次的状态
    areaSelect.innerHTML = `<option value="">--地区--</option>`
  })

  // 市 change → 获取地区
  citySelect.addEventListener('change', async () => {
    const areaRes = await axios.get('/api/area', {
      params: {
        pname: proSelect.value,
        cname: citySelect.value
      }
    })
    const areaHtml = areaRes.list.map(v => {
      return `<option value="${v}">${v}</option>`
    }).join('')
    areaSelect.innerHTML = `<option value="">--地区--</option>${areaHtml}`
  })
}

initSelect()

/* ================= 3. 数据新增 ================= */
document.querySelector('#submit').addEventListener('click', () => {
  addStudent()   // 抽取函数,方便后续编辑复用同一个确认按钮
})

async function addStudent() {
  const form = document.querySelector('#form')
  const data = serialize(form, { hash: true, empty: true })

  // 后端要求整数类型,字符串必须转换
  data.age = +data.age
  data.gender = +data.gender
  data.hope_salary = +data.hope_salary
  data.salary = +data.salary
  data.group = +data.group

  try {
    const res = await axios.post('/students', data)
    showToast(res.message)   // 成功提示
    getData()                // 重新渲染
  } catch (error) {
    showToast(error.response.data.message)   // 失败提示
  }
  modal.hide()               // 无论成功失败都关闭弹框
}

要点解释

  • 弹框要先实例化再 show():new bootstrap.Modal(modalDom),只拿 DOM 不实例化是弹不出来的。
  • GET 传参必须包一层 params: {},不能像 POST 那样直接跟 data 对象,这是最容易写错的地方。
  • option 的 value 一定要写,否则 select.value 拿到的永远是空字符串,省市联动会全部失效。
  • 省份切换时必须重置地区和城市,否则会出现「换了省,上一次的城市/地区还留着」的 Bug。
  • serialize(form, { hash: true }) 拿到的是字符串对象,年龄、性别、组号、两个薪资共 5 个字段必须用 + 转成数字,否则后端会报「校验失败,请检查字段 age」。
  • try/catch 中,失败信息取 error.response.data.message(不是 error.message),并且 modal.hide() 放在 try/catch 外面,保证成功失败都关框。

3. student.html —— 页面结构(列表 + 模态框表单) ​

html
<!-- 表格容器:.list 和 .total 是渲染的目标 -->
<table class="table">
  <thead>
    <tr>
      <th>姓名</th><th>年龄</th><th>性别</th><th>组号</th>
      <th>期望薪资</th><th>就业薪资</th><th>籍贯</th><th>操作</th>
    </tr>
  </thead>
  <tbody class="list"></tbody>
</table>
<p>共 <span class="total">0</span> 条</p>

<!-- 新增按钮 -->
<button id="openModal" class="btn btn-primary">+</button>

<!-- 模态框 -->
<div class="modal" id="modal" tabindex="-1">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">新增学员</h5>
        <button class="btn-close" data-bs-dismiss="modal"></button>
      </div>
      <div class="modal-body">
        <form id="form">
          <input type="text"   name="name"        class="form-control mb-2" placeholder="姓名">
          <input type="number" name="age"         class="form-control mb-2" placeholder="年龄">
          <select              name="gender"      class="form-select mb-2">
            <option value="0">男</option>
            <option value="1">女</option>
          </select>
          <input type="number" name="group"       class="form-control mb-2" placeholder="组号">
          <input type="number" name="hope_salary" class="form-control mb-2" placeholder="期望薪资">
          <input type="number" name="salary"      class="form-control mb-2" placeholder="就业薪资">
          <!-- 省市区,内容由 JS 渲染 -->
          <select name="province" class="form-select mb-2"></select>
          <select name="city"     class="form-select mb-2"></select>
          <select name="area"     class="form-select mb-2"></select>
        </form>
      </div>
      <div class="modal-footer">
        <button id="submit" class="btn btn-primary">确定</button>
      </div>
    </div>
  </div>
</div>

<!-- 依赖:axios / bootstrap / 表单序列化插件 / 公共逻辑 / 业务逻辑 -->
<script src="./lib/axios.min.js"></script>
<script src="./lib/bootstrap.bundle.min.js"></script>
<script src="./lib/form-serialize.js"></script>
<script src="./common.js"></script>
<script src="./student.js"></script>

要点解释

  • .list 挂在 tbody 上,渲染时整段 <tr> 覆盖 innerHTML,所以 thead 必须写在表格里、不要写进 .list。
  • 表单控件的 name 必须和接口字段一一对应(name / age / gender / group / hope_salary / salary / province / city / area),serialize 才能一次收全。
  • 三个省市区 select 标签内部留空,由 initSelect() 用 innerHTML 渲染,这样切换时能整体替换。
  • 脚本顺序不能错:axios、bootstrap 要先于 student.js 加载;common.js 要在 student.js 之前,因为要调用 checkLogin、showToast 等函数。
  • 模态框要用 bootstrap.bundle.min.js(含 Popper),只引 bootstrap.min.js 可能导致弹框异常。

常见错误 ​

  1. 性别列渲染结果全反 / 全是同一值

    • 现象:0 和 1 的数据渲染出来都是"女"或都是"男"。
    • 原因:写成 gender ? '女' : '男',把 0/1 当成布尔值用;数据源里 0 表示男、1 表示女,与直觉相反。
    • 修法:改为显式判断 gender === 0 ? '男' : '女';不确定时先 console.log 打印第一条原始数据核对字段含义,不要靠姓名猜性别。
  2. 点删除 / 编辑没反应,控制台报 null

    • 现象:列表能渲染出来,但点图标毫无反应,或报 Cannot read properties of null。
    • 原因:行是渲染后追加的 DOM,却在渲染之前就用 querySelectorAll 去绑事件,拿到的都是空。
    • 修法:事件委托绑到页面初始就存在的 .list 容器上,回调里用 e.target.classList.contains('bi-trash') 区分是删除还是编辑。
  3. 删除时把错误行删掉 / id 传成 undefined

    • 现象:点第一行删掉的是别的行,或请求地址变成 /students/undefined。
    • 原因:渲染时忘了把 id 落到 DOM 上,或解构时漏写 id;取的时候 parentNode.parentNode 层级数错。
    • 修法:渲染时写 data-id="${id}",取值用 e.target.closest('td').dataset.id,请求前先 console.log(id) 验证一次。
  4. 新增提交后端报"校验失败,请检查字段 age / group"

    • 现象:明明表单都填了,接口仍说字段缺失或类型不对。
    • 原因:serialize 收集出来的值全是字符串,后端要的是整数;报错文案伪装成"缺字段",容易误导排查方向。
    • 修法:提交前对 age / gender / hope_salary / salary / group 逐个 +data.xxx 转数字;排查时打开 Network 看响应体里的真实 message,而不是只看控制台。
  5. 新增成功后弹框关不掉,或下次打开还留着上次的数据

    • 现象:提示"添加成功"了,但弹框还杵在那;关掉再打开,输入框里还是上次的内容。
    • 原因:只写了 showToast,漏了 modal.hide() 和 form.reset();失败分支更是完全没处理。
    • 修法:成功与失败两条分支都要闭环——提示 + modal.hide() + formDom.reset(),成功时再补一次重新渲染。
  6. 省市区联动切了上级,下级还残留上一次的选项

    • 现象:换了个省份,城市下拉里还留着上个省的城市;选了城市,地区还停在旧值。
    • 原因:只渲染了下级,没在下级更新前重置它(尤其是 value 没清)。
    • 修法:上级 change 里先把下级 innerHTML 重置为占位 option,再发请求渲染;地区同理,城市一变就先清地区。
  7. get 请求的查询参数不生效

    • 现象:/api/city 返回的是全部城市或直接报错,pname 没带上。
    • 原因:把查询参数当成 post 的 body 直接跟在 url 后面,或者放错了位置。
    • 修法:查询参数必须写在第二个参数的 params 对象里;delete 只传 url,不传第二个参数。

练习 ​

  1. 渲染函数可读性改造:把 getData 里的模板字符串拆成「拼一行的函数 + map 调用」,并在薪资两列加上单位和 toLocaleString() 千分位。完成后回答:为什么这类改动应该落在拼行函数里,而不是渲染完再操作 DOM 改文本?

  2. 补齐编辑入口:复用现有的事件委托,在判断到 bi-pen 时拿到该行 id 并打印验证。要求:先只做到"能准确拿到 id"这一步,不写弹框回填,验证通过后再继续。

  3. 类型转换收敛:现在新增函数里手写了 5 行 data.xxx = +data.xxx。请把它改成一个辅助函数统一处理,并额外加一条"年龄必须是正整数"的前端校验——校验不通过时不发请求,直接提示并返回。

  4. 联动健壮性:改造 initSelect,做到「换省份 → 清空城市和地区」「换城市 → 清空地区」,且清空时下拉的 value 也要回到占位值。写完后用"内蒙古 → 包头 → 换山西"这条路径自测并说明验证点。

  5. 失败路径闭环:给删除功能加上 try/catch。要求:失败时提示"删除失败,请刷新重试"而不是把后端字段级报错甩给用户,且失败时不要执行重新渲染。思考并写出:为什么成功才渲染、失败不渲染?

原视频对照 ​

#视频时长
1学生信息案例-渲染业务完成13:07
2学生信息案例-新增业务-弹出模态框05:21
3学生信息案例-新增业务-省市区联动13:07
4学生信息案例-省市区联动完成04:25
5学生信息案例-新增完成16:50
6学生信息案例-提交代码到远程仓库01:45
7学生信息案例-上午回顾04:03
8学生信息案例-删除业务108:18

小结 ​

  • 渲染函数必须独立抽取并在页面加载时首次调用一次,因为新增、编辑、删除之后都要复用它——这是本模块所有功能的地基。
  • 后追加的 DOM 一律用事件委托绑在稳定父容器上;在渲染完成前用选择器批量绑事件,必然拿不到节点。
  • id 的唯一可靠来源是渲染阶段写进 DOM 的 data-id,点击时再取,而不是靠行号、索引或名字去猜。
  • 请求参数的位置是高频踩坑点:get 走 params、post 走 body、delete 只传 url,三者不能互换。
  • serialize 收上来的永远是字符串,只要后端类型约定是 number,就必须显式转换;否则报错信息会伪装成"缺字段",白白浪费排查时间。
  • 每个写操作都要把成功和失败两条分支走完:提示、关弹框、清表单、成功时重新渲染——只写 happy path 的代码在演示时一定翻车。
  • 联动下拉的正确顺序是"上级变化 → 先重置下级 → 再请求渲染",顺序颠倒就会残留脏数据。
  • 面向用户的提示要给能看懂的话,后端返回的字段级 message 只适合自己调试,不适合直接展示。