一个由DOM引起的面试题和延伸

221 阅读3分钟

一个由DOM引起的面试题和延伸

参考文献

1. 如何修改页面内容

页面上有个空无序列表,用<ul></ul>表示,要往列表里插入3个<li>,每个列表项的文本内容是列表项的插入顺序,取值1,2,3。
解答:可以为<ul>节点加上id或者class属性。
<!-- 一般解答 -->
<ul id="list"></ul>
<script>
	var container = document.getElementById('list')
    item.innerText = i + 1
    container.appendChild(item)
</script>
// 一般解答
var container = document.getElementById('list')
var html = []
for (var i = 0; i < 3; i++) {
    html.push('<li>' + (i + 1) + '</li>')
}
container.innerHTML = html.join('')

上面的两份代码只能说满足了需求,但是如果做到了以下几点,会有加分:

  1. 变量命名:节点类的变量,加上 nd 前缀,会更加容易辨识,当然,也有同学习惯借用 jquery 中的 $,关于变量命名的更多内容可以去阅读《可读代码的艺术》;
  2. 选择符命名:给 CSS 用和 JS 用的选择符分开,给 JS 用的选择符建议加上 js-J- 前缀,提高可读性,还有没有其他好处,请思考;
  3. 容错能力:应该对节点的存在性做检查,这样代码才能更健壮,实际工作中,很可能你的这段代码会把其他功能搞砸,因为单个地方 JS 报错是可能导致后续代码不执行的,为啥要这样做?不理解的同学可以去看看防御性编程
  4. 最小作用域原则:应该把代码段包在声明即执行的函数表达式(IIFE)里,不产生全局变量,也避免变量名冲突的风险,这是维护遗留代码必须谨记的。

针对第一份代码进行修改:

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

2. 绑定事件?

问题:要当每个 <li> 被单击的时候 alert 里面的内容,该怎么做?

// ...
for (let i = 0; i < 3; i++) {
    const ndItem = document.createElement('li')
    ndItem.innerText = i + 1
    ndItem.addEventListener('click', function () {
        alert(i) // 用alert(this.innerText)可以吗?this指向正确吗
    })
    ndContainer.appendChild(ndItem)
}

因为 EventListener 里面默认的 this 指向当前节点,比较喜欢使用箭头函数的同学则需要格外注意,因为箭头函数会强制改变函数的执行上下文。

3. 当数据量变大,用事件委托

如果要插入的<li>是300个,应该用事件委托解决

使用事件委托能有效的减少事件注册的数量,并且在子节点动态增减是无需修改代码的。

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

如果要在 <ul> 中插入 30000 个 <li>,会有什么问题?代码需要怎么改进?几乎可以肯定,页面体验不再流畅,甚至会出现明显的卡顿感,该怎么解决?

出现卡顿感的主要原因是每次循环都会修改 DOM 结构,外加大循环执行时间过长,浏览器的渲染帧率(FPS)过低。而实际上,包含 30000 个 <li> 的长列表,用户不会立即看到全部,大部分甚至根本都不会看,那部分都没有渲染的必要,好在现代浏览器提供了 requestAnimationFrame API 来解决非常耗时的代码段对渲染的阻塞问题。

该技术在 ReactAngular 里面都有使用,如果你理解了 requestAnimationFrame 的原理,就很容易理解最新的 React Fiber 算法

综合上面的分析,可以从减少 DOM 操作次数、缩短循环时间两个方面减少主线程阻塞的时间。减少 DOM 操作次数的良方是 DocumentFragment

而缩短循环时间则需要考虑使用分治的思想把 30000 个 <li> 分批次插入到页面中,每次插入的时机是在页面重新渲染之前。由于 requestAnimationFrame 并不是所有的浏览器都支持,Paul Irish 给出了对应的 polyfill

完整代码:

(() => {
    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
        }
    }
    doBatchAppend()
    
    ndContainer.addEventListener('click', function (e) {
        const target = e.target
        if (target.tagName === 'li') {
            alert(target.innerHTML)
        }
    })
})()

window.requestAnimationFrame