跳至主要內容

Ajax和异步请求

xmut-lby大约 15 分钟

Ajax和异步请求

为什么需要Ajax

到目前为止,我们写过的大多数页面逻辑,都发生在当前这张页面里:

  1. 读取用户输入。
  2. 修改当前 DOM。
  3. 让页面看起来“动起来了”。

这当然很重要,但真实网站还有一个更麻烦的问题:页面上展示的数据,往往不在当前 html 文件里,而是在服务器、数据库,或者某个 json 接口里。

如果没有 Ajax,那么每次你想拿到新数据,都只能重新请求一整张新页面。这样会带来几个直接问题:

  1. 页面要整体刷新,用户当前的滚动位置、输入状态、临时计算结果都可能丢失。
  2. 明明只想更新页面中的一小块内容,却要把整张页面重新下载一遍,浪费带宽。
  3. 页面等待服务器返回时,交互会变得很笨重,体验很差。
  4. 前端只能“收整页、换整页”,很难做出搜索联想、局部刷新、无刷新保存这类效果。

所以我们需要一种能力:让 JavaScript 在不刷新整张页面的前提下,主动请求数据,然后只更新需要变化的那一部分 DOM。

这就是 Ajax 的意义。

Ajax 这个词为什么看起来有点老?

Ajax 是 Asynchronous JavaScript and XML 的缩写。这个名字保留了历史痕迹,但今天实际开发里,返回的数据更多是 json,而不是 xml。 所以你要抓住它真正的核心:异步请求 + JavaScript 更新页面

下面这两条世界线的区别,正好能说明 Ajax 的价值:

像“加载更多”“刷新排行榜”“搜索建议”“聊天记录滚动加载”“提交后局部更新结果”这些效果,背后基本都离不开这一套思路。

用 axios 请求静态 json 并刷新页面

原生的 XMLHttpRequest 可以完成 Ajax,但写法比较繁琐。实际开发里通常会使用封装好的请求库,例如 axios

对我们当前这个阶段来说,最容易上手的方式是直接在页面里通过 CDN 引入:

<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

注意这行 script 要放在你自己写的请求代码前面,否则浏览器还没加载到 axios,你就开始调用它了。

准备一个静态 json 文件

假设当前目录下有一个 books.json

[
  {
    "title": "HTML 入门",
    "author": "张三",
    "price": 39
  },
  {
    "title": "CSS 页面设计",
    "author": "李四",
    "price": 45
  },
  {
    "title": "JavaScript 交互",
    "author": "王五",
    "price": 52
  }
]

配套示例资源建议怎么放

为了让你在本地测试时不容易把路径写乱,建议把本章示例整理成下面这种结构:

ajax-demo/
  index.html
  books.json

如果你想把结构稍微分清楚一点,也可以这样放:

ajax-demo/
  index.html
  data/
    books.json
  js/
    index.js

这两种都可以,关键是你要清楚请求路径是相对于当前页面地址来计算的

例如:

  1. 如果 index.htmlbooks.json 在同一级目录,那么请求可以写成 ./books.json
  2. 如果 books.json 放在 data/ 目录里,那么请求应该写成 ./data/books.json
  3. 如果你把自己的脚本拆到了 js/index.js,Ajax 请求路径也仍然是相对页面地址,而不是相对 js 文件地址。

初学者最常见的 404,往往不是 axios 不会用,而是目录和相对路径没有对应好。

你也可以直接打开本章配套的可运行示例,对照看目录结构和请求路径:

  1. 在线示例
  2. 示例压缩包下载

页面代码

我们希望页面加载时自动请求一次数据,点击按钮时还能再次刷新列表:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Ajax Demo</title>
</head>
<body>
  <h1>图书列表</h1>
  <button id="reload-btn">重新加载</button>
  <p id="status">尚未加载</p>
  <ul id="book-list"></ul>

  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <script>
    const statusEl = document.getElementById("status");
    const listEl = document.getElementById("book-list");
    const reloadBtn = document.getElementById("reload-btn");

    function renderBooks(books) {
      listEl.innerHTML = "";

      for (const book of books) {
        const li = document.createElement("li");
        li.innerText = `${book.title} - ${book.author} - ¥${book.price}`;
        listEl.appendChild(li);
      }
    }

    async function loadBooks() {
      statusEl.innerText = "正在加载...";

      try {
        const resp = await axios.get("./books.json");
        renderBooks(resp.data);
        statusEl.innerText = `已加载 ${resp.data.length} 条数据`;
      } catch (error) {
        console.error(error);
        statusEl.innerText = "加载失败,请按 F12 查看错误";
      }
    }

    reloadBtn.addEventListener("click", loadBooks);
    window.addEventListener("load", loadBooks);
  </script>
