聲明:本文為原創文章,如需轉載,請注明來源並保留原文鏈接Aaron,謝謝!
- 瀏覽器從下載文檔到顯示頁面的過程是個復雜的過程,這里包含了重繪和重排。各家瀏覽器引擎的工作原理略有差別,但也有一定規則。
- 簡單講,通常在文檔初次加載時,瀏覽器引擎會解析HTML文檔來構建DOM樹,之后根據DOM元素的幾何屬性構建一棵用於渲染的樹。渲染樹的每個節點都有大小和邊距等屬性,類似於盒子模型(由於隱藏元素不需要顯示,渲染樹中並不包含DOM樹中隱藏的元素)。
- 當渲染樹構建完成后,瀏覽器就可以將元素放置到正確的位置了,再根據渲染樹節點的樣式屬性繪制出頁面。由於瀏覽器的流布局,對渲染樹的計算通常只需要遍歷一次就可以完成
所以我們知道瀏覽器最終會將HTML文檔(或者說頁面)解析成一棵DOM樹,如下代碼將會翻譯成以下的DOM樹。
<div id="text"> <p> <input type="text" /> </p> <div class="aaron"> <input type="checkbox" name="readme" /> <p>Sizzle</p> </div> </div>
如果想要操作到當中那個checkbox,我們需要有一種表述方式,使得通過這個表達式讓瀏覽器知道我們是想要操作哪個DOM節點。
這個表述方式就是CSS選擇器,它是這樣表示的:div > p + .aaron input[type="checkbox"]
表達的意思是,div底下的p的兄弟節點,該節點的class為aaron 並且后代中有一個元素是input其屬性type為checkbox的。
常見的選擇器:
- #test表示id為test的DOM節點
- .aaron 表示class為aaron的DOM節點
- input表示節點名為input的DOM節點
- div > p表示div底下的p的DOM節點
- div + p表示div的兄弟DOM節點p
其實最終都是通過瀏覽器提供的接口實現的
獲取id為test的DOM節點
document.getElementById(“test”)
獲取節點名為input的DOM節點
document.getElementsByTagName(“input”)
獲取屬性name為checkbox的DOM節點
document.getElementsByName(“checkbox”)
高級的瀏覽器還提供
document.getElementsByClassName
document.querySelector
document.querySelectorAll
由於低級瀏覽器並未提供這些高級點的接口,所以才有了Sizzle這個CSS選擇器引擎。Sizzle引擎提供的接口跟document.querySelectorAll是一樣的,其輸入是一串選擇器字符串,輸出則是一個符合這個選擇器規則的DOM節點列表,因此第一步驟是要分析這個輸入的選擇器。
看看實際效果
window.onload = function() { console.log( Sizzle('div > div.Aaron p span.red') ) console.log( document.querySelectorAll('div > div.Aaron p span.red') ) }
在開始前,我們必須了解一個真相:為什么排版引擎解析 CSS 選擇器時一定要從右往左解析?
- HTML 經過解析生成 DOM Tree(這個我們比較熟悉);而在 CSS 解析完畢后,需要將解析的結果與 DOM Tree 的內容一起進行分析建立一棵 Render Tree,最終用來進行繪圖。Render Tree 中的元素(WebKit 中稱為「renderers」,Firefox 下為「frames」)與 DOM 元素相對應,但非一一對應:一個 DOM 元素可能會對應多個 renderer,如文本折行后,不同的「行」會成為 render tree 種不同的 renderer。也有的 DOM 元素被 Render Tree 完全無視,比如 display:none 的元素。
- 在建立 Render Tree 時(WebKit 中的「Attachment」過程),瀏覽器就要為每個 DOM Tree 中的元素根據 CSS 的解析結果(Style Rules)來確定生成怎樣的 renderer。對於每個 DOM 元素,必須在所有 Style Rules 中找到符合的 selector 並將對應的規則進行合並。選擇器的「解析」實際是在這里執行的,在遍歷 DOM Tree 時,從 Style Rules 中去尋找對應的 selector。
- 因為所有樣式規則可能數量很大,而且絕大多數不會匹配到當前的 DOM 元素(因為數量很大所以一般會建立規則索引樹),所以有一個快速的方法來判斷「這個 selector 不匹配當前元素」就是極其重要的。
- 如果正向解析,例如「div div p em」,我們首先就要檢查當前元素到 html 的整條路徑,找到最上層的 div,再往下找,如果遇到不匹配就必須回到最上層那個 div,往下再去匹配選擇器中的第一個 div,回溯若干次才能確定匹配與否,效率很低。
- 逆向匹配則不同,如果當前的 DOM 元素是 div,而不是 selector 最后的 em,那只要一步就能排除。只有在匹配時,才會不斷向上找父節點進行驗證。
- 但因為匹配的情況遠遠低於不匹配的情況,所以逆向匹配帶來的優勢是巨大的。同時我們也能夠看出,在選擇器結尾加上「*」就大大降低了這種優勢,這也就是很多優化原則提到的盡量避免在選擇器末尾添加通配符的原因。
簡單的來說瀏覽器從右到左進行查找的好處是為了盡早過濾掉一些無關的樣式規則和元素
例如:
<title>aQuery</title> <script src="sizzle.js"></script> <script src="core.js"></script> <style> div > div.Aaron p span.red{ color:red; } </style> <div> <div class="Aaron"> <p><span>s1</span></p> <p><span>s2</span></p> <p><span>s3</span></p> <p><span class='red'>s4</span></p> </div> </div>
CSS選擇器:
div > div.Aaron p span.red
而如果按從左到右的方式進行查找:
1. 先找到所有div節點
2. 第一個div節點內找到所有的子div,並且是class=”Aaron”
3. 然后再一次匹配p span.red等情況
4. 遇到不匹配的情況,就必須回溯到一開始搜索的div或者p節點,然后去搜索下個節點,重復這樣的過程。這樣的搜索過程對於一個只是匹配很少節點的選擇器來說,效率是極低的,因為我們花費了大量的時間在回溯匹配不符合規則的節點。
如果換個思路,我們一開始過濾出跟目標節點最符合的集合出來,再在這個集合進行搜索,大大降低了搜索空間
從右到左來解析選擇器:
則首先就查找到<span class='red'>的元素。
firefox稱這種查找方式為key selector(關鍵字查詢),所謂的關鍵字就是樣式規則中最后(最右邊)的規則,上面的key就是span.red。
緊接着我們判斷這些節點中的前兄弟節點是否符合p這個規則,這樣就又減少了集合的元素,只有符合當前的子規則才會匹配再上一條子規則
要知道DOM樹是一個什么樣的結構,一個元素可能有若干子元素,如果每一個都去判斷一下顯然性能太差。而一個子元素只有一個父元素,所以找起來非常方便。你可以看看css的選擇器的設計,完全是為了優化從子元素找父元素而決定的。
打個比如 p span.showing
你認為從一個p元素下面找到所有的span元素並判斷是否有class showing快,還是找到所有的span元素判斷是否有class showing並且包括一個p父元素快 ?
所以瀏覽器解析CSS的引擎就是用這樣的算法去解析
關於解析機制
就拿javascript而言,解析過程可以分為預編譯與執行兩個階段,具體這里不說多,但是有一個重要的點
在預編譯的時候通過詞法分析器與語法分期器的規則處理
在詞法分析過程中,js解析器要下把腳本代碼的字符流轉換成記號流
比如:
a=(b-c);
解析后轉換成:
NAME "a" EQUALS OPEN_PARENTHESIS NAME "b" MINUS NAME "c" CLOSE_PARENTHESIS SEMICOLON
把代碼解析成Token的階段在編譯階段里邊稱為詞法分析
代碼經過詞法分析后就得到了一個Token序列,緊接着拿Token序列去其他事情
大概就是這個意思,在JS征途這本書看的,沒有研究V8過引擎,反正大家有興趣去看看書吧
這里只想引申出一個思想:
CSS選擇器其實也就是一段字符串,我們需要分析出這個字符串背后對應的規則,在這里Sizzle用了簡單的詞法分析。
所以在Sizzle中專門有一個tokenize處理器干這個事情
我們簡單的看看處理后的結果:
選擇器
selector: "div > div.Aaron p span.red"
經過tokenize處理器處理過后分解為
一個數組對象,展開后
其實就是對每一個標記都做了分解了
Sizzle的Token格式如下 :
Token:{ value:'匹配到的字符串', type:'對應的Token類型', matches:'正則匹配到的一個結構' }
這樣拿到匹配后的結構Token就去干別的相關處理了!
看看整個源碼的解析:
//假設傳入進來的選擇器是:div > p + .aaron[type="checkbox"], #id:first-child //這里可以分為兩個規則:div > p + .aaron[type="checkbox"] 以及 #id:first-child //返回的需要是一個Token序列 //Sizzle的Token格式如下 :{value:'匹配到的字符串', type:'對應的Token類型', matches:'正則匹配到的一個結構'} function tokenize( selector, parseOnly ) { var matched, match, tokens, type, soFar, groups, preFilters, cached = tokenCache[ selector + " " ]; //這里的soFar是表示目前還未分析的字符串剩余部分 //groups表示目前已經匹配到的規則組,在這個例子里邊,groups的長度最后是2,存放的是每個規則對應的Token序列 //如果cache里邊有,直接拿出來即可 if ( cached ) { return parseOnly ? 0 : cached.slice( 0 ); } //初始化 soFar = selector; groups = []; //這是最后要返回的結果,一個二維數組 //比如"title,div > :nth-child(even)"解析下面的符號流 // [ [{value:"title",type:"TAG",matches:["title"]}], // [{value:"div",type:["TAG",matches:["div"]}, // {value:">", type: ">"}, // {value:":nth-child(even)",type:"CHILD",matches:["nth", // "child","even",2,0,undefined,undefined,undefined]} // ] // ] //有多少個並聯選擇器,里面就有多少個數組,數組里面是擁有value與type的對象 //這里的預處理器為了對匹配到的Token適當做一些調整 //自行查看源碼,其實就是正則匹配到的內容的一個預處理 preFilters = Expr.preFilter; //遞歸檢測字符串 //比如"div > p + .aaron input[type="checkbox"]" while ( soFar ) { // Comma and first run // 以第一個逗號切割選擇符,然后去掉前面的部分 if ( !matched || (match = rcomma.exec( soFar )) ) { if ( match ) { //如果匹配到逗號 // Don't consume trailing commas as valid soFar = soFar.slice( match[0].length ) || soFar; } //往規則組里邊壓入一個Token序列,目前Token序列還是空的 groups.push( tokens = [] ); } matched = false; // Combinators //將剛才前面的部分以關系選擇器再進行划分 //先處理這幾個特殊的Token : >, +, 空格, ~ //因為他們比較簡單,並且是單字符的 if ( (match = rcombinators.exec( soFar )) ) { //獲取到匹配的字符 matched = match.shift(); //放入Token序列中 tokens.push({ value: matched, // Cast descendant combinators to space type: match[0].replace( rtrim, " " ) }); //剩余還未分析的字符串需要減去這段已經分析過的 soFar = soFar.slice( matched.length ); } // Filters //這里開始分析這幾種Token : TAG, ID, CLASS, ATTR, CHILD, PSEUDO, NAME //將每個選擇器組依次用ID,TAG,CLASS,ATTR,CHILD,PSEUDO這些正則進行匹配 //Expr.filter里邊對應地 就有這些key /** * * *matchExpr 過濾正則 ATTR: /^\[[\x20\t\r\n\f]*((?:\\.|[\w-]|[^\x00-\xa0])+)[\x20\t\r\n\f]*(?:([*^$|!~]?=)[\x20\t\r\n\f]*(?:(['"])((?:\\.|[^\\])*?)\3|((?:\\.|[\w#-]|[^\x00-\xa0])+)|)|)[\x20\t\r\n\f]*\]/ CHILD: /^:(only|first|last|nth|nth-last)-(child|of-type)(?:\([\x20\t\r\n\f]*(even|odd|(([+-]|)(\d*)n|)[\x20\t\r\n\f]*(?:([+-]|)[\x20\t\r\n\f]*(\d+)|))[\x20\t\r\n\f]*\)|)/i CLASS: /^\.((?:\\.|[\w-]|[^\x00-\xa0])+)/ ID: /^#((?:\\.|[\w-]|[^\x00-\xa0])+)/ PSEUDO: /^:((?:\\.|[\w-]|[^\x00-\xa0])+)(?:\(((['"])((?:\\.|[^\\])*?)\3|((?:\\.|[^\\()[\]]|\[[\x20\t\r\n\f]*((?:\\.|[\w-]|[^\x00-\xa0])+)[\x20\t\r\n\f]*(?:([*^$|!~]?=)[\x20\t\r\n\f]*(?:(['"])((?:\\.|[^\\])*?)\8|((?:\\.|[\w#-]|[^\x00-\xa0])+)|)|)[\x20\t\r\n\f]*\])*)|.*)\)|)/ TAG: /^((?:\\.|[\w*-]|[^\x00-\xa0])+)/ bool: /^(?:checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped)$/i needsContext: /^[\x20\t\r\n\f]*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\([\x20\t\r\n\f]*((?:-\d)?\d*)[\x20\t\r\n\f]*\)|)(?=[^-]|$)/i * */ //如果通過正則匹配到了Token格式:match = matchExpr[ type ].exec( soFar ) //然后看看需不需要預處理:!preFilters[ type ] //如果需要 ,那么通過預處理器將匹配到的處理一下 : match = preFilters[ type ]( match ) for ( type in Expr.filter ) { if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || (match = preFilters[ type ]( match ))) ) { matched = match.shift(); //放入Token序列中 tokens.push({ value: matched, type: type, matches: match }); //剩余還未分析的字符串需要減去這段已經分析過的 soFar = soFar.slice( matched.length ); } } //如果到了這里都還沒matched到,那么說明這個選擇器在這里有錯誤 //直接中斷詞法分析過程 //這就是Sizzle對詞法分析的異常處理 if ( !matched ) { break; } } // Return the length of the invalid excess // if we're just parsing // Otherwise, throw an error or return tokens //放到tokenCache函數里進行緩存 //如果只需要這個接口檢查選擇器的合法性,直接就返回soFar的剩余長度,倘若是大於零,說明選擇器不合法 //其余情況,如果soFar長度大於零,拋出異常;否則把groups記錄在cache里邊並返回, return parseOnly ? soFar.length : soFar ? Sizzle.error( selector ) : // Cache the tokens tokenCache( selector, groups ).slice( 0 ); }
這里要提出幾點:
比如解析的規則
div > p + .aaron[type="checkbox"], #id:first-child
1:groups收集並聯關系的處理
div > p + .aaron[type="checkbox"], #id:first-child
分解成
groups:[
0:div > p + .aaron[type="checkbox"],
1:#id:first-child
]
然后往下還是會細分的
看看匹配第一個逗號切割選擇符,然后去掉前面的部分
match = rcomma.exec( soFar )
//並聯選擇器的正則 // /^[\x20\t\r\n\f]*,[\x20\t\r\n\f]*/ rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ),
科普一下:
空白符正則:
whitespace = [\x20\t\r\n\f]
\xnn 由十六進制數nn指定的拉丁字符,如,\x0A等價於\n;
\uxxxx 由十六進制數xxxx指定的Unicode字符,例如\u0009等價於\t;
所以上面:
\x20 化為二進制數為 0010 0000;
ASCII碼表 http://ascii.911cha.com/
字符編碼筆記 http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
\t 制表符;
\r 回車;
\n 換行;
\f 換頁;
Sizzle這么多正則關系,我就不信是直接寫出來的,呵呵
2:過濾簡單的單字符,幾個特殊的Token : >, +, 空格, ~
放入Token序列中,然后踢掉soFar中處理的字符
3: 將每個選擇器組依次用ID,TAG,CLASS,ATTR,CHILD,PSEUDO這些正則進行匹配
通過遞歸soFar 其實就是 selector = div > p + .aaron[type="checkbox"], #id:first-child
matchExpr就定義了匹配規則
4: tokenCache( selector, groups ).slice( 0 );
緩存到tokenCache 詞法分析階段需要的緩存器
畫一張直觀圖便於理解