Skip to content

跨域与封装一个 Ajax 函数(Day15 2/2) ​

本页对应 Day15_Ajax,共 6 段视频,约 1 小时 53 分。

学习目标 ​

  1. 能够解释同源策略与跨域限制,并使用 JSONP 实现搜索框提示案例,验证通过动态 script 请求、回调函数接收并解析响应数据。
  2. 能够封装一个通用 Ajax 函数,支持 GET、POST、DELETE 请求,设置请求体与响应类型,并区分成功、业务失败和网络失败回调。
  3. 能够使用 Ajax 获取指定用户账单数据,判断响应 code,通过模板字符串和 DOM 操作将账单动态渲染到页面,并正确显示收入/支出样式与日期信息。
  4. 能够监听表单提交事件并阻止默认提交,使用 FormData 作为请求体发送 POST 请求添加账单,并在添加成功后重新加载账单列表。
  5. 能够使用事件委托监听动态生成的删除按钮,通过自定义属性获取账单 ID,发送 DELETE 请求,并在后端确认删除成功后从页面移除对应元素。

知识点 ​

同源策略:为什么需要跨域方案 ​

浏览器有同源策略:协议、域名、端口三者相同才算同源。不同源时,浏览器会限制页面读取响应内容。转写稿里说得很直白:服务器本身通常是“来者不拒”的,真正限制的是浏览器。所以当我们在自己的页面里想拿百度搜索提示数据时,直接用普通 Ajax 请求会被浏览器拦住。

但 script 标签是个特殊入口:页面可以加载不同源的 JS 文件,服务器返回的 JS 又会在当前页面执行。JSONP 就是利用这一点:不直接“读响应”,而是让服务器返回一段“调用前端函数的 JS 代码”。这段代码执行时,就把数据交给了前端。

JSONP 怎么想:动态 script + 全局回调 ​

先看百度搜索框的现象:输入 A,页面会提示 A 开头的搜索词。打开 Network 能看到它向某个地址发了请求;直接把请求地址复制到浏览器打开,响应不是 JSON 对象,而是一段类似:

js
parseSearch({ ... });

也就是说,后端返回的是“调用 parseSearch 这个函数,并把数据作为实参传进来”。前端只要提前定义好 parseSearch,等这段 JS 被 script 加载执行,函数就会运行,实参就是数据。

因此 JSONP 的关键不是 XMLHttpRequest,而是:

  1. 前端动态创建 script;
  2. 设置 src,把回调函数名传给后端;
  3. 后端返回 函数名(数据);
  4. 前端提前定义同名全局函数来接收数据。

转写稿里拆百度地址时,关键参数是 wd(关键字)和 cb(回调函数名)。地址形如:

text
https://www.baidu.com/su?wd=关键字&cb=parseSearch

怎么做:搜索框提示案例 ​

页面结构用 input 的 list 属性关联 datalist:

html
<input id="wd" list="datalist">
<datalist id="datalist"></datalist>

回调函数必须是全局的,不能包在局部作用域里,否则后端返回的 parseSearch(...) 找不到它:

js
function parseSearch(res) {
  console.log(res);

  const datalist = document.querySelector('#datalist');
  datalist.innerHTML = '';

  res.g.forEach(function (item) {
    const option = new Option('', item.q);
    datalist.appendChild(option);
  });
}

绑定输入事件时,转写稿特意强调不要用 change,因为 change 往往要等失去焦点才触发;搜索提示要随着输入实时变化,应该用 input 事件:

js
const input = document.querySelector('#wd');

input.addEventListener('input', function () {
  const script = document.createElement('script');

  script.src = `https://www.baidu.com/su?wd=${encodeURIComponent(input.value)}&cb=parseSearch`;

  document.body.appendChild(script);
  document.body.removeChild(script);
});

添加 script 后马上移除,不会影响已经发出的请求;移除只是不留 DOM 痕迹。

调试过程与易错点 ​

调试时先看 Network:输入内容后是否发出 su?wd=...&cb=parseSearch 请求;再看响应,确认返回的是不是 parseSearch({...})。控制台里 console.log(res) 应看到对象,数据在 res.g 数组里,每项对象的 q 就是关键字。遍历 res.g,把 item.q 塞进 datalist 即可。

