前言
遇到這種問題實屬無奈,前端的瀏覽器相容性一直是一個讓人頭痛的問題
僅以此文記錄如此尷尬無奈的一天。拿來替大夥兒解悶T_T
場景再現
同事:快來!快來!線上出問題了!!
我:神馬?! 咩?! WHAT?! なに?!
同事:是這次釋出造成的嗎?
我:回滾!回滾!(為什麼要在快吃飯的時候掉鏈子!顧不上肚子了!快查吧)
......
一通混亂的對話後只能靜下心來“掃雷”了。
回滾、代理、抓包、對比、單因子排查。。。
一套組合拳打完,大概一炷香的時間,終於找到了破綻,竟然是 ajax 同步回撥的問題!不合理啊!不應該啊!還有這種操作?!
問題復現
一句話概括問題
使用 ajax 做“同步”請求,此請求會返回一個 cookie,在
success
回撥中讀取此目標cookie 失敗!ajax執行結束後document.cookie
才會被更新
影響範圍
PC 端和 Android 端影響範圍小,屬於偶現。
IOS 端是重災區,出來 Chrome 和 Safari 瀏覽器外的絕大多說瀏覽器都會出現此問題,並且 App 內建的 Webview 環境同樣不能倖免。
在本同步請求回撥內預讀取本請求返回的 cookie 會產生問題。
半壁江山都淪陷了,我要這鐵棒有何用!
追因溯果
小範圍的相容問題我姑且可以饒你,奈何你如此猖狂,怎能任你瞞天過海!
縱向對比
排除一些干擾項,還原其本質,我們分別用框架nej
,jQuery
和js
寫幾個相同功能的“同步” demo,走著瞧著。。
【nej.html】使用 NEJ 庫
<!DOCTYPE html>
<html>
<head>
<title>nej</title>
<meta charset="utf-8" />
</head>
<body>
test
<script src="http://nej.netease.com/nej/src/define.js?pro=./"></script>
<script>
define([
'{lib}util/ajax/xdr.js'
], function () {
var _j = NEJ.P('nej.j');
_j._$request('/api', {
sync: true,
method: 'POST',
onload: function (_data) {
alert("cookie:\n" + document.cookie)
}
});
});
</script>
</body>
</html>複製程式碼
【jquery.html】使用 jQuery 庫
<!DOCTYPE html>
<html>
<head>
<title>jquery</title>
<meta charset="utf-8" />
</head>
<body>
jquery
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script>
$.ajax({
url: '/api',
async: false,
method: 'POST',
success: function (result) {
alert("cookie:\n" + document.cookie)
}
});
</script>
</body>
</html>複製程式碼
【js.html】自己實現的 ajax 請求函式
<!DOCTYPE html>
<html>
<head>
<title>JS</title>
<meta charset="utf-8" />
</head>
<body>
js
<script>
var _$ajax = (function () {
/**
* 生產XHR相容IE6
*/
var createXHR = function () {
if (typeof XMLHttpRequest != "undefined") { // 非IE6瀏覽器
return new XMLHttpRequest();
} else if (typeof ActiveXObject != "undefined") { // IE6瀏覽器
var version = [
"MSXML2.XMLHttp.6.0",
"MSXML2.XMLHttp.3.0",
"MSXML2.XMLHttp",
];
for (var i = 0; i < version.length; i++) {
try {
return new ActiveXObject(version[i]);
} catch (e) {
return null
}
}
} else {
throw new Error("您的系統或瀏覽器不支援XHR物件!");
}
};
/**
* 將JSON格式轉化為字串
*/
var formatParams = function (data) {
var arr = [];
for (var name in data) {
arr.push(name + "=" + data[name]);
}
arr.push("nocache=" + new Date().getTime());
return arr.join("&");
};
/**
* 字串轉換為JSON物件,相容IE6
*/
var _getJson = (function () {
var e = function (e) {
try {
return new Function("return " + e)()
} catch (n) {
return null
}
};
return function (n) {
if ("string" != typeof n) return n;
try {
if (window.JSON && JSON.parse) return JSON.parse(n)
} catch (t) {
}
return e(n)
};
})();
/**
* 回撥函式
*/
var callBack = function (xhr, options) {
if (xhr.readyState == 4 && !options.requestDone) {
var status = xhr.status;
if (status >= 200 && status < 300) {
options.success && options.success(_getJson(xhr.responseText));
} else {
options.error && options.error();
}
//清空狀態
this.xhr = null;
clearTimeout(options.reqTimeout);
} else if (!options.requestDone) {
//設定超時
if (!options.reqTimeout) {
options.reqTimeout = setTimeout(function () {
options.requestDone = true;
!!this.xhr && this.xhr.abort();
clearTimeout(options.reqTimeout);
}, !options.timeout ? 5000 : options.timeout);
}
}
};
return function (options) {
options = options || {};
options.requestDone = false;
options.type = (options.type || "GET").toUpperCase();
options.dataType = options.dataType || "json";
options.contentType = options.contentType || "application/x-www-form-urlencoded";
options.async = options.async;
var params = options.data;
//建立 - 第一步
var xhr = createXHR();
//接收 - 第三步
xhr.onreadystatechange = function () {
callBack(xhr, options);
};
//連線 和 傳送 - 第二步
if (options.type == "GET") {
params = formatParams(params);
xhr.open("GET", options.url + "?" + params, options.async);
xhr.send(null);
} else if (options.type == "POST") {
xhr.open("POST", options.url, options.async);
//設定表單提交時的內容型別
xhr.setRequestHeader("Content-Type", options.contentType);
xhr.send(params);
}
}
})();
_$ajax({
url: '/api',
async: false,
type: 'POST',
success: function (result) {
alert("cookie:\n" + document.cookie)
}
});
</script>
</body>
</html>複製程式碼
三個檔案都是一樣的,在html 載入完之後發起一個同步請求,該請求會返回一個 cookie,在回撥中將document.cookie
列印出來,檢測是否已經在回撥時寫入的了 cookie。
下面使用 node 實現這個可寫 cookie 的服務。
【serve.js】
var express = require("express");
var http = require("http");
var fs = require("fs");
var app = express();
var router = express.Router();
router.post('/api', function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type,Content-Length, Authorization, Accept,X-Requested-With");
res.header("Set-Cookie", ["target=ccccccc|" + new Date()]);
res.end('ok');
});
router.get('/test1', function (req, res, next) {
fs.readFile("./nej.html", function (err, data) {
res.end(data);
});
});
router.get('/test2', function (req, res, next) {
fs.readFile("./jquery.html", function (err, data) {
res.end(data);
});
});
router.get('/test3', function (req, res, next) {
fs.readFile("./js.html", function (err, data) {
res.end(data);
});
});
app.use('/', router);
http.createServer(app).listen(3000);複製程式碼
好了,萬事大吉,run 一把
$ node serve.js複製程式碼
操作
我們依次執行如下操作,
- 使用 ios 端 QQ 瀏覽器,清空所有快取
- 載入其中一個頁面,觀察是否有目標 cookie 輸出
- 執行重新整理操作,觀察是否有目標 cookie 輸出,比較 cookie 輸出的時間戳,確認是否為上次 cookie 的同步結果而非本次請求獲取的 cookie,
- 清空所有快取,切換目標 html 檔案,迴圈執行2,3,4步驟
結果
【nej.html】
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,讀取到上一次請求返回的 cookie
【jquery.html】
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,未讀取到目標 cookie
【js.html】
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,未讀取到目標 cookie
咦?結果不一樣!使用 nej 的第二次載入讀取到了第一次 cookie。其他的兩次均為獲取到。
原因
nej 依賴框架的載入是非同步的,當同步請求發起時,dom 已經載入完畢,回撥相應時,document.cookie
已經呈“ready”狀態,可讀可寫。但請求依然獲取不到自身返回攜帶的 cookie。
而其他兩種載入的機制阻塞了 dom 的載入,導致同步請求發起時,dom 尚未載入完成,回撥相應時,document.cookie
依然不可寫。
單因子對照
我們將以上幾個 html 檔案的邏輯做下修改。
將同步請求推遲到 document 點選觸發時再發起。
如下
$('document').click(function () {
// TODO 發起同步請求
});複製程式碼
依然是上面的執行步驟,來看看此次的結果
結果
【nej.html】
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,讀取到上一次請求返回的 cookie
【jquery.html】
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,讀取到上一次請求返回的 cookie
【js.html】
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,讀取到上一次請求返回的 cookie
結果和預期一樣,本次請求無法獲取本期返回的目標 cookie,請求回撥執行後,目標cookie才會更新到document.cookie
上。
特例
在執行以上操作是,發現,【jquery.html】的執行結果時不時會有兩種結果
- 純淨環境載入,未讀取到目標 cookie
- 重新整理載入,讀取到上一次請求返回的 cookie
另外一種機率較小,但也會出現 - 純淨環境載入,讀取到目標 cookie
- 重新整理載入,讀取到目標 cookie
產生原因
一言不合看原始碼
我們在 jquery 的原始碼中看到,jquery 的success
回撥繫結在了 onload
事件上
code.jquery.com/jquery-3.2.… :9533行
而我自己實現的和 nej 的實現均是將success
回撥繫結在了 onreadystatechange
事件上,唯一的區別就在於此
一個正向的 ajax 請求,會先觸發兩次onreadystatechange
,在觸發onload
,或許原因在於document.cookie
的同步有機率在onload
事件觸發前完成??I'm not sure.
問題結論
- 在 PC 端,Android 端,IOS 端Chrome、Safari 瀏覽器環境下,ajax 的同步請求的回撥方法中,取到本請求返回的 cookie 失敗機率低
- IOS 端,QQ 瀏覽器、App 內建Webview瀏覽器環境下,失敗率極高。
解決方案
只有問題沒有方案的都是在耍流氓!
方案1 - 明修棧道暗度陳倉
將回撥方法中的 cookie 獲取方法轉化為非同步操作。
_$ajax({
url: '/api',
async: false,
type: 'POST',
success: function (result) {
setTimeout(function(){
// do something 在此處獲取 cookie 操作是安全的
},0)
}
});複製程式碼
方案2 - 不抵抗政策
沒有把握的方案,我們是要斟酌著實施的。
如果你不能100%卻被操作的安全性,那並不建議你強行使用 ajax 的同步操作,很多機制並不會像我們自以為是的那樣理所應當。
友情連結
善讀書可以醫愚
給大家推薦一本好書
《JavaScript框架設計(第2版)》
作者: 司徒正美
此書全面講解了JavaScript框架設計及相關的知識,主要內容包括種子模組、語言模組、瀏覽器嗅探與特徵偵測、類工廠、選擇器引擎、節點模組、資料快取模組、樣式模組、屬性模組、PC端和移動端的事件系統、jQuery的事件系統、非同步模型、資料互動模組、動畫引擎、MVVM、前端模板(靜態模板)、MVVM的動態模板、效能牆與複雜牆、元件、jQuery時代的元件方案、avalon2的元件方案、react的元件方案等。
本書適合前端設計人員、JavaScript開發者、移動UI設計者、程式設計師和專案經理閱讀,也可作為相關專業學習用書和培訓學校教材。