文章出處

前言

 最近實施的同事報障,說用戶審批流程后直接關閉瀏覽器,操作十余次后系統就報用戶會話數超過上限,咨詢4A同事后得知登陸后需要顯式調用登出API才能清理4A端,否則必然會超出會話上限。
 即使在頁面上增添一個登出按鈕也無法保證用戶不會直接關掉瀏覽器,更何況用戶已經習慣這樣做,增加功能好弄,改變習慣卻難啊。這時想起N年用過的window.onbeforeunloadwindow.onunload事件。
 本文記錄重拾這兩個家伙的經過,以便日后用時少坑。

為網頁寫個Dispose方法

 C#中我們會將釋放非托管資源等收尾工作放到Dispose方法中, 然后通過using語句塊自動調用該方法。對于網頁何嘗不是有大量收尾工作需要處理呢?那我們是否也有類似的機制,讓程序變得更健壯呢?——那就靠beforeunloadunload事件了。但相對C#通過using語句塊自動調用Dispose方法,beforeunloadunload的觸發點則復雜不少。
 我們看看什么時候會觸發這兩個事件呢?

  1. 在瀏覽器地址欄輸入地址,然后點擊跳轉;
  2. 點擊頁面的鏈接實現跳轉;
  3. 關閉或刷新當前頁面;
  4. 操作當前頁面的Location對象,修改當前頁面地址;
  5. 調用window.navigate實現跳轉;
  6. 調用window.opendocument.open方法在當前頁面加載其他頁面或重新打開輸入流。
     OMG!這么多操作會觸發這兩兄弟,怎么處理才好啊?沒啥辦法,針對功能需求做取舍咯。對于我的需求就是在頁面的Dispose方法中調用登出API,經過和實施同事的溝通——只要刷新頁面就觸發登出。

    ;(function(exports, $, url){
      exports.dispose = $.proxy($.get, $, url)
    }(window, $, "http://pseudo.com/logout"))

那現在剩下的問題就在于到底是在beforeunload還是unload事件處理函數中調用dispose方法呢?這里涉及兩點需要探討:

  1. beforeunloadunload的功能定位是什么?
  2. beforeunloadunload的兼容性.

beforeunloadunload的功能定位是什么?

beforeunload顧名思義就是在unload前觸發,可通過彈出二次確認對話框來試圖終斷執行unload.
unload就是正在進行頁面內容卸載時觸發的,一般在這里進行一些重要的清理善后工作,而這時頁面處于以下一個特殊的臨時狀態:

  1. 頁面所有資源(img, iframe等)均未被釋放;
  2. 頁面可視區域一片空白;
  3. UI人機交互失效(window.open,alert,confirm全部失效);
  4. 沒有任何操作可以阻止unload過程的執行。(unload事件的Cancelable屬性值為No)

 那么反過來看看beforeunload事件,這時頁面狀態大致與平常一致:

  1. 頁面所有資源均未釋放,且頁面可視區域效果沒有變化;
  2. UI人機交互失效(window.open,alert,confirm全部失效);
  3. 最后時機可以阻止unload過程的執行.(beforeunload事件的Cancelable屬性值為Yes)

beforeunloadunload的兼容性

 對于移動端瀏覽器而言(Safari, Opera Mobile等)而言不支持beforeunload事件,也許是因為移動端不建議干擾用戶操作流程吧。