易错点:

  • 回调函数必须是全局函数,不能写在局部函数里;
  • 每次回调先清空 datalist.innerHTML,否则旧提示会叠加;
  • 参数要 encodeURIComponent,避免关键字里的特殊字符破坏 URL;
  • JSONP 只能发 GET,不适合提交敏感或大量数据;
  • 百度接口可能变化,案例重点是理解 JSONP 原理,不要把某个地址当成永久可用接口;
  • 动态 script 的请求错误不容易像 Ajax 那样精细捕获,这是 JSONP 的局限。

这样,一个不刷新页面、从百度拿到搜索提示并显示到 datalist 的 JSONP 案例就完成了。

封装 Ajax 函数:从重复代码到可复用工具 ​

为什么需要:在记账本案例里,获取账单、添加账单、删除账单都要发请求。如果每次都用 new XMLHttpRequest() 从头写一遍,重复且容易漏步骤。封装后,调用方只关心“请求地址、方式、参数、成功/失败后做什么”。

怎么想:用一个 options 对象接收配置;常用项给默认值;成功和失败用回调传出去,让封装函数不绑定具体业务。

怎么做:先设计选项:url、method、headers、body、dataType、success、error。其中 method 默认 GET,headers 默认 {},success、error 默认空函数。

易错点:默认值要写在解构里;headers 不能默认成 null,否则后面 for...in 会出问题;回调默认空函数可避免调用方不传时报错。

创建 xhr 与响应类型 ​

为什么需要:XMLHttpRequest 是 Ajax 的核心对象;响应类型决定 xhr.response 是字符串还是对象。记账本调试时就踩过坑:后端返回 JSON 字符串,代码却直接写 res.code,结果判断失败。

怎么想:先创建 xhr;如果调用方传了 dataType,就设置 xhr.responseType = dataType。例如传 dataType: 'json',xhr.response 才是对象。

怎么做:按笔记代码保留如下实现:

js
/*  
            选项:
            url: 请求地址
            method: 请求方式,默认值 GET
            headers: 请求头,默认值 {}
            body: 请求体
            dataType: 响应体类型
            success: 成功的回调
            error: 失败的回调
*/
function ajax(options) {
    // 从 options 取出相关的选项
    const {url, method='GET', headers={}, body, dataType, success=()=>{}, error=()=>{}} = options;

    // 创建 xhr 对象
    const xhr = new XMLHttpRequest();

    // 如果指定了 dataType
    if (dataType) {
        xhr.responseType = dataType;
    }

    // 监听响应成功的事件
    xhr.onload = () => {
        if (xhr.status === 200) {
            success(xhr.response);
        } else {
            error();
        }
    }

    // 监听响应失败的事件
    xhr.onerror = error;

    // 请求初始化
    xhr.open(method, url);

    // 设置请求头
    for (let key in headers) {
        xhr.setRequestHeader(key, headers[key]);
    }

    // 发送
    xhr.send(body);

}

易错点:dataType 不传时不要强行设置;设置 responseType 后,xhr.response 的类型会变化;若后端返回 JSON 但没设 dataType,要自己 JSON.parse,否则 res.code 取不到。

监听成功与失败 ​

为什么需要:请求发出后,结果要么成功、要么失败;调用方需要拿到结果做页面更新或提示。

怎么想:xhr.onload 表示响应回来了,但还要看 HTTP 状态码;xhr.onerror 表示请求本身失败。笔记里用 xhr.status === 200 判断成功,否则走 error()。

怎么做:成功时把 xhr.response 传给 success;失败时调用 error。调用方可以这样用:success(res){ if(res.code==='0000'){...} else {...} }。记账本添加账单时,正是忘了 dataType:'json',导致 res 是字符串,res.code 判断失败,补上响应类型后才正常。

易错点:onload 不等于一定成功;onerror 与“业务失败”不是一回事,业务失败通常仍在 onload 里根据 code 判断;status 只认 200 是当前封装的约定,接口若返回 201/204 要相应调整。

初始化、请求头与发送 ​

为什么需要:open 负责确定方法和地址;setRequestHeader 负责加请求头;send 才真正发出请求。顺序错了会报错。

怎么想:先 open,再设请求头,最后 send。请求头来自 headers 对象,用 for...in 逐个设置。

