如果后台传给前端几万条数据,前端怎么渲染到页面上?
一次渲染几万条数据(比如13万)会造成页面加载速度缓慢,那么我们可以不要一次性渲染这么多数据,而是分批次渲染, 比如一次10000条,分13次来完成, 这样或许会对页面的渲染速度有提升。 然而,如果这13次操作在同一个代码执行流程中运行,那似乎不但无法解决糟糕的页面卡顿问题,反而会将代码复杂化。 类似的问题在其它语言最佳的解决方案是使用多线程,JavaScript虽然没有多线程,但是setTimeout和setInterval两个函数却能起到和多线程差不多的效果。
$.get("data.json", function (response) {
//response里大概有13万条数据
loadAll( response );
});
function loadAll(response) {
//将13万条数据分组, 每组500条,一共260组
var groups = group(response);
for (var i = 0; i < groups.length; i++) {
//闭包, 保持i值的正确性
window.setTimeout(function () {
var group = groups[i];
var index = i + 1;
return function () {
//分批渲染
loadPart( group, index );
}
}(), 1);
}
}
//数据分组函数(每组500条)
function group(data) {
var result = [];
var groupItem;
for (var i = 0; i < data.length; i++) {
if (i % 500 == 0) {
groupItem != null && result.push(groupItem);
groupItem = [];
}
groupItem.push(data[i]);
}
result.push(groupItem);
return result;
}
var currIndex = 0;
//加载某一批数据的函数
function loadPart( group, index ) {
var html = "";
for (var i = 0; i < group.length; i++) {
var item = group[i];
html += "<li>title:" + item.title + index + " content:" + item.content + index + "</li>";
}
//保证顺序不错乱
while (index - currIndex == 1) {
$("#content").append(html);
currIndex = index;
}
}
以上代码大致的执行流程是
- 用ajax获取到需要处理的数据, 共13万条
- 将数组分组,每组500条,一共260组
- 循环这260组数据,分别处理每一组数据, 利用setTimeout函数开启一个新的执行线程(异步),防止主线程因渲染大量数据导致阻塞。
loadPart函数中有这段代码
while (index - currIndex == 1) {
$("#content").append(html);
currIndex = index;
}
是为了保证不同的线程中最终插入html到文档中时顺序的一致性, 不至于同时执行的代码在插入html时互相篡位。
通过这种方式执行,页面瞬间就刷出来了,不用丝毫等待时间。从同步改为异步,虽然代码的整体资源消耗增加了, 但是页面却能瞬间响应,而且,前端的运行环境是用户的电脑,因此些许的性能损失带来的用户体验提升相对来说还是值得的。
前端的业务开发中会遇到一些无法使用分页方式来加载的列表,我们一般把这种列表叫做长列表。在本篇文章中,我们把长列表定义成数据长度大于 1000 条,并且不能使用分页的形式来展示的列表。
如果长列表不去做任何优化,一次完整渲染出来,到底需要多长时间呢?那么首先要先了解创建所有的 HTMLElement 并添加到 Document 中的时间消耗,因为业务中会混杂一些其他的代码,你的业务的性能不会比这个时间快。对浏览器创建元素的性能有大概的了解,才能知道长列表的优化极限在哪里。
我们可以写一个简单的方法来测试这个性能:
var createElements = function(count) {
var start = new Date();
for (var i = 0; i < count; i++) {
var element = document.createElement('div');
element.appendChild(document.createTextNode('' + i));
document.body.appendChild(element);
}
setTimeout(function() {
alert(new Date() - start);
}, 0);
};
我们给一个 Button 绑定了一个 onclick 事件,这个事件调用了 createElements(10000);。 从 Chrome 的 Profile 标签页看到的数据如下:

