破解前端面試(80% 應聘者不及格系列):從 DOM 提及

共 7384 字,讀完需 10 分鐘。本文爲《破解前端面試(80% 應聘者不及格系列)》文章的第二篇,包含 DOM、Event、瀏覽器端優化、數據結構和算法功底的考察。可能有同窗會問 DOM 有什麼好聊的,不就是節點的各類操做麼?DOM 是網頁構建的基石,熟練掌握各類操做、知曉可能的問題、熟悉優化手段,才能作到在工程實踐中從容不迫。系列文章連接:閉包篇。下面開始聊 DOM 的話題。css

如何修改頁面內容?

考察候選人對 DOM 基礎知識的掌握程度時,筆者常拋出這樣的問題:頁面上有個空的無序列表節點,用 <ul></ul> 表示,要往列表中插入 3 個 <li>,每一個列表項的文本內容是列表項的插入順序,取值 1, 2, 3,怎麼用原生的 JS 實現這個需求?同時約定,爲方便獲取節點引用,能夠根據須要爲 <ul> 節點加上 id 或者 class 屬性。html

超過 80% 的候選人能完成需求,先爲 ul 加上選擇符:前端

<ul id="list"></ul>複製代碼

而後給出節點建立代碼:node

var container = document.getElementById('list');
for (var i = 0; i < 3; i++) {
    var item = document.createElement('li');
    item.innerText = i + 1;
    container.appendChild(item);
}複製代碼

也有候選人給出下面的代碼:react

var container = document.getElementById('list');
var html = [];
for (var i = 0; i < 3; i++) {
    html.push('<li>' + (i + 1) + '</li>');
}
container.innerHTML = html.join('');複製代碼

這個都寫不出來的同窗要去面壁了(可能你能用各類庫、框架能寫出來,可是等你須要調試 bug,分析問題,就會捉襟見肘)。你也可能在內心嘀咕,上來就寫代碼,仍是面試麼?能夠說代碼是工程師最主要的產出,看着候選人編碼能讓你熟悉他的思考方式、編碼風格、代碼習慣,很容能看出來是否是「對味兒」的候選人。jquery

坦率的說,上面的兩份代碼只能說知足了需求,可是若是作到了如下幾點,會有加分:git

  1. 變量命名:節點類的變量,加上 nd 前綴,會更加容易辨識,固然,也有同窗習慣借用 jquery 中的 $,關於變量命名的更多內容能夠去閱讀《可讀代碼的藝術》;
  2. 選擇符命名:給 CSS 用和 JS 用的選擇符分開,給 JS 用的選擇符建議加上 js-J- 前綴,提升可讀性,還有沒有其餘好處,請思考;
  3. 容錯能力:應該對節點的存在性作檢查,這樣代碼才能更健壯,實際工做中,極可能你的這段代碼會把其餘功能搞砸,由於單個地方 JS 報錯是可能致使後續代碼不執行的,爲啥要這樣作?不理解的同窗能夠去看看防護性編程
  4. 最小做用域原則:應該把代碼段包在聲明即執行的函數表達式(IIFE)裏,不產生全局變量,也避免變量名衝突的風險,這是維護遺留代碼必須謹記的。

下面是綜合上面四點的改良版(只針對第1份代碼):github

(() => {
    var ndContainer = document.getElementById('js-list');
    if (!ndContainer) {
        return;
    }

    for (var i = 0; i < 3; i++) {
        var ndItem = document.createElement('li');
        ndItem.innerText = i + 1;
        ndContainer.appendChild(ndItem);
    }
})();複製代碼

在候選人給出代碼以後,筆者常順便追問:選取節點是否有其餘方法?還有哪些?這個問題留給你本身。面試

追問1:如何綁定事件?

如今頁面上有了內容,接下來添加交互。問題:要當每一個 <li> 被單擊的時候 alert 裏面的內容,該怎麼作?部分候選人不假思索地給出以下代碼:算法

//...
for (var i = 0; i < 3; i++) {
    var ndItem = document.createElement('li');
    ndItem.innerText = i + 1;
    ndItem.addEventListener('click', function () {
        alert(i);
    });
    ndContainer.appendChild(ndItem);
}
//...複製代碼

或下面的代碼:

