浏览器和服务器通讯方式的不完整总结

这篇文章最后更新的时间在六个月之前,文章所叙述的内容可能已经失效,请谨慎参考!

一些概念

定时刷新

定时刷新可以算是最古老的服务器和浏览器的通讯方式了。 大多数情况下不会刷新整个页面,而是刷新页面里的一些 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 的跨域方式

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 字符串。

类似于这样

  1. 新建了一个 script 标签
    <script src="jsonp.php?jsoncallback=callbackFunction&param1=value1&param2=value2"></script>
    
  2. 请求会输出一段 js 代码
    callbackFunction({'code':0, msg:'success', 'data':null});
    
  3. 在 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 高。 而且对音视频而言,一点的掉包其实是不影响实际体验的。

各种标签

这些标签都是利用浏览器加载资源的方式向服务器发送请求,一般是用于上报页面的监控数据。

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

axios 一个 http 请求库,代码可以和 nodejs 的通用,也是对 ajax 和 fetch 的封装。

总结

在 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 引擎解决了。 只能说,一项技术的发展并不只是技术的问题。