Event Click 只执行了 20.20ms,其他时间合计是 450ms,具体如下:
- Event Click: 20.20ms
- Recalculage Style: 16.86ms
- Layout: 410.6ms
- Update Layer Tree: 11.93ms
- Paint: 9.2ms
检测渲染时间的方法
你可能注意到了上面的测试代码中的时间计算过程中并没有直接在调用完 API 之后直接计算时间,而是使用了一个 setTimeout,下面会进行一些解释。
最简单的计算一段代码执行的时间可以这么写:
var start = Date.now();
// ...
alert(Date.now() - start);
但是对于 DOM 的性能测试这么做是不科学的,因为 DOM 的操作会引起浏览器的 (reflow),如果浏览器的 reflow 执行的时间远大于代码执行时间,会造成你时间计算完成之后,浏览器仍然在卡顿。统计的时间应该是从『开始创建元素』到『可以进行响应』的时间,所以一个合理的做法是把计算放在 setTimeout(function() {}, 0) 中。setTimeout() 中的 callback 会被推迟到浏览器主线程 reflow 结束后才执行,这个时间和 Chrome Devtools 下的 Profile 的时间基本吻合,可以信任这个时间作为渲染时间。
修改后的代码如下:
var start = Date.now();
// ...
setTimeout(function() {
alert(Date.now() - start);
}, 0);
如果需要更高的精度,可以使用 performance.now() 来替换 Date.now(),这个 API 可以精确到千分之一毫秒。
尝试使用不同的 DOM API
在前几年,优化元素创建性能经常提到的是使用 createDocumentFragment、innerHTML 来替代 createElement,通过 “createElement vs createDocumentFragment” 能找到相当多测测试结果。这篇文章中甚至说 『using DocumentFragments to append about 2700 times faster than appending with innerHTML』,我们可以做个简单的实验,看看这个结论在 Google Chrome 中是否仍然适用。
我们会分别测试以下 4 种情况:
- 创建一个空元素,并立即添加到 document 中。
- 创建一个包含文本的元素,并立即添加到 document 中。
- 创建一个 DocumentFragment,用来保存列表项,最后再把 DocumentFragment 添加到 document 中。
- 拼出所有列表项的 HTML,使用元素的 innerHTML 属性赋值。
测试代码的计算的时间每次执行都会有一些误差,表格中的数据使用的是进行 10 次测试的平均值:

从结果上来看,只有 innerHTML 会有 10% 的性能优势,createElement 和 createDocumentFragment 性能基本持平。对于现代浏览器来讲,性能瓶颈根本不在调用 DOM API 的阶段,无论使用哪种方式来使用 DOM API 添加元素,对性能的影响都微乎其微。
非完整渲染长列表
从上面的测试结果中可以看到,创建 10000 个节点就需要 500ms+,实际业务中的列表每个节点都需要 20 个左右的节点。那么,500ms 也仅能渲染 500 个左右的列表项。
所以完整渲染的长列表基本上很难达到业务上的要求的,非完整渲染的长列表一般有两种方式:
- 懒渲染:这个就是常见的无限滚动的,每次只渲染一部分(比如 10 条),等剩余部分滚动到可见区域,就再渲染另一部分。
- 可视区域渲染:只渲染可见部分,不可见部分不渲染。
懒渲染
懒渲染就是大家平常说的无限滚动,指的就是在滚动到页面底部的时候,再去加载剩余的数据。这是一种前后端共同优化的方式,后端一次加载比较少的数据可以节省流量,前端首次渲染更少的数据速度会更快。这种优化要求产品方必须接受这种形式的列表,否则就无法使用这种方式优化。
实现的思路非常简单:监听父元素的 scroll 事件(一般是 window),通过父元素的 scrollTop 判断是否到了页面是否到了页面底部,如果到了页面底部,就加载更多的数据。
可视区域渲染
可视区域渲染指的是只渲染可视区域的列表项,非可见区域的完全不渲染,在滚动条滚动时动态更新列表项。可视区域渲染适合下面这种场景:
- 每个数据的展现形式的高度需要一致(非必须,但是最小高度需要确定)。
- 产品设计上,一次需要加载的数据量比较大「1000条以上」。
- 产品设计上,滚动条需要挂载在一个固定高度的区域(在 window 上也可以,但是需要整个区域都只显示这个列表)。
例如
- 列表的高度为 400px。
- 列表中的每个元素的高度是 30px。
- 一次加载 10000 条数据。
<template>
<div class="list-view" @scroll="handleScroll($event)">
<div class="list-view-phantom" :style="{ height: data.length * 30 + 'px' }"></div>
<div v-el:content class="list-view-content">
<div class="list-view-item" v-for="item in visibleData"></div>
</div>
</div>
</template>
<style>
.list-view {
height: 400px;
overflow: auto;
position: relative;
border: 1px solid #666;
}
.list-view-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.list-view-content {
left: 0;
right: 0;
top: 0;
position: absolute;
}
.list-view-item {
padding: 5px;
color: #666;
height: 30px;
line-height: 30px;
box-sizing: border-box;
}
</style>
<script>
export default {
props: {
data: {
type: Array
},
itemHeight: {
type: Number,
default: 30
}
},
ready() {
this.visibleCount = Math.ceil(this.$el.clientHeight / this.itemHeight);
this.start = 0;
this.end = this.start + this.visibleCount;
this.visibleData = this.data.slice(this.start, this.end);
},
data() {
return {
start: 0,
end: null,
visibleCount: null,
visibleData: [],
scrollTop: 0
};
},
methods: {
handleScroll(event) {
const scrollTop = this.$el.scrollTop;
const fixedScrollTop = scrollTop - scrollTop % 30;
this.$els.content.style.webkitTransform = `translate3d(0, ${fixedScrollTop}px, 0)`;
this.start = Math.floor(scrollTop / 30);
this.end = this.start + this.visibleCount;
this.visibleData = this.data.slice(this.start, this.end);
}
}
};
</script>
可以参考这个说明来辅助理解这个例子:
- 使用一个 phantom 元素来撑起整个这个列表,让列表的滚动条出现。
- 列表里面使用变量 visibleData(Array 类型) 记录目前需要显示的所有数据。
- 列表里面使用变量 visibleCount 记录可见区域最多显示多少条数据。
- 列表里面使用变量 start、end 记录可见区域数据的开始和结束索引。
- 在滚动的时候,修改真实显示区域的 transform: translate2d(0, y, 0)。
上面只是一个简单的例子,如果要用在生产上,你可以建议使用 Clusterize 或者 React Virtualized。
你可能会发现无限滚动在移动端很常见,但是可见区域渲染并不常见,这个主要是因为 iOS 上 UIWebView 的 onscroll 事件并不能实时触发。笔者曾尝试过使用 iScroll 来实现类似可视区域渲染,虽然初次渲染慢的问题可以解决,但是会出现滚动时体验不佳的问题(会有白屏时间)。
另一篇文章
虚拟列表组件
长列表的优化是一个一直以来都很棘手的非常复杂的问题,Antd Design 的List组件就推荐与 react-virtualized 组件结合使用来对长列表进行优化。我们最好是使用一些现成的虚拟列表组件来对长列表进行优化,比较常见的有 react-virtualized 和 react-tiny-virtual-list 这两个组件,使用他们可以有效地对你的长列表进行优化。
实现一个简单的虚拟列表
方案一
第一种方案的dom结构如图
- 外层容器:设置height,overflow:scroll
- 滑动列表:绝对定位,然后用”列表元素高度”乘以”列表元素数量”计算出滑动列表高度
- 可视区域:动态计算可视区域在滑动列表中的偏移量,使用 translate3d 属性动态设置可视区域的偏移量,造成滑动的效果。

