【小技巧】超大滚动列表的优化及前端 DOM 优化

每一个前端童鞋都应该知道,只要是涉及到 DOM 的操作,都应该要小心对待,因为对 DOM 操作的代价是巨大的,特别是在移动端。因为手机处理器及内存限制,手机上的 DOM 性能远远不及 PC 端,一个 DOM 操作可能会引起整个页面的重新渲染,这也是移动端推荐使用 transform 的原因之一。

今天我们就“超大滚动列表”这个案例进行优化操作,像大家展示 DOM 优化的重要性!

超大列表在我们实际项目中出现频率很高,比如无限滚动的列表、聊天记录、瀑布流等等,对其实现大家肯定都会了,但是,很多童鞋可能就只进行到“实现”这一步,在 PC 端可能影响还不大,但是到了移动端这样寸土寸金的地方,你会发现,这个列表滚动没那么流畅了;更有甚者,可能加载完列表后,手机的其他程序全部退出了,因为内存不够了。

曾经我就见到过一个博客,首页博文列表是滚动加载的,因为博主写的博文还挺多,我就不停地往下翻。一开始并没有什么,翻着翻着就感觉浏览器越来越卡,到最后浏览器直接卡死崩溃了!

那个时候我就已经知道 DOM 优化对前端的重要性。

无优化方案

 一般的童鞋对于列表的方案是 —— 生成列表然后直接塞入 document,小数据量的时候没有什么特别,但是一旦数据量到了百万级别,性能问题就特别明显了。

比如下面的代码(请勿轻易尝试)(DEMO):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>超长列表滚动优化</title>
    <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1">
    <style>
        #list {
            position: relative;
            width: 200px;
            height: 500px;
            margin: 0;
            padding: 0;
            overflow: auto;
        }

        #list li {
            width: 100%;
            height: 20px;
        }
    </style>
</head>
<body>
    <ul id="list"></ul>
    <script>
        var docFrag = document.createDocumentFragment();
        var ul_el = document.querySelector("#list");

        for(var i = 0; i < 1000000; i++){
            var li_el = document.createElement("li");
            li_el.innerHTML = "item " + i;
            docFrag.appendChild(li_el);
        }

        ul_el.innerHTML = "";
        ul_el.appendChild(docFrag);
    </script>
</body>
</html>

不知道有多少童鞋的浏览器能完成渲染?就算渲染完了,有多少浏览器又能流畅滚动?

这里我们借用一张图,这张图描述的是 Chrome 浏览器从访问到渲染出页面结果的过程:

这几个过程分别是:

  • JavaScript:一般来说,我们会使用 JavaScript 来实现一些视觉变化的效果。比如做一个动画或者往页面里添加一些 DOM 元素等。
  • Style:计算样式,这个过程是根据 CSS 选择器,对每个 DOM 元素匹配对应的 CSS 样式。这一步结束之后,就确定了每个 DOM 元素上该应用什么 CSS 样式规则。
  • Layout:布局,上一步确定了每个 DOM 元素的样式规则,这一步就是具体计算每个 DOM 元素最终在屏幕上显示的大小和位置。web 页面中元素的布局是相对的,因此一个元素的布局发生变化,会联动地引发其他元素的布局发生变化。比如, 元素的宽度的变化会影响其子元素的宽度,其子元素宽度的变化也会继续对其孙子元素产生影响。因此对于浏览器来说,布局过程是经常发生的。
  • Paint:绘制,本质上就是填充像素的过程。包括绘制文字、颜色、图像、边框和阴影等,也就是一个 DOM 元素所有的可视效果。一般来说,这个绘制过程是在多个层上完成的。
  • Composite:渲染层合并,由上一步可知,对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

这里最性能最差、最容易出问题的就是 Layout 和 Paint,因为 Layout、Paint 涉及到元素的尺寸的重新计算,是最消耗性能的。如果像我们上面代码那样有超大量的数据,那么浏览器在这两个过程上会花费很多很多时间,造成页面卡死,影响用户体验。

优化方案DEMO