</body>
</html>

这段代码做了几件事:

  1. 页面加载完成后,调用 loadBooks() 发送请求。
  2. 请求成功时,从 resp.data 里取出 json 数据。
  3. 调用 renderBooks(),把数据渲染到页面的 ul 中。
  4. 请求失败时,把错误打印到控制台,同时在页面上给出提示。

这里最重要的一点是:页面刷新的不是浏览器窗口,而是我们自己选中的那一块 DOM。

为什么这个例子很有代表性

这个例子虽然简单,但它已经覆盖了 Ajax 的核心流程:

  1. 页面事件触发请求。
  2. 请求返回数据。
  3. JavaScript 解析数据。
  4. JavaScript 根据数据更新 DOM。

把这个模型理解透,后面的搜索框联想、商品列表分页、用户信息加载,本质上都是同一件事,只是数据和页面结构更复杂而已。

不要直接双击 html 文件测试

如果你直接用 file:/// 的方式打开 html,Ajax 请求经常会因为协议或路径问题失败。

请用我们前面说过的 Live Server,或者任何本地 HTTP 服务来测试。对你来说,最简单的方式仍然是:

  1. 用 VS Code 打开目录。
  2. 启动 Live Server。
  3. 在浏览器里打开页面并按 F12 调试。

axios 里怎么传参数和 json 请求体

前面的例子里,我们只是最简单地读取了一个静态 json 文件。但真实开发里,请求通常还会带上额外信息,例如:

  1. 搜索关键字。
  2. 页码和每页条数。
  3. 要提交给服务器的新数据。

这里最容易混淆的是两类“传值方式”:

  1. URL 参数:会出现在地址栏的 ? 后面。
  2. 请求体:不会拼到 URL 后面,而是放在请求正文里发送。

你可以先把它们理解成一句话:

查数据时,常见的是用 URL 参数;提交一整份对象数据时,常见的是用请求体。

GET 请求里传查询参数

如果你想按条件筛选列表,通常会把参数写进 params 里:

async function searchBooks() {
  try {
    const resp = await axios.get("/api/books", {
      params: {
        keyword: "JavaScript",
        page: 2,
        pageSize: 5
      }
    });

    console.log(resp.data);
  } catch (error) {
    console.error(error);
  }
}

这段代码发出去之后,请求地址通常会变成类似这样:

/api/books?keyword=JavaScript&page=2&pageSize=5

也就是说,下面这段配置:

{
  params: {
    keyword: "JavaScript",
    page: 2,
    pageSize: 5
  }
}

本质上是在告诉 axios:

请帮我把这些字段拼成 URL 查询参数。

这种写法特别适合下面这些场景:

  1. 搜索。
  2. 分页。
  3. 排序。
  4. 根据 id 或分类过滤列表。

如果你在浏览器的 Network 面板里看这类请求,最直观的特征就是:参数会直接出现在 Request URL 上。

POST 请求里传 json 请求体

如果你不是“查列表”,而是要“提交一份数据”给服务器,比如新增图书、保存表单、提交评论,通常会把对象放进请求体里:

async function createBook() {
  try {
    const resp = await axios.post("/api/books", {
      title: "JavaScript 交互",
      author: "王五",
      price: 52,
      inStock: true
    });

    console.log(resp.data);
  } catch (error) {
    console.error(error);
  }
}

这里第二个参数不是 params 配置,而是真正要提交给服务器的那份数据对象

axios 遇到普通对象时,通常会把它当成 json 发送。发送出去的请求体大致相当于:

{
  "title": "JavaScript 交互",
  "author": "王五",
  "price": 52,
  "inStock": true
}

所以你要记住一个很重要的区别:

  1. params 是给 URL 用的。
  2. post 的第二个参数,常常就是请求体数据。

参数、配置、请求体三者怎么区分

很多初学者一看到 axios 的括号里能放很多东西,就容易混乱。可以先记这两个最常见的函数签名:

axios.get(url, config)
axios.post(url, data, config)

对应到实际代码里就是:

axios.get("/api/books", {
  params: {
    page: 1
  }
});