import React from 'react';
// 应该接收的props: renderItem: Function<Promise>, getData:Function; height:string; itemHeight: string
// 下滑刷新组件
class InfiniteTwo extends React.Component {
constructor(props) {
super(props);
this.renderItem = props.renderItem
this.getData = props.getData
this.state = {
loading: false,
page: 1,
showMsg: false,
List: [],
itemHeight: this.props.itemHeight || 0,
start: 0,
end: 0,
visibleCount: 0
}
}
onScroll() {
let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper;
let showOffset = scrollTop - (scrollTop % this.state.itemHeight)
const target = this.refs.scrollContent
target.style.WebkitTransform = `translate3d(0, ${showOffset}px, 0)`
this.setState({
start: Math.floor(scrollTop / this.state.itemHeight),
end: Math.floor(scrollTop / this.state.itemHeight + this.state.visibleCount + 1)
})
if(offsetHeight + scrollTop + 15 > scrollHeight){
if(!this.state.showMsg){
let page = this.state.page;
page++;
this.setState({
loading: true
})
this.getData(page).then(data => {
this.setState({
loading: false,
page: page,
List: data.concat(this.state.List),
showMsg: data && data.length > 0 ? false : true
})
})
}
}
}
componentDidMount() {
this.getData(this.state.page).then(data => {
this.setState({
List: data
})
// 初始化列表以后,也需要初始化一些参数
requestAnimationFrame(() => {
let {offsetHeight} = this.refs.scrollWrapper;
let visibleCount = Math.ceil(offsetHeight / this.state.itemHeight)
let end = visibleCount + 1
console.log(this.refs.scrollContent.firstChild.clientHeight)
this.setState({
end,
visibleCount
})
})
})
}
render() {
const {List, start, end, itemHeight} = this.state
const renderList = List.map((item,index)=>{
if(index >=start && index <= end)
return(
this.renderItem(item, index)
)
})
console.log(renderList)
return(
<div>
<div
ref="scrollWrapper"
onScroll={this.onScroll.bind(this)}
style=
>
<div style=>
</div>
<div ref="scrollContent" style=>
{renderList}
</div>
</div>
{this.state.loading && (
<div>加载中</div>
)}
{this.state.showMsg && (
<div>暂无更多内容</div>
)}
</div>
)
}
}
export default InfiniteTwo;
方案一中,我们设置了几个变量
- start 渲染的第一个元素的索引
- end 渲染的最后一个元素的索引
- visibleCount 可见的元素个数 start + visibleCount = end
- List 所有列表项的数据
- showOffset 可视元素列表的偏移量 滚动的时候采用 scrollTop - (scrollTop % this.state.itemHeight) 计算

每次都只渲染了可视区域的几个 dom 元素,确实做到了对于大数据情况下的长列表的优化。但是,这里只是实现了列表元素固定高度的情况,对于高度不固定的列表,如何实现优化呢?
方案二
第二种方案的 dom 结构如图
- 外层容器:设置height,overflow:scroll
- 顶部:可视区域之前的元素高度
- 尾部:可视区域之后的元素高度
- 可视区域:可视区域内的列表元素

