Sizzle,是jQuery作者John Resig寫的DOM選擇器引擎,速度號稱業界第一。作為一個獨立全新的選擇器引擎,出現在jQuery 1.3版本之後,並被John Resig作為一個開源的專案。 Sizzle是獨立的一部分,不依賴任何函式庫,如果你不想用jQuery,可以只用Sizzle,也可以用於其他框架如:Mool, Dojo,YUI等。
前幾天在準備一個關於jQuery的分享PPT,問同事關於jQuery除了使用方法之外還有沒有其他特別想了解一下的,有人提到了想了解下它的選擇器是怎麼實現的,也有人提到jQuery的查詢速度跟其他框架比怎麼樣。關於速度,sizzle的官方網站上可以下載測試的例子,速度確實很有優勢。但是它為什麼會有這樣高效率的運行速度,就跟這裡想探討的實現原理有關係了。
在了解Sizzle之前必須先了解它的選擇器是怎麼回事,這裡有一個簡單的例子,熟悉jQuery的同學也一定很熟悉這樣的選擇器格式:
它基本上是從左到右層層深入過濾去尋找匹配的dom元素,這個語句還不算複雜。假設我們自己來實現這一查詢語句的話,也不難。但是,查詢語句只有基本的規則,沒有固定的選擇符個數和順序,我們自己寫程式碼怎麼能適應這種隨興的排列組合? Sizzle就能做到各種狀況的正常解析、執行。
Sizzle的原始碼確實錯綜複雜不容易理清楚它的思路。先拋開外面層層的包裹,直接看我個人認為整個實作裡面很核心的三個方法:
第一個核心方法。原始碼第1052行有一個tokenize函數:
第二個參數parseOnly為false的意思是只做token序列化操作,不回傳結果,這個情況下序列化的結果會被緩存起來備用。 Selector就是查詢語句了。
經過這個函數處理後,例如selector="#idtag.class , a:first"傳進去,可以得到一個格式類似下面的結果:
[ [ {matches:" id ",type:"ID"}, {matches:" tag ",type:"TAG"}, {matches:" class ",type:"CLASS"}, ... ], [ {matches:" a",type:"TAG"}, ... ], […], … ]
看到tokenize這個函數的命名和它的作用,讓我很容易就聯想到「編譯原理」這個字了。這裡就有點像是詞法分析了,不過這個詞法分析比程式編譯時做的詞法分析簡單。
tokenize方法會根據selector裡面的逗號,空格和關係選擇符的正則表達式做“分詞”,得到一個二維數組(請允許我冒用這個不是很準確的稱呼),其中第一維數組是根據逗號分隔出來的,在原始碼裡面被稱為groups。
我們再看原始碼第405行開始有一個Expr = Sizzle.selectors = {}的定義,其中到567行的時候有一個filter的定義,這裡我們可以找到基本的過濾類型:"ID"、 "TAG"、"CLASS"、"ATTR"、"CHILD"、"PSEUDO",tokenize最後分類出來的type也就是這幾種。
「分詞」完成之後,依舊看在405行定義的Expr= Sizzle.selectors = {}。這裡面能找到我們熟悉的所有選擇符,每個選擇符對應一個方法定義。到這裡應該想到Sizzle其實是不是就是透過對selector做“分詞”,打散之後再分別從Expr裡面去找對應的方法來執行具體的查詢或者過濾的操作?
答案基本上是肯定的。但是Sizzle有更具體和巧妙的做法。再來看我認為很核心的第二個方法:
原始碼1293行有一個matcherFromTokens函數:
傳入的參數正是從tokenize方法得到的。 Matcher可以理解成是「匹配程式」的意思,光從字面上看這個函數的作用就是透過tokens產生匹配程式。事實上確實如此。限於篇幅,這篇文章暫且只分享我理解到的一些Sizzle的實作原理,不貼原始碼。後面有時間我或是再整理一篇更詳盡的源碼分析的文章。
matcherFromTokens方法證實了前面的設想,它充當了selector「分詞」與Expr中定義的匹配方法的串聯與紐帶的作用,可以說選擇符的各種排列組合都是能適應的了。 Sizzle巧妙的就是它沒有直接將拿到的「分詞」結果與Expr中的方法逐一配對逐一執行,而是先根據規則組合出一個大的匹配方法,最後一步執行。但組合之後怎麼執行的,還要再看關鍵的第三個方法:
原始碼1350行有一個superMatcher方法:
這個方法並不是一個直接定義的方法,而是透過1345行的matcherFromGroupMatchers( elementMatchers, setMatchers )方法return出來的,但是最後執行起重要作用的是它。
superMatcher方法會根據參數seed、expandContext和context確定一個起始的查詢範圍,有可能是直接從seed中查詢過濾,也有可能在context或context的父節點範圍內。如果不是從seed開始,那麼它會先執行Expr.find["TAG"]( "*", expandContext && context.parentNode || context )這句程式碼等到一個elems集合(陣列)。然後對elems做一個遍歷,對裡面的元素逐一使用預先產生的matcher方法做匹配,如果結果為true的則直接將元素堆入返回結果集裡面。
好吧,看到這裡matcher方法原來運行的結果都是bool值,我們再回傳405行看一下Expr裡面filter包含的方法,都是回傳bool值的。包括PSEUDO(偽類)對應的更多的偽類方法都一樣。似乎有點顛覆我最初的設想,它原來不是一層一層往下查,卻有點倒回去向上做匹配、過濾的意思。 Expr裡面只有find和preFilter回傳的是集合。
儘管到這裡暫時還帶著一點疑問,就是最後它為什麼用的是逐個匹配、過濾的方法來得到結果集,但是我想Sizzle最基本的「編譯原理」應該已經解釋清楚了。
但是疑問不能留著,我們繼續。其實這篇文章本身已經有點倒過來寫的味道了。有興趣看源碼的同學不會一開始就看到這三個關鍵的方法。實際上Sizzle在進入這三個方法之前,還做了一系列的其他工作。
Sizzle的真正入口可以說是在原始碼的220行:
這個方法前面一段比較容易懂,如果能配對到selector是單一的ID選擇符(#id),則先根據id就直接用context.getElementById( m )方法把元素找出來。如果能配對到selector是單一的TAG選擇符,也先直接用context.getElementsByTagName( selector )方法把相關的元素找出來。如果目前瀏覽器只是原生的getElementsByClassName,並且配對到selector是單一的CLASS選擇符,也會也用context.getElementsByClassName( m )方法找出相關的元素。這個三個方法,都是瀏覽器支援的原生方法,執行效率肯定是最高的。
如果最基本的方法都用不上的話,才會進入到select方法。原始碼1480行有它的定義:
在select方法裡面,首先會對selector做我們前面提到的「分詞」操作。但是這個操作之後並沒有直接開始組裝匹配方法,而是先做了一些find的操作。這裡的find操作就可以對應到Expr裡面的find,它執行的是查詢操作,回傳的是結果集。
可以這樣理解,select利用「分詞」得到的選擇子依照它的type先將可以用find方法找出的結果集查出來。做find操作的時候,是依照選擇符的順序從左到右縮小結果集範圍的。如果一個遍歷下來,selector中的所有選擇符都可以執行find操作,則直接將結果傳回。否則,就進入前面介紹的「編譯」執行過濾的流程了。
到這裡,也可以順過來,基本上理清楚Sizzle的工作流程了。前面留下的疑問到此時其實也不算疑問了,因為執行反向匹配過濾的時候,它的查找範圍已經是經過層層過濾的最小集合了。而反向匹配過濾的方法對於它所對應的那些選擇符,例如偽類之類的,其實也已經是個高效的選擇。
再來簡單總結為什麼Sizzle很有效率。
首先,從處理流程上,它總是先使用最高效的原生方法來做處理。前面一直在介紹的還只是Sizzle本身的選擇器實作方法,真正Sizzle執行的時候,它還會先判斷目前瀏覽器是否支援querySelectorAll原生方法(原始碼1545行)。如果支援的話,則優先選用此方法,瀏覽器原生支援的方法,效率肯定比Sizzle自己js寫的方法要高,優先使用也能保證Sizzle更高的工作效率。 (關於querySelectorAll可以上網查閱更多資料)。在不支援querySelectorAll方法的情況下,Sizzle也是優先判斷是不是可以直接用getElementById、getElementsByTag、getElementsByClassName等方法來解決問題。
其次,相對複雜的情況,Sizzle總是選擇先盡可能利用原生方法來查詢選擇來縮小待選範圍,然後才會利用前面介紹的「編譯原理」來對待選範圍的元素逐一配對篩選。進入到「編譯」這個環節的工作流程有些複雜,效率相比前面的方法肯定會稍低一些,但Sizzle在努力盡量少用這些方法,同時也努力讓給這些方法處理的結果集盡量小和簡單,以便獲得更高的效率。
再,即便進入到這個「編譯」的流程,Sizzle還做了我們前面為了優先解釋清楚流程而暫時忽略、沒有介紹的快取機制。原始碼1535行是我們所謂的「編譯」入口,也就是它會呼叫第三個核心方法superMatcher。追蹤進去看1466行,compile方法將根據selector產生的匹配函數快取起來了。不只如此,再到1052行看tokenize方法,它其實也將根據selector做的分詞結果快取起來了。也就是說,當我們執行過一次Sizzle (selector)方法以後,下次再直接呼叫Sizzle (selector)方法,它內部最耗性能的「編譯」過程不會再耗太多性能了,直接取之前緩存的方法就可以了。我在想所謂「編譯」的最大好處之一可能也就是便於緩存,所謂「編譯」在這裡可能也可以理解成是生成預處理的函數儲存起來備用。
到此我希望基本能夠解答之前關心選擇器實現原理和執行效率問題的同學的疑問了。另外本文分析結論源自Sizzle最新版本源碼,文中提到的程式碼行號以此版本源碼為準,可以從http://sizzlejs.com/下載。時間倉促,分析有不周到的地方拍磚請手下留情,還有疑問的同學歡迎線下繼續交流。
以上所述就是本文的全部內容了,希望大家能夠喜歡。