阅读 301

可能不是史上最全但肯定能学会的 Ajax 教程

Ajax 是一个缩写,取自 Asynchronous JavaScript And XML(异步 JavaScript 和 XML)的首字母缩写。

同步与异步请求的区别

学习 Ajax,首先要弄清楚同步与异步请求的区别。

同步请求:form

在 Web 发展的初期,客户端和服务端进行交互的主要途径都是通过 form 标签。
form 标签的用户计较简单,需要设置 action 属性和 method 属性。form 表单内会包含一些具有输入功能的交互元素,比如 input、checkbox 或者 select 等。
这些交互元素会设置一个 name,作为字段的名称。
最终通过一个 submit 组件将数据提交到 action 设置的接口上面。
比如你要提交一个登陆表单,代码大致如下:

<form action="http://example.com/" method="post">
  <input name="accessName" placeholder="请输入用户名" />
  <input name="password" type="password" placeholder="请输入用户密码" />
  <button type="submit">登陆</button>
</form>
复制代码

form 标签有一些特性,我将其分类为优点和缺点两类,整理如下。

form 的优点

form 属于 DOM 早期就具有的接口,和 JavaScript 无关,即使浏览器不开启 JavaScript 也可以发送 http 请求。所以兼容性极好,在一些古老的浏览器中仍然可以工作。在我看来,这也是 form 表单唯一的优点。

form 的缺点

除去上述的这一条优点,form 标签的其他特点对现代的浏览器来说几乎全部都是缺点。
主要缺点大体上有两条:

  • 仅支持 get 和 post 两种请求方法。
  • 表单会改变 url,并且刷新整个 HTML 文档。
  • 必须等待服务端程序完全运行结束,才会加载新的 HTML 文档。

第一个缺点是功能上的缺失。目前的 http 接口可以设置很多种方法,不再仅限于 get、post,还可以设置 patch、put、delete、options 等。
后面两个缺点是致命的,它直接影响了用户体验度。如果每次调用一次接口,都要完整的刷新整个 html 文档,大大降低了用户体验度。一个用户体验度很差的项目,是很难成功的。特别是面对如今越来越挑剔的用户。

TODOList Demo

基于 form 来进行数据交互的项目,整体上的架构通常是 MVC 模式,前后端通常不分离,而是通过模板的方式对数据进行填充。
很多工作年限较少的前端工程师通常没有经历过这种模式,但我认为这是一个学习 Ajax 必须要知道的知识。毕竟计算机中的任何新技术几乎都是为了解决一些旧技术的弊端才诞生的,如果直接学习新技术,而不去看它的历史背景,往往很难真正搞懂它们诞生的初衷。
下面是用 express 实现的一个极其简单的 TODOList 例子。
express 是一个用 node.js 实现的服务端框架,做这个 Demo 的意义是为了帮助我们了解同步请求的真实情况,具体细节无需关注。
考虑到读者自身水平的差异,下面是一个完整的操作流程:
创建项目。

mkdir todo-list
复制代码

进入项目。

cd todo-list
复制代码

初始化 npm 项目。

npm init -y
复制代码

安装 express 和 ejs 模板引擎。

npm i express ejs
复制代码

编写服务端程序。
它只做了两件事:

  1. 存储数据。
  2. 渲染模板,并返回 html。
var express = require("express");
var ejs = require("ejs");
var app = express();

const todoList = []

app.get("/", function (req, res) {
  // 存储数据
  if(req.query.todo){
    todoList.push(req.query.todo)
  }
  // 渲染模板,并返回 html
  ejs.renderFile("index.html",
    {todoList},
    (err, data) => {
      res.end(data)
    })
})

app.listen(3000);
复制代码

编写模板页面,<% %> 是 ejs 的语法,你不需要可以去学 ejs,只需要大体明白它的作用即可。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TODO List</title>
  </head>
  <body>
    <form action="http://localhost:3000/" method="get">
      <input name="todo" />
      <button type="submit">添加</button>
    </form>
    <div>
      <% for (var i=0; i<todoList.length; i++) { %>
        <li> 
          <%= todoList[i] %>    
        </li>
      <% } %>
    </div>
  </body>
</html>
复制代码

最后启动服务。

node server.js
复制代码

访问 http://localhost:3000/,之后就可以体验 TODO 了。
通过上面的例子,我们可以得知,前后端 MVC 架构下,html 的构成大概如下图所示。
image.png
我们再来改造代码,体验一下用户体验度很差的情况。改造一下渲染模板部分的代码,假设有个耗时 2 秒的操作。

ejs.renderFile("./index.html", { todoList }, (err, data) => {
  setTimeout(() => res.end(data), 2000);
});
复制代码

这时再来体验 TODO List,会发现每次添加一个待办项,页面都需要加载 2 秒以上。在这 2 秒多的时间里,我们是不能做任何操作的,只能干等着浏览器加载结束。

Ajax 和 XMLHttpRequest

