来,发起一次 Ajax 请求!

1,503 阅读18分钟

作为一个前端菜鸡,现如今我不得不频繁地与 http 打交道。好吧,其实是浏览器打的交道,它通过 http 协议告诉服务器当前的 web 应用需要什么,然后服务器把对应的资源通过 http 协议发送回来。总之,大多数时候都是浏览器在主动搭讪,服务器被动回应,不带一句“废话”。想必时间久了,前者也会累吧。

然而,实际上却颇有出入,这一过程中总是上演着“不是我拒绝别人,就是别人拒绝我”这样的“狗血剧情”。

当我还是个前端小白的时候,我手捧着 Head First 系列丛书中的 HTML and CSS,敲下了下面的代码,并在浏览器打开:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
</head>
<body>
  <div>helloworld</div>
</body>
</html>

我想,浏览器跟服务器的“孽缘”便从此时在我的世界里上演了吧。

这时,浏览器只是从身边(本地)随手拿起了这个页面给我看,我按下 f12,切换到 network 面板,并没有看到什么潜藏的秘密。此时使用的是 file 协议。

fileprot
直到有一天,我觉得自己做的页面足够美观了,应该展示给更多的人欣赏,于是我试着把页面交给了服务器。并告诉浏览器,你不能再从身边拿到这些页面了,我会给你一个服务器的联系方式,你告诉它你需要什么,它会通过 http 把你需要的页面转交给你。

于是浏览器轻车熟路地按照这个地址: http://127.0.0.1:5500/index.html ,向服务器发送了一条消息(GET 请求),表达了希望拿到 index.html 页面的诉求。很快,服务器便爽快地将页面给了它,以至于让我觉得这似乎与在本地拿一个页面相比并没有什么不同。我按下 f12,切换到 network 面板:

get request

多了许多陌生的信息。浏览器的驾轻就熟,大概全因“天赋异禀”吧,人们将它设计成这样。这让我意识到浏览器身上有很多潜藏的秘密等待我去发掘。

提交一个表单

后来,我做了一个登录页面,需要将用户名和密码传递给服务端,从而为“有身份的”用户开放更多的内容。

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
</head>
<body>
  <form action="/login" method="post">
    <input type="text" name="usernmae" placeholder="username"><br>
    <input type="password" name="password" placeholder="password"><br>
    <input type="submit" value="login">
  </form>
</body>
</html>

首先用 expressjs 简单搞一个 web 服务器以方便观察,实际上可能没人这么写代码(包括这之后的众多示例):):

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use('/static', express.static("static"));

app.post('/login', (req, res) => {
  const name = req.body.username;
  const pwd = req.body.password;
  if(name === 'helloworld' && pwd === 'form') {
    res.send('success!');
  }else {
    res.send(`
      <script>
        alert('forbidden login!'); 
        location.href = "/static/form.html";
      </script>
    `);
  }
});

app.listen(1024);

console.log(`Server listening on http://127.0.0.1:1024`);


于是兴致勃勃地访问 “http://127.0.0.1:1024/static/form.html” 填入用户名 “helloworld”,密码 “form”,并点击了登录按钮。

login

浏览器告诉我,你登录成功啦! but,此时我们可以发现,浏览器同学的地址栏地址也发生了变化,变成了 “127.0.0.1:1024/login”。

login ok
network 面板有如下关于 login 的一大坨内容,且不去管它:

loginsubmit

总之我知道,因为登录成功了,浏览器带我来到了新的页面,一切看起来都是那么的和谐而有趣。直到有一次,我把密码输入成了from

猝不及防之下,我收到了浏览器给我的一个弹窗,上面写着 “forbidden login!”,我只能无奈地点了确定。随后浏览器进度条一闪,依然还是原来的页面,但是刚才填写的内容却不见了。试想我是一个真实的用户,表单项不是 2 个而是 20 个,此时内心一定是崩溃的吧。

于是我问浏览器,咱能不这么恶心人不?浏览器:你可以使用基于 XMLHttpRequest 的 Ajax 技术。

AJAX 和 XMLHttpRequest

乍听之下我内心一惊,这又是啥神仙技术?没听过,只好求科普。