怎么做:xhr.open(method, url);遍历 headers 调 setRequestHeader;xhr.send(body)。GET 通常不传 body;POST 可传 FormData。记账本删除时用 DELETE,并在后端确认删除成功后再删页面元素,避免“糊弄用户”。

易错点:setRequestHeader 要放在 open 之后、send 之前;body 为 FormData 时一般不要手动设 Content-Type;method 要写对,GET/POST/DELETE 不能混。

调试过程带来的提醒 ​

为什么需要:封装函数本身简单,真正容易错的是调用时的类型和时机。

怎么想:先看网络面板确认请求地址、方式、请求体、响应体;再看控制台确认 xhr.status 和 xhr.response 类型。

怎么做:记账本添加失败时,先输出 xhr.status 发现是 200,再输出 res 发现是字符串,于是调用时补 dataType:'json',res.code === '0000' 才成立。删除时则先发请求,成功回调里再 remove 元素。

易错点:不要把字符串当对象用;不要请求没成功就先改页面;不要只写成功分支,失败和 onerror 都要提示。

JSONP 搜索框提示:为什么需要、怎么想、怎么做、易错点 ​

为什么需要:浏览器有同源策略,普通 Ajax 不能随便读别的域名数据;但 <script> 不受这个限制,JSONP 就借 script 发 GET 请求。转写稿里百度搜索框就是典型:输入关键字后,页面动态创建 script,地址里带 wd(关键字)和 cb(回调函数名),后端返回“调用函数”的 JS 代码。
怎么想:先在前端定义一个全局函数,后端响应 parseSearch({...}) 时就能执行;函数实参就是数据对象。
怎么做:监听输入框的 input 事件,创建 script,设置 src,插入页面,请求发出后再删除 script。

js
function parseSearch(data) {
  console.log(data);
}
inputBox.addEventListener('input', () => {
  const script = document.createElement('script');
  // 转写稿拆出的关键参数:wd 是关键字,cb 是回调函数名
  script.src = `${搜索提示地址}?wd=${inputBox.value}&cb=parseSearch`;
  document.body.appendChild(script);
  document.body.removeChild(script);
});

拿到数据后,把提示词创建成 option 放进 datalist,并让 input 的 list 与 datalist 的 id 对应;每次渲染前先清空 datalist。
易错点:回调函数必须是全局的;用 change 不如 input 及时;script 删除前请求已经发出;new Option(text, value) 的第一个参数是显示文字,第二个才是 value;datalist 不清空会不断累加。

记账本项目分析:为什么需要、怎么想、怎么做、易错点 ​

为什么需要:传统页面跳来跳去,后端既管数据又管页面;Ajax 记账本要让页面不刷新,前端只负责发请求、渲染。
怎么想:后端已提供 API,前端把后端当“黑盒”。转写稿明确三个接口:GET /api/account/用户ID 获取指定用户账单;POST /api/account/用户ID 添加账单;DELETE /api/account/账单ID 删除账单。路径相似、请求方式不同,这是 REST 风格约定。
怎么做:在 index.ejs 中写前端 JS,引入封装好的 Ajax 函数;注意模板中的用户 ID 是后端 EJS 替换后发给浏览器的。
易错点:用户 ID 和账单 ID 不要混;API 文档要看请求方式、参数、响应结构;不要以为 EJS 里的 JS 在后端执行,它最终在浏览器执行。

获取数据:为什么需要、怎么想、怎么做、易错点 ​

为什么需要:页面初始账单不能写死,要从数据库按用户取。
怎么想:发 GET 请求,响应一般是 { code, message, data };code === '0000' 才成功,data 才是账单数组。
怎么做:封装 loadAccount(),先清空包裹元素,再请求并遍历 res.data,用模板字符串拼 HTML,最后赋给 innerHTML。

js
function loadAccount() {
  accountBooks.innerHTML = '';
  // 调用 ajax:GET /api/account/用户ID
  // success 中判断 res.code === '0000',再遍历 res.data 渲染
}

易错点:转写稿调试时添加失败,根因是忘了指定 responseType: 'json',拿到的 res 是字符串,res.code 自然不对;失败和请求未发出都要提示,可用 bootbox 弹框。

添加数据:为什么需要、怎么想、怎么做、易错点 ​