为了解决 form 这种同步请求模式带来的用户体验问题,Ajax 之父 Jesse James Garrett 在 2005 年发表的《Ajax: A New Approach to Web Applications》中提出了 Ajax 的概念。这个时间点是一个 Web 的里程碑,在此之前是 Web 1.0 时代,而在自之后,进入了 Web 2.0 时代。
Ajax 的意思是通过异步的方式使用 JavaScript和 XML 来进行接口请求,这种方式不需要刷新页面,并且支持所有的请求方法。所以完美的解决了 form 存在的缺点。
第一个具有代表性的技术是 XMLHttpRequest,取首字母简写为 XHR。
其实 XHR 出现的时间远早于 Ajax,在 1999 年就被微软植入 IE5 浏览器中了。但真正流行起来,是靠以 Google 的著名产品 Gmail 为代表的一批优秀 Web App。而 Gmail 是当年体验最佳的 Web App 之一,即便是放到今天,Gmail 的体验仍然是无数 Web App 中的佼佼者。直到 2006年,最 W3C 标准化,在此后的所有浏览器都拥有了这个 API。
在对 Ajax 和 XHR 有了初步了解之后,接下来简单体验一下 XHR。
它的基本用法如下:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/', true);
xhr.setRequestHeader("Content-type","application/x-www-form-urlencoded");  
xhr.responseType = 'json';

xhr.addEventListener("load", function () {
  // TODO: 成功的逻辑
});

xhr.addEventListener("error", function () {
  // TODO: 失败的逻辑
});

xhr.send();
复制代码

首先通过 new XMLHttpRequest 创建出一个 xhr 对象。
通过 open 方法设置基本的请求配置,第一个参数是请求方法,第二个参数是请求 URL,第三个参数用来描述是异步还是同步,true 表示异步,false 表示同步,默认是异步。
setRequestHeader 方法可以设置请求头字段,Content-type 表示内容的格式。
responseType 表示返回的数据格式,默认是 text,也就是文本类型。这里我们设置成 json。
xhr 对象可以通过 addEventListener 来监听一些方法,常见的有调用结束后触发的 load 方法和调用失败后触发的 error 方法。
最终通过 send 方法进行调用,send 可以接受一个可选参数,作为请求体。

使用 XHR 重构 TODOList

使用 Ajax 进行前后端分离的项目整体架构通常如下图所示:
image.png
我们可以使用 XHR 来改造之前写好的 TODOList 应用。
首先需要再安装 body-parser 库,它用来帮助 express 格式化 body。

npm i body-parser
复制代码

重构 server.js。

var express = require("express");
var path = require("path");
var app = express();
var bodyParser = require('body-parser');

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }))

const todoList = []

app.get("/", function (req, res) {
  res.sendFile(path.resolve(__dirname, './index.html'))
})

app.get("/todo", function (req, res) {
  res.send(todoList)
})

app.post("/todo", function (req, res) {
  if (req.body.todo) todoList.push(req.body.todo)
  res.send(JSON.stringify({ status: 'success' }))
})

app.listen(3000);
复制代码

添加了两个接口,分别是 get 方法的 todo 和 post 方法的 todo,分别用于获取数据和保存数据。
重构 index.html。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TODO List</title>
  </head>
  <body>
    <input id="todoInput" />
    <button id="addBtn">添加</button>
    <div id="list"></div>
    <script>
      document.getElementById("addBtn").addEventListener("click", add);
      const todoInput = document.getElementById("todoInput");

      function add() {
        const xhr = new XMLHttpRequest();
        xhr.open("post", "http://localhost:3000/todo");
        xhr.responseType = "json";
        xhr.setRequestHeader("Content-Type", "application/json");
        xhr.addEventListener("load", function () {
          if (xhr.response.status === "success") {
            todoInput.value = "";
            getData();
          }
        });
        const data = JSON.stringify({ todo: todoInput.value });
        xhr.send(data);
      }

      function getData() {
        const xhr = new XMLHttpRequest();
        xhr.open("get", "http://localhost:3000/todo");
        xhr.responseType = "json";
        xhr.addEventListener("load", () => {
          renderList(xhr.response);
        });
        xhr.send();
      }

      function renderList(todoList) {
        document.getElementById("list").innerHTML = todoList
          .map((i) => `<li>${i}</li>`)
          .join("");
      }

      getData();
    </script>
  </body>
</html>
复制代码

我们在 html 中添加了一段 JavaScript 脚本代码,主要作用是点击添加按钮后,创建一个 xhr 对象,通过该对象发送异步请求,这里设置了请求头字段 Content-Type 为 application/json,表示发送的数据内容格式为 json。关于 json 和 xml 的区别,稍后再讲。
在请求返回后,如果状态为 success 表示请求成功,紧接着再调用 getData 函数,获取最新的数据,并且将数据渲染到 list 元素中,从而实现页面不跳转,仅在当前页面进行局部刷新的效果。
至此,我们就完成了采用 Ajax 对 TODOList 的重构。

XHR 对象常用属性

readyState

readyState 是一个 Number 类型的属性,共有 5 个值,分别是 0-4,用来表示当前 xhr 对象的状态。

  • 0:未发送,xhr 对象已经被创建,但还没有调用 open 方法。
  • 1:开启,调用了 open 方法,但没有调用 send 方法。
  • 2:已发送,调用了 send 方法,并且头部和状态已经可以访问,但还不能访问响应体。
  • 3:下载中,开始接收返回数据,这时的数据并不完整。
  • 4:完成,接受完全部响应数据。

我们也可以把这个过程理解为一个请求的生命周期。

readyState 常量

为了提供更好的语义化,方便我们记忆,xhr 对象还存在 5 个常量,和 readyState 属性的 5 种状态相对应。这样方便我们直接利用这些常量对 xhr 的状态进行对比。

  • UNSENT-0
  • OPENED-1
  • HEADERS_RECEIVED-2
  • LOADING-3
  • DONE-4

status 和 statusText

