咱們要關心的兩大核心問題就是:「DOM 爲何這麼慢」以及「如何使 DOM 變快」。html
後者是一個比「生存仍是毀滅」更加經典的問題。不只咱們爲它「肝腸寸斷」,許多優秀前端框架的做者大大們也曾爲其絞盡腦汁。這一點可喜可賀——研究的人越多,產出優秀實踐的機率就越大。所以在本章的方法論環節,咱們不只會根據 DOM 特性及渲染原理爲你們講解基本的優化思路,還會涉及到一部分生產實踐。前端
循着這個思路,咱們把 DOM 優化這塊劃分爲三個小專題:「DOM 優化思路」、「異步更新策略」及「迴流與重繪」。本節對應第一個小專題。三個小專題休慼與共、你儂我儂,在思路上相互依賴、一脈相承,所以此處嚴格禁止任何姿式的跳讀行爲。瀏覽器
考慮到本節內容與上一節有着密不可分的關係,所以強烈不建議沒有讀完上一節的同窗直接跳讀本節。緩存
把 DOM 和 JavaScript 各自想象成一個島嶼,它們之間用收費橋樑鏈接。——《高性能 JavaScript》性能優化
JS 是很快的,在 JS 中修改 DOM 對象也是很快的。在JS的世界裏,一切是簡單的、迅速的。但 DOM 操做並不是 JS 一我的的獨舞,而是兩個模塊之間的協做。bash
上一節咱們提到,JS 引擎和渲染引擎(瀏覽器內核)是獨立實現的。當咱們用 JS 去操做 DOM 時,本質上是 JS 引擎和渲染引擎之間進行了「跨界交流」。這個「跨界交流」的實現並不簡單,它依賴了橋接接口做爲「橋樑」(以下圖)。前端框架
過「橋」要收費——這個開銷自己就是不可忽略的。咱們每操做一次 DOM(不論是爲了修改仍是僅僅爲了訪問其值),都要過一次「橋」。過「橋」的次數一多,就會產生比較明顯的性能問題。所以「減小 DOM 操做」的建議,並不是空穴來風。app
過橋很慢,到了橋對岸,咱們的更改操做帶來的結果也很慢。框架
不少時候,咱們對 DOM 的操做都不會侷限於訪問,而是爲了修改它。當咱們對 DOM 的修改會引起它外觀(樣式)上的改變時,就會觸發迴流或重繪。異步
這個過程本質上仍是由於咱們對 DOM 的修改觸發了渲染樹(Render Tree)的變化所致使的:
迴流:當咱們對 DOM 的修改引起了 DOM 幾何尺寸的變化(好比修改元素的寬、高或隱藏元素等)時,瀏覽器須要從新計算元素的幾何屬性(其餘元素的幾何屬性和位置也會所以受到影響),而後再將計算的結果繪製出來。這個過程就是迴流(也叫重排)。
重繪:當咱們對 DOM 的修改致使了樣式的變化、卻並未影響其幾何屬性(好比修改了顏色或背景色)時,瀏覽器不需從新計算元素的幾何屬性、直接爲該元素繪製新的樣式(跳過了上圖所示的迴流環節)。這個過程叫作重繪。
由此咱們能夠看出,重繪不必定致使迴流,迴流必定會致使重繪。硬要比較的話,迴流比重繪作的事情更多,帶來的開銷也更大。但這兩個說到底都是吃性能的,因此都不是什麼善茬。咱們在開發中,要從代碼層面出發,儘量把迴流和重繪的次數最小化。
知道了 DOM 慢的緣由,咱們就能夠對症下藥了。
咱們來看這樣一個🌰,HTML 內容以下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>DOM操做測試</title>
</head>
<body>
<div id="container"></div>
</body>
</html>
複製代碼
此時我有一個假需求——我想往 container 元素裏寫 10000 句同樣的話。若是我這麼作:
for(var count=0;count<10000;count++){
document.getElementById('container').innerHTML+='<span>我是一個小測試</span>'
}
複製代碼
這段代碼有兩個明顯的可優化點。
第一點,過路費交太多了。咱們每一次循環都調用 DOM 接口從新獲取了一次 container 元素,至關於每次循環都交了一次過路費。先後交了 10000 次過路費,但其中 9999 次過路費均可以用緩存變量的方式節省下來:
// 只獲取一次container
let container = document.getElementById('container')
for(let count=0;count<10000;count++){
container.innerHTML += '<span>我是一個小測試</span>'
}
複製代碼
第二點,沒必要要的 DOM 更改太多了。咱們的 10000 次循環裏,修改了 10000 次 DOM 樹。咱們前面說過,對 DOM 的修改會引起渲染樹的改變、進而去走一個(可能的)迴流或重繪的過程,而這個過程的開銷是很「貴」的。這麼貴的操做,咱們居然重複執行了 N 屢次!其實咱們能夠經過就事論事的方式節省下來沒必要要的渲染:
let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){
// 先對內容進行操做
content += '<span>我是一個小測試</span>'
}
// 內容處理好了,最後再觸發DOM的更改
container.innerHTML = content
複製代碼
所謂「就事論事」,就像你們所看到的:JS 層面的事情,JS 本身去處理,處理好了,再來找 DOM 打報告。
事實上,考慮JS 的運行速度,比 DOM 快得多這個特性。咱們減小 DOM 操做的核心思路,就是讓 JS 去給 DOM 分壓。
這個思路,在 DOM Fragment 中體現得淋漓盡致。
DocumentFragment 接口表示一個沒有父級文件的最小文檔對象。它被當作一個輕量版的 Document 使用,用於存儲已排好版的或還沒有打理好格式的XML片斷。由於 DocumentFragment 不是真實 DOM 樹的一部分,它的變化不會引發 DOM 樹的從新渲染的操做(reflow),且不會致使性能等問題。
在咱們上面的例子裏,字符串變量 content 就扮演着一個 DOM Fragment 的角色。其實不管字符串變量也好,DOM Fragment 也罷,它們本質上都做爲脫離了真實 DOM 樹的容器出現,用於緩存批量化的 DOM 操做。
前面咱們直接用 innerHTML 去拼接目標內容,這樣作當然有用,但卻不夠優雅。相比之下,DOM Fragment 能夠幫助咱們用更加結構化的方式去達成一樣的目的,從而在維持性能的同時,保住咱們代碼的可拓展和可維護性。咱們如今用 DOM Fragment 來改寫上面的例子:
let container = document.getElementById('container')
// 建立一個DOM Fragment對象做爲容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
// span此時能夠經過DOM API去建立
let oSpan = document.createElement("span")
oSpan.innerHTML = '我是一個小測試'
// 像操做真實DOM同樣操做DOM Fragment對象
content.appendChild(oSpan)
}
// 內容處理好了,最後再觸發真實DOM的更改
container.appendChild(content)
複製代碼
咱們運行這段代碼,能夠獲得與前面兩種寫法相同的運行結果。
能夠看出,DOM Fragment 對象容許咱們像操做真實 DOM 同樣去調用各類各樣的 DOM API,咱們的代碼質量所以獲得了保證。而且它的身份也很是純粹:當咱們試圖將其 append 進真實 DOM 時,它會在乖乖交出自身緩存的全部後代節點後全身而退,完美地完成一個容器的使命,而不會出如今真實的 DOM 結構中。這種結構化、乾淨利落的特性,使得 DOM Fragment 做爲經典的性能優化手段大受歡迎,這一點在 jQuery、Vue 等優秀前端框架的源碼中均有體現。
相比 DOM 命題的博大精深,一個簡單的循環 Demo 顯然不能說明全部問題。不過不用着急,在本節,我只但願你們能牢記原理與宏觀思路。「藥到病除」到這裏纔剛剛開了個頭,下個小節,咱們將深挖事件循環機制,從而深刻 JS 層面的生產實踐。