關於編寫效能高效的javascript事件的技術

夏天的森林發表於2014-11-24

如何能做出高效的web前端程式是我每次做前端開發都會不自覺去考慮的問題。幾年前雅虎裡牛逼的前端工程師們出了一本關於提升web前端效能的書籍,轟動了整個web開發技術界,讓神祕的web前端優化問題成為了大街的白菜,web前端優化變成了菜鳥和大牛都能回答的簡單問題,當整個業界都知道了驚天祕密的答案,那麼現有的優化技術已經不能對你開發的網站產生的質的飛越,為了讓我們開發的網站效能比別人的網站更加優秀,我們需要更加深入的獨立思考,儲備更加優秀的技能。

Javascript裡的事件系統是我想到的第一個突破點。為什麼會是javascript的事件系統呢?我們都知道web前端包含三個技術:html、css和javascript,html和css如何結合真是一目瞭然:style、class、id以及html標籤,這個沒啥好講的,但是javascript是如何切入到html和css中間,讓三者融合呢?最後我發現這個切入點就是javascript的事件系統,不管我們寫多長多複雜的javascript程式碼,最終都是通過事件系統體現在html和css上,因此我就在想既然事件系統是三者融合的切入點,那麼一個頁面裡,特別是當今越來越複雜的網頁裡必然會有大量事件操作,沒有這些事件我們精心編寫的javascript程式碼只有刀槍入庫,英雄無用武之地了。既然頁面會存在大量事件函式,那麼我們按習慣寫事件函式,會存在影響效率的問題嗎?我研究下來的答案是真有效率問題,而且還是嚴重的效率問題。

為了說清楚我的答案,我要先詳細講解下javascript的事件系統。

事件系統是javascript和html以及css融合的切入點,這個切人點好比java裡的main函式,一切神奇都是由這裡開始,那麼瀏覽器是如何完成這種切入呢?我研究下來一共有3種方式,它們分別是:

方式一:html事件處理

html事件處理就是將事件函式直接寫在html標籤裡,因為這種寫法和html標籤緊耦合,所以稱為html事件處理。例如下面程式碼:

如果click事件函式複雜了,這麼寫程式碼肯定會帶來不便,因此我們常常把函式寫在外部,onclick直接呼叫函式名,例如:

上面這個寫法是一種很美的寫法,所以時下還是很多人會不自覺的使用它,但是也許很多人不知道,後一種寫法其實沒有前一種寫法健壯,這個也是我前不久在研究非阻塞載入指令碼技術時候碰到的問題,因為根據前端優化的原則,javascript程式碼往往是位於頁面的底部,當頁面有被指令碼阻塞時候,html標籤裡引用的函式可能還沒執行到,這個時候我們點選頁面按鈕,結果會報出“XXX函式未定義的錯誤”,在javascript裡這樣的錯誤是會被try,catch所捕獲,因此為了讓程式碼更加健壯,我們會有如下的改寫:

看到上面程式碼豈是一個噁心能描述的。

方式二:DOM0級事件處理

DOM0級事件處理是當今所有瀏覽器都支援的事件處理,不存在任何相容性問題,看到這樣一句話都會讓每個做web前端的人們激動不已。DOM0事件處理的規則是:每個DOM元素都有自己的事件處理屬性,該屬性可以賦值一個函式,例如下面的程式碼:

DOM0級事件處理的事件屬性都是採用“on+事件名稱”的方式定義,整個屬性都是小寫字母。我們知道DOM元素在javascript程式碼裡就是一個javascript物件,因此從javascript物件角度理解DOM0級事件處理就非常容易,例如下面程式碼:

那麼按鈕的點選事件被取消了。

再看下面的程式碼:

後面一個函式會將第一個函式覆蓋。

方式三:DOM2事件處理和IE事件處理

DOM2事件處理是標準化的事件處理方案,但是IE瀏覽器自己搞了一套,功能和DOM2事件處理相似,但是程式碼寫起來就不太一樣了。

在講解方式三之前,我必須要補充一些概念,否則是無法講清楚方式三的內涵。

第一個概念是:事件流

