每一个前端童鞋都应该知道,只要是涉及到 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 操作时十分小心!