为什么需要:点“添加”不能跳页面,要真正写数据库。
怎么想:监听表单 submit,阻止默认提交;用 FormData 收集表单内容,POST 到指定用户路径;成功后重新加载列表。
怎么做:

js
const fd = new FormData(addForm);
// ajax:POST /api/account/用户ID,请求体 fd
// success 中判断 res.code === '0000',提示添加成功并调用 loadAccount()

易错点:一定 preventDefault();FormData 默认不需要手动设 Content-Type;添加成功后不能只提示,要刷新数据,否则页面看不到新记录。转写稿还出现后端未正确解析 FormData 的问题,前端请求体正常时不要误判为前端错。

删除账单:为什么需要、怎么想、怎么做、易错点 ​

为什么需要:删除要真删数据库,不能只删页面元素糊弄用户。
怎么想:删除按钮是动态生成的,用事件委托绑到包裹元素;渲染时给删除按钮加 data-id 保存账单 ID;点击后发 DELETE,确认 code === '0000' 再删 DOM。
怎么做:

js
accountBooks.addEventListener('click', (e) => {
  if (e.target.classList.contains('remove-btn')) {
    const id = e.target.dataset.id;
    // ajax:DELETE /api/account/账单ID
    // 成功后沿 parentElement 找到账单项并 removeChild
  }
});

易错点:不要提前删元素;classList.contains 判断类名;账单 ID 每个都不同,必须从 data-id 取;删除成功后可按模板层级用 parentElement 找到整条账单再删。

添加后的列表刷新:为什么需要、怎么想、怎么做、易错点 ​

为什么需要:账单按时间排序,新记录不一定加在末尾,直接 append 会乱。
怎么想:要么 location.reload(),要么不刷新页面,重新执行“清空 + 请求 + 渲染”。转写稿选择封装 loadAccount()。
怎么做:添加成功后调用 loadAccount();函数内先 accountBooks.innerHTML = '' 再请求渲染。
易错点:清空要写在渲染前;转写稿曾把 innerHTML 写错导致添加两遍;删除和添加都要以数据库结果为准,再更新页面。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 1:封装一个 Ajax 函数(可直接运行的 HTML 页面) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>封装 ajax 函数</title>
  <style>
    body { font-family: sans-serif; padding: 24px; }
    button { padding: 6px 16px; }
    #result { margin-top: 16px; padding: 12px; background: #f5f5f5; white-space: pre-wrap; }
  </style>
</head>
<body>
  <button id="btn">请求 /api/user</button>
  <div id="result">尚未请求</div>

  <script>
    /*
      选项:
      url: 请求地址
      method: 请求方式,默认值 GET
      headers: 请求头,默认值 {}
      body: 请求体
      dataType: 响应体类型
      success: 成功的回调
      error: 失败的回调
    */
    function ajax(options) {
      // 从 options 取出相关的选项
      const { url, method = 'GET', headers = {}, body, dataType, success = () => {}, error = () => {} } = options;

      // 创建 xhr 对象
      const xhr = new XMLHttpRequest();

      // 如果指定了 dataType
      if (dataType) {
        xhr.responseType = dataType;
      }

      // 监听响应成功的事件
      xhr.onload = () => {
        if (xhr.status === 200) {
          success(xhr.response);
        } else {
          error();
        }
      };

      // 监听响应失败的事件
      xhr.onerror = error;

      // 请求初始化
      xhr.open(method, url);

      // 设置请求头
      for (let key in headers) {
        xhr.setRequestHeader(key, headers[key]);
      }

      // 发送
      xhr.send(body);
    }
  </script>

  <script>
    document.querySelector('#btn').onclick = () => {
      ajax({
        url: '/api/user',          // 换成你自己的接口地址
        method: 'GET',
        dataType: 'json',          // 关键:让 xhr.response 直接是对象
        success(res) {
          document.querySelector('#result').textContent = JSON.stringify(res, null, 2);
        },
        error() {
          document.querySelector('#result').textContent = '请求失败';
        }
      });
    };
  </script>
</body>
</html>