import React from 'react';
// 应该接收的props: renderItem: Function<Promise>, getData:Function; height:string;
// 下滑刷新组件
class InfiniteOne extends React.Component {
constructor(props) {
super(props);
this.renderItem = props.renderItem
this.getData = props.getData
this.state = {
loading: false,
page: 0,
showMsg: false,
List: []
}
this.pageHeight = []
}
onScroll() {
let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper;
// 判断一下需要展示的列表,其他的列表都给隐藏了
let ListShow = [...this.state.List]
ListShow.forEach((item, index) => {
if(this.pageHeight[index]){
let bottom = this.pageHeight[index].top + this.pageHeight[index].height
if((bottom < scrollTop - 50) || (this.pageHeight[index].top > scrollTop + offsetHeight + 50)){
ListShow[index].visible = false
}else{
ListShow[index].visible = true
}
}
})
this.setState({
List: ListShow
})
if(offsetHeight + scrollTop + 5 > scrollHeight){
if(!this.state.showMsg){
let page = this.state.page;
page++;
this.setState({
loading: true
})
this.getData(page).then(data => {
this.setState(prevState => {
let List = [...prevState.List]
List[page] = {data, visible: true}
return {
loading: false,
page: page,
List: List,
showMsg: data && data.length > 0 ? false : true
}
})
// setState之后,更新了dom,这时候需要知道每个page的top和height
requestAnimationFrame(() => {
const target = this.refs[`page${page}`]
let top = 0;
if(page > 0){
top = this.pageHeight[page - 1].top + this.pageHeight[page - 1].height
}
this.pageHeight[page] = {top, height: target.offsetHeight}
})
})
}
}
}
componentDidMount() {
this.getData(this.state.page).then(data => {
this.setState((prevState) => {
let List = [...prevState.List]
List[this.state.page] = {data, visible: true}
return {List}
})
requestAnimationFrame(() => {
this.pageHeight[0] = {top: 0, height: this.refs['page0'].offsetHeight}
})
})
}
render() {
const {List} = this.state
let headerHeight = 0;
let bottomHeight = 0;
let i = 0;
for(; i < List.length; i++){
if(!List[i].visible){
headerHeight += this.pageHeight[i].height
}else{
break;
}
}
for(; i < List.length; i++){
if(!List[i].visible){
bottomHeight += this.pageHeight[i].height
}
}
const renderList = List.map((item,index)=>{
if(item.visible){
return <div ref={`page${index}`} key={`page${index}`}>
{item.data.map((value, log) => {
return(
this.renderItem(value, `${index}-${log}`)
)
})}
</div>
}
})
console.log(renderList)
return(
<div
ref="scrollWrapper"
onScroll={this.onScroll.bind(this)}
style=
>
<div style=></div>
{renderList}
<div style=></div>
{this.state.loading && (
<div>加载中</div>
)}
{this.state.showMsg && (
<div>暂无更多内容</div>
)}
</div>
)
}
}
export default InfiniteOne;
方案二中,我们设置了几个变量
- List:所有列表项的数据。List 是一个数组,每一项的 data 属性存储的是一页的数据,visible 属性用来在 render 的时候判断是否渲染该页数据,滚动地时候会动态地更新 List 中每一项的 visible 属性,从而控制需要渲染的元素。
- pageHeight:所有项的位置信息。pageHeight 也是一个数组。每一项的 top 属性表示该页的顶部滚动的距离,height 表示该页的高度。pageHeight 用来在滚动的时候根据 scrollTop 来更新 List 数组中每一项的visible属性。
方案二实现的组件相比方案一来说可以支持列表元素的高度不一致的情况。那方案二是不是就基本可以满足需求了呢?显然并不是。我们在前言和上文中说过,虚拟列表是用于长列表优化的(一次性加载成千上万条数据)。方案二中的列表高度和位置是在每一次下拉加载完成以后,计算得来的;并且这个列表高度和位置还决定了 headerHeight 和 bottomHeight (即列表里前后两块无渲染区域的高度)。所以方案二的思路不能直接用在长列表里。 我们想先研究研究 react-tiny-virtual-list 和 react-virtualized,以期望获得一些改进上的思路。
react-tiny-virtual-list 虽然可以无限下拉滚动,但是对于列表元素的动态高度,并不支持。需要明确指定每个元素的高度。 我们再来看一下 react-virtualized 这个组件,他虽然比 react-tiny-virtual-list 功能更完善,但是也依然需要明确指定每个元素的高度。 可能有其他方法,可以支持解决这个元素高度不固定的情况下无限滚动的问题。 react-virtualized 也意识到了这个问题,所以提供了一个 CellMeasurer 组件,这个组件能够动态地计算子元素的大小。
有人可能会有疑问:那在计算的时候,元素不是就已经被加载出来了吗,那计算还有什么用呢?这里使用的方法是:在 cell 元素被渲染之前,用的是预估的列宽值或者行高值计算的,此时的值未必就是精确的,而当 cell 元素渲染之后,就能获取到其真实的大小,因而缓存其真实的大小之后,在组件的下次 re-render 的时候就能对原先预估值的计算进行纠正,得到更精确的值。 我们也可以借鉴一下这种思路来对方案二进行一些改造使其能够应对长列表的情况。为了方便,我们单独写出一个组件来应对长列表的情况;对于下拉加载,仍然采用方案二。
方案三