//...
for (var i = 0; i < 3; i++) {
    var ndItem = document.createElement('li');
    ndItem.innerText = i + 1;
    ndItem.addEventListener('click', function () {
        alert(ndItem.innerText);
    });
    ndContainer.appendChild(ndItem);
}
//...複製代碼

若是你對閉包和做用域理解沒問題,就很容易發現問題:alert 出來的內容其實都是 3,而不是每一個 <li> 的文本內容。上面兩段代碼都不能知足需求,由於 indItem 的做用域範圍是相同的。使用 ES6 的塊級做用域能把問題解決:

//...
for (let i = 0; i < 3; i++) {
    const ndItem = document.createElement('li');
    ndItem.innerText = i + 1;
    ndItem.addEventListener('click', function () {
        alert(i);
    });
    ndContainer.appendChild(ndItem);
}
//...複製代碼

而熟悉 addEventListener 文檔的候選人會給出下面的方法:

//...
for (var i = 0; i < 3; i++) {
    var ndItem = document.createElement('li');
    ndItem.innerText = i + 1;
    ndItem.addEventListener('click', function () {
        alert(this.innerText);
    });
    ndContainer.appendChild(ndItem);
}
//...複製代碼

由於 EventListener 裏面默認的 this 指向當前節點,比較喜歡使用箭頭函數的同窗則須要格外注意,由於箭頭函數會強制改變函數的執行上下文。筆者的判斷標準是到這裏算及格,你及格了麼?

聊到這裏,筆者有時候還會追問:綁定事件除了 addEventListener 還有其餘方式麼?若是使用 onclick 會存在什麼問題?

追問2:數據量變大以後?

貌似上面的問題都沒啥挑戰,彆着急,難度繼續增長。若是要插入的 <li> 是 300 個,該怎麼解決?

部分同窗會粗暴的把循環終止條件修改成 i < 300,這樣沒有明顯的問題,但細想你會發現,在 DOM 中註冊的事件監聽函數增長了 100 倍,有更好的辦法麼?讀到這裏你確定已經想到了,對,就是事件委託(英文 Event Delegation,亦稱事件代理)。

使用事件委託能有效的減小事件註冊的數量,而且在子節點動態增減是無需修改代碼,使用事件委託的代碼以下:

(() => {
    var ndContainer = document.getElementById('js-list');
    if (!ndContainer) {
        return;
    }

    for (let i = 0; i < 300; i++) {
        const ndItem = document.createElement('li');
        ndItem.innerText = i + 1;
        ndContainer.appendChild(ndItem);
    }

    ndContainer.addEventListener('click', function (e) {
        const target = e.target;
        if (target.tagName === 'LI') {
            alert(target.innerHTML);
        }
    });
})();複製代碼

若是你不知道事件委託是什麼、實現原理是什麼、使用它有什麼好處,請花點時間去研究下,能讓你寫出更好的代碼,遇到沒聽過事件委託的候選人我會追問「標準 DOM 事件的發生流程」,若是熟悉,再引導他理解事件委託,直到寫出代碼,這個過程能看出來候選人思惟是否靈活。

回到正題,至關部分的代碼在數據量變大以後容易出各類問題。若是要在 <ul> 中插入 30000 個 <li>,會有什麼問題?代碼須要怎麼改進?幾乎能夠確定,頁面體驗再也不流暢,甚至會出現明顯的卡頓感,該怎麼解決?

出現卡頓感的主要緣由是每次循環都會修改 DOM 結構,外加大循環執行時間過長,瀏覽器的渲染幀率(FPS)太低。而實際上,包含 30000 個 <li> 的長列表,用戶不會當即看到所有,大部分甚至根本都不會看,那部分都沒有渲染的必要,好在現代瀏覽器提供了 requestAnimationFrame API 來解決很是耗時的代碼段對渲染的阻塞問題,不知道 requestAnimationFrame 用法和原理的請研究下這篇文章,該技術在 ReactAngular 裏面都有使用,若是你理解了 requestAnimationFrame 的原理,就很容易理解最新的 React Fiber 算法

綜合上面的分析,能夠從減小 DOM 操做次數、縮短循環時間兩個方面減小主線程阻塞的時間。減小 DOM 操做次數的良方是 DocumentFragment;而縮短循環時間則須要考慮使用分治的思想把 30000 個 <li> 分批次插入到頁面中,每次插入的時機是在頁面從新渲染以前。因爲 requestAnimationFrame 並非全部的瀏覽器都支持,Paul Irish 給出了對應的 polyfill,這個 Gist 也很是值得你學習。