关键行与易错点

  • const { url, method = 'GET', ... } = options;:用解构默认值一次性拿到所有选项,调用时不必写全。
  • xhr.responseType = dataType;:只有设置了 dataType: 'json',回调里拿到的 xhr.response 才是对象;不设置就是字符串,后面 res.code 永远取不到值(记账本里“明明成功却走了失败分支”就是这个坑)。该属性必须在 send() 之前设置。
  • xhr.onload 里判断 xhr.status === 200 才走 success,其它状态码走 error;而 xhr.onerror 处理的是“请求根本没发出去/网络断开”这种情况,两条路都要有回调。
  • success = () => {}、error = () => {}:默认空函数,避免调用方不传回调时报 not a function。
  • for (let key in headers) 必须在 xhr.open() 之后调用,open 之前 setRequestHeader 会报错。
  • 如果 body 是 FormData 对象,不要手动设置 Content-Type,浏览器会自动带上 boundary,手写反而导致后端解析不出数据。

示例 2:JSONP 搜索框提示(datalist + 动态 script) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>JSONP 搜索框提示</title>
  <style>
    body { font-family: sans-serif; padding: 24px; }
    #wd { width: 320px; padding: 6px 10px; }
  </style>
</head>
<body>
  <!-- list 的值必须和 datalist 的 id 一致,两者才会关联 -->
  <input type="text" id="wd" list="searchList" autocomplete="off">
  <datalist id="searchList"></datalist>

  <script>
    const inputBox = document.querySelector('#wd');
    const dataListBox = document.querySelector('#searchList');

    // 这个函数必须是全局的,后端返回的脚本里才能调用到
    function parseSearch(res) {
      // 每次响应先清空,否则提示词会不断累加
      dataListBox.innerHTML = '';

      res.g.forEach(item => {
        // new Option(text, value),文字留空,value 就是提示词
        dataListBox.appendChild(new Option('', item.q));
      });
    }

    inputBox.oninput = function () {
      if (!this.value) {
        dataListBox.innerHTML = '';
        return;
      }

      // 第一步:动态创建 script 元素
      const script = document.createElement('script');

      // 第二步:设置 src(wd 是关键字,cb 是前端定好的函数名)
      script.src = `https://www.baidu.com/sugrec?wd=${encodeURIComponent(this.value)}&cb=parseSearch`;

      // 第三步:添加到页面,请求才会真正发出
      document.body.appendChild(script);

      // 第四步:请求已发出,删掉标签不留痕迹
      document.body.removeChild(script);
    };
  </script>
</body>
</html>

关键行与易错点

  • 监听事件用 input 而不是 change:change 要内容改变且失去焦点才触发,而百度那种是边打字边提示。
  • parseSearch 必须挂在全局(不能包在 IIFE 或模块作用域里),否则后端响应回来的 parseSearch({...}) 执行时找不到函数,直接报 is not defined。
  • cb=parseSearch 里的函数名是前端定的,后端只是把这段名字原样拼进响应的 JS 代码里;改函数名就要同步改 cb 参数。
  • script.src 里拼关键字要 encodeURIComponent,中文/特殊字符不编码会拼出非法 URL。
  • appendChild 之后请求就发出去了,removeChild 删的是标签,不影响已发出的请求——这就是“不留痕迹”的原因。
  • 每次回调进来先 dataListBox.innerHTML = '' 清空,否则每输入一次就往 datalist 里再塞一批,提示项越堆越多。
  • new Option('', item.q):第一个参数是显示文字,第二个是 value;datalist 里文字为空时会显示 value,所以提示词能正常显示。