status 表示的是 http 的状态码,它由三个十进制数组组成,第一位数字用于表示状态码的类型,后面两位数字表示更细粒度的含义。http 的状态码分为 5 个大类型。分别以 1-5 开头,常见的有 200表示请求成功;404 表示资源未找到;500 表示服务器内部错误。其它更加具体的分类会在文章后面介绍。
statusText 是对 status 的语义化描述,比如表示成功状态的 200,对应的 statusText 应该是 OK,404 的 statusText 应该是 Not Found。
status 和 statusText 都是由服务器设置的。

response*

以 response 开头的属性有 5 个,分别是 response、responseType、responseURL、responseText、responseXML。

  • response 是只读属性,表示返回的数据,它的具体值取决于 responseType 的值。
  • responseType 是我们设置的数据类型,可以设置字符串枚举值,可选值有 arraybuffer、blob、document、json。如果什么都不设置,默认是 text 类型。这个属性必须和服务端返回数据格式兼容,否则会造成数据格式错乱。
  • responseURL 是经过序列化后的响应 URL。
  • responseText,只读属性,返回数据的纯文本值。
  • responseXML,只读属性,返回一个 Document 对象。需要注意,如果 responseType 设置的值不为 document 或者 空字符串,使用 responseText 和 responseXML 会抛出异常。

timeout

如果服务器一直不返回响应,xhr 对象就会一直占用资源。比如网络连接不稳定的情况,这时就需要有一种机制来关闭这次请求。timeout 属性就是为了这个需求而设计的。
timeout 的值可以设置为 Number,单位是毫秒。当响应时间超过了这个值,请求就会关闭。
image.png

upload

理想情况下的接口返回应该非常快,都是毫秒级别的。但在一些特殊的场景下,接口请求可能会比较慢,比如上传大文件。这时我们需要知道当前的上传进度,来对用户进行实时的提示,来降低用户的焦虑情绪。upload 属性就是为了这个需求而设计的。
upload 返回一个 XMLHttpRequestUpload 对象,监听这个对象的 onprogress 事件可以获取到当前的上传进度。

XHR 对象方法

XHR 的方法都比较简单,功能也比较单一,这里就一带而过。

open

初始化一个请求,第一个参数是 Method,第二个参数是 URL。

send

开始发送请求。可以传递一个参数,作为 Post 请求的 Body。
如果 XHR 是同步的话,会产生阻塞。

setRequestHeader

设置请求头的字段,第一个参数是字段名,第二个参数是字段值。

getResponseHeader

获取某个响应头字段。

getAllResponseHeaders

获取所有的响应头字段。

abort

立即中断请求。

overrideMimeType

重写服务器返回的 MIME 类型。

XHR 对象事件

XHR 的事件共有 8 个,分别是 abort、error、load、loadend、loadstart、progress、readystatechange 和 timeout。
从字面意思上也很容易理解,分别对应请求中断、加载发生异常、加载成功、加载结束、加载开始、加载进行中、readState 发生变化时和请求超时。
监听方式也很简单,可以使用 on* = callback 的方式。
除了 on* 的方式,也可以使用 addEventListener 来监听这些事件。我更推荐使用 addEventListener 的方式来监听事件。

XML 和 JSON

XMLJSON 都是数据传输格式。
虽然 Ajax 和 XMLHttpRequest 的名字中都带有一个 XML,但如今最流行的数据传输格式已经不再是 XML,而是 JSON。
在 08 年之前,最流行的数据传输格式是 XML,在 08 年之后,JSON 开始出现在人们的视野中,逐渐被人们接受,并且迅速普及,开始成为 XML 的最强竞争对手。直到 2013 年,JSON 彻底超越 XML,成为目前最流行的数据传输格式。现在仍然使用 XML 作为数据传输格式的项目通常都是一些具有非常深远历史渊源的项目。所以如果你没有听说过 XML,或者只是听说过,但没有使用 XML 也属于正常现象。
下面是一张 2009-2019 年这 10 年里不同的数据传输格式的趋势图。除了 XML 和 JSON 外,还有 csv 和 soap 等其它数据传输格式。image.png
既然 JSON 是最流行的数据传输格式,那为什么 Ajax 和 XMLHttpRequest 都不是叫作 Ajaj 和 JSONHttpRequest 呢?
这些问题的答案都是历史原因。XML 诞生于 1997 年,XMLHttpRequest 诞生于 1999 年,JSON 诞生于 2001 年。也就是说 XMLHttpRequest 出现的时候,还不存在 JSON,当然不可能叫做 JSONHttpRequest,并且 XMLHttpRequest 在最初也只是单纯获取 XML 数据而已。
一个技术一旦通过标准化后就会得到普及,就必须考虑兼容性问题,改名字也很不现实。如果再制造一个 JSONHttpRequest 对象会显得很多余,因为 XMLHttpRequest 同样可以完成 JSON 格式的数据传输。
Ajax 的概念诞生于 2005 年,但 JSON 逐渐成为主流是在 12 年之后,所以 Ajax 也不会叫做 Ajaj,同样它也没有改名的必要,而且那么多年大家也已经叫习惯了。
那么 XML 和 JSON 在真实的数据格式上有什么区别呢?拿 TODOList 接口的数据来举例。
XML 可能是下面这种结构:

<list>
  <item>读书</item>
  <item>蹦迪</item>
  <item>写作</item>
  <item>刷知乎</item>
</list>
复制代码

JSON 的格式可能是下面这样:

["读书", "蹦迪", "写作", "刷知乎"]
复制代码

关于两者的语法和其它区别,我就不在这里详细说了。如果你对 JSON 不熟悉,可以点击这一节开头的官网链接自行学习。不过我不建议你再去详细学习 XML 的语法规则了,因为它毕竟已经过时,没有太大意义。优先学习 JSON 可能是一种更好的选择。

