JavaScript中的网络请求

447 阅读11分钟

Fetch 基本使用

fetch 返回一个 promise ,常用方式:

let response = await fetch(url, options); // 解析 response header
let result = await response.json(); // 将 body 读取为 json
  • options :
    • method :HTTP 方法,如 get、post……
    • headers :请求头,但是有一些 header 是不被允许的,详见规范 
    • body :request body,要以 string , FormData , BufferSource , Blob  或 UrlSearchParams  对象的形式发送的数据

接收到的 response 属性如下:

  • response.status —— response 的 HTTP 状态码,
  • response.ok —— HTTP 状态码为 200-299,则为 true
  • response.headers —— 类似于 Map 的带有 HTTP header 的对象。

注意 ,只有在无法建立一个 HTTP 请求的时候, fetch 才会 reject 。而异常的 404、500 等状态不会导致异常

获取 response body :

  • response.text() —— 读取 response,并以文本形式返回 response,
  • response.json() —— 将 response 解析为 JSON 对象形式,
  • response.formData() —— 以 FormData 对象(form/multipart 编码,参见下一章)的形式返回 response,
  • response.blob() —— 以 Blob(具有类型的二进制数据)形式返回 response,
  • response.arrayBuffer() —— 以 ArrayBuffer(低级别的二进制数据)形式返回 response。

FormData

发送表单

FormData 对象可以发送表单对象内容,比如:

<form id="formElem">
  <input type="text" name="name" value="John">
  <input type="text" name="surname" value="Smith">
  <input type="submit">
</form>

<script>
  formElem.onsubmit = async (e) => {
    e.preventDefault();

    let response = await fetch('/article/formdata/post/user', {
      method: 'POST',
      body: new FormData(formElem)
    });

    let result = await response.json();

    alert(result.message);
  };
</script>

这样可以把整个表单对象发送出去。

我们也可以通过以下方法去修改添加表单对象到 FormData  :

  • formData.append(name, value)  —— 添加具有给定 name 和 value 的表单字段,
  • formData.append(name, blob, fileName)  —— 添加一个字段,就像它是<input type='file'> ,第三个参数 fileName 设置文件名(而不是表单字段名),因为它是用户文件系统中文件的名称
  • formData.set(name,value) —— set 方法会清楚当前 formData 中所有此 name 的字段,然后插入新的 value
  • formData.set(name,blob,value) —— 原理同上
  • formData.delete(name)  —— 移除带有给定 name 的字段,
  • formData.get(name)  —— 获取带有给定 name 的字段值,
  • formData.has(name)  —— 如果存在带有给定 name 的字段,则返回 true,否则返回 false。

FormData 对象是可以循环的 : for(let[key,value] of formData){}

发送文件

 <input type="file"> 允许我们发送一个文件:

<input type="file" id="pic">
  
<script>
  let formData = new FormData()
  pic.onchange = function(e){
		formData.append("fileIn",this.value)
	}
  let response = await fetch('/xxx', {
    method: 'POST',
    body: formData
  });

  let result = await response.json();
  
  
</script>

下载进度

可以通过 response.body ,它是 ReadableStream —— 一个特殊的对象,它可以逐块(chunk)提供 body。在 Streams API 规范中有对 ReadableStream 的详细描述。 然后可以通过他的 getReader() 来获取一个流读取器(stream reader):

// Step 1:启动 fetch,并获得一个 reader
let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100');

const reader = response.body.getReader();

// Step 2:获得总长度(length)
const contentLength = +response.headers.get('Content-Length');

// Step 3:读取数据
let receivedLength = 0; // 当前接收到了这么多字节
let chunks = []; // 接收到的二进制块的数组(包括 body)
while(true) {
  // read() 会返回done —— 读取完成时是 true,否则 false
  //还有 value —— 字节的类型化数组:Uint8Array。
  const {done, value} = await reader.read();

  if (done) {
    break;
  }
	// 每拿到一块 就放入二进制数组
  chunks.push(value);
  receivedLength += value.length;

  console.log(`Received ${receivedLength} of ${contentLength}`)
}
// 接收已完成,以下是处理过程。
// Step 4:将块连接到单个 Uint8Array
let chunksAll = new Uint8Array(receivedLength); // (4.1)
let position = 0;
for(let chunk of chunks) {
  chunksAll.set(chunk, position); // (4.2)
  position += chunk.length;
}

