如何在页面跳转时优雅的发送Ajax请求

3,030 阅读5分钟

场景描述

很多时候我们需要在离开页面的时候,需要发送预设的埋点事件,做web的页面埋点需求,用于后续的数据统计。但是我们知道,正常的单页面应用Ajax请求会在页面卸载的时候被取消,这个Ajax上传的数据很大程度上会丢失。

解决方案

  1. 发送同步Ajax。
  2. 动态图片。
  3. navigator.sendBeacon。
  4. 最终方案(保证数据有效性,并减小对用户体验的不良影响)。

发送同步Ajax

通过修改Ajax请求为同步请求,阻塞页面,等待请求结束之后,再触发页面跳转。

用法示例
// ajax 请求
function ajaxrequest(type) {
	if (XMLHttpRequest) {
		var xhrRequest = new XMLHttpRequest();
		xhrRequest.open(method, `${url}?${params.join('&')}`, type);
		xhrRequest.send();
	}
}
发送同步Ajax案总结
优势
  • 在网络良好的情况下,可以保证信息上报的稳定性。
劣势
  • 会存在阻塞页面跳转的情况,尤其在网络差的环境下,用户体验很不好。
  • 部分浏览器已经开始不支持ajax同步请求

动态图片

道理和同步ajax差不多,都是通过阻塞的方式去完成请求,通过在unload事件处理器中,创建一个图片元素并设置它的 src 属性的方法来延迟卸载以保证数据的发送。因为绝大多数浏览器会延迟卸载以保证图片的载入,所以数据可以在卸载事件中发送。但是需要服务端配合解析请求url。

用法示例
// 伪装image请求
function imageRequest() {
	const params = [];
	Object.keys(datas).forEach((key) => {
		params.push(`${key}=${encodeURIComponent(datas[key])}`);
	});
	var img = document.createElement('img');
	img.onload = function() {
		console.log('success');
		img = null;
	};
	img.onerror = function() {
		console.log('error');
		img = null;
	};
	img.src = `${url}?${params.join('&')}`;;
}

动态图片方案总结
优势
  • 在有网络的情况下,可以保证信息上报的稳定性。
劣势
  • 该方案需要服务端配合,构建好传输数据的格式,且只能get的形式。
  • 会存在阻塞页面跳转的情况,用户体验不是很良好。

navigator.sendBeacon

mdn文档

这个方法主要用于满足统计和诊断代码的需要,这些代码通常尝试在卸载(unload)文档之前向web服务器发送数据。过早的发送数据可能导致错过收集数据的机会。然而,对于开发者来说保证在文档卸载期间发送数据一直是一个困难。因为用户代理通常会忽略在 unload 事件处理器中产生的异步 XMLHttpRequest。

用法示例
function sendBeaconRequest() {
	if (window.navigator.sendBeacon) {
		window.navigator.sendBeacon(url, JSON.stringify(datas));
	}
}
sendBeacon 问题总结
优势
  • 用户无感知,不阻塞页面跳转,不影响用户体验。
劣势
  • 存在兼容性问题。
  • 大量发送数据,无法保证数据发送的可靠性。在ios上测试,能连续发送15次左右,就无法继续发送。

最终方案

保证数据有效性、准确性的基础上,并减小对用户体验的不良影响,本方案采用动态图片的方案用于用户行为的上报。

方案

通过管理所有的Ajax请求以及监听监听页面的foreunload事件(浏览器有两个事件可以用来监听页面关闭,beforeunload和unload。 beforeunload是在文档和资源将要关闭的时候调用的。==unload事件好像有点问题==,这个时候初始化image并不会发送),然后给此时未完成的Ajax请求abort。未完成的请求,通过动态image的方案发送到服务器端。

unload则是在页面已经正在被卸载时发生,此时文档所处的状态是:1.所有资源仍存在(图片,iframe等);2.对于用户所有资源不可见;3.界面交互无效(window.open, alert, confirm 等);4.错误不会停止卸载文档的过程。

优劣势见上述动态image方案。
用法示例
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
	<div id='final'>finalProject</div>