Ajax(Asynchronous JavaScript And XML),异步的 JavaScript 和 XML。然而,时至今日,由于与 JS 更般配的数据交互格式 JSON(JavaScript Object Notation) 的存在,XML 在 web 开发领域已经没有太多的存在感了。所以,它实际上更多地指通过 XMLHttpRequest 对象与服务器进行通信的技术。此时,页面不再跳转或刷新,却可以从服务器拿到想要的数据,改变页面的状态,自然就解决了上面例子中的问题。

从此,JS 也可以参与同服务器间的 http 通信了。

世上竟有如此神仙操作,一番自我科普之下,马上行动。随后,web 服务器提供了新的地址供 JavaScript 同学使用:/ajaxform

app.post('/ajaxform', (req, res) => {
  const name = req.body.username;
  const pwd = req.body.password;
  if(name === 'helloworld' && pwd === 'form') {
    res.status(200).json({
      msg: 'success!',
    });
  }else {
    res.status(400).json({
      msg: 'fail!'
    });
  }
});

同时,修改 form.html 如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <input 
    id="username"
    type="text" 
    name="usernmae" 
    placeholder="username"
  ><br>
  <input 
    id="pwd"
    type="password" 
    name="password" 
    placeholder="password"
  ><br>
  <input 
    id="submit"
    type="submit" 
    value="login"
    onclick="submit()"
  >
</body>
<script>
  function submit(e) {
    const params = {
      username: document.getElementById('username').value,
      password: document.getElementById('pwd').value,
    }
    
    if(!params.username || !params.password) {
      alert('invalid request!');
      return;
    }

    ajax({
      method: 'POST',
      url: '/ajaxform',
      params,
      success(res) {
        alert('success!');
      },
      fail(err) {
        alert('fail!');
      }
    });
  }

  function ajax(options) {
    const params = options.params 
      ? JSON.stringify(options.params)
      : null;
    const method = options.method || 'GET';
    const xhr = new XMLHttpRequest();

    xhr.open(method, options.url);
    params && xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(params);

    xhr.onreadystatechange = function(){
      if(xhr.readyState === 4) {
        if(xhr.status === 200) {
          const res = JSON.parse(xhr.responseText);
          options.success(res);
        } else {
          const statusMsg = xhr.statusText;
          options.fail(statusMsg);
        }
      }
    }
  }
</script>
</html>

输入用户名和密码,点击 login,在浏览器的 network 面板出现了这样的 request:

ajax submit

并且,就算输错了密码,也不会再刷新浏览器清空之前的输入了。

从此,又可以愉快地扩展应用的能力了。得益于 ajax 技术的特点,浏览器可以不必再刷新/改变页面就可以参与用户交互及视图的改变,javascript 的能力边界也的得到了极大的扩展,由此开始了蓬勃发展之路~

就这样,随着时间的流逝,web 应用功能越发丰富,直到有一天,甚至要去对接来自别的应用的接口。

怀着激动的心情,我按照以往的操作,发送了一个请求。but,意外发生了,浏览器在它的 Console 面板上告诉我:Access to XMLHttpRequest at 'http://127.0.0.1:8080/member' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.。但它的 Network 面板又告诉我,request 成功了,并且拿到了数据。

sameorigin

我只能一脸懵逼地问浏览器,兄哋这到底发生了什么?

浏览器一脸高深莫测:这就是我体内被称为同源策略(Same-Origin Policy)的存在啊!

同源策略

同源策略于 1995 年由网景公司带到了浏览器的体内,旨在保护用户隐私及数据安全,此后由越来越多的公司在自家浏览器产品中将其实现,成为了浏览器之上构建 web 应用的安全根基。比如用户访问 A 网站下的 cookie 不允许在用户访问 B 网站时被获取;比如上文中的例子,不允许发起跨源的 ajax 请求。

同源指相同的协议、相同的域名、相同的端口号,以上三者不满足任意一个即视为跨源。显然上文中的例子,两个url 有不同的端口号,由此触发了浏览器的同源策略,虽然请求成功了,也拿到了数据,但浏览器依然将其“拒之门外”了。

又是一番自我科普,浏览器果然有太多潜藏的秘密!

但是,虽然知道了原因,我却依然对如何突破这道“天谴”毫无头绪。浏览器这时吐槽了一句:我不是已经在 Console 面板中告诉你了吗?让那个 8080 端口的服务器小子学习一下 CORS 策略再来找我吧!

CORS 跨域资源共享

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器,让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。所以,上文中的例子发起了一次跨域的 HTTP 请求。