使用 XML 重构 TODO List

为了直观的感受 XML 和 JSON 的差异,下面我们使用 XML 对 TODO List 进行重构。
改造 server.js 的 todo 接口。

app.get("/todo", function (req, res) {
  if (req.query.format === "xml") {
    return res.send(
      `<list>${todoList.map((item) => `<item>${item}</item>`).join("")}</list>`
    );
  }
  res.send(todoList);
});
复制代码

主要是在 req 的查询参数上添加了 format 字段,用来兼容 xml 和 json 两种格式。
默认仍然支持 json 格式。如果传递了 format 字段,并且设置为 xml,才会返回 xml 格式的数据。这时返回的数据就是 xml 格式了。
上面的这种编程模式在很多场景下都很实用。
接下来改造 index.html 中的 getData 函数。

function getData() {
  const xhr = new XMLHttpRequest();
  xhr.open("get", "http://localhost:3000/todo?format=xml");
  xhr.addEventListener("load", () => {
    const parser = new DOMParser();
    const xml = parser.parseFromString(xhr.response, "text/xml");
    const todoList = Array.from(xml.getElementsByTagName("item")).map(
      (itemEl) => itemEl.innerHTML
    );
    renderList(todoList);
  });
  xhr.send();
}
复制代码

我们首先在 url 上面添加了 format=xml 的参数,并且取消了 xhr.responseType 的设置,这样会使用默认的 text。
之后又通过 DOMParser 对象将返回的数据解析为 xml 格式。然后再用 DOM API 查询出所有的 item 标签,转换为 JavaScript 对象。
通过对比,我们可以得出以下结论。
JSON 之所以可以取代 XML,主要有两个原因:

  • JSON 可以非常方便的转换为 JavaScript 原生对象,解析和访问更加容易。
  • JSON 拥有更小的数据传输体积。

真实场景下的实用技巧

给用户提示当前上传和下载进度,减少等待焦虑。

假设我现在在网速不好的情况下下载一张体积比较大的图片。
下载图片的方案通常有两种,第一种是直接在 img 标签上设置 src 属性,由浏览器自动下载并展示。由于这种方式是以数据流来加载的,所以视觉效果是图片在页面由上到下逐行加载,由模糊逐渐到清晰。
另一种方案是由 JavaScript 代码执行下载,等图片完全下载结束时再一次性展示到页面上,这种方案的图片不会产生上述的加载过程。
为了便于演示效果,我们采用第二种方式。
编写服务端代码:

var express = require("express");
var ejs = require("ejs");
var path = require("path");
var app = express();
var bodyParser = require("body-parser");

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

const todoList = [];

app.get("/", function (req, res) {
  res.sendFile(path.resolve(__dirname, "./index.html"));
});

app.get("/img", function (req, res) {
  res.sendFile(path.resolve(__dirname, "./img.jpg"));
});

app.listen(3000);
复制代码

编写 index.html。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>img</title>
  </head>
  <body>
    <div id="container"></div>
    <div id="procress"></div>
    <script>
      const procress = document.getElementById("procress");
      const container = document.getElementById("container");

      const xhr = new XMLHttpRequest();
      xhr.open("GET", "http://localhost:3000/img");
      xhr.responseType = "blob";
      xhr.addEventListener("progress", (e) => {
        procress.innerText = `已加载:${Math.floor(
          (e.loaded / e.total) * 100
        )}`;
      });
      xhr.addEventListener("load", (e) => {
        const img = new Image();
        img.style.width = "100%";
        img.src = URL.createObjectURL(e.currentTarget.response);
        container.replaceWith(img);
      });
      xhr.send();
    </script>
  </body>
</html>
复制代码

放置资源图片。
你可以使用我的这张图片,或者使用一张自己的图片,只要文件名是 img.jpg 就可以。
model-5277622.jpg
温馨提示:如果你的网速过快,难以查看效果,可以在 chrome 的 devtools network 中将网速调低。
image.png
核心 API 是 progress 事件,它的回调函数的 event 参数有一个 total 和 loaded 属性。total 表示资源的总大小,loaded 表示当前已下载的大小。单位都是字节(byte,简称B)。total / loaded * 100 就可以得到当前百分比单位的进度。

给用户取消上传/下载的选择

当用户发现下载速度比较慢时,可以给用户一个取消继续下载的选择。
继续拿上面的例子举例。
我们提供一个停止下载的按钮和重新下载的按钮。
重构 index.html。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>img</title>
  </head>
  <body>
    <div id="container"></div>
    <div id="procress"></div>
    <button id="cancel">取消下载</button>
    <button id="retry">重新下载</button>
    <script>
      const procress = document.getElementById("procress");
      const container = document.getElementById("container");
      const cancel = document.getElementById("cancel");
      const retry = document.getElementById("retry");

      let abort = null;

      function downloadImg() {
        const xhr = new XMLHttpRequest();
        xhr.open("GET", "http://localhost:3000/img");
        xhr.responseType = "blob";
        xhr.addEventListener("progress", (e) => {
          procress.innerText = `已加载:${Math.floor(
            (e.loaded / e.total) * 100
          )}`;
        });
        xhr.addEventListener("load", (e) => {
          const img = new Image();
          img.style.width = "100%";
          img.src = URL.createObjectURL(e.currentTarget.response);
          container.replaceWith(img);
        });
        xhr.addEventListener("abort", (e) => {
          procress.innerText = "已停止下载";
        });
        xhr.send();
        return xhr.abort.bind(xhr);
      }

      abort = downloadImg();
      cancel.addEventListener("click", () => abort());
      retry.addEventListener("click", () => (abort = downloadImg()));
    </script>
  </body>