</body>
</html>
<script type="text/javascript">
	// 本地启动的node服务
	let url = 'http://localhost:8090/uploadUserAction';
	let method = 'GET';
	let datas = {
		data1: 'haha',
		token: 'test'
	};
    let params = [];
	Object.keys(datas).forEach((key) => {
		params.push(`${key}=${encodeURIComponent(datas[key])}`);
	});
    // Ajax 请求列表
    let requestList = [];
	let final = document.getElementById('final');
    // step1: 异步ajax请求
	final.onclick = function () {
        // 模拟同时发送10个请求,服务端也做了延时,用于模仿请求
        for(let a =0; a < 10; a++){
            ajaxrequest(a);
        }
        setTimeout(function() {
            pushNewPage();
        }, 1500);
	}

	// ajax 请求
	function ajaxrequest(key) {
		var xhr = new XMLHttpRequest();
        // 生成唯一的key 用于识别xhr
		xhr.open(method, `${url}?${params.join('&')}&key=${key}`);
		xhr.send(datas);
        requestList.push({
            key,
            xhr
        })
        // 监听ajax请求变化
        xhr.onreadystatechange = () => {
            if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
                // 当请求完成的时候,从请求列表移除
                requestList.forEach((item, index)  => {
                    if (item.key === key) requestList.splice(index, 1);
                });
            }
        }
	}

    // step2: 监听页面即将卸载。处理未完成的请求
    window.addEventListener('beforeunload', function(event) {
        if (requestList && requestList.length) {
            requestList.map((item, index) => {
                item.xhr.abort();
                imageRequest(item.key);
            });
        }
    });

    // 监听页面卸载。处理未完成的请求
    window.addEventListener('unload', function(event) {});

    // step3: 伪装成image请求
	function imageRequest(key) {
		var img = document.createElement('img');
		img.onload = function() {
			console.log('success');
			img = null;
		};
		img.onerror = function() {
			console.log('error');
			img = null;
		};
		img.src = `${url}?${params.join('&')}&key=${key}`;;
	}

	// 页面跳转
	function pushNewPage() {
		window.location.href = 'http://www.baidu.com'
	}
</script>

参考链接-MDN-XMLHttpRequest

参考链接-MDN-Beacon_API

参考链接-MDN-unload

1-3方案代码示例

<html>
<head>
	<title>test</title>
</head>
<body>
	<div id='asyncAjax'>asyncAjax</div>
	<div id='syncAjax'>syncAjax</div>
	<div id='dynamicImage'>dynamicImage</div>
	<div id='sendBeacon'>sendBeacon</div>
	<div id='final'>finalProject</div>
</body>

</html>
<script type="text/javascript">
	// 本地启动的node服务
	var url = 'http://localhost:8090/uploadUserAction';
	var method = 'GET';
	var datas = {
		data1: '111',
		token: 'test'
	};
	var params = [];
	Object.keys(datas).forEach((key) => {
		params.push(`${key}=${encodeURIComponent(datas[key])}`);
	});

	var asyncAjax = document.getElementById('asyncAjax');
	var syncAjax = document.getElementById('syncAjax');
	var dynamicImage = document.getElementById('dynamicImage');
	var sendBeaconClick = document.getElementById('sendBeacon');
	var delayed = document.getElementById('delayed');

	// 异步ajax请求
	asyncAjax.onclick = function () {
		ajaxrequest(true);
		// pushNewPage();
	}

	// 同步ajax请求
	syncAjax.onclick = function () {
		ajaxrequest(false);
		// pushNewPage();
	}

	// 动态图片请求
	dynamicImage.onclick = function () {
		imageRequest();
		pushNewPage();
	}

	// sendBeacon 发送请求
	sendBeaconClick.onclick = function () {
		sendBeaconRequest();
		// pushNewPage();
	}

	// ajax 请求
	function ajaxrequest(type) {
		if (XMLHttpRequest) {
			var xhrRequest = new XMLHttpRequest();
			xhrRequest.open(method, `${url}?${params.join('&')}`, type);
			xhrRequest.send();
		}
	}
	
	// 伪装image请求
	function imageRequest() {
		
		var img = document.createElement('img');
		img.onload = function() {
			console.log('success');
			img = null;
		};
		img.onerror = function() {
			console.log('error');
			img = null;
		};
		img.src = `${url}?${params.join('&')}`;;
	}

	function sendBeaconRequest() {
		if (window.navigator.sendBeacon) {
			window.navigator.sendBeacon(url, JSON.stringify(datas));
		}
	}

	// 页面跳转
	function pushNewPage() {
		window.location.href = 'http://www.baidu.com';
	}
</script>

node代码示例

const express = require('express');
const app = express();
const Response = require('express/lib/response');

Response.sendFormatData = function (code, data, message = 'succeed') {
    this.send({
        code,
        data,
        message,
    });
};

app.listen(8090, () => {
    console.log('服务启动')
});

app.all('*', function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  res.header('Access-Control-Allow-Methods', '*');
  res.header('Content-Type', 'application/json;charset=utf-8');
  next();
});
app.get('/uploadUserAction', (req, res) => {
	console.log(req.url)
	setTimeout(function() {
		res.sendFormatData('200', {data: 'succeed'},'请求成功')
	}, 1000)
})