在頁面開發裡我們常常會碰到這樣的情況,一個頁面的工作區間在javascript可以用document表示,頁面裡有個div,div等於是覆蓋在document元素上,div裡面有個button元素,button元素是覆蓋在div上,也等於覆蓋著document上,所以問題來了,當我們點選這個按鈕時候,這個點選行為其實不僅僅發生在button之上,div和document都被作用了點選操作,按邏輯這三個元素都是可以促發點選事件的,而事件流正是描述上述場景的概念,事件流的意思是:從頁面接收事件的順序。

第二個概念:事件冒泡和事件捕獲

事件冒泡是微軟公司提出解決事件流問題的方案,而事件捕獲則是網景公司提出的事件流解決方案,它們的原理如下圖:

210018361404965

冒泡事件由div開始,其次是body,最後是document,事件捕獲則是倒過來的先是document,其次是body,最後是目標元素div,相比之下,微軟公司的方案更加人性化符合人們的操作習慣,網景的方案就很彆扭了,這是瀏覽器大戰的惡果,網景慢了一步就以犧牲使用者習慣的程式碼解決事件流的問題。

微軟公司結合冒泡事件設計了一套新的事件系統,業界習慣稱為ie事件處理,ie事件處理方式如下面程式碼所示:

在ie下通過DOM元素的attachEvent方法新增事件,和DOM0事件處理相比,新增事件的方式由屬性變成了方法,所以我們新增事件就需要往方法裡傳遞引數,attachEvent方法接收兩個引數,第一個引數是事件型別,事件型別的命名和DOM0事件處理裡的事件命名一樣,第二個引數是事件函式了,使用方法的好處就是如果我們在為同一個元素新增個點選事件,如下所示:

執行之,兩個對話方塊都能正常彈出來,方法讓我們可以為DOM元素新增多個不同的點選事件。如果我們不要某個事件呢?我們該怎麼做了,ie為刪除事件提供了detachEvent方法,引數列表和attachEvent一樣,如果我們要刪除某個點選事件,只要傳遞和新增事件一樣的引數即可,如下程式碼所示:

執行之,後果很嚴重,我們很迷惑,第二個click居然沒有被刪除,這是怎麼回事?前面我講到刪除事件要傳入和新增事件一樣的引數,但是在javascript的匿名函式裡,兩個匿名函式哪怕程式碼完全一樣,javascript都會在內部使用不同變數儲存,結果就是我們看到的現象無法刪除點選事件的,因此我們的程式碼要這麼寫:

這樣新增的方法和刪除的方法就是指向了同一個物件,所以事件刪除成功了。這裡的場景告訴我們寫事件要有個良好的習慣即操作函式要獨立定義,不要用匿名函式用成了習慣。

接下來就是DOM2事件處理,它的原理如下圖所示:

210020099537539

DOM2是標準化的事件,使用DOM2事件,事件傳遞首先從捕獲方式開始即從document開始,再到body,div是一箇中介點,事件到了中介點時候事件就處於目標階段,事件進入目標階段後事件就開始冒泡處理方式,最後事件在document上結束。(捕獲事件的起點以及冒泡事件的終點,我本文都是指向document,實際情況是有些瀏覽器會從window開始捕獲,window結束冒泡,不過我覺得開發時候不管瀏覽器本身怎麼設定,我們關注document更具開發意義,所以我這裡一律都是使用document)。人們習慣把目標階段歸為冒泡的一部分,這主要是因為開發裡冒泡事件使用的更加廣泛。

DOM2事件處理很折騰,每次事件促發時候都會把所有元素遍歷兩遍,這點和ie事件相比效能就差多了,ie只有冒泡,所以ie只需要遍歷一次,不過遍歷少了並不代表ie的事件體系效率更高,從開發設計角度同時支援兩種事件系統會給我們開發帶來更大的靈活度,從這個角度而言DOM2事件還是很有可取之處。DOM2事件的程式碼如下:

DOM2事件處理裡新增事件使用的是addEventListener,它接收三個引數比ie事件處理多一個,前兩個的意思和ie事件處理方法的兩個引數一樣,唯一的區別就是第一個引數裡要去掉on這個字首,第三個引數是個布林值,如果它的取值是true,那麼事件就按照捕獲方式處理,取值為false,事件就是按照冒泡處理,有第三個引數我們可以理解為什麼DOM2事件處理裡要把事件元素跑個兩遍,目的就是為了相容兩種事件模型,不過這裡要請注意下,不管我們選擇是捕獲還是冒泡,兩遍遍歷是永遠進行,如果我們選擇一種事件處理方式,那麼另外一個事件處理流程裡就不會促發任何事件處理函式,這和汽車掛空擋空轉的道理一樣。通過DOM2事件方法的設計,我們知道DOM2事件在執行時候只能執行兩種事件處理方式中的一種,不可能兩個事件流體系同時促發,所以雖然元素遍歷兩遍,但是事件函式絕不可能被促發兩遍,注意我這裡指不促發兩遍是指一個事件函式,其實我們可以模擬兩個事件流模型同時執行的情況,例如下面程式碼:

但這種寫法是多事件處理,相當於我們點選兩次按鈕。

DOM2也提供了刪除事件的函式,這個函式就是removeEventListener,寫法如下:

使用和ie事件的一樣即引數要和定義事件的引數一致,不過removeEventListener使用時候,第三個引數不傳,預設是刪除冒泡事件,因為第三個引數不傳預設都是false,例如:

執行之,發現事件沒有被刪除成功。

最後我要說的是DOM2事件處理在ie9包括ie9以上的版本都得到了很好的支援,ie8以下是不支援DOM2事件的。

下面我們對三種事件方式做個比較,比較如下:

比較一:方式一為一方和其他兩種方式比較

方式一的寫法是html和javascript結合在一起,你中有我我中有你,把這種方式深化一下就是html和javascript混合開發,用一個軟體術語表達就是程式碼耦合,程式碼耦合不好,而且是非常不好,這是菜鳥程式設計師的級別,所以方式一完敗,另外兩種方式完勝。

比較二:方式二和方式三

它們兩個寫法差不多,有時真的很難說誰好誰壞,縱觀上述內容我們發現方式二和方式三的最大區別就是:使用方式二一個DOM元素某個事件有且只有一次,而方式三則可以讓DOM元素某個事件擁有多個事件處理函式,在DOM2事件處理裡,方式三還能讓我們精確控制事件流的方式,因此方式三的功能比方式二更加的強大,所以相比之下方式三略勝一籌。

下面就是本文的重點:事件系統的效能問題,解決效能問題必須找到一個著力點,這裡我從兩個著力點來思考事件系統的效能問題,它們分別是:減少遍歷次數和記憶體消耗。

首先是遍歷次數,不管是捕獲事件流還是冒泡事件流,都會遍歷元素,而是都是從最上層的window或document開始的遍歷,假如頁面DOM元素父子關係很深,那麼遍歷的元素越多,像DOM2事件處理這種,遍歷危害程度就越大了,如何解決這個事件流遍歷問題了?我的回答是沒有,這裡有些朋友也許會有疑問,怎麼會沒有了?事件系統裡有個事件物件即event,這個物件有阻止冒泡或捕獲事件的方法,我怎麼說沒有呢?這位朋友的疑問很有道理,但是如果我們要使用該方法減少遍歷,那麼我們程式碼就要處理父子元素的關係,爺孫元素關係,如果頁面元素巢狀很多,這就是沒法完成的任務,所以我的回答是沒法改變遍歷的問題,只能去適應它。

看來減少遍歷是沒法解決事件系統效能問題了,那麼現在只有從記憶體消耗考慮了。我常聽人說C#很好用,對於web前端開發它就更好用了,我們可以直接在C#的IDE拖一個按鈕到頁面,按鈕到了頁面之後javascript程式碼會自動為該按鈕新增個事件,當然裡面的事件函式是個空函式,於是我想我們可以按這種方式在頁面放置100個按鈕,一個程式碼都不行就有了100個按鈕事件處理,超級方便,最後我們對其中一個按鈕新增具體的按鈕事件,讓頁面跑起來,請問大家這個頁面效率會高嗎?在javascript裡,每個函式都是一個物件,每個物件都會耗費記憶體,所以這無用的99個事件函式程式碼肯定消耗了很多寶貴的瀏覽器記憶體。當然現實開發環境裡我們不會這麼幹的,但是在當今ajax流行,單頁面開發瘋狂普及的時代,一個網頁上的事件都是超級多的,這就意味我們每個事件都有一個事件函式,但是我們每次操作都只會促發一個事件,此時其他事件都是躺著睡覺,起不到任何作用同時還要消耗計算機的記憶體。