既然我们现在知道,当存在超大量的 DOM 元素时,浏览器的 Layout 时间很长,那么我们的优化方向就很明确了 —— 减少 document 中的 DOM 节点数量。

因为在一个列表中,我们实际上能看到的元素十分有限,如图所示:

其中红色框代表我们的显示器,黑色部分代表一个列表组,从上图可以知道,我们看见的列表部分只是整个列表部分很小的一块,处在视窗之外的部分完全可以不用添加到 DOM 中去,于是我们有了下面的代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>超长列表滚动优化</title>
    <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1">
    <style>
        #list {
            position: relative;
            width: 200px;
            height: 500px;
            margin: 0;
            padding: 0;
            overflow: auto;
        }

        #list li {
            position: absolute;
            width: 100%;
            height: 20px;
        }
    </style>
</head>
<body>
    <ul id="list"></ul>
    <script>
        // 变量声明
        var li_h = 20;
        var li_c = 25;
        var last_item;
        var docFrag = document.createDocumentFragment();
        var ul_el = document.querySelector("#list");
        var start_index, end_index;

        // 数据获取
        var data = [];
        var total_count = data.length;
        for(var i = 0; i < 1000000; i++){
            data.push("item " + i);
        }

        // 根据滚动距离生成显示元素
        function showItems(){
            var scrollTop = ul_el.scrollTop;
            var count = parseInt(scrollTop / li_h);

            // 添加显示元素
            // 前后各加 20 个缓冲数据
            start_index = count - 20 >= 0 ? count - 20 : 0;
            end_index = count + li_c + 20 > total_count ? count + li_c : count + li_c + 20;
            for(var i = start_index; i < end_index; i++){
                var li_el = document.createElement("li");
                li_el.innerHTML = data[i];
                li_el.style.top = li_h * i + "px";
                docFrag.appendChild(li_el);
            }

            // 添加占位元素
            if (count < (data.length - li_c)) {
                last_item = document.createElement("li");
                last_item.innerHTML = data[data.length - 1];
                last_item.style.top = (data.length - 1) * li_h + "px";
                docFrag.appendChild(last_item);
            }

            ul_el.innerHTML = "";

            ul_el.appendChild(docFrag);
        }

        ul_el.onscroll = showItems;

        showItems();
    </script>
</body>
</html>

通过计算得出应该显示多少个元素,将这些元素组合在一起,最后一次性存入 document 中。

就这么简单的优化,前面我们还无法打开的列表,我们现在可以随意拖动了。

但是,上面的代码还存在不合理的地方,如果我们是小范围拖动,增删的列表项也就那么几个,但是上面的代码总是将所有的列表项重新更新一遍,这势必也会造成性能问题。

改进方案DEMO

在上面方案的基础上,我们进行元素更新方式的判断:如果在视窗外,那删除元素;如果在视窗内,那无需操作;如果需要增加元素,那就执行 appendChild 操作。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>超长列表滚动优化</title>
    <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1">
    <style>
        #list {
            position: relative;
            width: 200px;
            height: 500px;
            margin: 0;
            padding: 0;
            overflow: auto;
        }

        #list li {
            position: absolute;
            width: 100%;
            height: 20px;
        }
    </style>
</head>
<body>
    <ul id="list"></ul>
    <script>
        // 变量声明
        var li_h = 20;
        var li_c = 25;
        var last_item;
        var docFrag = document.createDocumentFragment();
        var ul_el = document.querySelector("#list");
        var start_index, end_index;
        var temp_list;

        // 数据获取
        var data = [];
        for(var i = 1; i < 1000000; i++){
            data.push("item " + i);
        }
        var total_count = data.length;

        // 根据滚动距离生成显示元素
        function showItems(){
            var scrollTop = ul_el.scrollTop,
                index = parseInt(scrollTop / li_h);
            temp_list = [];

            // 添加显示元素
            // 前后各加 20 个缓冲数据
            start_index = index - 20 > 0 ? index - 20 : 0;
            end_index = index + li_c + 20 < total_count - 1 ? index + li_c + 20 : total_count - 1;
            for(var i = start_index; i <= end_index; i++){
                temp_list.push(i);
            }

            // 添加占位元素
            if (end_index < total_count - 1) {
                temp_list.push(total_count - 1);
            }

            // 进行判断
            // 已经不再视窗的元素直接删除
            // 已经存在的视窗内元素继续保留
            // 视窗内没有的元素进行添加
            [].slice.call(ul_el.children).forEach(function(item){
                var i = item.dataset.index * 1;
                if((i < start_index || i > end_index) && i != total_count - 1){
                    ul_el.removeChild(item);
                } else {
                    temp_list.splice(temp_list.indexOf(i), 1);
                }
            })

            temp_list.forEach(function(i){
                var li = document.createElement("li");
                li.innerHTML = data[i];
                li.style.top = li_h * i + "px";
                li.dataset.index = i;
                docFrag.appendChild(li);
            })

            ul_el.appendChild(docFrag);
        }

        ul_el.onscroll = showItems;

        showItems();
    </script>
