目录
尝试4. table + 绝对定位 + scroll动态加载
大数据表格,就是能够没有分页的情况下,一次展示上万条数据的表格。
若直接渲染上万条数据的,页面会一直卡着,直到浏览器渲染完成后才显示且响应用户操作。
比如加载10000条数据,效果
那么如何做到打开、刷新大数据表格页面的时候能够马上显示用户可见部分的数据,剩下数据在后台慢慢加载呢。
但是理想是美好的,现实是骨感的。
这里就出现了矛盾,由于浏览器渲染线程与JS线程是互斥的,也就是说在渲染页面的时候js就停止执行,js执行时,页面停止渲染。[参考资料1]
所以在web前端中,难以将页面渲染放到“后台”执行(JS的话可以通过Web Workers 另启起一个线程进行复杂计算)
即便是这样,我想到了cpu时间片的概念,打算少量多次进行渲染表格—让出js线程—渲染表格—让出线程—...
下面动手实践:
尝试1.使用table初级实现
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Large Table</title>
<style>
#table{
border-collapse: collapse;
table-layout: fixed;
width: 100%;
}
#table tr:hover{
background-color: #bbb;
}
#table td {
border: 1px solid #ddd;
}
</style>
</head>
<body>
<div style="height: 200px;overflow:auto;">
<table id="table" >
<colgroup>
<col style="background: #ddd;">
<col style="font-weight: bold;">
</colgroup>
</table>
</div>
<h2 id="loading"></h2>
</body>
<script>
console.time()
const ROW_TEMP = [{ type: 'id' }, '王小虎', '28', '170cm', '80kg', 'xx省xxxx有限公司', { type: 'button', label: '详情' }];
const ROWS = 20000;
let table = document.getElementById('table');
let fgmt = document.createDocumentFragment();
for (let i = 1; i <= ROWS; i++) { // row
let tr = document.createElement('tr')
for (let j = 0; j < ROW_TEMP.length; j++) { // column
let item = ROW_TEMP[j];
let td = document.createElement('td');
td.className = 'item';
if (typeof item == 'string') {
td.textContent = ROW_TEMP[j];
} else {
if (item.type == 'id') {
td.textContent = i;
} else if (item.type == 'button') {
let btn = document.createElement('button');
btn.textContent = item.label;
td.append(btn);
}
}
tr.appendChild(td);
}
fgmt.appendChild(tr);
if( !(i % 10) ){ // 10条数据加载一次
let tmp = fgmt;
setTimeout(() => {
table.appendChild(tmp);
document.querySelector('#loading').textContent = `loading...(${Math.ceil(i/ROWS*100)}%)`;
}, i*3);
fgmt = document.createDocumentFragment();
}
}
console.timeEnd()
</script>
</html>
动态加载关键代码
if(!(i % 10)){ // 10条数据加载一次
let tmp = fgmt;
setTimeout(() => {
table.appendChild(tmp);
document.querySelector('#loading').textContent = `loading...(${Math.ceil(i/ROWS*100)}%)`;
}, i*3);
fgmt = document.createDocumentFragment();
}
加载10条数据后延时,延时的地方为3倍的 i,控制上一次加载和下一次加载间间隔为10 × 3 = 30 ms
是为了确保在30ms期间内能够把本次(10条)数据加载完。且有剩下的时间会交给js线程,使浏览器相应用户行为,防止页面卡住。
由于涉及到大量元素的新增和append,这里使用了DocumentFragement,来将保存创建的元素片段,之后一次性加到table中,据说能在一定程度上提升性能。
table添加子元素的时候会导致浏览器reflow(重排),因为table列宽会根据该列撑开的最大宽度调整。
因此这里CSS设置了table-layout:fixed 使每列的宽度固定,据说可以提升性能。
效果
这里看到,表中的数据是在不停增加的,右侧滚动条位置也在变化。滚动卡顿可接受。
但是加载完成后又流畅了,如果不介意的话,就可以直接用了。
尝试2.使用绝对定位优化表格
那么根据页面优化原则,减少浏览器reflow(重排),使用position:absolute定位的元素不会导致浏览器reflow。
那么自行用div布局实现一下表格,每行内容使用flex布局,行的位置使用absolute绝对定位计算出来
表格在渲染前,先计算总高度,再用一个元素来占位高度(.table-height元素),这样在加载表格的时候,右侧滚动条就不会乱动了。
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Large Table</title>
<style>
.table {
position: relative;
border-top: 1px solid #ddd;
}
.table .row {
box-sizing: border-box;
width: 100%;
display: flex;
position: absolute;
height: 30px;
line-height: 30px;
}
.table-height {
width: 1px;
background: #ddd;
}
.table .row .item {
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
width: 100px;
border-right: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div id="tableContent" style="height: 200px;overflow:auto;">
<div class="table" id="table">
<div class="table-height"></div>
</div>
</table>
</div>
<h2 id="loading"></h2>
</body>
<script>
console.time()
const LINE_HEIGHT = 30;
const ROWS = 20000;
const COLS = 10;
const DATA_STEP = 200;
const ROW_TEMP = [{ type: 'id' }, '王小虎', '28', '170cm', '80kg', 'xx省xxxx有限公司', { type: 'button', label: '详情' }];
let table = document.getElementById('table');
let fgmt = document.createDocumentFragment();
document.querySelector('.table-height').style = 'height:' + LINE_HEIGHT * ROWS + 'px';
for (let i = 1; i <= ROWS; i++) { // row
let tr = document.createElement('div');
tr.className = 'row';
tr.style.top = (i - 1) * LINE_HEIGHT + 'px';
for (let j = 0; j < ROW_TEMP.length; j++) { // column
let item = ROW_TEMP[j];
let td = document.createElement('div');
td.className = 'item';
if (typeof item == 'string') {
td.textContent = ROW_TEMP[j];
} else {
if (item.type == 'id') {
td.textContent = i;
} else if (item.type == 'button') {
let btn = document.createElement('button');
btn.textContent = item.label;
td.append(btn);
}
}
tr.appendChild(td);
}
fgmt.appendChild(tr);
if (ROWS >= DATA_STEP && !(i % DATA_STEP)) { // 多少条数据加载一次
let tmp = fgmt;
setTimeout(() => {
table.appendChild(tmp);
document.querySelector('#loading').textContent = `loading...(${Math.round(i / ROWS * 100)}%)`;
}, i * 2); // 保证让出线程时间片
fgmt = document.createDocumentFragment(); // 清空
}
if (ROWS < DATA_STEP) {
table.appendChild(fgmt);
}
}
console.timeEnd()
</script>
</html>
效果
可以看到刚开始加载的时候还是很流畅的,随着数据增多,滚动也变得卡了起来。且在加载中,下面未加载的数据都是空白,用户体验相对差一些(即使做了加载进度百分比提示)
问题是这个方法在表格加载完成后,也巨卡。
那么能不能用懒加载的形式加载数据呢,比如我滚动到哪个位置,哪里的数据就开始加载,继续尝试
尝试3.绝对定位+scroll动态加载优化尝试
正是使用了绝对定位自己实现了表格,所以懒加载可以轻易实现。否则普通table实现懒加载或需要其他特殊方式。
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Large Table2</title>
<style>
.table {
position: relative;
border-top: 1px solid #ddd;
}
.table .row {
box-sizing: border-box;
width: 100%;
display: flex;
position: absolute;
height: 30px;
line-height: 30px;
}
/*高度占位元素*/
.table-height {
width: 1px;
background: #ddd;
}
.table .row:hover {
background: #ddd;
}
.table .row .item {
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
width: 100px;
border-right: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div id="tableContent" style="height: 200px;overflow:auto;">
<div class="table" id="table">
<!--高度占位元素-->
<div class="table-height"></div>
</div>
</table>
</div>
</body>
<script>
console.time()
const LINE_HEIGHT = 30;
const PAGE_SIZE = 100;
const ROWS = 50000;
const COLS = 10;
const DATA_STEP = 200;
const PRELOAD_PAGES = 2; // 预加载页数
const ROW_TEMP = [{ type: 'id' }, '王小虎', '28', '170cm', '80kg', '浙江省杭州市xxxxxx有限公司', { type: 'button', label: '详情' }];
const LOADED_INDEX = new Set(); // 存已经加载的页
const TOTAL_PAGES = Math.floor(ROWS / PAGE_SIZE); // 总页数
let tableContent = document.querySelector("#tableContent");
let table = document.getElementById('table');
document.querySelector('.table-height').style = 'height:' + LINE_HEIGHT * ROWS + 'px';
/**
* 找到未加载的页
*/
function findUnloadPage(pageIndex) {
let arr = [];
for (let i = pageIndex; i <= pageIndex + PRELOAD_PAGES && i < TOTAL_PAGES; i++) {
if (!LOADED_INDEX.has(i)) arr.push(i);
}
return arr;
}
/**
* 从第几条数据开始加载
* @param startIndex 开始加载的数据index(通过scrollTop/height得出
*/
function loadPage(pageIndex) {
if (pageIndex > TOTAL_PAGES) return;
let unLoadedPages = findUnloadPage(pageIndex);
if (!unLoadedPages.length) return;
unLoadedPages.forEach((unLoadedPage) => {
let start = unLoadedPage * PAGE_SIZE;
let end = (unLoadedPage + 1) * PAGE_SIZE;
LOADED_INDEX.add(unLoadedPage); // 记录已加载的
let fgmt = loadRowsRange(start, end);
table.appendChild(fgmt);
})
}
/**
* 加载数据区间
* @param {Number} start 开始index
* @param {Number} end 结束index
* @return {DocumentFragement}
*/
function loadRowsRange(start, end) {
let fgmt = document.createDocumentFragment();
for (let i = start; i < end; i++) { // row
let row = document.createElement('div');
row.className = 'row';
row.style.top = i * LINE_HEIGHT + 'px';
for (let j = 0; j < ROW_TEMP.length; j++) { // column
let item = ROW_TEMP[j];
let td = document.createElement('div');
td.className = 'item';
if (typeof item == 'string') {
td.textContent = ROW_TEMP[j];
} else {
if (item.type == 'id') {
td.textContent = i;
} else if (item.type == 'button') {
let btn = document.createElement('button');
btn.textContent = item.label;
td.append(btn);
}
}
row.appendChild(td);
}
fgmt.appendChild(row);
}
return fgmt;
}
loadPage(0);
let debunceTimeout = null; // 防抖
tableContent.addEventListener('scroll', (e) => {
let pageIndex = Math.floor(e.target.scrollTop / (PAGE_SIZE * LINE_HEIGHT));
console.log(pageIndex);
if (debunceTimeout) {
clearTimeout(debunceTimeout);
}
debunceTimeout = setTimeout(() => {
loadPage(pageIndex);
// console.log(LOADED_INDEX);
}, 100);
})
console.timeEnd()
</script>
</html>
效果
这里可以看到,通过懒加载的形式去加载数据,页面流畅度得到了很大的提高。
但是如果滚动条拉动过快,还是会有一瞬间的白屏问题。
而且也会随着已渲染数据量的增加而变卡。
接下来就得解决数据加载完成后,滚动表格卡的问题,我想到的方案分为两类
- 回头是岸,用回table标签,因为加载完后不卡(后来发现是td的overflow:hidden引起的)。缺点是不能懒加载。或者想办法使用table去实现懒加载。
- 仍旧使用绝对定位+懒加载,只是设置一个数据队列,最大值比如5000条,通过后面若再加载,就把最先加载的数据删除了。缺点是闪屏,数据永远都要加载。
继续探索
尝试4. table + 绝对定位 + scroll动态加载
综合考虑1.使用table初级实现 和3.绝对定位+scroll动态加载优化尝试 后,由于table加载完数据后滚动的流畅性,因此打算用回table标签做表格。
那么,如何使table中的元素也懒加载呢——每页用单独一个table拼接起来,每个table再使用绝对定位。
其次,也加回了自动加载的代码(如下function autoLoadData),在用户没什么操作的时候,默默把剩下的数据也加载进去。同时,也支持懒加载,用户点到哪儿,哪儿的数据开始加载。
同时LINE_HEIGHT也根据第一次加载后,动态获取。因为系统缩放和浏览器缩放下,一行的高度不一定是30px,如下
下面改造第3部分的代码
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Large Table4</title>
<style>
.table-content {
height: 200px;
overflow:auto;
position: relative;
}
.table {
position: absolute;
width: 100%;
table-layout: fixed;
border-collapse: collapse;
border-spacing: 0px;
}
.table .row {
box-sizing: border-box;
height: 30px;
}
/*高度占位元素*/
.table-height {
float:left;
width: 1px;
}
.table .row:hover {
background: #ddd;
}
.table .row .item {
padding: 0;
white-space: nowrap;
/* overflow: hidden; 严重影响性能*/
/* text-overflow: ellipsis; */
border: 1px solid #ddd;
}
.loading{
--width: 50%;
height: 5px;
width: var(--width);
background-color: cadetblue;
}
</style>
</head>
<body>
<div id="tableContent" class="table-content">
<div class="table-height"></div> <!--高度占位元素-->
<!-- <table class="table" id="table"></table> -->
</div>
<div class="loading"></div>
</body>
<script>
console.time()
let LINE_HEIGHT = 30;
const PAGE_SIZE = 200;
const ROWS = 50000;
const AUTO_LOAD_MS = 20; // 自动加载间隔ms
const PRELOAD_PAGES = 1; // 预加载页数
const ROW_TEMP = [{ type: 'id' }, '王小虎', '28', '170cm', '80kg', '浙江省xxxxxx有限公司', { type: 'button', label: '详情' }];
let LOADED_INDEX = new Set(); // 存已经加载的页
const TOTAL_PAGES = Math.floor(ROWS / PAGE_SIZE); // 总页数
let tableContent = document.querySelector("#tableContent");
document.querySelector('.table-height').style = 'height:' + LINE_HEIGHT * ROWS + 'px';
window.onload = function(){
tableContent.addEventListener('scroll', scrollEvent)
autoLoadData() // init page
}
/**找到未加载的页 */
function findUnloadPage(pageIndex) {
let arr = [];
for (let i = pageIndex; i <= pageIndex + PRELOAD_PAGES && i < TOTAL_PAGES; i++) {
if (!LOADED_INDEX.has(i)) arr.push(i);
}
return arr;
}
/**
* 从第几条数据开始加载
* @param startIndex 开始加载的数据index(通过scrollTop/height得出
*/
function loadPage(pageIndex) {
if (pageIndex > TOTAL_PAGES) return;
let unLoadedPages = findUnloadPage(pageIndex);
if (!unLoadedPages.length) return;
unLoadedPages.forEach((unLoadedPage) => {
let start = unLoadedPage * PAGE_SIZE;
let end = (unLoadedPage + 1) * PAGE_SIZE;
LOADED_INDEX.add(unLoadedPage); // 记录已加载的
let fgmt = loadRowsRange(start, end);
tableContent.appendChild(fgmt);
if(unLoadedPage == 0){
let row = document.querySelector('.row');
LINE_HEIGHT = parseFloat(getComputedStyle(row).height); // 计算出实际高度
}
})
}
/**
* 加载数据区间
* @param {Number} start 开始index
* @param {Number} end 结束index
* @return {DocumentFragement}
*/
function loadRowsRange(start, end) {
let fgmt = document.createDocumentFragment();
let table = document.createElement('table');
table.classList.add('table');
table.style.top = start * LINE_HEIGHT + 'px';
for (let i = start; i < end; i++) { // row
let row = document.createElement('tr');
row.className = 'row';
for (let j = 0; j < ROW_TEMP.length; j++) { // column
let item = ROW_TEMP[j];
let td = document.createElement('td');
td.className = 'item';
if (typeof item == 'string') {
td.textContent = ROW_TEMP[j];
} else {
if (item.type == 'id') {
td.textContent = i;
} else if (item.type == 'button') {
let btn = document.createElement('button');
btn.textContent = item.label;
td.append(btn);
}
}
row.appendChild(td);
}
table.appendChild(row);
}
fgmt.appendChild(table);
return fgmt;
}
let debunceTimeout = null; // 防抖
function scrollEvent(e) {
let pageIndex = Math.floor(e.target.scrollTop / (PAGE_SIZE * LINE_HEIGHT));
// console.log(pageIndex);
if (debunceTimeout) {
clearTimeout(debunceTimeout);
}
debunceTimeout = setTimeout(() => {
loadPage(pageIndex);
// console.log(LOADED_INDEX);
}, AUTO_LOAD_MS);
}
/*auto load data*/
function autoLoadData(){
let pageIndex = 0;
let loading = document.querySelector('.loading');
let interval = setInterval(() => {
// console.log(pageIndex);
if(pageIndex >= TOTAL_PAGES){ // fininsh load all data
clearInterval(interval);
tableContent.removeEventListener('scroll', scrollEvent); // remove scroll listener to improve performance
LOADED_INDEX = null; // try to gc
}
loading.style.setProperty('--width', pageIndex/TOTAL_PAGES * 100 + '%');
loadPage(pageIndex++);
}, 100)
}
console.timeEnd()
</script>
</html>
经过研究比较,发现css中给每个td设置overflow:hidden; 会严重影响滚动性能,因此我选择注释css中的那一部分。同时我也试着将本文尝试3中的over-flow:hidden去除,数据加载完成后,果然流畅不少,但仍比不上table标签。
去掉overflow:hidden后,在数据加载完成后滚动表格就变得丝般顺滑。
这样导致如果仍按30px来计算高度的话,得到的top值会出现问题
其中有几个关键的变量与加载性能挂钩,如下
- PAGE_SIZE: 表示每页的大小,这个值越大,那么加载一页的时候,渲染线程占用的时间就越长。
-
AUTO_LOAD_MS:自动加载时,每次加载的间隔时间,值越小,则渲染线程执行完后,剩下的时间给js就越短。
-
PRELOAD_PAGES: 懒加载时,预加载的页数,如拉滚动条瞬间跳转到第10页,如果这个值设置为2,则会预加载11,12页的内容。
-
ROWS: 加载的记录数,直接影响页面性能,不过多介绍
效果
加载过程中有点卡,但是加载完成后就很流畅了。
接下来就是讨论overflow:hidden的问题,一个表格中td内容总会溢出td的,那既然over-flow:hidden; 这么影响性能,如何解决表格内容溢出问题呢。
大体思考了几个方向。
- 尝试用css选择器或js,单独将可能溢出的列td设置为over-flow:hidden;
- 设置懒加载队列,队列溢出时,将最先加载的页删除。
- 使其换行,但是换行的话会影响表格的高度,使懒加载时不好计算位置。
- 虚拟滚动(这个在行业内已经很成熟了)
- css 属性content-visibility 优化性能,chrome > 85
- setTimeout 换为 requestAnimationFrame 加载数据。
有关大数据表格加载,这个方向大体上是有了,之后的代码我就放到github上了,欢迎指导 GitHub - 601286825nj/big-table
参考资料
若有错误/补充,敬请指出更改
两年后。我基于vue封装了一个大数据表格。stk-table-vue - npm
欢迎start&PR