// Step 5:解码成字符串
let result = new TextDecoder("utf-8").decode(chunksAll);

// 我们完成啦!
let commits = JSON.parse(result);

Fetch 中止

我们有一个 AbortController 对象,他的用法很简单:

let aContro = new AbortController()
// 属性 signal,我们可以在这个属性上设置事件监听器。
let singal = aContro.signal
// 方法 abort()
aContro.abort()
// 事件触发后,signal.aborted 变为 true
alert(signal.aborted); // true

结合 fetch:

// 1 秒后中止
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  let response = await fetch('/article/fetch-abort/demo/hang', {
    signal: controller.signal
  });
} catch(err) {
  if (err.name == 'AbortError') { // handle abort()
    alert("Aborted!");
  } else {
    throw err;
  }
}

Fetch Api

fetch 还有很多 api,详见 现代教程 

CORS 跨域

 "CORS" :跨源资源共享(Cross-Origin Resource Sharing)。

JSONP

JSONP 的原理很简单,就是利用一个 script 标签,然后我们的信息作为回调函数传过去来实现跨域:

function gotWeather({ temperature, humidity }) {
  alert(`temperature: ${temperature}, humidity: ${humidity}`);
}

let script = document.createElement('script');
script.src = `http://another.com/weather.json?callback=gotWeather`;
document.body.append(script);

简单跨域请求

首先,简单跨域请求需要满足如下条件:

  • method —— GET,POST 或 HEAD
  • headers —— 仅允许自定义下列 header:
    • Accept,
    • Accept-Language,
    • Content-Language,
    • Content-Type 的为 application/x-www-form-urlencoded,multipart/form-data 或 text/plain。

如果我们的跨域请求是一个简单请求,在发送 request 的时候,浏览器会自动添加 Origin header,服务器需要进行对 Origin 的检查,如果同意这个源的 request ,那么在 response 里就会添加一个 Access-Control-Allow-Origin 的响应头,这个响应头的内容可以是我们刚才的 Origin ,或者是一个 * 号 : image.png

而且在这种简单请求中,我们只能访问简单的 response header :

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

其他的都不可访问

非简单跨域请求

不满足我们上面说的 request 条件的请求视为非简单请求,比如 methodPUT ,发送这样的请求的时候,浏览器会在发送正式请求前 加一个预检(preflight) 的请求

预检请求使用 OPTIONS method,它没有 body ,但是有两个关键 header

  • Access-Control-Request-Method —— 正式请求中带有的非简单请求的 method 
  • Access-Control-Request-Headers —— 正式请求中带有的非简单的 HTTP-Header 列表,用 , 分隔

而服务器端同意处理也会返回一个状态码200 ,没有 body 但是有一些特殊 header 的 response:

  • Access-Control-Allow-Origin 必须为 * 或进行请求的源(例如 [https://javascript.info](https://javascript.info))才能允许此请求。
  • Access-Control-Allow-Methods 必须具有允许的方法。
  • Access-Control-Allow-Headers 必须具有一个允许的 header 列表。
  • 另外,header Access-Control-Max-Age 可以指定缓存此权限的秒数。因此,浏览器不是必须为满足给定权限的后续请求发送预检。

image.png  预检请求整个过程对于 JavaScript 是不可见的!JavaScript 仅仅获取主请求的 response 

非简单跨域例子

假设我们现在有一个跨域请求

let response = await fetch('https://site.com/service.json', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'API-Key': 'secret'
  }
});

他的 method 、 headers 都不满足简单请求的规则,那么接下来会发生非简单请求:

第一步 发送预检请求