示例 3:Ajax 记账本前端(渲染 + 删除 + 添加,页面自始至终不刷新) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Ajax 记账本</title>
  <style>
    body { font-family: sans-serif; padding: 24px; }
    .panel { border: 1px solid #ccc; border-radius: 4px; margin-bottom: 10px; position: relative; }
    .panel-heading { padding: 8px 12px; background: #f5f5f5; }
    .panel-body { padding: 8px 12px; }
    .panel-success .panel-heading { background: #dff0d8; }
    .panel-danger  .panel-heading { background: #f2dede; }
    .remove-btn { position: absolute; top: 6px; right: 10px; cursor: pointer; font-weight: bold; }
    #addForm { margin-top: 20px; }
    #addForm input, #addForm select { display: block; margin: 6px 0; padding: 4px 8px; }
  </style>
</head>
<body>
  <h3>账单列表</h3>
  <div id="accountBooks"></div>

  <form id="addForm">
    <input type="text"   name="title"   placeholder="事项">
    <input type="text"   name="remarks" placeholder="备注">
    <input type="number" name="account" placeholder="金额">
    <select name="type">
      <option value="1">收入</option>
      <option value="-1">支出</option>
    </select>
    <input type="date" name="time">
    <button type="submit">添加</button>
  </form>

  <!-- 上一段封装的 ajax 函数,放在 public/js/ajax4.js -->
  <script src="/ajax4.js"></script>

  <script>
    // 由后端模板渲染替换进来的当前用户 ID(单独运行时手动改成真实 ID)
    const userId = '<%= user.id %>';

    const accountBooks = document.querySelector('#accountBooks');  // 账单列表包裹元素
    const addForm = document.querySelector('#addForm');

    // 请求并把账单渲染到页面上
    function loadAccount() {
      ajax({
        url: '/api/account/' + userId,
        dataType: 'json',
        success(res) {
          if (res.code !== '0000') {
            alert('数据获取失败');
            return;
          }

          // 遍历前先清空,否则数据会累加
          accountBooks.innerHTML = '';

          res.data.forEach(item => {
            accountBooks.innerHTML += `
              <div class="panel ${item.type > 0 ? 'panel-success' : 'panel-danger'}">
                <div class="panel-heading">
                  ${item.title}
                  <span class="remove-btn" data-id="${item._id}">×</span>
                </div>
                <div class="panel-body">
                  <p>${item.type > 0 ? '<span>收入</span>' : '<span>支出</span>'}</p>
                  <p>备注:${item.remarks}</p>
                  <p>金额:${item.account}</p>
                  <p>时间:${item.time}</p>
                </div>
              </div>`;
          });
        },
        error() {
          alert('数据获取失败');
        }
      });
    }
    loadAccount();

    // 删除:事件委托(按钮是动态生成的)
    accountBooks.addEventListener('click', function (e) {
      if (!e.target.classList.contains('remove-btn')) return;

      ajax({
        url: '/api/account/' + e.target.dataset.id,
        method: 'DELETE',
        dataType: 'json',
        success(res) {
          if (res.code === '0000') {
            // 确认后端删掉了,再删页面元素
            accountBooks.removeChild(e.target.parentElement.parentElement);
            alert('删除成功');
          } else {
            alert('删除失败');
          }
        },
        error() {
          alert('删除失败');
        }
      });
    });

    // 添加:监听表单提交事件并阻止默认提交
    addForm.addEventListener('submit', function (e) {
      e.preventDefault();

      const fd = new FormData(this);   // 表单内容直接作为请求体

      ajax({
        url: '/api/account/' + userId,
        method: 'POST',
        body: fd,
        dataType: 'json',
        success(res) {
          if (res.code === '0000') {
            alert('添加成功');
            loadAccount();           // 重新拉数据,保证顺序正确
          } else {
            alert('添加失败');
          }
        },
        error() {
          alert('添加失败');
        }
      });

      this.reset();
    });
  </script>
</body>
</html>

关键行与易错点

  • dataType: 'json' 必须写:后端返回的是 JSON 字符串,不指定的话 res 是字符串,res.code 得到 undefined,于是“添加成功”却弹“添加失败”——这是转写稿里真实踩过的坑。
  • e.preventDefault():表单里有 type="submit" 的按钮,不阻止默认行为页面就会跳转刷新,Ajax 也就失去意义了。
  • 渲染前 accountBooks.innerHTML = '':清空再拼;如果写成 accountBooks.innerHTML += ... 而不清空,每请求一次列表就翻倍(转写稿里“怎么加载了两遍”就是这个原因)。
  • 模板字符串里 ${item.type > 0 ? 'panel-success' : 'panel-danger'} 和收入/支出文字的三元判断,${} 里只能写表达式,不能写 if 语句。
  • data-id="${item._id}":每个账单 ID 都不一样,渲染时用自定义属性把它存到删除按钮上,删除时通过 e.target.dataset.id 取回。
  • 事件委托绑在 accountBooks 上而不是每个 × 上:列表元素是动态生成的,直接给按钮绑事件会绑不到;判断用 e.target.classList.contains('remove-btn'),不要用 className。
  • 删除必须先等后端返回 code === '0000' 再删 DOM,否则数据库没删、页面却没了,就是糊弄用户;removeChild(e.target.parentElement.parentElement) 删的是 panel 那一层,层级要数清楚。
  • 添加成功后调用 loadAccount() 重新请求:列表是按时间倒序排的,自己插到页面里位置不一定对,重新拉一遍最稳。
  • new FormData(this) 作为 body 时,headers 不要传 Content-Type,浏览器会自己带 boundary。
  • const userId = '<%= user.id %>':这是后端模板引擎在返回页面之前替换好的,浏览器拿到时已经是具体 ID 了——不要以为浏览器有这本事。

常见错误 ​

  1. JSONP 回调报 xxx is not defined → 回调函数写在局部作用域或请求发出后才定义 → 请求前把回调挂到全局(如 window.parseSearch),并确保函数名与 cb 参数一致。
  2. 多次输入后页面残留多个 script、回调重复执行 → 只 append 未清理,或未处理重复请求 → 请求发出后立即移除 script,必要时清理旧节点并做输入防抖。
  3. datalist 提示项越积越多 → 每次只追加 option,没有清空旧选项 → 渲染前先 datalist.innerHTML = '',再追加新 option。
  4. res.code 判断永远失败、res 像对象却取不到属性 → 未设置 responseType = 'json',拿到的是字符串 → 设置 xhr.responseType = 'json' 或显式 JSON.parse,并确认后端返回确是 JSON。
  5. 添加成功后列表重复或看不到新记录 → 重载函数被调用两次、容器未清空、未重新请求 → 重载前清空列表容器,确保只调用一次,按后端排序重新渲染。
  6. 删除提示成功但元素还在,或刷新后才消失 → 只删 DOM 未等后端成功,或只发请求未删 DOM → 先等后端返回成功码,再 removeChild 目标行;失败时不要改 DOM。
  7. 动态生成的删除按钮点不动 → 直接给新增元素绑事件或绑定时元素还不存在 → 用事件委托绑到包裹元素,通过 e.target.classList.contains(...) 判断目标。
  8. 点“添加”页面刷新/跳转 → 表单默认提交未被阻止 → 在 submit 回调中 e.preventDefault(),再用 FormData 发 Ajax 请求。

练习 ​

  1. 给出一段 JSONP 搜索提示代码:回调定义在局部、script 未移除、datalist 未清空。请指出三处问题并改写关键代码。
  2. 改写 ajax 封装:要求同时区分“网络失败”“HTTP 状态非 200”“业务 code !== '0000'”三种情况,并说明各自触发哪个回调。
  3. 记账本新增账单后出现“列表重复/顺序错乱/新记录不显示”。请设计一个重载列表函数,并说明清空容器与重新请求的先后顺序。
  4. 删除按钮是动态渲染的。请写出“事件委托 + 请求成功后再删 DOM”的伪代码,并说明为什么不能先删 DOM 再发请求。
  5. 用 FormData 提交添加账单表单:列出关键步骤,并写出两个必须检查的失败分支。

原视频对照 ​

#视频时长
1jsonp案例-搜索框提示18:31
2Ajax记账本分析12:52
3Ajax记账本-获取数据34:14
4Ajax记账本-添加数据25:32
5Ajax记账本-删除账单12:29
6Ajax记账本-添加账单0209:57

小结 ​

  1. JSONP 的本质仍是 script 跨域,回调必须是请求前可访问的全局函数,且只适合 GET 场景。
  2. Ajax 封装不能只看 onload,必须同时覆盖网络失败、HTTP 失败和业务码失败。
  3. responseType 与后端实际响应必须匹配,否则最容易出现“看着像对象、实际是字符串”的判断错误。
  4. 记账本不跳转的核心是:页面不动,请求照发,成功后局部重载或局部删改 DOM。
  5. 新增后要“先清空再重载”,否则会出现重复渲染;删除要“后端成功后再删 DOM”,否则会糊弄用户。
  6. 动态元素不要逐个绑事件,事件委托更稳;表单提交必须先阻止默认行为。
  7. 前后端模板混用时,模板替换发生在服务端,前端拿到的已是替换后的代码;调试时要分清问题在前端请求、后端解析还是模板渲染。