</body>
</html>

通过判断元素是否需要更新,我们就可以把 DOM 操作进一步简化,实现只操作应该操作的元素。

但是,做到上面就算结束了?还有没有优化的地方呢?其实还有,因为上述代码中,对列表的检测更新频率太高了,仅仅一个列表项的显隐就会触发一次,因为 onscroll 的原因,触发频率实在太高了。DOM 操作优化的另一个点就是 —— 减低 DOM 操作频率。

最终方案DEMO

这次我们的检测单位不再是一个个列表项,而是将列表项分成一组一组,将原来的触发频率进一步降低。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>超长列表滚动优化</title>
    <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1">
    <style>
        #list {
            position: relative;
            width: 200px;
            height: 500px;
            margin: 0;
            padding: 0;
            overflow: auto;
        }

        #list li {
            position: absolute;
            width: 100%;
            height: 20px;
        }
    </style>
</head>
<body>
    <ul id="list"></ul>
    <script>
        // 变量声明
        var li_h = 20;
        var li_c = 25;
        var last_item;
        var docFrag = document.createDocumentFragment();
        var ul_el = document.querySelector("#list");
        var start_index, end_index;
        var temp_list;

        // 数据获取
        var data = [];
        for(var i = 1; i < 1000000; i++){
            data.push("item " + i);
        }
        var total_count = data.length;
        var last_index = Math.ceil(total_count / li_c);

        // 根据滚动距离生成显示元素
        function showItems(){
            var scrollTop = ul_el.scrollTop,
                index = parseInt(scrollTop / li_h / li_c);
            temp_list = [];

            // 添加显示元素
            // 前后各加 2 组缓冲数据
            start_index = index - 2 > 0 ? index - 2 : 0;
            end_index = index + 3 < last_index ? index + 3 : last_index;
            for(var i = start_index * li_c; i <= end_index * li_c; i++){
                temp_list.push(i);
            }

            // 添加占位元素
            if (end_index * li_c < total_count - 1) {
                temp_list.push(total_count - 1);
            }

            // 进行判断
            // 已经不再视窗的元素直接删除
            // 已经存在的视窗内元素继续保留
            // 视窗内没有的元素进行添加
            [].slice.call(ul_el.children).forEach(function(item){
                var i = item.dataset.index * 1;
                if((i < start_index * li_c || i > end_index * li_c) && i != total_count - 1){
                    ul_el.removeChild(item);
                } else {
                    temp_list.splice(temp_list.indexOf(i), 1);
                }
            })

            temp_list.forEach(function(i){
                var li_data = data[i];
                if(li_data){
                    var li = document.createElement("li");
                    li.innerHTML = li_data;
                    li.style.top = li_h * i + "px";
                    li.dataset.index = i;
                    docFrag.appendChild(li);
                }
            })

            ul_el.appendChild(docFrag);
        }

        ul_el.onscroll = showItems;

        showItems();
    </script>
</body>
</html>

通过分组操作,当你小范围滚动时,就不会出现元素重新渲染的情况。

最后

上面的代码不是十分完善,但这里主要是给大家展示优化 DOM 操作是多么的重要,作为一个合格的前端,大家一定要在 DOM 操作时十分小心!