從 Chrome 原始碼看瀏覽器如何 layout 佈局
假設有以下html/css:
<div style="border:1px solid #000; width:50%; height: 100px; margin: 0 auto"></div>
這在瀏覽器上面將顯示一個框:
為了畫出這個框,首先要知道從哪裡開始畫、畫多大,其次是邊緣stroke的顏色,就可以把它畫出來了:
void draw(SkCanvas* canvas) { SkPaint paint; paint.setStrokeWidth(1); //從位置為(200, 200)的地方開始畫,寬度為400,高度為100 SkRect rect = SkRect::MakeXYWH(200, 200, 400, 100); canvas->drawRect(rect, paint); }
上面是用Skia畫的程式碼,Skia是一個跨平臺的開源2D圖形庫,是Chrome/firefox/android採用的底層Paint引擎。
為了能夠獲取到具體的值,就得進行layout。什麼叫layout?把css轉化成維度位置等可直接用來描繪的資訊的過程就叫layout,如下Chrome原始碼對layout的解釋:
// The purpose of the layout tree is to do layout (aka reflow) and store its // results for painting and hit-testing. Layout is the process of sizing and // positioning Nodes on the page.
《從Chrome原始碼看瀏覽器如何計算CSS》這篇文章介紹了怎麼把css轉化成ComputedStyle,上面的div,它被轉化後的style如下所示:
width的大小是50,型別是百分比,而margin值是0,型別是auto,這兩種都不能直接用來畫的。所以需要通過layout計算出具體的數字。
1. 建立layout樹
《從Chrome原始碼看瀏覽器如何構建DOM樹》這篇文章介紹瞭如何html文字的過程。當解析完收到的html片段後,會觸發Layout Tree的構建:
void Document::finishedParsing() { updateStyleAndLayoutTree(); }
每個非display:none/content的Node結點都會相應地建立一個LayoutObject,如下blink原始碼的註釋:
// Also some Node don't have an associated LayoutObjects e.g. if display: none // or display: contents is set.
並建立起它們的父子兄弟關係:
LayoutObject* newLayoutObject = m_node->createLayoutObject(style); parentLayoutObject->addChild(newLayoutObject, nextLayoutObject);
形成一棵獨立的layout樹。
當layout樹建立好之後,緊接著用style計算layout的值。
2. 計算layout值
以上面的div為例,它需要計算它的寬度和margin。
(1)計算寬度
寬度的計算是根據數值的型別:
switch (length.type()) { case Fixed: return LayoutUnit(length.value()); case Percent: // Don't remove the extra cast to float. It is needed for rounding on // 32-bit Intel machines that use the FPU stack. return LayoutUnit( static_cast<float>(maximumValue * length.percent() / 100.0f)); }
如上所示,如果是Fixed,則直接返回一個LayoutUnit封裝的資料,1px = 1 << 6
= 64 unit,這也是Blink儲存的精度。從這裡可以看到,設定小數的px其實是有用的。
如果是Percent百分比,則用百分比乘以最大值,而這個最大值是用容器傳進來的寬度。
(2)計算margin值
上面的div的margin給它設定了margin: 0 auto,需要計算實際的數字。blink會檢測兩邊是不是都為auto,如果是的話就認為是居中:
// CSS 2.1: "If both 'margin-left' and 'margin-right' are 'auto', their used // values are equal. This horizontally centers the element with respect to // the edges of the containing block." const ComputedStyle& containingBlockStyle = containingBlock->styleRef(); if (marginStartLength.isAuto() && marginEndLength.isAuto()) { LayoutUnit centeredMarginBoxStart = std::max( LayoutUnit(), (availableWidth - childWidth) / 2); marginStart = centeredMarginBoxStart; marginEnd = availableWidth - childWidth - marginStart; return; }
上面第8行用容器的寬度減掉本身的寬度,然後除以2就得到margin-left,接著用容器的寬度減掉本身的寬度和margin-left就得到margin-right。為什麼margin-right還要再算一下,因為上面的程式碼是刪減版的,它還有另外一種情況要處理,這裡不是很重要,被我省掉了。
margin和width算好了,便把它放到layoutObject結點的盒模型資料結構裡面:
m_frameRect.setWidth(width); m_marginBoxOutsets.setStart(marginLeft);
(3)盒模型資料結構
在blink的原始碼註釋裡面,很形象地畫出了盒模型圖:
// ***** THE BOX MODEL ***** // The CSS box model is based on a series of nested boxes: // http://www.w3.org/TR/CSS21/box.html // // |----------------------------------------------------| // | | // | margin-top | // | | // | |-----------------------------------------| | // | | | | // | | border-top | | // | | | | // | | |--------------------------|----| | | // | | | | | | | // | | | padding-top |####| | | // | | | |####| | | // | | | |----------------| |####| | | // | | | | | | | | | // | ML | BL | PL | content box | PR | SW | BR | MR | // | | | | | | | | | // | | | |----------------| | | | | // | | | | | | | // | | | padding-bottom | | | | // | | |--------------------------|----| | | // | | | ####| | | | // | | | scrollbar height ####| SC | | | // | | | ####| | | | // | | |-------------------------------| | | // | | | | // | | border-bottom | | // | | | | // | |-----------------------------------------| | // | | // | margin-bottom | // | | // |----------------------------------------------------| // // BL = border-left // BR = border-right // ML = margin-left // MR = margin-right // PL = padding-left // PR = padding-right // SC = scroll corner (contains UI for resizing (see the 'resize' property) // SW = scrollbar width
上面的盒模型耳熟能詳,不太一樣的是,它還把滾動條給畫出來了。
這個盒模型border及其以內區域是用一個LayoutRect m_frameRect物件表示的:
// The CSS border box rect for this box. // // The rectangle is in this box's physical coordinates. // The location is the distance from this // object's border edge to the container's border edge (which is not // always the parent). Thus it includes any logical top/left along // with this box's margins. LayoutRect m_frameRect;
上面原始碼註釋說得很明白,意思是說這個LayoutRect的位置是從它本身的邊到容器的邊的距離,因此它的距離/位置包含了margin值和left/top的位移偏差。LayoutRect記錄了一個盒子的位置和大小:
LayoutPoint m_location; LayoutSize m_size;
上面(1)和(2)計算好寬度後就去設定這個大小,儲存起來。
可以在原始碼裡面看到用這個物件對處理的一些獲取寬度的方式,如clientWidth:
// More IE extensions. clientWidth and clientHeight represent the interior of // an object excluding border and scrollbar. LayoutUnit LayoutBox::clientWidth() const { return m_frameRect.width() - borderLeft() - borderRight() - verticalScrollbarWidth(); }
clientWidth是除去border和scrollbar的寬度。
而offsetWidth是frameRect的寬度——算上border和scrollbar:
// IE extensions. Used to calculate offsetWidth/Height. LayoutUnit offsetWidth() const override { return m_frameRect.width(); } LayoutUnit offsetHeight() const override { return m_frameRect.height(); }
Margin區域是用一個LayoutRectOutsets表示的,這個物件記錄了margin的上下左右值:
LayoutUnit m_top; LayoutUnit m_right; LayoutUnit m_bottom; LayoutUnit m_left;
上面已經分析寬高的計算,還差位置的計算。
(4)位置計算
位置計算就是要算出x和y或者說left和top的值,這兩個值分別在下面兩個函式計算得到:
// Now determine the correct ypos based off examination of collapsing margin // values. LayoutUnit logicalTopBeforeClear = collapseMargins(child, layoutInfo, childIsSelfCollapsing, childDiscardMarginBefore, childDiscardMarginAfter); // Now place the child in the correct left position determineLogicalLeftPositionForChild(child);
用以下html做為例子:
<!DOCType html> <html> <head></head> <body> <div id="div-1" style="border:5px solid #000; width:50%; height: 100px; margin: 0 auto;"></div> <div id="div-2" style="margin: 50px; padding:80px; border: 20px solid"> <div id="div-3" style="margin:15px">hello, world</div> </div> </body> </html>
我先把計算出來的結果列印出來,如下所示:
[LayoutBlockFlow.cpp(925)] location is: “190.25”, “0” size is “400.5”, “110” (div-1)
[LayoutBlockFlow.cpp(925)] location is: “115”, “115” size is “451”, “18” (div-3)
[LayoutBlockFlow.cpp(925)] location is: “50”, “160” size is “681”, “248” (div-2)
[LayoutBlockFlow.cpp(925)] location is: “8”, “8” size is “781”, “408” (body)
[LayoutBlockFlow.cpp(925)] location is: “0”, “0” size is “797”, “466” (html)
由於它是一個遞迴的過程,所以上面列印的順序是由子元素到父元素的。以div-2為例算一下,它的x = 50, y = 160:因為div-1佔據的空間為h = border * 2 + height = 5 * 2 + 100 = 110,並且div-2有一個margin-top = 50,所以div-2的y = 110 + 50 = 160.
對於div-3,由於div-2有一個80px的padding和20px的border,同時它自己本身有一個15px的margin,所以div-3的y = 50 + 20 + 15 = 115.
如果把行內元素也列印出來,那麼結果是這樣的:
[LayoutBlockFlowLine.cpp(1997)] inline location is: “0”, “0” size is “400.5”, “10” (div-1 content)
[LayoutBlockFlow.cpp(925)] location is: “190.25”, “0” size is “400.5”, “110”
[LayoutBlockFlowLine.cpp(1997)] inline location is: “0”, “115” size is “451”, “18” (div-3 text)
[LayoutBlockFlow.cpp(925)] location is: “115”, “115” size is “451”, “18”
…(後面一樣)
第三行是div-3的文字節點建立的layoutObject,它的行高是18px,所以它的size高度是18px。
這裡可以看到塊級元素間的空白節點不會產生layoutObject,這在程式碼裡面可以找到佐證:
bool Text::textLayoutObjectIsNeeded(const ComputedStyle& style, const LayoutObject& parent) const { if (!length()) return false; if (style.display() == EDisplay::None) return false; if (!containsOnlyWhitespace()) return true; //其它判斷 }
上面程式碼第7行,如果Text結點含有非空白字元,則馬上返回true,否則的話繼續判斷:
if (parent.isLayoutBlock() && !parent.childrenInline() && (!prev || !prev->isInline())) return false;
第二行——如果存在上一個相鄰結點,並且這個結點不是行內元素則返回false,不建立layout物件。
所以在塊級元素後面的空白文字結點將不會參與渲染,這個就解釋了為什麼塊級元素後的換行不會被轉換成一個空格。在原始碼裡面還可以看到,塊級元素內的開頭空白字元將會被忽略:
// Whitespace at the start of a block just goes away. Don't even // make a layout object for this text.
這裡有個問題,為什麼它要遞迴地算,即先運算元元素的再回過頭來算父元素呢?因為有些屬性必須得先知道子元素的才能知道父元素,例如父元素的高度是子元素撐起的,但是有些屬性要先知道父元素的才能運算元元素的,例如子元素的寬度是父元素的50%。所以在計運算元元素之前會先把當前元素的layout計算一下,然後再傳給子元素,子元素計算好之後會返回父元素是否需要重新layout,如下:
// Use the estimated block position and lay out the child if needed. After // child layout, when we have enough information to perform proper margin // collapsing, float clearing and pagination, we may have to reposition and // lay out again if the estimate was wrong. bool childNeededLayout = positionAndLayoutOnceIfNeeded(child, logicalTopEstimate, layoutInfo);
具體的計算過程,這裡舉一兩個例子,例如計算left值時,會先取父元素的border-left和padding-left作為起始位置,然後再加上它自己的margin-left就得到它的x/left值。
void LayoutBlockFlow::determineLogicalLeftPositionForChild(LayoutBox& child) { LayoutUnit startPosition = borderStart() + paddingStart(); LayoutUnit initialStartPosition = startPosition; LayoutUnit childMarginStart = marginStartForChild(child); LayoutUnit newPosition = startPosition + childMarginStart; //other code }
我們知道浮動的規則比較複雜,所以相應的計算也比較複雜,我們簡單研究一下。
(5)浮動
用以下三欄佈局作為說明:
<div> <div style="float:left">hello, world</div> <div style="float:right"><p style="width:100px"></p></div> <div style="margin:0 100px;"></div> </div>
先來看寬度的計算,對於第一個float: left的div,首先它會先判斷一下寬度是否需要fit content:
bool LayoutBox::sizesLogicalWidthToFitContent( const Length& logicalWidth) const { if (isFloating() || isInlineBlockOrInlineTable()) return true; //other code }
如果它是浮動的或者是inlne-block,則需要寬度適應內容。由於子元素是一個行內文字,它需要計算這個行內元素的寬度,計算的規則非常複雜,這裡我把部分註釋說明貼出來:
// (3) A text object. Text runs can have breakable characters at the // start, the middle or the end. They may also lose whitespace off the // front if we're already ignoring whitespace. In order to compute // accurate min-width information, we need three pieces of // information. // (a) the min-width of the first non-breakable run. Should be 0 if // the text string starts with whitespace. // (b) the min-width of the last non-breakable run. Should be 0 if the // text string ends with whitespace. // (c) the min/max width of the string (trimmed for whitespace).
第二個浮動的div,它的子元素是一個p標籤,並且它已經指定了寬度。它會去運算元元素的寬度加上margin值的寬度,還要判斷是否為浮動,迴圈所有子元素處理,取一個最大值。
再來看位置的計算,計算位置的程式碼還是能夠稍微看出點苗頭,例如對float: left的計算:
//如果當前行的剩餘空間小於float的寬度,則迴圈條件成立 while (logicalRightOffsetForPositioningFloat( logicalTopOffset, logicalRightOffset, &heightRemainingRight) - floatLogicalLeft < floatLogicalWidth) { //往下挪 logicalTopOffset += std::min<LayoutUnit>(heightRemainingLeft, heightRemainingRight); //計算新的float left位置 floatLogicalLeft = logicalLeftOffsetForPositioningFloat( logicalTopOffset, logicalLeftOffset, &heightRemainingLeft); } } //迴圈結束,找到位置 floatLogicalLeft = std::max( logicalLeftOffset - borderAndPaddingLogicalLeft(), floatLogicalLeft);
上面它會先判斷當前行剩餘空間是否小於浮動元素的寬度,如果是的話就一直往下挪。
通過上面的層層計算,就可以拿到位置座標和具體大小,上面兩個浮動的div最後計算的結果是:
[LayoutBlockFlow.cpp(1475)] location is: “0”, “0” size is “77.3281”, “18”
[LayoutBlockFlow.cpp(1475)] location is: “681”, “0” size is “100”, “16”
有了這些資訊,結合顏色等style,就可進行Paint了。
3. Paint
Paint又是一塊很塊很複雜的東西,試圖在一篇文章裡面講明layout都已經是一件不太可能的事情。
Paint的初始化會使用layout的資料,如下面的BoxPainter的建構函式:
BoxPainter(const LayoutBox& layoutBox) : m_layoutBox(layoutBox) {}
Paint會呼叫最上面說的Skia的SkCanvas畫:
SkCanvas* canvas() { return m_canvas; }
這個SkCanvas和JS裡面的canvas有什麼聯絡和區別?
Blink JS裡的canvas就是這個canvas,當在js裡面獲取canvas物件進行描繪時:
var canvas = document.getElementById("canvas"); var ctx = canvas.getContext("2d"); ctx.fillStyle = "green"; ctx.fillRect(10, 10, 100, 100)
就會去獲取SkCanvas例項。
SkCanvas* HTMLCanvasElement::drawingCanvas() const { return buffer() ? m_imageBuffer->canvas() : nullptr; }
所以不管是用html/css畫,還是用canvas畫,它們都是同宗同源的,區別就在於藉助html/css比較直觀簡單,瀏覽器幫你進行layout。而直接用canvas就得從點線面一點一點地去畫,但同時它的靈活度就比較大。
4. 觸發layout
什麼時候會觸發layout,上文的分析已經提及當html片段解析完會觸發layout。在上一篇也有提及載入完css後也會觸發layout,同時resize頁面的時候也會觸發layout。因為layout的計算是比較複雜的,所以應減少layout的次數。例如CSS要寫在head標籤裡面,不然寫在body裡面,一旦遇到新的CSS又會重新layout。
第二個是獲取clientWidth/scrollTop等維度資訊時,普遍的說法是會觸發layout用於獲取值,但是在筆者的觀察下並沒有觸發layout:
如下使用getComputedStyle或者獲取clientWidth應該會觸發layout:
var style = window.getComputedStyle(document.getElementById("body")); var width = style.width; console.log(document.getElementById("canvas").clientWidth)
但是無論是我打斷點還是打log,都無法觀察到layout的觸發,而是直接去獲取clientWidth的值:
LayoutUnit LayoutBox::clientWidth() const { return m_frameRect.width() - borderLeft() - borderRight() - verticalScrollbarWidth(); }
不過,改變它的clientWidth的時候是一定會觸發layout的:
document.getElementById("canvas").style.width = "500px";
在layout的函式裡面列印的Log:
上面幾行是重新計算CSS,下面幾行是進行layout。
另外需要注意的是儘可能地減少layout的範圍,如下的demo——當點選選單按鈕的時候把選單給放出來:
<style> #menu, #show-btn{ display: none; } .show-menu #menu, .show-menu #show-btn{ display: block; } </style> <body> <span id="show-btn">Menu</span> <nav id="menu"> <ul><li></li></ul> </nav> </body> <script> document.getElementById("menu").onclick = function(){ document.body.addClass("show-menu"); }; </script>
上面為了圖方便,給body新增了一個類,用這個類控制選單的狀態。但是這樣會有很大的問題,因為給body新增了一個類導致它要重新計算style和layout,它一旦layout了,它的子元素也要跟著layout,也就是說整個頁面都要重新layout。所以這個代價就很高了,我們應該縮小影響範圍。因此,把show-menu的class加到直接相關的元素上面就好了。
至此,整一個頁面渲染過程就介紹完畢了。我們從html -> CSS -> layout -> paint一步步分析了其中的過程,雖然介紹得不是很全面,但已經把核心的過程剖析了一遍。由於寫html/css很多東西都是不透明,完全不知道背後是怎麼工作的,只能是看文件說這個標籤是怎麼用的,那個屬性會有什麼效果,然後在瀏覽器上面看效果,有點任瀏覽器宰割的感覺。所以這個原始碼解讀就是為了能夠窺探瀏覽器背後工作原理,這樣對寫程式碼會有幫助,能夠做到心中有數。當遇到一些比較困難的問題時,能夠很快的找到解決方案或者解決的方向。
例如筆者就遇到一個奇芭的問題,就是使用height: calc(100% - 80px)
的時候,在手機Safari上面展開某個子選單時,偶現選單滑不動的情況。當時就想很可能是在Safari在展開選單時高度算錯了,導致overflow: auto不管用。所以在展開選單後再手手動計算和設定height,然後就解決問題了。
相關文章
- 從 Chrome 看瀏覽器的渲染機制Chrome瀏覽器
- Element原始碼分析系列1一Layout(佈局)原始碼
- 從Chrome原始碼看WebSocketChrome原始碼Web
- layout佈局
- 從Chrome原始碼看HTTP/2Chrome原始碼HTTP
- 從Chrome原始碼看事件迴圈Chrome原始碼事件
- 如何正確解除安裝Chrome瀏覽器及其元件Keystone?有哪些瀏覽器可以替代Chrome?Chrome瀏覽器元件
- 從Chrome原始碼看DNS解析過程Chrome原始碼DNS
- chrome開啟瀏覽器的python指令碼Chrome瀏覽器Python指令碼
- Chrome 瀏覽器修改 UA 模擬其它瀏覽器,包括移動瀏覽器Chrome瀏覽器
- puppeteer chrome/chrome canary 登入瀏覽器Chrome瀏覽器
- chrome禁用js怎麼設定 如何禁止chrome瀏覽器JavaScriptChromeJS瀏覽器JavaScript
- ubuntu chrome瀏覽器安裝UbuntuChrome瀏覽器
- Google Chrome for Mac(谷歌瀏覽器)GoChromeMac谷歌瀏覽器
- 使用chrome瀏覽器驅動自動開啟瀏覽器Chrome瀏覽器
- Mac下chrome瀏覽器跨域MacChrome瀏覽器跨域
- 工具篇---Chrome瀏覽器快捷鍵Chrome瀏覽器
- Chrome瀏覽器手動新增CookieChrome瀏覽器Cookie
- 谷歌(chrome)瀏覽器快捷鍵大全谷歌Chrome瀏覽器
- Chrome 瀏覽器 131 版本新特性Chrome瀏覽器
- Elementary OS安裝Chrome瀏覽器Chrome瀏覽器
- 如何利用 Chrome 瀏覽器實現滾動截圖Chrome瀏覽器
- 從HTTP向HTTPS遷移,Chrome瀏覽器的UI變化HTTPChrome瀏覽器UI
- 全圖文教你Chrome瀏覽器如何加入brupsuite新增ssl證書(一看就會)Chrome瀏覽器UI
- Chrome 瀏覽器擴充套件 - Dark Web - Dark Theme for ChromeChrome瀏覽器套件Web
- 進擊的佈局之Grid Layout
- Xamarin 學習筆記 - Layout(佈局)筆記
- Chrome瀏覽器實用外掛集合Chrome瀏覽器
- 前端chrome瀏覽器除錯總結前端Chrome瀏覽器除錯
- 禁用edge、chrome瀏覽器自動更新Chrome瀏覽器
- [20191118]使用Chrome瀏覽器問題.txtChrome瀏覽器
- selenium 啟動 chrome 瀏覽器非常慢。Chrome瀏覽器
- Chrome 瀏覽器擴充套件 - Night EyeChrome瀏覽器套件
- chrome瀏覽器win10無法啟動怎麼辦_chrome瀏覽器win10無法啟動如何解決Chrome瀏覽器Win10
- 從Chrome原始碼看audio/video流媒體實現二Chrome原始碼IDE
- 替代Edge瀏覽器?微軟開發新的瀏覽器:採用Chrome核心瀏覽器微軟Chrome
- Chrome瀏覽器擴充套件程式可竊取明文密碼Chrome瀏覽器套件密碼
- Win10系統如何開啟Chrome瀏覽器黑暗模式Win10Chrome瀏覽器模式
- chrome 瀏覽器如何設定不儲存表單資訊Chrome瀏覽器