上述解释中的它使用额外的 HTTP 头来告诉浏览器的表述告诉我们,浏览器在跨域资源共享的过程中并不处于主导地位,而只能被动的等待第三方告诉它:嘿,这是我的通关文牒,开门放人吧!

浏览器核验“通关文牒”没有问题了,就会允许本次的响应报文“进门”了。很显然,这个使用额外的 HTTP 头的“第三者”就是外域的服务器了。那么这些额外的 HTTP 头有哪些呢?

  • Access-Control-Allow-Origin: 服务器使用这个字段设置一个 URI (与当前浏览器访问的 web 应用相同的)或通配符*,告诉浏览器你可以允许那个 web 应用使用我这里的资源,开门吧。
  • Access-Control-Expose-Headers: 服务器允许浏览器在跨域情况下访问某些本不该它访问到的头信息。
  • Access-Control-Allow-Credentials: 假如一个应用的请求设置了"credentials" 为 true,服务器通过这个字段告诉浏览器 web 应用是否可以拿到 response 信息。如果是 false,浏览器将拦截本次响应;如果是 true,则不但浏览器可以将 cookie 携带给服务器,服务器也可以为当前的 web 应用设置 cookie。这样很大程度上保证了信息安全。
  • 因此这可以作为一种通过 cookie 携带身份凭证的方式,但此时,Access-Control-Allow-Origin 字段必须指明 URI,不能再使用通配符(*)了。

  • Access-Control-Max-Age: 服务器告诉浏览器,你上次发送的那个预检请求(preflight request)在多少多少秒内都有效,在此之前,不要给我再发 OPTIONS 请求了。
  • Access-Control-Allow-Methods: 当应用使用一个 GET \ POST \ HEAD 以外的方法发起请求时,服务器会在预检请求的响应中告诉 web 应用,接下来的实际请求中,只能使用我列给你的这些 HTTP 方法发起请求,要不然浏览器就把我的响应拦截了。
  • Access-Control-Allow-Headers: 当应用发起的请求携带有不属于CORS规范安全首部集合字段/值的头字段时,服务器会在预检请求的响应中告诉浏览器,接下来的实际请求中,只能携带我列给你的这些头字段发起请求,要不然浏览器又要把我的响应拦截了。

当看到第一个首部字段时,我不禁内心恍然,这不正是浏览器刚才在 Console 面板中告诉我的吗 —— No ‘Access-Control-Allow-Origin' header is present on the requested resource.。虽然对于上文提到的预检请求依然一脸懵逼,但这不能阻止我看到曙光之后的冲动。于是兴冲冲赶紧让服务器加上它。

app.post('/member', (req, res) => {
  res.append('Access-Control-Allow-Origin', 'http://127.0.0.1:5500');
  res.status(200).json([
    {user: '李雷'},
    {user: '韩梅梅'},
    {user: 'Tom'},
  ]);
});

然后点击按钮,发送请求。but,自古好事多磨,Network 面板中非但没有像之前那样展示一个虽然被浏览器拦截了但是响应成功的 POST 请求,反而多了一个陌生的 OPTIONS 请求。

options request

同时,浏览器在它的 Console 面板中又发话了:Access to XMLHttpRequest at 'http://127.0.0.1:8080/member' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.。终于,我寻到了预检请求(preflight request)的踪迹。

我问浏览器这又是为啥?浏览器说:对于每一个跨域的请求和响应,我都会遵循 CORS 规范对它们进行检查,你刚才的这个请求是一个非简单请求,所以我只能“自作主张”发送一个 OPTIONS 请求给服务器,问问它是否允许这次跨域请求了。

预检请求 和 简单请求

也就是说,在我刚才点击按钮的时候,发送了一个非简单请求。那怎样的请求会被认为是简单请求呢?

若请求满足所有下述条件,则该请求可视为“简单请求”(Simple Request):

  1. 使用下列方法之一:
  • GET
  • POST
  • HEAD
  1. 人为设置 CORS 安全首部字段集合之外的首部字段。集合如下:
  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type
  • DPR
  • Downlink
  • Save-Data
  • Viewport-Width
  • Width
  1. Content-Type 的值仅限于下列三者之一:
  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded
  1. 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器(XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问)。

  2. 请求中没有使用 ReadableStream 对象。

一旦请求不满足上述条件,则被视为“非简单请求”(Not-So-Simple Request)。此时浏览器必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。"预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

