浏览器和服务器通讯方式的不完整总结
一些概念
- 单工(simplex): 简单的说就是一方只能发信息,另一方则只能收信息,通信是单向的。
- 半双工(half duplex): 比单工先进一点,就是双方都能发信息,但同一时间则只能一方发信息。
- 全双工(full duplex): 比半双工再先进一点,就是双方不仅都能发信息,而且能够同时发送。
- 短连接(short connection): 每一次请求都建立一个新的连接,例如这样 连接->传输数据->关闭连接 , 连接->传输数据->关闭连接
- 长连接(long connection): 建立一个连接后,复用这个连接直到全部请求完成,例如这样 连接->传输数据->保持连接->传输数据->......->直到一方关闭连接
- 短轮询(short polling): 在一次通讯里,接收方(浏览器)等待发送方(服务器)的信息,如果发送方没有消息,则连接关闭,间隔一段时间后,接收方会再次发起连接,直到获得发送方的消息
- 长轮询(long polling): 在一次通讯里,接收方(浏览器)等待发送方(服务器)的信息,连接一直保持,直到获得发送方的消息
- 长连接和短连接一般指的是传输层的长连接和短连接,长轮询和短轮询一般指的是应用层的长轮询和短轮询。长轮询/短轮询和长连接/短连接没有必然关系。长轮询未必要使用长连接,使用了长连接也未必是使用长轮询。
- 跨域(cross origin): 浏览器的同源策略(same origin policy)要求 协议(protocol) 域名(domain) 端口号(port) 三个一致,不然就无法访问对应的资源(例如,无法获得 ajax 的响应),在 CORS 出现之前,跨域一直前端的难点。
- 在大多数浏览器的实现里,跨域的请求是可以发送的,但无法读取响应
- 服务器推(server push): 旧时代的前端里,把服务器主动发送消息给浏览器的技术称为 服务器推 。因为没有 sse 和 websocket ,旧时代的前端要实现服务器推其实是挺复杂的,大多数情况下会借助各种插件(Flash, Java Applet, Silverlight, ActiveX)。这里提及的服务器推和 HTTP/2 的 server push 是不一样的
- Comet(彗星): Alex Russell(Dojo Toolkit 的项目 Lead)把无须在浏览器端安装插件的 服务器推 技术称为 Comet(彗星)
- ~~ 之所以称为 Comet 是因为 Ajax 和 Comet 都是美国常见的家用清洁剂 ~~
- Dojo Toolkit 是一个旧时代的,开源的,模块化的 js 库,类似于 ExtJS 和 YUI 。
定时刷新
定时刷新可以算是最古老的服务器和浏览器的通讯方式了。 大多数情况下不会刷新整个页面,而是刷新页面里的一些 iframe 标签。 通过这种方式可以让页面里的一部分看上去像是实时更新。
通过 meta标签 定时刷新页面
<meta http-equiv="refresh" content="3;url=https://www.mozilla.org">
通过 javascript 定时刷新页面
setTimeout(function(){ window.location.reload(); }, 3000);
iframe
iframe 可以算是 ajax 未大规模应用时,唯一可以不借助插件的实现双工通讯的方式。 早期的 ajax 其实也是插件,依赖 ActiveX 。
其实是利用了 form 提交表单后响应的页面在 iframe 标签里显示的特性。
目标 iframe标签 会隐藏,然后表单提交的响应会输出一个 script 标签,然后 script 标签里会有一段 js 代码,这段 js 代码会调用一个外部函数,然后这个外部函数的输入就是响应的结果,这个响应的结果大多数情况下是一个 json 字符串。
iframe 的例子
a.html
<form action='b.php' method='post' name='' id='' target='formTarget'>
<input type='text' name='jsoncallback' value='callbackFunction' />
<input type='text' name='param1' value='value1' />
<input type='text' name='param2' value='value2' />
<input type='submit' />
<span id='msg'></span>
</form>
<iframe src='' name='formTarget' id='formTarget' style='display:none'></iframe>
<script>
function callbackFunction(ret) {
console.log(ret);
}
</script>
b.php
$callbackFunction = $_POST['jsoncallback'];
$jsonData = [$_POST['param1'], $_POST['param2']];
$jsonData = json_encode($jsonData);
$output = <<<EOF
<script>
parent.$callbackFunction($jsonData);
</script>
EOF;
echo $output;
iframe 的跨域方式
- location.hash
- 在 iframe 用 js 修改 location.hash ,让父窗口获得数据
- window.name
- 这是一个全局的对象,只能是字符串
- postMessage
- 这是 h5 新增的,能替换 window.name
- document.domain
- 如果主域相同的情况下,可以在 iframe 和 宿主 页面里加一句 document.domain = "example.com" 那么两个页面就可以像没有跨域那样通讯
ajax
ajax = Asynchronous JavaScript and XML
ajax 短轮询
轮询就是指客户端不断地向服务器发起请求以获得新的数据。 可以看作是一种 服务器推 的技术。 虽然现在已经有 websocket 和 sse 但轮询还是有一些应用场景。 轮询不会长时间占用一个连接,轮询可以控制轮询的间隔和次数,从这两点看,虽然轮询的时效性差一点,但能节约服务器的性能。
一般语境下的轮询就是指 短轮询 。
这是 ajax 短轮询的例子
var timer = null;
var intervalTime = 3000;
var count = 0;
var maxCount = 100;
function polling() {
var xhr = new XMLHttpRequest();
var received = 0;
var result = '';
xhr.open('get', '/httpstream', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
count++;
if (xhr.responseText == 'finish') {
clearInterval(timer); // 请求成功时清除定时器
}
if (count > maxCount) {
clearInterval(timer); // 达到最大请求次数也清除定时器
}
}
}
xhr.send();
}
timer = setInterval(polling, intervalTime);
ajax 长轮询 (long polling)
长连接实现比较简单,但太长时间没有数据,可能会被防火墙关闭连接。 可以把长轮询看作一个普通的但等待时间长一点的 ajax 。
其实长轮询和短轮询可以结合来使用的,例如 设定一次轮询的等待时间为 60 秒,超时后再次发起请求。
可以使用 setTimeout + xhr.abort() 实现 ajax 的超时。 当请求成功时就调用 clearTimeout 清除定时器。 当请求超时,定时器就会调用 xhr.abort() 主动中止请求。
ajax 流 (stream)
当 ajax 接收到服务器的数据后,客户端的 readyState 会变成 3 , responseText 包含所有的数据源。 通过 received 来记录之前已经处理过的数据长度,然后在 responseText 中截取最新的数据。 IE 不支持这种方式, IE 在 readyState 为 3 时无法读取 responseText 里的数据。
这是 ajax 流 的例子
var xhr = new XMLHttpRequest();
var received = 0;
var result = '';
xhr.open('get', '/httpstream', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 3) { // readystate 3 表示正在解析数据
result = xhr.responseText.substring(received);// 截取最新的数据
received += result.length;
console.log(result);
}
}
xhr.send();
jsonp
jsonp = JSON with Padding
jsonp 主要是为了规避跨域的限制。 现在解决跨域基本都是用 CORS 了。
jsonp 和 iframe 十分类似。
jsonp 本质上是新建了一个 script 标签, 然后这个标签里的地址会带着请求的参数, 请求会输出一段 js 代码,这段 js 代码会调用一个外部函数, 然后这个外部函数的输入就是响应的结果,这个响应的结果大多数情况下是一个 json 字符串。
类似于这样
- 新建了一个 script 标签
<script src="jsonp.php?jsoncallback=callbackFunction¶m1=value1¶m2=value2"></script>
- 请求会输出一段 js 代码
callbackFunction({'code':0, msg:'success', 'data':null});
- 在 callbackFunction 的函数体里处理响应的结果
function callbackFunction(result) { console.log(result); }
这是 jsonp 的例子
jsonp.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>JSONP 实例</title>
</head>
<body>
<script>
function callbackFunction(result) {
console.log(result);
}
// 提供 jsonp 服务的 url 地址(不管是什么类型的地址,最终生成的返回值都是一段javascript代码)
var param = {
jsoncallback: 'callbackFunction',
param1: 'value1',
param2: 'value2',
};
var url = 'jsonp.php';
url += '?';
for (var x in param) {
url += x + '=' param[x] + '&';
}
// 创建 script 标签,设置其属性
var script = document.createElement('script');
script.setAttribute('src', url);
// 把 script 标签加入 head ,此时调用开始
document.getElementsByTagName('head')[0].appendChild(script);
</script>
</body>
</html>
jsonp.php
$jsoncallback = $_GET['jsoncallback'] ?? null;
if (!is_null($jsoncallback)) {
header('Content-type: application/json');
// 获取回调函数名
$jsoncallback = htmlspecialchars($jsoncallback);
// json数据
$jsonData = '["' + $_GET['param1'] + '","' + $_GET['param2'] + '"]';
// 输出jsonp格式的数据
echo $jsoncallback . "(" . $jsonData . ")";
exit(0);
}
fetch
fetch 用于取代 ajax 的,本质上也是发送 http 请求,不过 API 是基于 Promise 设计。 fetch 天生支持异步,可以避免 ajax 那种回调地狱。 但 fetch 无法获取请求时的状态,只能等待请求完成, ajax 可以通过 readyState 获取请求的状态。 fetch 同样可以实现短轮询/长轮询,但无法实现 ajax 流。
SSE
SSE (server-sent events)
SSE 本质上是一个不关闭的 http 请求,和 ajax 流 差不多, 能不断地接受来自服务器的数据,但不能发送数据给服务器。
使用 SSE 时,如果前面有反向代理,反向代理不能有缓存。
如果 SSE 不通过 HTTP/2 使用时,会受到最大连接数的限制, 这在打开各种选项卡时特别麻烦,因为该限制是针对每个浏览器的,并且被设置为一个非常低的数字(6)。 该问题在 Chrome 和 Firefox 中被标记为“无法解决”。 此限制是针对每个浏览器+域的,因此这意味着您可以跨所有选项卡打开 6 个 SSE 连接到 www.example1.com ,并打开 6 个 SSE 连接到 www.example2.com 。 使用 HTTP/2 时, HTTP 同一时间内的最大连接数由服务器和客户端之间协商(默认为100)。
JS 的例子
// 新建一个 SSE 对象
const evtSource = new EventSource("ssedemo.php");
// 处理默认消息的函数
evtSource.onmessage = function(event) {
// 输出的流里没有 event 字段,或者没有对应的监听,就会调用这里
console.log('default message');
console.log(event);
}
// 处理错误的函数
evtSource.onerror = function(err) {
console.error("EventSource failed:", err);
};
// 监听 ping 事件
evtSource.addEventListener("ping", function(event) {
console.log('ping message');
console.log(event);
});
// 关闭事件流
// evtSource.close();
php 实现 SSE 的例子
header("Cache-Control: no-cache");
header("Content-Type: text/event-stream");
$counter = rand(1, 10);
while (true) {
// Every second, send a "ping" event.
echo "event: ping\n";
$curDate = date(DATE_ISO8601);
echo 'data: {"time": "' . $curDate . '"}';
echo "\n\n";
// Send a simple message at random intervals.
$counter--;
if (!$counter) {
echo 'data: This is a message at time ' . $curDate . "\n\n";
$counter = rand(1, 10);
}
ob_end_flush();
flush();
sleep(1);
}
参考 https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
websocket
websocket 是唯一可靠的全双工通讯的 api 。 iframe ajax fetch 这类只能算是半双工通讯。 定时刷新 sse sendBeacon 这类只能算是单工通讯。
websocket 的 js 代码比较简单,参考以下 mdn 的文档就可以了。
websocket 服务端的实现可以参考以下笔者的这个仓库 https://github.com/f2h2h1/WebSocketServer
使用 websocket 时要注意实现心跳连接。
webrct
webrct 也能实现全双工通讯,但 webrct 是用 udp 协议的,而且需要一个 websocket 作为信令服务器。
webrct 一般是用来传输音频或视频,一般是两个客户端直接通讯的。
笔者对 webrct 了解得比较少,主要是因为中文互联网上相关的资料比较少,而且平时也用不到。 websocket 也能传输音视频,但 websocket 是用 tcp 协议的,效率肯定没有用 udp 的 webrct 高。 而且对音视频而言,一点的掉包其实是不影响实际体验的。
各种标签
- script 标签
- img 标签
- link 标签
- CSS 背景图
这些标签都是利用浏览器加载资源的方式向服务器发送请求,一般是用于上报页面的监控数据。
sendBeacon
sendBeacon 是一个专门用于上报数据的 api 是为了取代用标签发送数据的方式。 使用标签发送数据,是不符合规范的,而且还可能造成页面渲染的阻塞。 sendBeacon 是异步的,而且能发送 post ,但发送二进制数据时好像有点限制。
navigator.sendBeacon(url, data);
详细的使用方式可以查看 mdn 的文档。
a 标签的 ping 属性
a 标签的 ping 属性主要是为了让浏览器对外发送一个异步请求,达到广告的追踪、点击率统计的效果。 当 a 标签触发 click 事件时,浏览器会向 ping 属性中的 url 发送带有正文 PING 的 POST 请求。 ping 属性中可以有多个 url ,多个 url 用空格分隔。
例子
<a href="https://www.example.com" ping="https://ping.example.com">example</a>
<a href="https://www.example.com" ping="https://track.example.com https://ping.example.com">example</a>
和 sendBeacon 相比,ping 属性无需 JavaScript 代码参与,网页功能异常也能上报。 ping 属性有更明确的语义。 从 mdn 中的描述来看, sendBeacon 的作用上报数据分析和诊断代码, ping 属性的作用是追踪和统计。 sendBeacon 和 ping 属性都是异步的,跨域的。 sendBeacon 和 ping 属性都能避免缓存的影响。 sendBeacon 是 w3c 标准, ping 属性不是。
其它
- axios 一个 http 请求库,代码可以和 nodejs 的通用,也是对 ajax 和 fetch 的封装。
- Socket.IO 是一个库,可以在客户端和服务器之间实现 低延迟, 双向 和 基于事件的 通信。
- 如果客户端不支持 websocket 时会退回 http 长轮询,但是不能连接普通的 websocket 服务器
总结
在 SSE 和 websocket 出现之前,旧时代的前端搞了很多奇技淫巧来实现 服务器推 。 例如 定时刷新, iframe , ajax 轮询, ajax 流。
在 CORS 出现之前,旧时代的前端搞了很多奇技淫巧来实现 跨域 。 例如 jsonp , iframe 。
在前端发展的历程中,插件相关的技术基本都被抛弃了 (Flash, Java Applet, Silverlight, ActiveX) 。
在前端发展的历程中,如果一项功能需要奇技淫巧去实现,估计过一段时间就会有一个标准的实现出现, 类似于 websocket 和 Comet , CORS 和 跨越 ,各类标签请求和 sendBeacon 。
旧时代的奇技淫巧笔者认为没有学习的必要,笔者只是对这方面有点兴趣就多写一些记录。 理论上 jsonp 和 iframe 也能实现 短轮询/长轮询 ,但考虑到这已经是过时的技术,笔者就没有深入了。 现代的前端开发应该使用现代的方法而不是钻研那些已经被标记为过时的技巧。 例如,一般的接口就用 axios ,服务器的推送就是用 SSE ,需要双工通讯的就用 websocket ,需要跨域就用 CORS ,需要上报数据的就用 sendBeacon 。
笔者认为 Falsh 和 Java Applet 虽然被市场抛弃了,但单从技术的角度看,这两者放到今天依然是不过时的。 ActionScript 比 ES6 早很多年实现面向对象,即使现在最新的(2021) ES 标准面向对象也只是原型链的语法糖。 ActionScript 比 nodejs 早很多年实现命令空间。 静态类型至今也只能靠 TS 实现。 Java Applet 和 wasm 相比, wasm 也只是把 Java Applet 当年的路再走一次,不同的是 wasm 是 js 字节码, Java Applet 是 Java 字节码, wasm 是 w3c 的标准, Java Applet 当年是 sun 的产品。不同的语言编译到 wasm 和 不同的语言在 jvm 上运行,其实区别不会很大,甚至不同的语言在 jvm 上运行会比编译到 wasm 简单一点。 内存占用和安全问题,确实是一个大问题,但这不是不可以解决的。 早期的 js 同样也有安全问题和运行速度慢, js 的安全问题通过沙箱和同源限制的方式基本杜绝,运行速度慢的问题,基本被 v8 引擎解决了。 只能说,一项技术的发展并不只是技术的问题。