</html>
复制代码

核心 API 是 xhr.abort 方法。需要注意 abort 方法是原生函数,必须将它的 this 绑定为 xhr,否则会得到一个非法调用的错误(Uncaught TypeError: Illegal invocation)。

关闭页面时记录一些信息

现在再考虑一个场景,我们的网站要在用户关闭网页时记录用户的最后在线时间。
在早期,解决方案是监听 window 的 unload 事件,然后发送一个 XHR 请求。但是浏览器在关闭文档时不会处理异步代码,所以必须使用同步的 XHR 来延迟用户关闭页面,直到服务器响应之后才可以继续关闭。这种方案对用户来说体验并不好。
下面是一个例子。
添加一个接口。

app.post("/offline", function (req, res) {
  setTimeout(() => {
    console.log(req.body);
    res.send("");
  }, 3000);
});
复制代码

重新编写 index.html,在 unload 时使用 XHR 进行同步请求。

<html>
  <body>
    <div>请关掉我!</div>
    <script>
      window.addEventListener(
        "unload",
        () => {
          const data = JSON.stringify({ userID: "12345b" });
          localStorage.setItem("data", data);
          const xhr = new XMLHttpRequest();
          xhr.open("post", "http://localhost:3000/offline", false);
          xhr.setRequestHeader(
            "Content-Type",
            "application/json;charset=UTF-8"
          );
          xhr.send(data);
        },
        false
      );
    </script>
  </body>
</html>
复制代码

不过在很多主流的浏览器上已经不再支持在 unload 事件中执行同步的 xhr 了。
localStorage.setItem("data", data); 是生效的。
可以在 Application 面板的 Local Storage 中查看到存储的数据。
image.png
除了这种方案,还存在另一种更加可靠和可行的方案,就是 sendBeacon API。
sendBeacon API 非常简单,只能接收两个参数,第一个参数是 URL,第二个参数是数据。
由于它的应用场景单一,所以不能设置请求头和请求方法,默认只能是 post 请求,也无法使用 JSON 格式的数据,可以使用 ArrayBufferView、Blob、FormData 和 string。
这里使用 FormData 进行数据传输。
修改 index.html。

<html>
  <body>
    <div>请关掉我!</div>
    <script>
      window.addEventListener(
        "unload",
        () => {
          let data = new FormData();
          data.append("userID", "12345b");
          // const data = JSON.stringify({ userID: "12345b" });
          navigator.sendBeacon("http://localhost:3000/offline", data);
        },
        false
      );
    </script>
  </body>
</html>

复制代码

由于 express 无法直接解析 formdata 的数据,所以需要再借助一个中间件。

npm i express-formidable
复制代码

添加中间件。

var formidable = require("express-formidable");

app.use(formidable());
复制代码

修改接口。

app.post("/offline", function (req, res) {
  setTimeout(() => {
    console.log(req.fields);
    res.send("");
  }, 3000);
});
复制代码

Beacon 并不会立即执行,而是加入到异步队列中,等待浏览器空闲时执行。

请求失败时的重试

假设由于我们的网络状况不好,导致浏览器和服务器之间的通信中断,我们想在失败时重新发送一个请求,继续重试,直到重试成功为止。
添加一个接口。

app.get("/data", function (req, res) {
  const value = Math.random() * 100;
  if (value > 10) {
    res.status(500);
  }
  return res.send({ value });
});
复制代码

该接口的逻辑是产生一个 0-100 的随机数,如果这个随机数大于 10,就会将 http 状态码设置为 500,表示服务器内部错误。
修改 index.html。

<html>
  <body>
    <div id="log"></div>
    <script>
      const log = document.getElementById("log");
      let count = 0;

      function getData() {
        const xhr = new XMLHttpRequest();
        xhr.open("get", "http://localhost:3000/data");
        xhr.responseType = "json";
        xhr.addEventListener("load", () => {
          if (xhr.status !== 200) {
            log.innerHTML = `请求失败,正在重试,当前正在进行第 ${count++} 次重试`;
            setTimeout(getData, 1000);
            return;
          }
          log.innerHTML = `请求成功,数据为:${JSON.stringify(xhr.response)}`;
        });
        xhr.send();
      }

      getData();
    </script>
  </body>
</html>
复制代码

index.html 的逻辑是监听 xhr 的 load 事件,当 xhr.status 不为 200 时,认为本次请求失败,间隔 1 秒后重新发起请求。直到请求成功。
需要注意,xhr.status 不为 200 并不会触发 xhr 的 error 事件,需要在 load 中自行判断。

Ajax 的意义

Ajax 所带来的最终价值体现为用户体验大幅提升。在技术方面的影响同样巨大,几乎是现代所有前端技术的基石。直接和间接的推动了前后端分离架构、MVVM 架构和 SPA(Single Page Application)架构的诞生和演进。同时 Ajax 也是推动 Web 浪潮崛起的核心技术之一。甚至我们可以认为,如果没有 Ajax,Web 也许不能发展到如今这幅局面。

Fetch

在现代浏览器中,XHR 并不是发送 http 请求的唯一选择。除了 XHR 外,在浏览器原生环境下还有另一个可以发送 http 请求的 API,就是 fetch。
前面我有提到:**计算机中的任何新技术几乎都是为了解决一些旧技术的弊端才诞生的。**那么 XHR 是完美的产物吗?显然不是,计算机中不存在任何完美的东西。就像我们人类社会的科学,始终是在向前进步的,我们现在看到的所有技术,终有一天要被彻底颠覆。