有了上述理论基础,就可以很容易地发现,上例中发出的请求使用了 “application/json” 作为 “Content-Type” 值,所以它被视为一个非简单请求,浏览器便“自作主张”用 OPTIONS 方法发送了一个预检请求,询问服务器是否允许此次跨域请求。OK,明白了症结所在,便让我一睹跨域请求的全貌吧。修改服务器代码如下:

const memberLs = (req, res) => {
  res.append('Access-Control-Allow-Origin', 'http://127.0.0.1:5500');
  if(req.method === 'OPTIONS') {
    res.append('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
    res.append('Access-Control-Allow-Headers', 'Content-Type');
    res.status(200).end();
  }else {
    res.status(200).json([
      {user: '李雷'},
      {user: '韩梅梅'},
      {user: 'Tom'},
    ]);
  }
}

app.post('/member', memberLs);

再次点击按钮,终于大功告成了。可以在浏览器的 Network 面板中看到两个 http 请求的记录。

HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: http://127.0.0.1:5500
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: Content-Type
Date: Tue, 26 Nov 2019 11:49:52 GMT
Connection: keep-alive
Content-Length: 0

--------------------------------------------------

OPTIONS /member HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Access-Control-Request-Method: POST
Origin: http://127.0.0.1:5500
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36
Access-Control-Request-Headers: content-type
Accept: */*
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Referer: http://127.0.0.1:5500/static/list.html
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8

HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: http://127.0.0.1:5500
Set-Cookie: name=tobi; Path=/
Content-Type: application/json; charset=utf-8
Content-Length: 55
ETag: W/"37-30cJYOjzuCqPVyovsDoOf7e+UBQ"
Date: Tue, 26 Nov 2019 11:49:52 GMT
Connection: keep-alive

--------------------------------------------------

POST /member HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Length: 26
Origin: http://127.0.0.1:5500
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36
Content-Type: application/json
Accept: */*
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Referer: http://127.0.0.1:5500/static/list.html
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8

并且最终,也如愿更新了视图:

cors success

至此,一切都如 CORS 规范描绘的那样,“岁月静好”,仿佛这便彻底解决了跨域问题。相信其他诸如携带身份凭证、传递自定义头字段等等问题都将在规范的指导下变成小菜一碟~

but,回过头来仔细一琢磨,假如每次请求都要额外发送一次 OPTIONS 请求,也将造成极大的网络资源消耗。虽然 OPTIONS 请求本身数据量很小并得益于 keep-alive 机制,虽然可以让服务端设置 Access-Control-Max-Age 响应头来极大程度上削减额外的 preflight request 带来的弊端,但总归弊端仍在。

再次虽然,对于上文例子中的请求,可以通过修改 web 应用脚本,使其发送 “applicaiton/x-www-form-urlencoded” 格式的数据,使其被视为一个“简单请求”。但凡事总有例外,这也只是个例嘛。真是让人头疼。

这时,浏览器发话了:据说在跨域之初,有一个叫 JSONP 的先驱者突破了蕴藏于我体内的“同源策略”的限制,使得异域间的通信成为可能,你不妨了解一下。

JSONP

JSONP(JSON with padding),一种 JSON 数据格式的“使用模式”。它正是利用 <script> 标签加载资源不受“同源策略”限制这一“漏洞”,以一种 hack 的方式实现了异域数据通信。首先由前后端约定统一的 callback 函数,然后服务端定制接口,前端则通过动态创建 script 加载接口指向的资源,并通过查询字符串将需要的数据及 callback 名称告诉服务端,最终服务端返回一段可执行 js 脚本,其中使用这个约定的 callback 包裹了前端需要的数据。如此一来,在前端预置的 callback 函数被执行,进入愉快的数据处理过程。

这样一来,也意味着这种方式只能使用 GET 方法,终归限制颇多。

JSNOP 的大名我自然早有耳闻,不妨接受浏览器的建议,看看它怎样运作吧。

首先,服务端给出了一个接口:

app.get('/memberjsonp', (req, res) => {
  res.jsonp([
    {user: '李雷'},
    {user: '韩梅梅'},
    {user: 'Tom'},
  ]);
})

然后,web 应用:

<body id="body">
  <button onclick="memberlsJonp()">jsonp</button>
</body>
<script>
  const $body = document.getElementsByTagName('body')[0];
  function memberlsJonp() {
    jsonp({
      url: 'http://127.0.0.1:8080/memberjsonp',
      query: 'page=1&type=member&callback=resCallback',
    });
  } 

  function jsonp(options) {
    const url = options.url + '?' + options.query;
    const $script = document.createElement('script');
    $script.src = url;
    $body.append(script);
  }

  function resCallback(res) {
    for(let item of res) {
      const $p = document.createElement('p');
      $p.innerText = item.user;
      $body.append($p);
    }
  }
</script>

OK,点击按钮,如愿得到了想要的视图更新。同时,Network 面板如实记录了 js 脚本加载的痕迹,服务端返回了一段这样的脚本:

typeof resCallback === 'function' 
  && resCallback([{"user":"李雷"},{"user":"韩梅梅"},{"user":"Tom"}]);

以上,不论是 CORS 还是 JSONP,前提是我们要访问的异域资源的服务器可以配合实现对这两种规范的支持。自家的服务器还好说,但是如果别家的服务器,想必去找人家做对接的愿望只能是一场空了。于是,我又怀着期许的目光望向了浏览器。但这次,它沉默了。

过了一会,承载 web 应用的服务器悄悄过来搭话:嘿,告诉你个秘密,所谓的同源策略的限制在我们服务器身上压根就不存在。所以,不用去管浏览器那一套,直接让它来我的域名下请求想要的资源,只要我把对应的异域请求转发出去,拿到需要的资源再丢给浏览器就可以了。这样一来,它就会傻傻地以为它一直活动在相同的域下,虽然这么做“欺骗”了它,但生活嘛,大家好才是真的好。这样一来,所有人都省事了。

我一听之下,大为心动。服务器说,这就是传说中的反向代理。

反向代理 (Reverse Proxy)

反向代理正是让客户端以为它直接从代理服务器获取了资源,而不知道代理服务器背后还有一个真正的服务器存在。

心动不如行动,立刻开搞。

一般情况下,生产环境会使用诸如 nginx 这样的工具来开启一个服务,所以这里以此为例,在 nginx.conf 中增加如下配置:

server {
  listen       8080;
  server_name  127.0.0.1;

  location ^~ /apis/ {
      proxy_pass http://127.0.0.1:3000/;
  }
}

然后,就可以在 list.html 中像这样发起请求:

<script>
ajax({
  method: 'POST',
  url: '/apis/member',
  params,
  success(res) {
    const $body = document.getElementById('body');
    for(item of res) {
      const $p = document.createElement('p');
      $p.innerHTML = item.user
      $body.append($p);
    }
  },
  fail(err) {
    console.log('failed')
  }
});
</script>

这时,web 应用访问 “http://127.0.0.1:8080/apis/member”,将被 nginx 转发至 http://127.0.0.0:3000/member。

最后来看一眼 Network 面板中的请求记录

HTTP/1.1 200 OK
Server: nginx/1.17.3
Date: Tue, 26 Nov 2019 14:32:32 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 55
Connection: keep-alive
X-Powered-By: Express
Set-Cookie: name=tobi; Path=/
ETag: W/"37-30cJYOjzuCqPVyovsDoOf7e+UBQ"

---------------------------------------------

POST /apis/member HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Content-Length: 26
Origin: http://127.0.0.1:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36
Content-Type: application/json
Accept: */*
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Referer: http://127.0.0.1:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: LiveWSBYT63791127=b02bfbcd8cba468299b726e9b17fe161; NBYT63791127fistvisitetime=1567320685477; UM_distinctid=16ceb97bc4cd02-0257ef8249e93c-38637701-13c680-16ceb97bc4d31b; _ga=GA1.1.2056984704.1570163359; CNZZDATA1275303586=1208628234-1567319233-%7C1572869768; NBYT63791127visitecounts=3; NBYT63791127lastvisitetime=1572874605770; NBYT63791127visitepages=8; Hm_lvt_35442676cb689f804c3274d59b25d5a9=1572873197,1573651216

一切就如在同域下的请求了,一次完美的 Reverse Proxy。


以上。对于解决跨域问题的方案来说,不论是 CORS、JSONP 还是反向代理,都有其优缺点和使用场景,需要根据具体的业务场景加以取舍。但不管怎样,CORS 和反向代理都是毫无争议的主流解决方案啦。