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