XHR 的问题所在

那么 XHR 的问题到底在哪?

不合符 SOC 原则

软件设计领域有一个著名的关注点分离原则(Separation of concerns,简称 SOC)。只有遵循了 SOC 原则,才可以设计出高内聚低耦合可扩展的系统。
如果你不懂 SOC 和高内聚低耦合这类看上去高大上的术语,也不需要惊慌失措。它们很好理解。
关注点,就是你要做什么事,把焦点都放在一件事情上,只把这一件事情做好,保持职责单一。
系统中存在很多个模块,每个模块都只专注于做自己的那件事。最终把很多模块组合起来,形成了系统。只有这样做,模块和模块之间才不会有过多的耦合性,在对模块进行升级和重构时才可以得心应手。
这些知识也是软件设计的基本原则。毫不夸张地说,评价一个程序员的技术能力强弱,和他知道多少个 API、会用多少个框架、做过多少个项目、写过多少行代码、拥有多少个头衔、在哪家著名公司、担任什么重要职务、每月拿多少薪资等等都没有多少关系。真正取决定性作用的是设计能力。
很明显,XHR 不符合关注点分离原则(SOC)。因为它的 request、response、events 等等所有东西都放在 xhr 这个对象上。
其实不难观察,在 BOM API 和 DOM API 中很多接口都是不符合 SOC 原则的,它们都像是一坨错综复杂的功能混合在一起,最终挂载到一个对象上面,导致很多 Web API 都拥有非常多但彼此间又毫无关系的属性和方法。造成这个现象的原因也很好理解,在那个软件规范还处于刀耕火种的蛮荒时代,Web 标准的制定者们还没有能力也没有必要设计出我们目前所要求的那种水准的 API。

回调地狱

在 JavaScript 中,回调地狱(Callback Hell)这个问题一直困扰着很多程序员。
特别是对写过 Node.js 的人来说,这个体验更为明显。
比如下面这段代码:

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})
复制代码

整个代码形状就像是一个旋转了 90° 的金字塔一样,最后面一大堆的 } 和 ),让人眼花缭乱。
如果你对回调地狱不够了解,可以点击上面链接中的文章学习一下。这里不做过多介绍。
回调地狱的问题是不够简洁,代码结构难以理解和维护。
如果使用 XHR,很容易写出回调地狱风格的代码。

fetch 的诞生

由于 XHR 已经被大范围使用,考虑到兼容性问题,标准制定者们无法再对 XHR 进行大刀阔斧的修改,只能扩展一些功能。
在这个历史背景下,他们决定再制作一个新的 API,fetch 由此而生。
很多人对 fetch 的第一印象是一个非常新的 API。但实际上 fetch 已经存在 10 年了。它和 jQuery 的 ajax 都是在 2011 年出现的。只不过 fetch 被浏览器真正实现是在 2015 年。
为什么是 2015 年呢?因为 fetch 依赖了 Promise API,它需要等 Promise API 通过提案被标准化才可以进入标准化。而 Promise API 是在 ES2015 中被标准化,所以 fetch 也是在同年相继标准化。

fetch 的特点

fetch 在用法上比 XHR 更加简单,但不意味着 fetch 会比 XHR 更加高级,相反,fetch 是一个低级的 API。
用法如下:

fetch('https://api.github.com/users/luzhenqian')
    .then((response) => {
        return response.json();
    })
		.then((data) => {
  		console.log(data)
		})
    .catch(function (err) {
        console.log(err);
    });
复制代码

这里对上面这段代码进行简单解释。fetch 是一个函数,它挂载在 window 对象上,在浏览器环境下可以直接使用。第一个参数是接口的 URL。可以忽略 method,默认是 get。如果要设置 method,需要传递第二个参数。它的返回值是一个 Promise,通过 then 和 catch 方法来处理数据和异常。
下面再来看一下 fetch 是如何解决 XHR 的两大问题的。

遵循 SOC 原则

Fetch API 共包含 1 个函数和 3 个接口。分别是 fetch、Headers、Request 和 Response。
fetch 用于获取资源的函数。
Headers 表示请求头和响应头,可以通过这个接口查询它们。
Request 表示资源请求。
Response 表示对请求的响应。
设置请求头的方式像下面这样:

let headers = new Headers();
headers.append('Content-Type', 'text/json');
let initConfig = { headers };

fetch('https://api.github.com/users/swapnilbangare', initConfig)
    .then(function (response) {
        return response.json();
    })
    .then(function (data) {
        console.log(data);
    })
    .catch(function (err) {
        console.log(err);
    });
复制代码

fetch 第一个参数也可以传递一个 Request 对象。对应的,then 的参数是一个 Response 对象。
这种设计是一种遵循 SOC 原则的设计。

解决回调地狱

由于 fetch 返回的是一个 Promise 对象,所以自然不存在回调地狱的问题。
Promise 是针对异步回调函数带来的回调地狱而设计出的解决方案,将原本层层嵌套的代码结构改造为一种扁平化的结构。
这里不再过多介绍 Promise 的内容,如果你感兴趣推荐你去查阅 promisejs

使用 fetch 重构 TODO List

改造过程也比较简单,只需要调整 add 函数和 getData 函数即可。

