概念到性能優(yōu)化的前端核心技術(shù))
1. 從“文檔”到“對象”DOM到底是什么如果你剛開始學(xué)JavaScript或者已經(jīng)寫了一些交互效果但總覺得對頁面操作的理解還隔著一層紗那你大概率是被“DOM”這個詞給繞暈了。很多教程一上來就告訴你“DOM是文檔對象模型”然后就開始講getElementById這就像學(xué)開車教練直接說“這是方向盤用來轉(zhuǎn)向的”但你心里還是不明白為什么我轉(zhuǎn)它車就會動今天我們不談那些抽象的定義。我們換個角度把瀏覽器想象成一個超級精密的玩具組裝工廠。你寫的HTML和CSS就是一份詳細的“玩具零件清單”和“組裝說明書”。瀏覽器拿到這份清單和說明書后會立刻在它的車間里按照你的指示把一個個零件標簽組裝成一個立體的、有樣式的玩具模型。這個在瀏覽器內(nèi)存里構(gòu)建出來的、完整的、立體的玩具模型就是DOM。所以DOM不是那份寫在.html文件里的靜態(tài)文本那是“零件清單”而是瀏覽器根據(jù)那份清單動態(tài)創(chuàng)建出來的、存在于內(nèi)存中的、一個結(jié)構(gòu)化的對象集合。這個集合以樹形結(jié)構(gòu)組織每一個HTML標簽、每一段文本、甚至每一個屬性都變成了這個樹上的一個“節(jié)點”Node。JavaScript的強大之處在于它被賦予了直接進入這個“工廠車間”去查看、修改、甚至重新組裝這個玩具模型的權(quán)限。我們寫的那些document.getElementById、element.style.color本質(zhì)上就是JavaScript在對著這個內(nèi)存中的模型發(fā)號施令。理解這一點至關(guān)重要。它解釋了為什么我們能用JS改變頁面樣式而無需刷新直接改模型為什么事件能綁定到元素上模型里的對象有監(jiān)聽能力以及為什么說DOM操作是前端交互的基石。接下來我們就深入這個“車間”看看具體怎么操作。2. 進入車間的大門DOM的基本操作全解析要對DOM模型進行操作你得先找到你想操作的那個“零件”對象。這個過程我們稱之為“獲取DOM元素”。這是所有交互的起點方法很多但各有各的適用場景和脾氣。2.1 精準定位通過ID、類名和標簽名獲取最經(jīng)典的三劍客是getElementById、getElementsByClassName和getElementsByTagName。它們的名字已經(jīng)說明了用途。// 1. 通過ID獲取 - 返回單個元素 // ID在HTML中應(yīng)該是唯一的所以這個方法返回的就是那個元素對象本身。 const header document.getElementById(main-header); console.log(header); // 例如h1 idmain-header.../h1 // 2. 通過類名獲取 - 返回一個HTMLCollection元素集合 // 注意是Elements復(fù)數(shù)即使只有一個類名匹配返回的也是集合。 const buttons document.getElementsByClassName(btn); console.log(buttons); // 例如HTMLCollection(3) [button.btn, button.btn, button.btn] console.log(buttons[0]); // 獲取第一個按鈕 // 3. 通過標簽名獲取 - 返回一個HTMLCollection const allDivs document.getElementsByTagName(div);這里有個早期很容易踩的坑HTMLCollection是一個“活的”live集合。什么意思假如你通過getElementsByClassName(item)獲取了3個元素然后你用JS動態(tài)地在頁面中又添加了一個帶有item類的元素這個HTMLCollection會自動更新變成包含4個元素。這有時會導(dǎo)致意料之外的循環(huán)問題。相比之下我們后面要講的querySelectorAll返回的是一個“靜態(tài)的”NodeList它不會自動更新。2.2 現(xiàn)代CSS選擇器querySelector 與 querySelectorAll如果你熟悉CSS選擇器那么這兩個方法將是你的最愛。它們強大且靈活完全沿用了CSS選擇器的語法。// 1. querySelector - 返回匹配的第一個元素 // 用法和CSS選擇器一模一樣。 const container document.querySelector(.container); // 獲取第一個類為container的元素 const firstListItem document.querySelector(ul li:first-child); // 獲取第一個ul下的第一個li const specialInput document.querySelector(input[typetext][required]); // 獲取第一個type為text且required的input // 2. querySelectorAll - 返回匹配的所有元素是一個NodeList // 注意它返回的是NodeList不是HTMLCollection。它是靜態(tài)的。 const allImages document.querySelectorAll(img); const evenRows document.querySelectorAll(tr:nth-child(even));為什么更推薦使用querySelector/querySelectorAll一致性你只需要記住CSS選擇器的語法就可以應(yīng)付絕大多數(shù)查找需求無需在getElementById、getElementsByClassName等不同方法間切換。強大靈活可以實現(xiàn)非常復(fù)雜和精準的查找比如div.content p:not(.ignore)這是傳統(tǒng)方法做不到的。靜態(tài)集合querySelectorAll返回的靜態(tài)NodeList避免了“活的集合”可能帶來的意外在循環(huán)遍歷時更安全可控。2.3 遍歷與導(dǎo)航在DOM樹中“行走”有時候你已經(jīng)拿到了一個元素想找到它的爸爸、兒子或者鄰居。DOM節(jié)點對象提供了一系列屬性供我們導(dǎo)航。const item document.querySelector(.list-item); // 向上找父級 console.log(item.parentNode); // 父節(jié)點可能是元素節(jié)點也可能是文檔節(jié)點等 console.log(item.parentElement); // 父元素節(jié)點只找元素類型的父級更常用 // 向下找子級 console.log(item.children); // 所有子元素節(jié)點HTMLCollection console.log(item.firstElementChild); // 第一個子元素節(jié)點 console.log(item.lastElementChild); // 最后一個子元素節(jié)點 console.log(item.childNodes); // 所有子節(jié)點包括文本節(jié)點、注釋節(jié)點等范圍更廣 // 平行找兄弟 console.log(item.previousElementSibling); // 上一個兄弟元素節(jié)點 console.log(item.nextElementSibling); // 下一個兄弟元素節(jié)點 // 對應(yīng)的還有 previousSibling 和 nextSibling它們會包含文本節(jié)點等容易踩坑。實操心得在大多數(shù)操作元素的場景下請優(yōu)先使用帶Element后綴的屬性如parentElement,children,previousElementSibling。因為它們會過濾掉文本節(jié)點、注釋節(jié)點等非元素節(jié)點讓你直接拿到可以操作的元素對象減少很多判斷和意外。例如兩個div之間如果有換行符在DOM中就是一個文本節(jié)點用nextSibling拿到的是這個換行符文本節(jié)點而不是下一個div這常常讓初學(xué)者困惑。3. 不只是長寬高徹底搞懂盒子模型與13個關(guān)鍵API當(dāng)我們談?wù)撘粋€元素在頁面上的大小和位置時我們其實是在談?wù)撍摹昂凶幽P汀?。CSS的盒子模型大家應(yīng)該都懂content內(nèi)容、padding內(nèi)邊距、border邊框、margin外邊距。但在JavaScript里我們需要更精確的數(shù)字比如“這個元素距離屏幕左邊多遠”“它包含邊框在內(nèi)的總高度是多少”。瀏覽器為我們提供了13個主要的只讀屬性來獲取這些信息它們都以像素px為單位。理解它們的關(guān)鍵在于分清**“模型”** 和**“視圖”** 兩個維度。3.1 模型尺寸元素“自己有多大”這組屬性關(guān)注元素自身盒子模型的尺寸與它在頁面中的位置無關(guān)。屬性描述計算公式以寬度為例clientWidth可視內(nèi)容內(nèi)邊距的寬度。不包含滾動條、邊框、外邊距。width padding-left padding-rightclientHeight可視內(nèi)容內(nèi)邊距的高度。height padding-top padding-bottomoffsetWidth元素整體的寬度。包含內(nèi)容內(nèi)邊距邊框垂直滾動條如果存在。width padding border verticalScrollbaroffsetHeight元素整體的高度。height padding border horizontalScrollbarscrollWidth元素實際內(nèi)容的寬度包括因溢出而不可見的部分。如果沒有溢出則等于clientWidth。內(nèi)容的總寬度含paddingscrollHeight元素實際內(nèi)容的高度包括因溢出而不可見的部分。如果沒有溢出則等于clientHeight。內(nèi)容的總高度含padding為什么需要區(qū)分client和offset想象一個帶邊框和內(nèi)邊距的按鈕。clientWidth告訴你按鈕中間可點擊區(qū)域有多寬內(nèi)容內(nèi)邊距這對于計算布局內(nèi)間距非常有用。而offsetWidth告訴你這個按鈕在頁面上總共占了多寬的空間包括它自己的邊框這對于計算元素占位、判斷是否與其他元素重疊至關(guān)重要。scrollWidth/Height的典型應(yīng)用判斷內(nèi)容是否溢出。const box document.getElementById(myBox); // 如果元素的實際內(nèi)容高度大于它的可視區(qū)域高度說明出現(xiàn)了垂直滾動條 if (box.scrollHeight box.clientHeight) { console.log(內(nèi)容溢出了需要滾動); }3.2 視圖位置元素“在哪兒”這組屬性關(guān)注元素相對于某個參照物的位置。屬性描述參照物offsetTop元素上邊框相對于最近定位祖先元素position不為static頂部的距離。如果無定位祖先則相對于body。最近定位祖先offsetLeft元素左邊框相對于最近定位祖先元素左側(cè)的距離。最近定位祖先clientTop元素上邊框的寬度。其實就是border-top-width。元素自身clientLeft元素左邊框的寬度。其實就是border-left-width。注意如果存在垂直滾動條這個值會包含滾動條寬度。元素自身scrollTop元素內(nèi)容區(qū)域向上滾動的像素數(shù)。可讀寫設(shè)置它可以控制滾動位置。元素自身scrollLeft元素內(nèi)容區(qū)域向左滾動的像素數(shù)??勺x寫。元素自身offsetTop/Left的深度解析這是計算元素位置時最常用的屬性但也是最容易用錯的。關(guān)鍵在于“最近定位祖先”。我們來看個例子style #grandparent { position: relative; width: 400px; height: 400px; background: #eee; } #parent { margin: 50px; padding: 30px; background: #ccc; } /* #child 的父級 #parent 沒有設(shè)置 position */ #child { width: 100px; height: 100px; background: orange; } /style div idgrandparent div idparent div idchild/div /div /div script const child document.getElementById(child); console.log(child.offsetTop); // 輸出多少 /script#child的父級#parent沒有設(shè)置定位position: static所以會繼續(xù)向上找找到了#grandparentposition: relative。因此child.offsetTop是#child的上邊框到#grandparent上邊框內(nèi)側(cè)的距離。這個距離等于#parent的margin-top (50px) padding-top (30px)所以結(jié)果是80px。理解這個計算過程你就能精準定位頁面中的任何元素。scrollTop的妙用實現(xiàn)“回到頂部”按鈕const backToTopBtn document.getElementById(backToTop); const scrollableElement document.documentElement; // 通常滾動的是整個頁面 window.addEventListener(scroll, () { // 當(dāng)頁面向下滾動超過200px時顯示按鈕 if (scrollableElement.scrollTop 200) { backToTopBtn.style.display block; } else { backToTopBtn.style.display none; } }); backToTopBtn.addEventListener(click, () { // 平滑滾動到頂部 scrollableElement.scrollTo({ top: 0, behavior: smooth }); // 或者直接設(shè)置 scrollTop // scrollableElement.scrollTop 0; });3.3 綜合案例判斷元素是否進入可視區(qū)域這是一個非常常見的需求比如實現(xiàn)圖片懶加載、無限滾動、動畫觸發(fā)等。我們可以利用getBoundingClientRect()方法它返回一個DOMRect對象包含了元素相對于視口viewport的位置和大小信息。function isElementInViewport(el) { const rect el.getBoundingClientRect(); return ( rect.top 0 rect.left 0 rect.bottom (window.innerHeight || document.documentElement.clientHeight) rect.right (window.innerWidth || document.documentElement.clientWidth) ); } // 使用示例滾動時檢查圖片是否需要加載 const lazyImages document.querySelectorAll(img.lazy); window.addEventListener(scroll, () { lazyImages.forEach(img { if (isElementInViewport(img)) { // 加載圖片 img.src img.dataset.src; img.classList.remove(lazy); } }); });getBoundingClientRect()的top,left,bottom,right都是相對于瀏覽器視口左上角計算的。當(dāng)top小于0說明元素的上方已經(jīng)滾出視口當(dāng)bottom小于等于視口高度說明元素的底部進入了視口。這個方法比單純計算offsetTop和頁面滾動距離更直接、更可靠。4. 動起來用DOM API實現(xiàn)動態(tài)交互與樣式修改獲取元素、知道它的尺寸位置之后我們就要讓它“動”起來響應(yīng)用戶操作。這包括修改內(nèi)容、屬性和樣式。4.1 內(nèi)容與屬性的增刪改查1. 操作HTML內(nèi)容innerHTML: 獲取或設(shè)置元素內(nèi)部的HTML字符串。功能強大但需謹慎因為它會解析字符串并替換所有子元素。const div document.querySelector(div); div.innerHTML strong警告/strong 這是一條新消息。; // 會替換div內(nèi)所有原有內(nèi)容 console.log(div.innerHTML); // 輸出strong警告/strong 這是一條新消息。注意直接使用innerHTML插入用戶輸入的字符串有XSS跨站腳本攻擊風(fēng)險。如果內(nèi)容來自用戶務(wù)必進行轉(zhuǎn)義處理。textContent: 獲取或設(shè)置元素的文本內(nèi)容包括隱藏元素的文本它會忽略所有HTML標簽將其作為純文本處理。設(shè)置時HTML標簽會被轉(zhuǎn)義不會解析。div.textContent strong安全文本/strong; // 頁面上會直接顯示字符串“strong安全文本/strong”2. 操作元素屬性標準屬性如id,href,src,value可以直接通過元素對象.出來。const link document.querySelector(a); console.log(link.href); // 獲取 link.href https://new-site.com; // 設(shè)置自定義屬性>// div iduser>const img document.querySelector(img); img.setAttribute(alt, 一張描述性圖片); // 設(shè)置alt屬性 const altText img.getAttribute(alt); // 獲取 img.removeAttribute(title); // 刪除title屬性4.2 動態(tài)修改樣式修改樣式有兩種主要方式直接操作style屬性或者增刪CSS類。1. 操作style對象行內(nèi)樣式每個DOM元素都有一個style對象對應(yīng)著它的style屬性。通過它設(shè)置的樣式優(yōu)先級最高行內(nèi)樣式。const box document.getElementById(myBox); // 設(shè)置單個樣式 (注意CSS屬性名使用駝峰命名) box.style.backgroundColor skyblue; box.style.width 200px; box.style.borderRadius 8px; // 一次性設(shè)置多個樣式 (使用cssText或setProperty) box.style.cssText width: 200px; height: 100px; background: red;; // 或者 box.style.setProperty(width, 200px);這種方式適合動態(tài)的、一次性的樣式變化。缺點是樣式混在JS邏輯里不利于維護且無法利用CSS偽類、媒體查詢等特性。2. 操作classList推薦這是現(xiàn)代前端操作樣式的首選方式。classList是一個DOMTokenList對象提供了豐富的方法來操作元素的類名。const button document.querySelector(button); // 添加類 button.classList.add(btn-active, btn-large); // 刪除類 button.classList.remove(btn-default); // 切換類有則刪無則加 button.classList.toggle(hidden); // 檢查是否包含某個類 if (button.classList.contains(btn-active)) { // 執(zhí)行某些操作 } // 替換類 button.classList.replace(old-theme, new-theme);通過增刪預(yù)定義在CSS中的類可以實現(xiàn)復(fù)雜的樣式切換和狀態(tài)管理如激活態(tài)、禁用態(tài)、主題切換讓樣式CSS和行為JS更好地分離代碼更清晰、更易維護。4.3 創(chuàng)建、插入與刪除節(jié)點有時候我們需要動態(tài)地添加或移除頁面結(jié)構(gòu)。// 1. 創(chuàng)建新元素 const newListItem document.createElement(li); newListItem.textContent 我是新來的列表項; newListItem.className list-item new; // 2. 插入到DOM樹中 const parentList document.getElementById(myList); // 追加到末尾 parentList.appendChild(newListItem); // 插入到某個子元素之前 const firstItem parentList.firstElementChild; parentList.insertBefore(newListItem, firstItem); // 3. 克隆節(jié)點 const clonedItem newListItem.cloneNode(true); // true表示深度克隆包括所有子節(jié)點 parentList.appendChild(clonedItem); // 4. 刪除節(jié)點 // 方法一父元素.removeChild(子元素) parentList.removeChild(firstItem); // 方法二現(xiàn)代更簡潔元素自身.remove() const itemToRemove document.querySelector(.item.to-remove); itemToRemove.remove();實操心得DocumentFragment的妙用如果你需要一次性插入大量節(jié)點頻繁操作DOMappendChild會引發(fā)瀏覽器多次重排重繪非常消耗性能。這時可以使用DocumentFragment文檔片段作為“離線”的容器。const list document.getElementById(hugeList); const fragment document.createDocumentFragment(); // 創(chuàng)建一個內(nèi)存中的片段 for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent Item ${i}; fragment.appendChild(li); // 在內(nèi)存中操作不涉及DOM } list.appendChild(fragment); // 一次性插入只觸發(fā)一次重排DocumentFragment本身不會被渲染到頁面上它就像一個臨時的“袋子”我們把所有新節(jié)點放進去最后一次性把“袋子”里的內(nèi)容倒進真實的DOM樹性能提升顯著。5. 性能陷阱與最佳實踐寫出高效的DOM操作代碼DOM操作是相對昂貴的因為它往往會導(dǎo)致瀏覽器的“重排”Reflow和“重繪”Repaint。理解并優(yōu)化它們是進階前端必須掌握的技能。5.1 理解重排與重繪重排Reflow當(dāng)元素的幾何屬性位置、尺寸發(fā)生變化時瀏覽器需要重新計算所有受影響元素的幾何信息并重新構(gòu)建渲染樹的一部分或全部。非常消耗性能。觸發(fā)重排的操作包括修改寬高、邊距、位置offsetTop、字體大小、增刪可見DOM元素等。重繪Repaint當(dāng)元素的外觀樣式改變但不影響其布局時如顏色、背景色、可見性瀏覽器只需要重新繪制受影響的部分。比重排開銷小但依然有消耗。黃金法則盡量減少重排的次數(shù)和范圍。5.2 常見性能陷阱與優(yōu)化策略陷阱1在循環(huán)中直接進行DOM查詢和樣式修改。// 糟糕的寫法每次循環(huán)都觸發(fā)重排 const elements document.querySelectorAll(.animate-item); for (let i 0; i elements.length; i) { // 讀取offsetTop會強制觸發(fā)一次重排以獲取最新值“強制同步布局” const top elements[i].offsetTop; // 然后立即修改樣式又觸發(fā)一次重排 elements[i].style.top top 10 px; }優(yōu)化策略分離“讀”和“寫”操作。瀏覽器有優(yōu)化機制可以將連續(xù)的DOM操作合并。但如果一次“讀”操作緊跟著一次依賴這次“讀”的“寫”操作瀏覽器為了給你正確的值不得不中斷優(yōu)化立即進行重排強制同步布局。我們應(yīng)該批量讀取再批量寫入。// 好的寫法先讀后寫 const elements document.querySelectorAll(.animate-item); const tops []; // 批量讀取階段 for (let i 0; i elements.length; i) { tops.push(elements[i].offsetTop); } // 批量寫入階段 for (let i 0; i elements.length; i) { elements[i].style.top tops[i] 10 px; } // 或者使用現(xiàn)代的 requestAnimationFrame 來安排寫入與瀏覽器刷新率同步更流暢。陷阱2頻繁操作內(nèi)聯(lián)樣式。如前所述直接修改style是行內(nèi)樣式每次修改都可能觸發(fā)重排/重繪。優(yōu)化策略使用classList和 CSS 類。將樣式變化定義在CSS類中通過JS切換類名。瀏覽器可以更智能地優(yōu)化這類變更。對于復(fù)雜動畫考慮使用CSS3的transform和opacity屬性它們可以由GPU加速且通常不會觸發(fā)重排。陷阱3不必要的DOM查詢。// 低效在頻繁觸發(fā)的事件如mousemove中重復(fù)查詢DOM document.addEventListener(mousemove, (e) { const tooltip document.getElementById(tooltip); // 每次事件都查詢 tooltip.style.left e.pageX px; });優(yōu)化策略緩存DOM引用。// 高效只查詢一次并緩存 const tooltip document.getElementById(tooltip); // 緩存起來 document.addEventListener(mousemove, (e) { tooltip.style.left e.pageX px; // 直接使用緩存的對象 });5.3 事件委托提升動態(tài)交互性能當(dāng)你有大量子元素需要綁定相同事件時如列表的每一項都有點擊事件給每個子元素單獨綁定監(jiān)聽器會消耗大量內(nèi)存。// 傳統(tǒng)方式性能差尤其對于長列表 const listItems document.querySelectorAll(#longList li); listItems.forEach(item { item.addEventListener(click, function() { console.log(this.textContent); }); }); // 問題如果動態(tài)添加新的li它不會有事件監(jiān)聽。使用事件委托利用事件冒泡機制將事件監(jiān)聽器綁定在父元素上通過判斷事件觸發(fā)源event.target來處理。const list document.getElementById(longList); list.addEventListener(click, function(event) { // 檢查點擊的目標是否是li元素 if (event.target.tagName LI) { console.log(event.target.textContent); } // 或者如果li內(nèi)部還有子元素可以使用 closest 方法向上查找 const listItem event.target.closest(li); if (listItem) { console.log(listItem.textContent); } });這樣做的好處是內(nèi)存占用少一個監(jiān)聽器、性能好、自動適用于動態(tài)添加的子元素。DOM操作是前端工程師的日常理解其本質(zhì)內(nèi)存對象模型、掌握其工具API、并規(guī)避其陷阱性能問題就能寫出交互流暢、體驗出色的網(wǎng)頁。從精準獲取元素到深入盒子模型的每一個像素再到高效地增刪改查和事件處理這條路徑上的每一個環(huán)節(jié)都值得反復(fù)琢磨和實踐。最好的學(xué)習(xí)方式就是打開瀏覽器的開發(fā)者工具在Console里親手試試這些屬性和方法看看它們返回的值到底是什么你會對頁面有全新的、立體的認識。