浏览器检测到非简单跨域请求后,会自动先发送一个预检

OPTIONS /service.json
Host: site.com
Origin: https://javascript.info
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type,API-Key

包含了上面说的非简单请求的 method 和 非简单的 HTTP-Header 列表

第二步 服务器响应预检请求

200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400

这个 response 表示:

  1. 请求通过 200 ,
  2. 允许的 Origin 是我们 request 的源,
  3. 他不仅允许我们 request 的 PATCH ,还允许 PUT/DELETE 等特殊 method ,当然 GET,POST 或 HEAD 本身就是允许的,response 不用写进去
  4. 也允许了一些 header : API-Key,Content-Type,If-Modified-Since,Cache-Control 
  5. 设置了缓存时间一天,后续的请求就不用再触发预检了

第三步 发送实际请求

通过了预检,自然是开始发送实际 requset,注意浏览器一定会加上 Origin header ,因为他是跨域的 

PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascript.info

第四步 返回实际响应

服务器返回的 response 也必须加上 Access-Control-Allow-Origin,即时我们已经通过了预检

Access-Control-Allow-Origin: https://javascript.info

 至此一个非简单跨域完成

凭据

在上面的跨域情况中,无法发送任何 凭据(cookies 或者 HTTP 认证)  这是因为具有凭据的请求比没有凭据的请求要强大得多。如果被允许,它会使用它们的凭据授予 JavaScript 代表用户行为和访问敏感信息的全部权力。 如果需要发送凭据,需要加上 credentials: "include" :

fetch('http://another.com', {
  credentials: "include"
});

而服务器如果同意接受带有凭据的请求,那么需要加上一个 header: Access-Control-Allow-Credentials: true ,当然也别忘记必须有的 Access-Control-Allow-Origin 

200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Credentials: true

URL 对象

以往我们打开一个窗口,可能会用如下方式:

window.open("https://zh.javascript.info/url")

这种方式,url 是一串字符串,而现在我们有一个内建的 URL 对象

new URL(url, [base])

如下两个 url 是一样的

let url1 = new URL('https://javascript.info/profile/admin');
let url2 = new URL('/profile/admin', 'https://javascript.info');

alert(url1); // https://javascript.info/profile/admin
alert(url2); // https://javascript.info/profile/admin

也可以根据现有 URL 创建新 URL

let url = new URL('https://javascript.info/profile/admin');
let newUrl = new URL('tester', url);

alert(newUrl); // https://javascript.info/profile/tester

还可以访问他的组件属性

let url = new URL('https://javascript.info/url');

alert(url.protocol); // https:
alert(url.host);     // javascript.info
alert(url.pathname); // /url

image.png 我们还可以处理 url 的参数:

  • append(name, value)  —— 按照 name 添加参数,
  • delete(name)  —— 按照 name 移除参数,
  • get(name)  —— 按照 name 获取参数,
  • getAll(name) —— 获取相同 name 的所有参数(这是可行的,例如 ?user=John&user=Pete),
  • has(name)  —— 按照 name 检查参数是否存在,
  • set(name, value)  —— set/replace 参数,
  • sort()  —— 按 name 对参数进行排序,很少使用,

这些方法处理参数的时候,会自动进行编码 传统的编码一般是用这些方法:

轮询

短轮询

短轮询就是定时地对服务器进行 request

长轮询

发送 request -> 消息挂起 -> 服务器 response 返回 -> 客户端处理 response -> 发起下一个 request

async function subscribe() {
  let response = await fetch("/subscribe");

  if (response.status == 502) {
    // 状态 502 是连接超时错误,
    // 连接挂起时间过长时可能会发生,
    // 远程服务器或代理会关闭它
    // 让我们重新连接
    await subscribe();
  } else if (response.status != 200) {
    // 一个 error —— 让我们显示它
    showMessage(response.statusText);
    // 一秒后重新连接
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    // 获取并显示消息
    let message = await response.text();
    showMessage(message);
    // 再次调用 subscribe() 以获取下一条消息
    await subscribe();
  }
}