function add() {
  const headers = new Headers({ "Content-type": "application/json" });
  const request = new Request("http://localhost:3000/todo", {
    method: "post",
    headers,
    body: JSON.stringify({ todo: todoInput.value }),
  });
  fetch(request)
    .then((response) => response.json())
    .then((data) => {
    if (data.status === "success") {
      todoInput.value = "";
      getData();
    }
  });
}

function getData() {
  const request = new Request("http://localhost:3000/todo");
  fetch(request)
    .then((response) => response.json())
    .then(renderList);
}
复制代码

不过由于 fetch API 相对比较新,一些稍微老旧版本的浏览器可能不支持 fetch,这时候就需要使用 polyfill 了。比较推荐的是 whatwg-fetch

fetch 的缺点

fetch 虽然解决了 XHR 的一些问题,但它自身并不是完美无缺的。
在 fetch 从提案到标准化的几年里,社区对它进行了激烈的讨论。其中最被诟病的两个问题是:

  • 超时中断
  • 进度事件

超时中断

在 fetch 刚出现的时候,是没有超时中断能力的。经过社区激烈的讨论,最终 AbortController 通过了提案。
目前 fetch 的超时中断是使用 AbortController 配合 setTimeout 实现的,整体上比较繁琐。
代码如下:

const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 5000);

fetch(url, { signal })
复制代码

创建一个控制器,给请求一个信号。
设置一个延时器,超过延时时间执行 控制器的 abort 方法中断请求。

进度事件

目前为止 fetch 没有任何与进度相关的事件。这也就意味着 fetch 无法实现 XHR 的 onprocess 事件。这是非常致命的。
但也并非完全无解,fetch-progress-indicators 项目中使用 Service Workers 来解决了进度问题。但是存在一些小问题,比如页面取消加载时,网络读取的文件并不会终止。在 window 和 img 标签上的 abort 回调并不会触发。
关于这个问题,只能期待规范能够彻底解决。

Axios

axios 是一个 ajax 库,这种库也可以叫做 Http Client。
我们学习了 XHR 和 fetch 两套 API 后,为什么还需要学习这种库呢?
虽然 XHR 和 fetch 都可以使用,但是由于各自的原因(XHR 属于上一个时代的产物、fetch 的低级性),我们在做工程化项目的开发时很少会直接使用这两个 API。而是会使用一些封装好的高级 API 和功能。
在 ajax 出现的近二十年里,ajax 库层出不穷,在早期 Web 框架中,像 YUI、Ext.js、jQuery 等优秀框架都内置了 ajax 模块。
随着 Web 的发展,现代 JavaScript 框架和库逐渐向轻量化、功能专一化的方向迈进,在 ajax 这个特定领域,也有 whatwg-fetchsuperagentcross-fetch 等诸多优秀 ajax 库。
每种库的侧重点都不同,比如 whatwg-fetch 仅仅是个 fetch 的 polyfill,并没有对 API 进行封装。superagent 的特点是使用 Nodejs API 来在 Nodejs 环境下和浏览器环境下运行同样的代码。cross-fetch 的特点是跨平台,支持 Nodejs、浏览器和 React Native(一个使用 JavaScript 技术和 React 框架开发移动端 App 的框架)。
为什么要单独拿出来 axios 来讲呢?
因为 Axios 是目前最受欢迎、最流行、且真实使用数最高的 ajax 库就是 axios 无疑。如果没有什么特殊要求,Axios 几乎是 http client 的首先项。
Axios 的最大特点是一套 API 同时支持 Nodejs 和浏览器两套环境,这种模式也称为同构。不过在纯前端开发中是很难体会到这个优势的。
除了同构以外,Axios 还提供了一些常用的功能和特性:

  • 自动转换 JSON 数据。
  • 支持 Promise API。
  • 拦截器。
  • 转换器。
  • 错误处理。
  • 防御 XSRF 攻击。

这些功能和特性在做一个工程化项目时非常有用。

使用 Axios 重构 TODOList

这篇文章的主旨不是为了讲解 Axios 的用法,如果你需要更详细的 Axios 文档,请查阅 Axios 官方网站
在这里,我们使用 Axios 对 TODOList 再次进行重构,来直观体验一下 Axios 的用法。
为了方便使用,这里使用 CND 的方式安装 Axios。
添加 CDN。

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

重构 index.html 中的 add 方法和 getData 方法。

function add() {
  const data = { todo: todoInput.value };
  axios.post("http://localhost:3000/todo", data).then((res) => {
    if (res.data.status === "success") {
      todoInput.value = "";
      getData();
    }
  });
}

function getData() {
  axios("http://localhost:3000/todo").then((res) => {
    renderList(res.data);
  });
}
复制代码

在这里可以看到遵循 SOF 原则来编写程序的优点,每个函数只专注做一件事情,当我们重构程序时,只需要修改需要重构的部分即可,renderList 函数我们从来都没有重构过。
上面的例子只是展示了 axios 最基本的用法。
如果你想学习 axios 更多功能,非常推荐你去看它的官方文档,axios 的官方文档写的非常简单易读,真的没有什么文章比它的官方文档更适合学习了,我也不想再多此一举。

http 基本常识

由于 Ajax 是通过 http 协议来进行异步请求的,所以你有必要学习一些 http 的基本常识。如果你对 http 比较熟悉,可以跳过这一小节。如果你对 http 不太熟悉,这一小节算是一个知识补充。

get 和 post 的区别