axios.post(
  "/api/books",
  {
    title: "新书"
  },
  {
    headers: {
      "Content-Type": "application/json"
    }
  }
);

这里要分清:

  1. url 是请求地址。
  2. data 是请求体。
  3. config 是额外配置,例如 paramsheaders、超时时间等。

如果你把 paramsdata 的位置放错,请求虽然能发出去,但服务器收到的内容就可能不是你以为的那样。

一个同时包含两种传值方式的例子

有些请求会同时出现“地址上的条件”和“请求体里的数据”。例如你要修改 id 为 12 的图书,并且在 URL 上带一个调试参数:

async function updateBook() {
  try {
    const resp = await axios.put(
      "/api/books/12",
      {
        title: "JavaScript 高级交互",
        price: 68
      },
      {
        params: {
          debug: true
        }
      }
    );

    console.log(resp.data);
  } catch (error) {
    console.error(error);
  }
}

这时:

  1. URL 里会有 ?debug=true
  2. 请求体里会有要更新的那份 json 数据。

所以不要把“请求带参数”只理解成一种形式。参数可能在 URL 上,请求数据也可能在 body 里,它们是两层不同的东西。

调试时应该看哪里

如果你怀疑自己把参数传错了,不要靠猜,直接去 F12 里看:

  1. Headers 里的 Request URL,确认查询参数有没有真的拼上去。
  2. PayloadRequest Payload,确认 json 请求体是不是你想提交的内容。
  3. Response,确认服务器返回的是成功信息,还是参数错误提示。

很多“我明明传了,为什么后端收不到”的问题,最后都不是 axios 出错,而是:

  1. 你把该放在 params 的内容写进了 data
  2. 你把该放在请求体里的对象错写成了查询字符串。
  3. 你以为自己发的是 POST,其实代码里写成了 GET

异步请求的时序和处理逻辑

Ajax 最容易让初学者犯错的地方,不是语法,而是异步

一个非常常见的错误

很多人第一次写 Ajax,会下意识地把它当成同步代码:

let books = [];

function loadBooksWrong() {
  axios.get("./books.json").then(function(resp) {
    books = resp.data;
  });

  console.log(books);
}

初学者往往以为 console.log(books) 会打印出请求回来的数据,但实际上这里大概率只会看到空数组。

原因不是 axios 坏了,而是你的思维还停留在“写在下面的代码一定后执行完”的同步世界里。

真正发生的时序

下面这张图更接近真实情况:

也就是说:

  1. 请求发出去之后,浏览器不会傻等着
  2. 主线程会先继续执行后面的同步代码。
  3. 等网络结果真的回来了,再执行 then(...)await 后面的那段逻辑。

所以在 Ajax 代码里,一个非常重要的原则是:

依赖请求结果的逻辑,必须写在请求成功之后。

例如渲染页面、修改状态、显示成功提示,都应该放在 then 回调里,或者放在 await 之后。

then 写法和 async/await 写法

下面这两种写法,本质上是在表达同一件事。

先看 then

function loadBooks() {
  axios.get("./books.json")
    .then(function(resp) {
      renderBooks(resp.data);
    })
    .catch(function(error) {
      console.error(error);
    });
}

再看 async/await

async function loadBooks() {
  try {
    const resp = await axios.get("./books.json");
    renderBooks(resp.data);
  } catch (error) {
    console.error(error);
  }
}

后者更像“按顺序写代码”,可读性通常更好,但你要牢牢记住一件事:

await 并没有把异步请求变成同步请求,它只是让你能用更接近同步的写法来描述异步流程。

换句话说,async/await 解决的是“代码难读”的问题,不是“网络很慢”的问题。

this 为什么经常写错

Ajax 里另一个高频坑是 this

先看一个常见错误:

const page = {
  books: [],
  load: function() {
    axios.get("./books.json").then(function(resp) {
      this.books = resp.data;
      this.render();
    });
  },
  render: function() {
    console.log(this.books);
  }
};

很多同学会认为 this 一定指向 page。但在 then(function(resp) { ... }) 这个普通函数里,this 并不是你想象中的那个对象,因此 this.booksthis.render() 很容易出问题。

对于初学者来说,有两种最常用的处理办法。

第一种,用箭头函数:

const page = {
  books: [],
  load: function() {
    axios.get("./books.json").then((resp) => {
      this.books = resp.data;
      this.render();
    });
  },
  render: function() {
    console.log(this.books);
  }
};

