JS 原生自动加载的大数据表格探索

目录

尝试1.使用table初级实现

动态加载关键代码

效果

尝试2.使用绝对定位优化表格

效果

尝试3.绝对定位+scroll动态加载优化尝试

效果

尝试4. table + 绝对定位 + scroll动态加载

效果

参考资料


大数据表格,就是能够没有分页的情况下,一次展示上万条数据的表格。

若直接渲染上万条数据的,页面会一直卡着,直到浏览器渲染完成后才显示且响应用户操作。

比如加载10000条数据,效果

加载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>

效果

效果图

这里可以看到,通过懒加载的形式去加载数据,页面流畅度得到了很大的提高。

但是如果滚动条拉动过快,还是会有一瞬间的白屏问题。

而且也会随着已渲染数据量的增加而变卡。


接下来就得解决数据加载完成后,滚动表格卡的问题,我想到的方案分为两类

  1. 回头是岸,用回table标签,因为加载完后不卡(后来发现是td的overflow:hidden引起的)。缺点是不能懒加载。或者想办法使用table去实现懒加载。
  2. 仍旧使用绝对定位+懒加载,只是设置一个数据队列,最大值比如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; 这么影响性能,如何解决表格内容溢出问题呢。

大体思考了几个方向。

  1. 尝试用css选择器或js,单独将可能溢出的列td设置为over-flow:hidden;
  2. 设置懒加载队列,队列溢出时,将最先加载的页删除。
  3. 使其换行,但是换行的话会影响表格的高度,使懒加载时不好计算位置。
  4. 虚拟滚动(这个在行业内已经很成熟了)
  5. css 属性content-visibility 优化性能,chrome > 85 
  6. setTimeout 换为 requestAnimationFrame 加载数据。

有关大数据表格加载,这个方向大体上是有了,之后的代码我就放到github上了,欢迎指导 GitHub - 601286825nj/big-table


参考资料

  1. 从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理——“GUI渲染线程与JS引擎线程互斥”小节

若有错误/补充,敬请指出更改


两年后。我基于vue封装了一个大数据表格。stk-table-vue - npm

欢迎start&PR

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值