subscribe();

WebSocket

创建连接

WebSocket  协议提供了一种在浏览器和服务器之间建立持久连接来交换数据的方法。数据可以作为“数据包”在两个方向上传递,而不会断开连接和其他 HTTP 请求。

let socket = new WebSocket("ws://javascript.info");

ws 是一个特殊的协议 ,还有一个 wss 协议,类似于 HTTPS 他是加密的,建议使用 wss:// 

  • open   —— 连接已建立,
  • message   —— 接收到数据,
  • error   —— WebSocket 错误,
  • close   —— 连接已关闭。
let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] Connection established");
  alert("Sending to server");
  socket.send("My name is John");
};

socket.onmessage = function(event) {
  alert(`[message] Data received from server: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
  } else {
    // 例如服务器进程被杀死或网络中断
    // 在这种情况下,event.code 通常为 1006
    alert('[close] Connection died');
  }
};

socket.onerror = function(error) {
  alert(`[error] ${error.message}`);
};

request

以下是由 new WebSocket("wss://javascript.info/chat")  发出的请求的浏览器 header 示例。

GET /chat
Host: javascript.info
Origin: https://javascript.info    [客户端页面的源]
Connection: Upgrade		[Upgrade —— 表示客户端想要更改协议。]
Upgrade: websocket		[websocket —— 请求的协议是 “websocket”]
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==		[浏览器随机生成的安全密钥]
Sec-WebSocket-Version: 13		[WebSocket 协议版本,当前为 13]

这些 header 在JavaScript 中不可见。 还有 Sec-WebSocket-Protocol: soap, wamp 代表我们不仅要传输任何数据,还要传输 SOAP 或 WAMP 协议中的数据。这个是我们可设置的:

let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]);

数据传输

WebSocket 通信由 “frames”(即数据片段)组成,可以从任何一方发送,并且有以下几种类型:

  • “text frames” —— 包含各方发送给彼此的文本数据。
  • “binary data frames” —— 包含各方发送给彼此的二进制数据。
  • “ping/pong frames” 被用于检查从服务器发送的连接,浏览器会自动响应它们。
  • 还有 “connection close frame” 以及其他服务 frames。

客户端请求 .send() 可以发送文本和二进制数据(包括 Blob,ArrayBuffer 等)

接收到 response 的时候,文本肯定是字符串的形式,但是如果是个二进制数据,可以通过 socket.binaryType 来设置格式类型,默认值就是 blob 。

socket.binaryType = "arraybuffer"; // 默认值是 blob 可以不设置
socket.onmessage = (event) => {
  // event.data 可以是文本(如果是文本),也可以是 arraybuffer(如果是二进制数据)
};

缓冲数据

假设我们一次发送了很多数据,但是网很慢,当我们反复 send() 的情况下,数据会缓存进内存, socket.bufferedAmount 可以查询当前缓存了多少字节

// 每 100ms 检查一次 socket
// 仅当所有现有的数据都已被发送出去时,再发送更多数据
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);

关闭连接

不管是客户端还是服务端都可以主动关闭连接,当需要关闭的时候:

socket.close(code,reason)
  • code —— 一个关闭码:
    • 1000 —— 默认,正常关闭(如果没有指明 code 时使用它),
    • 1006 —— 没有办法手动设定这个数字码,表示连接丢失(没有 close frame)。
    • 1001 —— 一方正在离开,例如服务器正在关闭,或者浏览器离开了该页面,
    • 1009 —— 消息太大,无法处理,
    • 1011 —— 服务器上发生意外错误
  • reason —— 关闭原因,比如 “work complete”

我们可以在 close 事件中获取他们: event.code / event.reason

连接状态

要获取连接状态,可以通过带有值的 socket.readyState 属性:

  • 0 —— “CONNECTING”:连接还未建立,
  • 1 —— “OPEN”:通信中,
  • 2 —— “CLOSING”:连接关闭中,
  • 3 —— “CLOSED”:连接已关闭。