防數據丟失機制——二次確認

 當用戶正在編輯狀態時,若因誤操作離開頁面而導致數據丟失常作為例外處理。處理方式大概有3種:

  1. 丟了就丟唄,然后就是誰用誰受罪了;
  2. 簡單粗暴——偵測處于編輯狀態時,監聽beforeunload事件作二次確定,也就是將責任拋給用戶;
  3. 自動保存,甚至做到Work in Progress(參考john papa的分享John Papa-Progressive Savingr-NG-Conf)
     這里我們選擇方式2,彈出二次確定對話框。想到對話框自然會想到window.confirm,然后很自然地輸入以下代碼

    window.addEventListener('beforeunload', function(e){
      var msg = "Do u want to leave?\nChanges u made may be lost."
      if (!window.confirm(msg)){
    e.preventDefault()
      }
    })

    然后刷新頁面發現啥都沒發生,接著直接蒙了。。。。。。

    坑1: 無視window.alert/confirm/prompt/showModalDialog

    beforeunloadunload是十分特殊的事件,要求事件處理函數內部不能阻塞當前線程,而window.alert/confirm/prompt/showModalDialog卻恰恰就會阻塞當前線程,因此H5規范中以明確在beforeunloadunload中直接無視這幾個方法的調用。

    Since 25 May 2011, the HTML5 specification states that calls to window.showModalDialog(), window.alert(), window.confirm() and window.prompt() methods may be ignored during this event.(onbeforeunload#Notes)[https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#Notes]

在chrome/chromium下會報"Blocked alert/prompt/confirm() during beforeunload/unload."的JS異常,而firefox下則連異常都懶得報。
 既然不給用window.confirm,那么如何彈出二次確定對話框呢?其實beforeunload事件已經為我們準備好了。只要改成

window.onbeforeunload = function(){
  var msg = "Do u want to leave?\nChanges u made may be lost."
  return msg
}

 通過DOM0 Event Model的方式監聽beforeunload事件時,只需返回值不為undefined或null,即會彈出二次確定對話框。而IE和Chrome/Chromium則以返回值作為對話框的提示信息,Firefox4開始會忽略返回值僅顯式內置的提示信息.
 太不上道了吧,還在用DOM0 Event Model:( 那我們來看看DOM2 Event Model是怎么一個玩法

// Microsoft DOM2-ish Event Model
window.attachEvent('onbeforeunload', function(){
  var msg = "Do u want to leave?\nChanges u made may be lost."
  var evt = window.event
  evt.returnValue = msg
})

對于巨硬獨有的DOM2 Event Model,我們通過設置window.event.returnValue為非null或undefined來實現彈出窗的功能(注意:函數返回值是無效果的)
那么標準的DOM2 Event Model呢?我記得window.event.returnValue是 for ie only的,但事件處理函數的返回值又木有效果,那只能想到event.preventDefault()了,但event.preventDefault()沒有帶入參的重載,那么是否意味通過標準DOM2 Event Model的方式就不支持自定義提示信息呢?

window.addEventListeners('beforeunload', function(e){
  e.preventDefault()
})

在FireFox上成功彈出對話框,但Chrome/Chromium上卻啥都沒發生。。。。。。

坑2: HTMLElement.addEventListener事件綁定

event.preventDefault()這一玩法就FireFox支持,Chrome這次站到IE的隊列上了。綜合起來的玩法是這樣的

;(function(exports){
  exports.genDispose = genDispose

  /**
   * @param {Function|String} [fnBody] - executed within the dispose method when it's data type is Function
   *                                     as return value of dispose method when it's data type is String
   * @param {String} [returnMsg]       - as return value of dispose method
   * @returns {Function}               - dispose method
   */
  function genDispose(fnBody, returnMsg){
    var args = getArgs(arguments)

    return function(e){
      args.fnBody && args.fnBody()
      if(e = e || window.event){
        args.returnMsg && e.preventDefault && e.preventDefault()
        e.returnValue = args.returnMsg
      }

      return args.returnMsg    
    }
  }

  function getArgs(args){
    var ret = {fnBody: void 0, returnMsg: args[1]},
        typeofArg0 = typeof args[0]

    if ("string" === typeofArg0){
      ret.returnMsg = args[0]
    }
    else if ("function" === typeofArg0){
      ret.fnBody = args[0]
    }

    retrn ret
  }

}(window))

// uses
var dispose = genDispose("Do u want to leave?\nChanges u made may be lost.")
window.onbeforeunload = dispose
window.attachEvent('onbeforeunload', dispose)
window.addEventListener('beforeunload', dispose)

坑3: 尊重用戶的選擇

 有辦法阻止用戶關閉或刷新頁面嗎?沒辦法,二次確定已經是對用戶操作的最大限度的干擾了。

問題未解決——Cross-domain Redirection

;(function(exports){
  exports.Logout = Logout

  function Logout(url){
    if (this instanceof Logout);else return new Logout(url)
    this.url = url
  }
  Logout.prototype.exec = function(){
    var xhr = new XMLHttpRequest()
    xhr.open("GET", this.url, false)
    xhr.send()
  }
}(window))

var url = "http://pseudo.com/logout",
    logout = new Logout(url)
var dispose = $.proxy(logout.exec, logout)

var prefix = 'on'
(window.attachEvent || (prefix='', window.addEventListener))(prefix + 'unload', dispose)

 當我以為這樣就能交功課時,卻發現登出url響應狀態編碼為302,而響應頭Location指向另一個域的資源,并且不存在Access-Control-Allow-Origin等CORS響應頭信息,而XHR對象不支持Cross-domain Redirection,因此登出失效。
 以前只知道XHR無法執行Cross-domain資源的讀操作(支持寫操作),但只以為僅僅是不支持respose body的讀操作而已,沒想到連respose header的讀操作也不支持。那怎么辦呢?既然讀操作不行那采用嵌套Cross-domain資源總行吧。然后有了以下的填坑過程:

  1. 第一想到的就是嵌套iframe來實現,當iframe的實例化成本太高了,導致iframe還沒來得及發送請求就已經完成unload過程了;
  2. 于是想到了通過script發起請求, 因為respose body的內容不是有效腳本,因此會報腳本解析異常,若設置type="text/tpl"等內容時還不會發起網絡請求;另外iframe、script等html元素均要加入DOM樹后才能發起網絡請求;
  3. 最后想到HTMLImageElement,只要設置src屬性則馬上發起網絡請求,而且返回非法內容導致解析失敗時還是默默忍受,特別適合這次的任務:)

 于是得到下面的版本

;(function(exports){
  exports.Logout = Logout

  function Logout(url){
    if (this instanceof Logout);else return new Logout(url)
    this.url = url
  }
  Logout.prototype.exec = function(){
    var img = Image ? new Image() : document.createElement("IMG")
    img.src = this.url
  }
}(window))

[before]unload導致性能下降?

 現在我們都明白如何利用[before]unload來做資源釋放等善后工作了。
 但請記住一點:由于[before]unload事件會降低頁面性能,因此僅由于需要做重要的善后或不可逆的清理工作時才監聽這兩個事件。
 以前,當我們從頁面A跳轉到頁面B時,頁面A的所有資源將被釋放(銷毀DOM對象,回收JS對象, 釋放解碼后的Image資源等);后來各大瀏覽器廠商分別采用bfcache/page cache/fast history navigation機制,將頁面A的狀態保存到緩存中,當通過瀏覽器的后退/前進按鈕跳轉時馬上從緩存中恢復頁面,而不是重新實例化。以下情況將不被緩存起來:

  1. 監聽unloadbeforeunload事件;
  2. 響應頭Cache-Control: no-store;
  3. 對于采用HTTPS協議的響應頭,滿足以下一個或以上:
    3.1. Cache-Control: no-cache
    3.2. Pragma: no-cache
    3.3. 存在Expires超期的
  4. 發生跳轉時,頁面存在未加載完的資源
  5. 旗下iframe存在上述情況的
  6. 頁面在iframe中渲染,當用戶修改iframe.src加載其他文檔到該iframe時

 因此若執行不可逆的清理工作時,對于現代瀏覽器而言我們應該訂閱pagehide事件,而不是unload事件,以便利用Page Cache機制。
事件發生順序:load->pageshow->pagehide->unload
pageshowpagehide的事件對象存在一個persisted屬性,為true時表示從cache中恢復,false表示重新實例化。
 經簡單測試發現chrome默認沒有啟用該特性,而Firefox則默認啟用。實驗代碼:

// index.html
window.addEventListener('load', function(){
  console.log("index.load")
  window.test = true
})
window.addEventListener('pageshow', function(e){
  console.log("index.pageshow.persisted:" + e.persisted)
  console.log("index.test:" + window.test)
})

<a href="./next.html">next.html</a>
// next.html
window.addEventListener('load', function(){
  console.log("next.load")
})
window.addEventListener('pageshow', function(e){
  console.log("next.pageshow.persisted:" + e.persisted)
})

運行環境:FireFox
操作步驟:1.首先訪問index.html,2.然后點擊鏈接跳轉到next.html,3.然后點擊瀏覽器的回退按鈕跳轉到index.html,4.最后點擊瀏覽器的前進按鈕跳轉到next.html。
輸出結果:

// 1
index.load
index.pageshow.persisted:false
index.test:true
// 2
next.load
next.pageshow.persisted:false
// 3
index.pageshow.persisted:true
index.test:true
//4
next.pageshow.persisted:true

 看到頁面是從bfcache恢復而來的,所以JS對象均未回收,因此window.test值依然有效。另外load僅在頁面初始化后才會觸發,因此從bfcache中恢復頁面時并不會觸發。
 假如在index.html上訂閱了unloadbeforeunload事件,那么該頁面將不會保存到bfcache。
 另外通過jQuery.ready來監聽頁面初始化事件時,不用考慮bfcache的影響,因為它幫我們處理好了:)

總結

若有紕漏望請指正,謝謝!
尊重原創,轉載請注明來自:http://www.cnblogs.com/fsjohnhuang/p/5647649.html 肥子John^_^

感謝

window-onbeforeunload-not-working
beforeunload
unload
prompt-to-unload-a-document
webkit page cache i - the basics
webkit page cache ii - the unload event
pagehide
pageshow
Redirects Do’s and Don’ts
Using_Firefox_1.5_c aching#New_browser_events
cross-browser-onload-event-and-the-back-button


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()