这是一道非常经典的面试题,无论是前端工程师、后端工程师、网络工程师,基本上都是必问的。
虽然它简单而又初级。但之所以是经典,一定有其道理。这个问题涉及的方面较广,所以从这道题目中可以观察出面试者对 http 的综合理解程度。
Web 经历了很多个时代,http 协议作为 Web 世界的核心组成之一,也经历了多个时代。
http 0.9 版本只支持 get 方法。
http 1.0 版本支持 get、head、post 三个方法。
http 1.1 版本支持 9 个方法。
目前绝大多数客户端使用的都是 http1.1 这个版本。
虽然 http 的初衷只是用于浏览器和服务器通讯这种特定场景。但时至今日,http 早已不再单单只用于浏览器和服务器之间通讯了。服务器和服务器之间同样可以使用 http 协议进行通讯。
这里只针对浏览器和服务器通讯这一种情况进行讲解。
简单总结就两句话。
get 用于获取数据,无副作用,是幂等的,可以缓存,请求参数放在 url 的 queryString 中。
post 用于修改数据,有副作用,不是幂等的,不可以被缓存,请求数据可以放在 url 的 queryString 中,也可以放在 body 体中。
其中设计一些术语:副作用、幂等、浏览器缓存等。
这里简单解释一下。

副作用/幂等

副作用是指程序运行时对数据进行改变。
幂等是指多次重复的结果始终相等,也可以理解为没有副作用就是幂等,有副作用就不是幂等。
下面这个函数就是无副作用/幂等的。

function add(a, b) {
  return a + b;
}
复制代码

无论你调用多少次,结果始终是相等的。

add(1, 2)// 3
add(1, 2)// 3
add(1, 2)// 3
add(1, 2)// 3
// ...
复制代码

下面这个函数就是带有副作用/非幂等的。

var c = 1;
function add2(a, b) {
  return a + b + c++;
}
复制代码

因为它依赖了外部的 c,随着调用次数的增加,数值会越来越大。

add2(1, 2)// 4
add2(1, 2)// 5
add2(1, 2)// 6
add2(1, 2)// 7
// ...
复制代码

浏览器缓存

浏览器缓存是指浏览器在请求数据后,将数据存储到本地。
每次发送请求时会先检查本地是否存在缓存,如果存在缓存,就会返回本地缓存数据,而不会真正到达服务器。
缓存会有一个过期时间,到了这个时间后,缓存就会失效了,请求会再次到达服务器,获取最新的数据,并且更新本地缓存。
大致流程可查看下图:image.png
当然浏览器的缓存策略远不止这么简单,更详细的内容不在本篇文章讨论范围以内,感兴趣可以自行学习。

其他常见方法介绍

http 中定义的所有方法都在 RFC2616 方法定义中查看。共有 options、get、head、post、put、delete、trace 和 connect 8 种。后来又在 RFC5789 中针对 put 方法补充了 patch 方法,所以目前共有 9 种方法。
这里简单介绍一下它们的作用:
最常用的方法有 5 种。get 用于获取数据,post 用于添加数据,patch 用于修改部分数据,put 用于修改完整数据,delete 用于删除数据。
其它 4 种方法比较少用。head 类似于 get,但不返回消息正文,只返回头信息。options 通常用于转发。trace 通常用于获取消息回路。connect 可以动态切换到隧道的代理服务器。
这些方法可以分为两类,一类是安全方法,一类是幂等方法。
安全方法是指除了获取数据外没有其它行为的方法,其中只有 get 和 head 属于安全方法。
幂等方法是指 N 次请求和单次请求所产生的副作用是相同的,get、head、put、delete、options 和 trace 都属于幂等方法。

http 常见状态码分类

记录在 RFC2616 状态码和原因短语中的 HTTP 状态码就达 40 种,再加上 WebDAV(RFC4918RFC5842)和附加 HTTP 状态码 (RFC6585)等扩展,数量就达 60 余种。
当然我们没有必要将它们全部记住,只需要记住几个常用的状态码和规则即可。
其中 1xx 表示正在连接中,2xx 表示成功,3xx 表示重定向,4xx 表示客户端错误,5xx 表示服务器错误。
当你遇到其它情况可以去上面的链接中查看。
或者直接查看下面的这张图。
image.png

总结

这篇文章从 DOM 的 form 到 JavaScript 的 XHR、再到 fetch,最后再到像 axios 这类 Ajax 库,我们看到技术是不断向前演进的,新的技术也在不断发展。而驱动这一切的背后,都是必要性。正对应了一句名言:Necessity is the mother of all inventions(必要性是所有发明之母)。

练习

相信到这里,我想你已经有了一些收获。如果你并没有从我的文章中得到任何一点收获,那么恭喜你,你对 Ajax 的理解和应用已经达到一个较高的水平了。
只看文章是远远不够的,这只是学习了知识。你还需要将知识转化为你自己的能力。所以,除了文章中的各种代码示例,你还需要进行一些练习。
你可以尝试使用 Ajax 来实现一个带有进度提示的图片上传组件。
下面是我实现的一个图片上传组件。
picture2.gif
服务端接口我已经写好了,你可以直接使用:

const Koa = require("koa");
const Router = require("@koa/router");
const cors = require("@koa/cors");
const multer = require("@koa/multer");

const app = new Koa();
const router = new Router();
const upload = multer();

app.use(cors());

router.post(
  "/upload",
  upload.fields([{ name: "file", maxCount: 1024 * 1024 }]),
  (ctx) => {
    ctx.body = "done";
  }
);

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(9002);
复制代码

不是它不是使用 express 来实现的,而是使用 koa 技术栈实现的,你需要安装一下对应的依赖包。

npm i koa @koa/router @koa/cors @koa/multer
复制代码
文章分类
前端