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

王仕軍發表於2017-04-18

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

如何修改頁面內容?

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

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

<ul id="list"></ul>複製程式碼

然後給出節點建立程式碼:

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

也有候選人給出下面的程式碼:

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,分析問題,就會捉襟見肘)。你也可能在心裡嘀咕,上來就寫程式碼,還是面試麼?可以說程式碼是工程師最主要的產出,看著候選人編碼能讓你熟悉他的思考方式、編碼風格、程式碼習慣,很容能看出來是不是“對味兒”的候選人。

坦率的說,上面的兩份程式碼只能說滿足了需求,但是如果做到了以下幾點,會有加分:

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

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

(() => {
    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

本文作者王仕軍,商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。如果你覺得本文對你有幫助,請點贊!如果對文中的內容有任何疑問,歡迎留言討論。想知道我接下來會寫些什麼?歡迎訂閱我的掘金專欄

相關文章