下面是完整的代碼示例:

(() => {
    const ndContainer = document.getElementById('js-list');
    if (!ndContainer) {
        return;
    }

    const total = 30000;
    const batchSize = 4; // 每批插入的節點次數,越大越卡
    const batchCount = total / batchSize; // 須要批量處理多少次
    let batchDone = 0;  // 已經完成的批處理個數

    function appendItems() {
        const fragment = document.createDocumentFragment();
        for (let i = 0; i < batchSize; i++) {
            const ndItem = document.createElement('li');
            ndItem.innerText = (batchDone * batchSize) + i + 1;
            fragment.appendChild(ndItem);
        }

        // 每次批處理只修改 1 次 DOM
        ndContainer.appendChild(fragment);

        batchDone += 1;
        doBatchAppend();
    }

    function doBatchAppend() {
        if (batchDone < batchCount) {
            window.requestAnimationFrame(appendItems);
        }
    }

    // kickoff
    doBatchAppend();

    ndContainer.addEventListener('click', function (e) {
        const target = e.target;
        if (target.tagName === 'LI') {
            alert(target.innerHTML);
        }
    });
})();複製代碼

讀到這裏的同窗,應該已經理解這一節討論的要點:大批量 DOM 操做對頁面渲染的影響以及優化的手段,性能對用戶來講是功能不可分割的部分。

追問3:DOM 樹的遍歷?

數據結構和算法在不少人前端同窗看來是沒啥用的東西,實際上他們掌握的也很差,但不論前端仍是後端,紮實的 CS 基礎是工程師必備的知識儲備,有了這種儲備在面臨複雜的問題,才能彰顯出工程師的價值。JS 中的 DOM 能夠自然的跟這種數據結構聯繫起來,相信你們都不陌生,好比給定下面的 HTML 片斷:

<div class="root">
    <div class="container">
        <section class="sidebar">
            <ul class="menu"></ul>
        </section>
        <section class="main">
            <article class="post"></article>
            <p class="copyright"></p>
        </section>
    </div>
</div>複製代碼

對這顆 DOM 樹,指望給出廣度優先遍歷(BFS)的代碼實現,遍歷到每一個節點時,打印出當前節點的類型及類名,例如上面的樹廣度優先遍歷結果爲:

DIV .root
DIV .container
SECTION .sidebar
SECTION .main
UL .menu
ARTICLE .post
P .copyright複製代碼

這要求候選人對 DOM 樹中節點關係的表示方式比較清楚,關鍵屬性是 childNodeschildren,二者有細微的差異。若是是深度優先的遍歷(DFS),使用遞歸很是容易寫出來,可是廣度優先則須要使用隊列這種數據結構來管理待遍歷的節點,讀到這裏,請你找出紙筆,思考 1 分鐘,看能不能本身寫出來。

下面給出一種參考的實現,代碼比較簡單,就很少作解釋:

const traverse = (ndRoot) => {
    const queue = [ndRoot];
while (queue.length) {
const node = queue.shift();
printInfo(node); if (!node.children.length) { continue; } Array.from(node.children).forEach(x => queue.push(x));
} }; const printInfo = (node) => { console.log(node.tagName, `.${node.className}`); }; // kickoff traverse(document.querySelector('.root'));複製代碼

若是你對樹和樹的遍歷理解不清,請仔細看上文的外鏈。最後,再追問一個問題,若是要在打印節點的時候輸出節點在樹中的層次,該怎麼解決?

總結和思考題

本文以基本的 DOM 操做爲出發點,接下來聊到事件綁定,和渲染性能優化,最後聊到工程師避不開的數據結構和算法。若是你是面試官,你會怎麼跟候選人聊?若是你想學好 DOM,只看這篇文章遠遠不夠,文中給你們留了 3 道思考題,也外鏈超過 10 個學習資料,但願對你們有用。

One More Thing

本文做者王仕軍,商業轉載請聯繫做者得到受權,非商業轉載請註明出處。若是你以爲本文對你有幫助,請點贊!若是對文中的內容有任何疑問,歡迎留言討論。想知道我接下來會寫些什麼?歡迎訂閱個人掘金專欄

相關文章
相關標籤/搜索