箭头函数不会自己创建新的 this,它会沿用外层 load 方法里的 this,因此这里就更容易得到我们想要的效果。

第二种,提前把 this 保存下来:

const page = {
  books: [],
  load: function() {
    const self = this;

    axios.get("./books.json").then(function(resp) {
      self.books = resp.data;
      self.render();
    });
  },
  render: function() {
    console.log(this.books);
  }
};

这两种写法都很常见。对于我们现在这个阶段,我更建议你优先理解:

  1. 普通函数里的 this 不一定是你想的那个对象。
  2. 箭头函数会继承外层的 this
  3. 如果代码本来就不复杂,甚至可以少用 this,直接用普通函数和局部变量。

一个更完整的对象写法

thisasync 放在一起看,会更清楚:

const page = {
  books: [],

  async load() {
    try {
      const resp = await axios.get("./books.json");
      this.books = resp.data;
      this.render();
    } catch (error) {
      console.error(error);
    }
  },

  render() {
    console.log(this.books);
  }
};

page.load();

这里的处理逻辑非常清晰:

  1. load() 发出请求。
  2. await 等待这一次请求的结果。
  3. 请求成功后,把数据写入对象状态 this.books
  4. 立刻调用 render() 刷新页面。

这也是你今后做前端页面时最常见的套路之一:

请求数据 -> 写入状态 -> 刷新视图

如何测试和调试 Ajax

Ajax 出问题时,不要只盯着代码发呆。前端调试最有价值的工具依旧是浏览器 F12。

先看 Network 面板

按 F12 打开开发者工具,切到 Network 面板,再重新触发你的请求。你至少应该确认这几件事:

  1. 请求到底有没有真的发出去。
  2. 请求的 URL 对不对。
  3. 返回状态码是不是 200
  4. 返回的数据到底是什么。

点开某条请求后,可以重点看:

  1. Headers:请求地址、请求方法、状态码。
  2. Preview:浏览器帮你预览返回的数据。
  3. Response:服务器真正返回的原始内容。

如果你明明觉得“代码写对了”,却还是没有数据显示,那么第一反应就应该是:先去 Network 看看到底请求到了什么。

再看 Console 面板

控制台最适合看两类信息:

  1. 你自己打印的调试信息。
  2. 浏览器抛出的错误信息。

例如:

async function loadBooks() {
  try {
    const resp = await axios.get("./books.json");
    console.log("返回的数据是", resp.data);
    renderBooks(resp.data);
  } catch (error) {
    console.error("请求失败", error);
  }
}

如果请求失败,error 里往往会带有很关键的信息。

断点要打在两个地方

调试 Ajax,最值得打断点的地方一般有两个:

  1. 发请求之前。
  2. 请求成功或失败之后。

这样你就能分清楚:

  1. 到底有没有走到发请求这一步。
  2. 请求回来之后,程序有没有进入成功分支。
  3. 数据拿到了之后,是请求错了,还是渲染错了。

最常见的几类问题

现象常见原因你应该先看哪里
请求 404路径写错了,文件不在那个位置Network 里的 URL
请求没发出去按钮事件没绑上,或者代码根本没执行Console、断点
返回了但页面没更新数据拿到了,但 DOM 渲染逻辑写错了Console、Elements
提示跨域错误请求地址和页面地址不在同一个源Console、Network
json 解析失败json 文件格式本身有错误Response、Console

你应该主动做的几种测试

不要只测“成功”这一种情况。至少可以主动试这几种:

  1. books.json 改成错误路径,看看 404 时页面是否有提示。
  2. 故意把网络切换到慢速,观察“正在加载...”是否会正常显示。
  3. 故意写错一条 json,看看控制台报什么错。
  4. 请求成功后,确认页面里的列表项数量和 json 数据条数一致。

开发者工具的 Network 面板里可以手动切换网络速度,这对理解“为什么异步不会卡死页面”很有帮助。

小结

这一章真正要掌握的,不只是“会发一个 axios 请求”,而是下面这四件事:

  1. Ajax 的意义,是不刷新整张页面,只更新需要变化的部分
  2. 请求回来的数据,通常在 resp.data 里。
  3. 异步意味着依赖请求结果的逻辑,必须写在 then 回调或 await 之后。
  4. 调试 Ajax 时,先看 Network,再看 Console,不要靠猜。

练习

在上面的图书列表示例中继续增加两个功能:

  1. 加载失败时,把错误提示显示成红色。
  2. 列表为空时,在页面中显示“暂无数据”。