- 外层容器:设置height,overflow:scroll
- 顶部:可视区域之前的元素高度
- 尾部:先采用预估高度计算,在向下滚动的过程中再获取实际高度进行调整
- 可视区域:可视区域内的列表元素
这样的话,我们就需要对方案二进行一些优化。首先我们组件接收的属性里需要一个预估的列表高度。然后需要接收一个数据列表,resource。接着,我们按照方案二的思路,对数据分好页。我们先用预估高度来计算headerHeight和bottomHeight,从而撑开滚动容器。当滑动到需要加载的页时,动态地更新所存储的页码的高度。
import React from 'react';
// 应该接收的props: renderItem: Function<Promise>, height:string; estimateHeight:Number, resource: Array
// 下滑刷新组件
class InfiniteThree extends React.Component {
constructor(props) {
super(props);
this.renderItem = props.renderItem
this.getData = props.getData
this.estimateHeight = Number(props.estimateHeight) * 10 //一页10条数据,进行一页数据的预估
this.resource = props.resource
this.listLength = props.resource.length
let pageList = []
// 对接收到的大数据进行分页整理,保存在List里面
let array = []
for(let i = 0; i < props.resource.length; i++){
if(i % 10 === 0 && i || i === (props.resource.length - 1)){
pageList.push({
data: array,
visible: false
})
array = []
}
array.push(props.resource[i])
}
pageList[0].visible = true
// 然后对pageHeight根据预估高度进行预估初始化,后续重新进行计算
this.pageHeight = []
for(let i = 0; i < this.listLength; i++){
if(i === 0){
this.pageHeight.push({
top: 0,
height: this.estimateHeight,
isComputed: false,
})
}else{
this.pageHeight.push({
top: this.pageHeight[i-1].top + this.pageHeight[i-1].height,
height: this.estimateHeight,
isComputed: false
})
}
this.state = {
loading: false,
page: 0,
showMsg: false,
List: pageList,
}
}
}
onScroll() {
requestAnimationFrame(() => {
let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper;
// 判断一下需要展示的列表,其他的列表都给隐藏了
let ListShow = [...this.state.List]
ListShow.forEach((item, index) => {
if(this.pageHeight[index]){
let bottom = this.pageHeight[index].top + this.pageHeight[index].height
if((bottom < scrollTop - 5) || (this.pageHeight[index].top > scrollTop + offsetHeight + 5)){
ListShow[index].visible = false
}else{
// 根据预估高度算出来它在视野内的时候,先给它变成visible,让他出现,才能拿到元素高度
this.setState(prevState => {
let List = [...prevState.List]
List[index].visible = true
return {
List
}
})
// 出现以后,然后计算高度,替换掉之前用预估高度设置的height
let target = this.refs[`page${index}`]
let top = 0;
if(index > 0){
top = this.pageHeight[index - 1].top + this.pageHeight[index - 1].height
}
if(target && target.offsetHeight && !ListShow[index].isComputed){
this.pageHeight[index] = {top, height: target.offsetHeight}
console.log(target.offsetHeight)
ListShow[index].visible = true
ListShow[index].isComputed = true
// 计算好了以后,还要再setState一下,调整列表高度
this.setState({
List: ListShow,
})
}else{
this.pageHeight[index] = {top, height: this.estimateHeight}
}
}
}
})
})
}
componentDidMount() {
}
render() {
let {List} = this.state
let headerHeight = 0;
let bottomHeight = 0;
let i = 0;
for(; i < List.length; i++){
if(!List[i].visible){
headerHeight += this.pageHeight[i].height
}else{
break;
}
}
for(; i < List.length; i++){
if(!List[i].visible){
bottomHeight += this.pageHeight[i].height
}
}
const renderList = List.map((item,index)=>{
if(item.visible){
return <div ref={`page${index}`} key={`page${index}`}>
{item.data.map((value, log) => {
return(
this.renderItem(value, `${index}-${log}`)
)
})}
</div>
}
})
return(
<div
ref="scrollWrapper"
onScroll={this.onScroll.bind(this)}
style=
>
<div style=></div>
{renderList}
<div style=></div>
{this.state.loading && (
<div>加载中</div>
)}
{this.state.showMsg && (
<div>暂无更多内容</div>
)}
</div>
)
}
}
export default InfiniteThree;
方案三中我们在方案二的基础上给pageHeight数组的每一项增加了isComputed属性,初始化时每一项的height是使用的estimateHeigh(预估高度)的值。只有在使用真实高度更新了这一项的height后,isComputed才会置为true。
值得一提的是,这个预估高度的值,尽量要大于等于实际的高度值,从而做到能把容器撑开。
林秀栋的技术博客