什么是 DOM
DOM(Document Object Model,文檔對象模型)是 JAVAScript 操作 html 的接口(這里只討論屬于前端范疇的 HTML DOM),屬于前端的入門知識,同樣也是核心內容,因為大部分前端功能都需要借助 DOM 來實現,比如:
動態渲染列表、表格表單數據;
監聽點擊、提交事件;
懶加載一些腳本或樣式文件;
實現動態展開樹組件,表單組件級聯等這類復雜的操作。
如果你查看過 DOM V3 標準,會發現包含多個內容,但歸納起來常用的主要由 3 個部分組成:
DOM 節點
DOM 事件
選擇區域
選擇區域的使用場景有限,一般用于富文本編輯類業務,我們不做深入討論;DOM 事件有一定的關聯性,將在下一課時中詳細討論;對于 DOM 節點,需與另外兩個概念標簽和元素進行區分:
標簽是 HTML 的基本單位,比如 p、div、input;
節點是 DOM 樹的基本單位,有多種類型,比如注釋節點、文本節點;
元素是節點中的一種,與 HTML 標簽相對應,比如 p 標簽會對應 p 元素。
舉例說明,在下面的代碼中,“p” 是標簽, 生成 DOM 樹的時候會產生兩個節點,一個是元素節點 p,另一個是字符串為“亞里士朱德”的文本節點。
復制<p>亞里士朱德</p>
會框架更要會 DOM
有的前端工程師因為平常使用 Vue、React 這些框架比較多,覺得直接操作 DOM 的情況比較少,認為熟悉框架就行,不需要詳細了解 DOM。這個觀點對于初級工程師而言確實如此,能用框架寫頁面就算合格。
但對于屏幕前想成為高級/資深前端工程師的你而言,只會使用某個框架或者能答出 DOM 相關面試題,這些肯定是不夠的。恰恰相反,作為高級/資深前端工程師,不僅應該對 DOM 有深入的理解,還應該能夠借此開發框架插件、修改框架甚至能寫出自己的框架。
因此,這一課時我們就深入了解 DOM,談談如何高效地操作 DOM。
為什么說 DOM 操作耗時
要解釋 DOM 操作帶來的性能問題,我們不得不提一下瀏覽器的工作機制。
線程切換
如果你對瀏覽器結構有一定了解,就會知道瀏覽器包含渲染引擎(也稱瀏覽器內核)和 JavaScript 引擎,它們都是單線程運行。單線程的優勢是開發方便,避免多線程下的死鎖、競爭等問題,劣勢是失去了并發能力。
瀏覽器為了避免兩個引擎同時修改頁面而造成渲染結果不一致的情況,增加了另外一個機制,這兩個引擎具有互斥性,也就是說在某個時刻只有一個引擎在運行,另一個引擎會被阻塞。操作系統在進行線程切換的時候需要保存上一個線程執行時的狀態信息并讀取下一個線程的狀態信息,俗稱上下文切換。而這個操作相對而言是比較耗時的。
每次 DOM 操作就會引發線程的上下文切換——從 JavaScript 引擎切換到渲染引擎執行對應操作,然后再切換回 JavaScript 引擎繼續執行,這就帶來了性能損耗。單次切換消耗的時間是非常少的,但是如果頻繁的大量切換,那么就會產生性能問題。
比如下面的測試代碼,循環讀取一百萬次 DOM 中的 body 元素的耗時是讀取 JSON 對象耗時的 10 倍。
// 測試次數:一百萬次
const times = 1000000
// 緩存body元素
console.time('object')
let body = document.body
// 循環賦值對象作為對照參考
for(let i=0;i<times;i++) {
let tmp = body
}
console.timeEnd('object')// object: 1.77197265625ms
console.time('dom')
// 循環讀取body元素引發線程切換
for(let i=0;i<times;i++) {
let tmp = document.body
}
console.timeEnd('dom')// dom: 18.302001953125ms
雖然這個例子比較極端,循環次數有些夸張,但如果在循環中包含一些復雜的邏輯或者說涉及到多個元素時,就會造成不可忽視的性能損耗。
重新渲染
另一個更加耗時的因素是元素及樣式變化引起的再次渲染,在渲染過程中最耗時的兩個步驟為重排(Reflow)與重繪(Repaint)。
瀏覽器在渲染頁面時會將 HTML 和 css 分別解析成 DOM 樹和 CSSOM 樹,然后合并進行排布,再繪制成我們可見的頁面。如果在操作 DOM 時涉及到元素、樣式的修改,就會引起渲染引擎重新計算樣式生成 CSSOM 樹,同時還有可能觸發對元素的重新排布(簡稱“重排”)和重新繪制(簡稱“重繪”)。
可能會影響到其他元素排布的操作就會引起重排,繼而引發重繪,比如:
修改元素邊距、大小
添加、刪除元素
改變窗口大小
與之相反的操作則只會引起重繪,比如:
設置背景圖片
修改字體顏色
改變 visibility 屬性值
如果想了解更多關于重繪和重排的樣式屬性,可以參看這個網址:https://csstriggers.com/。
下面是兩段驗證代碼,我們通過 Chrome 提供的性能分析工具來對渲染耗時進行分析。
第一段代碼,通過修改 div 元素的邊距來觸發重排,渲染耗時(粗略地認為渲染耗時為紫色 Rendering 事件和綠色 Painting 事件耗時之和)3045 毫秒。
const times = 100000
let html = ''
for(let i=0;i<times;i++) {
html+= `<div>${i}</div>`
}
document.body.innerHTML += html
const divs = document.querySelectorAll('div')
Array.prototype.forEach.call(divs, (div, i) => {
div.style.margin = i % 2 ? '10px' : 0;
})
第二段代碼,修改 div 元素字體顏色來觸發重繪,得到渲染耗時 2359 ms。
const times = 100000
let html = ''
for(let i=0;i<times;i++) {
html+= `<div>${i}</div>`
}
document.body.innerHTML += html
const divs = document.querySelectorAll('div')
Array.prototype.forEach.call(divs, (div, i) => {
div.style.color = i % 2 ? 'red' : 'green';
})
從兩段測試代碼中可以看出,重排渲染耗時明顯高于重繪,同時兩者的 Painting 事件耗時接近,也應證了重排會導致重繪。
如何高效操作 DOM
明白了 DOM 操作耗時之處后,要提升性能就變得很簡單了,反其道而行之,減少這些操作即可。
在循環外操作元素
比如下面兩段測試代碼對比了讀取 1000 次 JSON 對象以及訪問 1000 次 body 元素的耗時差異,相差一個數量級。
const times = 10000;
console.time('switch')
for (let i = 0; i < times; i++) {
document.body === 1 ? console.log(1) : void 0;
}
console.timeEnd('switch') // 1.873046875ms
var body = JSON.stringify(document.body)
console.time('batch')
for (let i = 0; i < times; i++) {
body === 1 ? console.log(1) : void 0;
}
console.timeEnd('batch') // 0.846923828125ms
當然即使在循環外也要盡量減少操作元素,因為不知道他人調用你的代碼時是否處于循環中。
批量操作元素
比如說要創建 1 萬個 div 元素,在循環中直接創建再添加到父元素上耗時會非常多。如果采用字符串拼接的形式,先將 1 萬個 div 元素的 html 字符串拼接成一個完整字符串,然后賦值給 body 元素的 innerHTML 屬性就可以明顯減少耗時。
const times = 10000;
console.time('createElement')
for (let i = 0; i < times; i++) {
const div = document.createElement('div')
document.body.AppendChild(div)
}
console.timeEnd('createElement')// 54.964111328125ms
console.time('innerHTML')
let html=''
for (let i = 0; i < times; i++) {
html+='<div></div>'
}
document.body.innerHTML += html // 31.919921875ms
console.timeEnd('innerHTML')
雖然通過修改 innerHTML 來實現批量操作的方式效率很高,但它并不是萬能的。比如要在此基礎上實現事件監聽就會略微麻煩,只能通過事件代理或者重新選取元素再進行單獨綁定。批量操作除了用在創建元素外也可以用于修改元素屬性樣式,比如下面的例子。
創建 2 萬個 div 元素,以單節點樹結構進行排布,每個元素有一個對應的序號作為文本內容。現在通過 style 屬性對第 1 個 div 元素進行 2 萬次樣式調整。下面是直接操作 style 屬性的代碼:
const times = 20000;
let html = ''
for (let i = 0; i < times; i++) {
html = `<div>${i}${html}</div>`
}
document.body.innerHTML += html
const div = document.querySelector('div')
for (let i = 0; i < times; i++) {
div.style.fontSize = (i % 12) + 12 + 'px'
div.style.color = i % 2 ? 'red' : 'green'
div.style.margin = (i % 12) + 12 + 'px'
}
如果將需要修改的樣式屬性放入 JavaScript 數組,然后對這些修改進行 reduce 操作,得到最終需要的樣式之后再設置元素屬性,那么性能會提升很多。代碼如下:
const times = 20000;
let html = ''
for (let i = 0; i < times; i++) {
html = `<div>${i}${html}</div>`
}
document.body.innerHTML += html
let queue = [] // 創建緩存樣式的數組
let microTask // 執行修改樣式的微任務
const st = () => {
const div = document.querySelector('div')
// 合并樣式
const style = queue.reduce((acc, cur) => ({...acc, ...cur}), {})
for(let prop in style) {
div.style[prop] = style[prop]
}
queue = []
microTask = null
}
const setStyle = (style) => {
queue.push(style)
// 創建微任務
if(!microTask) microTask = Promise.resolve().then(st)
}
for (let i = 0; i < times; i++) {
const style = {
fontSize: (i % 12) + 12 + 'px',
color: i % 2 ? 'red' : 'green',
margin: (i % 12) + 12 + 'px'
}
setStyle(style)
}
從下面的耗時占比圖可以看到,紫色 Rendering 事件耗時有所減少。
virtualDOM 之所以號稱高性能,其實現原理就與此類似。
緩存元素集合
比如將通過選擇器函數獲取到的 DOM 元素賦值給變量,之后通過變量操作而不是再次使用選擇器函數來獲取。
下面舉例說明,假設我們現在要將上面代碼所創建的 1 萬個 div 元素的文本內容進行修改。每次重復使用獲取選擇器函數來獲取元素,代碼以及時間消耗如下所示。
for (let i = 0; i < document.querySelectorAll('div').length; i++) {
document.querySelectorAll(`div`)[i].innerText = i
}
如果能夠將元素集合賦值給 JavaScript 變量,每次通過變量去修改元素,那么性能將會得到不小的提升。
const divs = document.querySelectorAll('div')
for (let i = 0; i < divs.length; i++) {
divs[i].innerText = i
}
對比兩者耗時占比圖可以看到,兩者的渲染時間較為接近。但緩存元素的方式在黃色的 Scripting 耗時上具有明顯優勢。
總結
本課時從深入理解 DOM 的必要性說起,然后分析了 DOM 操作耗時的原因,最后再針對這些原因提出了可行的解決方法。
除了這些方法之外,還有一些原則也可能幫助我們提升渲染性能,比如:
盡量不要使用復雜的匹配規則和復雜的樣式,從而減少渲染引擎計算樣式規則生成 CSSOM 樹的時間;
盡量減少重排和重繪影響的區域;
使用 CSS3 特性來實現動畫效果。
希望你首先能理解原因,然后記住這些方法和原則,編寫出高性能代碼。
最后布置一道思考題:說一說你還知道哪些提升渲染速度的方法和原則?