我在同步 ajax 的 cookie 上栽了個"無語"的跟頭

前言

遇到這種問題實屬無奈,前端的瀏覽器兼容性一直是一個讓人頭痛的問題

僅以此文記錄如此尷尬無奈的一天。拿來替大伙兒解悶T_T

場景再現

同事:快來!快來!線上出問題了!!
我:神馬?! 咩?! WHAT?! なに?!
同事:是這次發布造成的嗎?
我:回滾!回滾!(為什么要在快吃飯的時候掉鏈子!顧不上肚子了!快查吧)
......

一通混亂的對話后只能靜下心來“掃雷”了。

回滾、代理、抓包、對比、單因子排查。。。

一套組合拳打完,大概一炷香的時間,終于找到了破綻,竟然是 ajax 同步回調的問題!不合理啊!不應該啊!還有這種操作?!

問題復現

一句話概括問題

使用 ajax 做“同步”請求,此請求會返回一個 cookie,在success回調中讀取此目標cookie 失敗!ajax執行結束后 document.cookie 才會被更新

影響范圍

PC 端和 Android 端影響范圍小,屬于偶現。

IOS 端是重災區,出來 Chrome 和 Safari 瀏覽器外的絕大多說瀏覽器都會出現此問題,并且 App 內置的 Webview 環境同樣不能幸免。

在本同步請求回調內預讀取本請求返回的 cookie 會產生問題。

半壁江山都淪陷了,我要這鐵棒有何用!

追因溯果

小范圍的兼容問題我姑且可以饒你,奈何你如此猖狂,怎能任你瞞天過海!

縱向對比

排除一些干擾項,還原其本質,我們分別用框架nej,jQueryjs寫幾個相同功能的“同步” 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

操作

我們依次執行如下操作,

  1. 使用 ios 端 QQ 瀏覽器,清空所有緩存
  2. 加載其中一個頁面,觀察是否有目標 cookie 輸出
  3. 執行刷新操作,觀察是否有目標 cookie 輸出,比較 cookie 輸出的時間戳,確認是否為上次 cookie 的同步結果而非本次請求獲取的 cookie,
  4. 清空所有緩存,切換目標 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 事件上

https://code.jquery.com/jquery-3.2.1.js :9533行

而我自己實現的和 nej 的實現均是將success回調綁定在了 onreadystatechange 事件上,唯一的區別就在于此

一個正向的 ajax 請求,會先觸發兩次onreadystatechange,在觸發onload,或許原因在于document.cookie的同步有幾率在onload事件觸發前完成??I'm not sure.

問題結論

  1. 在 PC 端,Android 端,IOS 端Chrome、Safari 瀏覽器環境下,ajax 的同步請求的回調方法中,取到本請求返回的 cookie 失敗幾率低
  2. 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 的同步操作,很多機制并不會像我們自以為是的那樣理所應當。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容