我們需要一種方案改變這種情況,現實中的確有這種方案。為了清晰描述這個方案,我要先補充一些背景知識,在講述DOM2事件處理裡我提到了目標物件這個概念,拋開DOM2事件處理方式,在捕獲事件處理和冒泡事件處理裡也有目標物件的概念,目標物件就是事件具體操作的DOM元素,例如點選按鈕操作裡按鈕就是目標物件,不管哪個事件處理方式,事件函式都會包含一個event物件,event物件有個屬性target,target是永遠指向目標物件的,event物件還有個屬性就是currentTarget,這個屬性指向的是捕獲或冒泡事件流動到的DOM元素。由上文描述我們知道,不管是捕獲事件還是冒泡事件,事件流都會流動到document上,假如我們在document上新增點選事件,頁面上的按鈕不新增點選事件,這時候我們點選按鈕,我們知道document上的點選事件會促發,這裡有個細節就是促發document點選事件時候,event的target的指向是button而不是document,那麼我們可以這樣寫程式碼:

執行之,我們發現效果和我們單獨寫按鈕事件一樣。但是它的好處是不言而喻的,一個函式搞定了整個頁面的事件函式,而且沒有事件函式被空閒,簡直完美,這個方案還有個專業名稱:事件委託。jQuery的delegate方法就是按這個原理做的。其實事件委託的效率不僅僅體現在事件函式的減少,它還能減少dom遍歷操作,例如上面例子裡我們在document上新增函式,document是頁面裡的頂層物件,讀取它的效率是很高的,到了具體的物件事件我們也沒有通過dom操作而是使用事件物件的target屬性,所有這些只能用一句話概括:真是快,沒理由的快。

事件委託還能給我們帶來一個很棒副產品,使用過jQuery的朋友都應該用過live方法,live方法特點是你可以為頁面元素新增事件操作,哪怕這個元素目前在頁面還不存在,你也可以新增它的事件,理解了事件委託機制,live的原理就很好理解了,其實jQuery的live就是通過事件委託做的,同時live還是一種高效的事件新增方式。

理解了事件委託,我們會發現jQuery的bind方法是個低效的方法,因為它使用原始的事件定義方式,所以bind我們要慎用,其實jQuery的開發者也注意到這個問題,新版的jQuery裡都有一個on方法,on方法包含了bind、live和delegate方法所有功能,所以我建議看了本文的朋友要摒棄以前使用新增事件的方式,多使用on函式新增事件。

事件委託還有個好處,上文裡事件委託的例子我是在document上新增事件,這裡我要做個比較,在jQuery裡我們習慣把DOM元素事件的定義放在ready方法裡,如下所示:

ready函式是在頁面DOM文件載入完畢後執行,它比onload函式先執行,這種提前好處很多,好處之一也是帶來效能提升,jQuery這種事件定義也算是個標準做法,我相信有些朋友一定又把某些事件繫結放在ready外面,最後發現按鈕會無效,這種無效場景有時一剎那,過會兒就好了,所以我們常常忽視了該問題的原理,不在ready函式繫結事件,這個操作其實是在DOM載入完畢之前繫結事件,而這個時間段下,很有可能某些元素還沒在頁面構造好,所以事件繫結會出現無效情況,因此ready定義事件的道理就是保證頁面所有元素載入完畢後在定義DOM元素的事件,但是使用事件委託時可以避免問題的發生,例如將事件繫結在document,document代表整個頁面,所以它載入完畢的時間可謂最早,所以在document上實現事件委託,就很難發生事件無效的情況,也很難發生瀏覽器報出“XXX函式未定義”的問題了。總結一下這個特點:事件委託程式碼可以執行在頁面載入的任何階段,這點對提升網頁效能還是增強網頁效果上都會給開發人員提供更大自由度

相關文章