用canvas画了个table,手写滚动条

在之前业务有幸接触过复杂的大数据业务渲染,所用的table居然是用canvas以及虚拟列表的方式实现,也有看到飞书的统计信息表就是canvas绘制,一直没太明白为什么要用canvas去做,今天记录一下如何用canvas绘制一个table的简易版,希望看完在项目中能带来一些思考和帮助。

正文开始...

在开始本文之前,主要是从以下方向去思考:

1、canvas绘制table必须满足我们常规table方式

2、因为table内容是显示在画布中,那如何实现滚动条控制,canvas是固定高的

3、内容的分页显示需要自定义滚动条,也就是需要自己实现一个滚动条

4、如何在canvas中扩展类似vue插槽能力

5、在canvas中的列表事件操作,比如删除,编辑等。

canvas画个table
首先我们确定一个普通的表就是header和body组成,在html中,我们直接用thead与tbody以及tr,td就可以轻松画出一个表,或者用div也可以布局一个table出来

那在canvas中,就需要自己绘制了head与body了

我们把table主要分成两部分

thead表头,在canvas画布我们是以左侧顶点为起始点的一个逆向的x,y坐标系



我们看下对应的代码,我们把预先html基本结构以及部分mock数据自己先模拟一份

  <div id="app">
      <div class="content-table">
        <canvas id="canvans" width="600" height="300"></canvas>
      </div>
    </div>
    <script src="./index.js"></script>
    <script>
      const slideWrap = document.getElementById("slide-wrap");
      const slide = slideWrap.querySelector(".slide");
      const canvansDom = document.getElementById("canvans");
      const columns = [{label: "姓名",key: "name",},{label: "年龄",key: "age",},
      {label: "学校",key: "school"},{label: "分数",key: "source"},{label: "操作",key: "options"}];
      const mockData = [
        {
          name: "张三",
          id: 0,
          age: 0,
          school: "公众号:Web技术学苑",
          source: 800,
        },
      ];
      const tableData = new Array(30).fill(mockData[0]).map((v, index) => {
        return {
          ...v,
          id: index,
          name: `${v.name}-${index + 1}`,
          age: v.age + index + 1,
          source: v.source + index + 1,
        };
      });
      const table = {
        rowHeight: 30,
        headerHight: 30,
        columns,
        tableData,
      };
      const canvans = new CanvasTable({
        el: canvansDom,
        slideWrap,
        slide,
        table,
        touchCanvans: true // 点击事件默认作用在canvans上
      });
  </script>
我们看到CanvasTable最主要的几个参数就是下面几个

el 具体操作canvasdom

slideWrap 自定义滚动条

slide 自定义滚动内部

table 画布表格需要的一些参数数据

我们再来看下引入的index.js

class CanvasTable {
    constructor(options = {}) {
        this.options = options;
        const { el, slideWrap, slide, table: { rowHeight, columns, headerHight } } = options;
        this.el = el; // canvans dom
        this.ctx = el.getContext("2d"); // cannvans画布环境
        this.rowHeight = rowHeight; // 表col的高度
        this.headerHight = headerHight; // 表头高度
        this.slideWrap = slideWrap; // 自定义滑块容器
        this.slide = slide; // 自定义滑块
        this.columns = columns; // 表列
        this.tableData = []; // canvans渲染的数据
        this.startIndex = 0; // 数据起始位
        this.endIndex = 0; // 数据末尾索引
        this.init();
    }
    ...
}
我们看到constructor主要是一些canvas对应元素以及对应自定义滚动条

在constructor还有调用init方法,init方法主要是做了两件事

1、一个是初始化根据数据填充画布内容,setDataByPage方法

2、canvas事件,根据内部滚动设置渲染canvas内容,setScrollY纵向Y轴自定义滚动条

 init() {
  // 初始化数据
  this.setDataByPage();
  // 纵向滚动条Y
  this.setScrollY();
}
setDataByPage 设置数据
 ...
 setDataByPage() {
    const { el, rowHeight, options: { table: { tableData: sourceData = [] } } } = this;
    const limit = Math.floor((el.height - rowHeight) / rowHeight); // 最大限度展示可是区域条数
    const endIndex = Math.min(this.startIndex + limit, sourceData.length)
    this.endIndex = endIndex;
    this.tableData = sourceData.slice(this.startIndex, this.endIndex);
    if (this.tableData.length === 0 || this.startIndex + limit > sourceData.length) {
        console.log('到底了')
        return;
    }
    console.log(this.tableData, 'tableData')
    // 清除画布
    this.clearCanvans();
    // 绘制表头
    this.drawHeader();
    // 绘制body
    this.drawBody();
}
其实上面这段代码非常简单

1、根据canvas高度以及col的高度确定显示最大的可视区域row的limit

2、确认起始末尾索引endIndex,根据起始索引startIndex对原数据sourceData进行slice操作,本质上就是前端做了一个假分页

3、每次设置数据要清除画布,重置画布宽高,重新绘制

clearCanvans() {
  // 当宽高重新设置时,就会重新绘制
  const { el } = this;
  el.width = el.width;
  el.height = el.height;
}
4、绘制表头,以及绘制表体

 ...
 this.drawHeader();
    // 绘制body
this.drawBody();
绘制表头
 ...
drawHeader() {
    const { ctx, el: canvansDom, rowHeight } = this;
    // 第一条横线
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(canvansDom.width, 0);
    ctx.lineWidth = 0.5;
    ctx.closePath();
    ctx.stroke();
    // 第二条横线
    ctx.beginPath();
    ctx.moveTo(0, rowHeight);
    ctx.lineTo(canvansDom.width, rowHeight);
    ctx.lineWidth = 0.5;
    ctx.stroke();
    ctx.closePath();
    const colWidth = Math.ceil(canvansDom.width / columns.length);
    // 绘制表头文字内容
    for (let index = 0; index < columns.length + 1; index++) {
        if (columns[index]) {
            ctx.fillText(columns[index].label, index * colWidth + 10, 18);
        }
    }
}
回顾下上面绘制的那张图,其实就是绘制两条横线,然后根据columns填充表头的文案

再看下表body

...
drawBody() {
    const { ctx, el: canvansDom, rowHeight, tableData, columns } = this;
    const row = Math.ceil(canvansDom.height / rowHeight);
    const tableDataLen = tableData.length;
    const colWidth = Math.ceil(canvansDom.width / columns.length);
    // 画横线
    for (let i = 2; i < row + 2; i++) {
        ctx.beginPath();
        ctx.moveTo(0, i * rowHeight);
        ctx.lineTo(canvansDom.width, i * rowHeight);
        ctx.stroke();
        ctx.closePath();
    }
    console.log(this.tableData, 'tableDataLen')
    // 绘制竖线
    for (let index = 0; index < columns.length + 1; index++) {
        ctx.beginPath();
        ctx.moveTo(index * colWidth, 0);
        ctx.lineTo(index * colWidth, (tableDataLen + 1) * rowHeight);
        ctx.stroke();
        ctx.closePath();
    }
    // 填充内容
    const columnsKeys = columns.map((v) => v.key);
    //   ctx.fillText(tableData[0].name, 10, 48);
    for (let i = 0; i < tableData.length; i++) {
        columnsKeys.forEach((keyName, j) => {
            const x = 10 + colWidth * j;
            const y = 18 + rowHeight * (i + 1);
            if (tableData[i][keyName]) {
                ctx.fillText(tableData[i][keyName], x, y);
            }
        });
    }
 }
我们会发现,body也是画线的方式绘制表体的,不过是从第三根横线开始绘制,因为表头已经占用了两根横线了,所以我们看到是从第三根横线位置开始,竖线是将表头与表体一起绘制的,然后就是填充数据内容






所以我们看到canvas绘制表就是下面这样的



自定义滚动条
这是一个比较关键的点,因为canvas中绘制的内容不像dom渲染的,如果是dom结构,父级容器给固定高度,那么子级容器超过就会溢出隐藏,但是canvans溢出内容,高度固定,所以画布的多余数据部分会被直接隐藏,所以这也是为什么需要我们自己模拟写个滚动条的原因

对应的html

<!---自定义滚动条-->
<div id="slide-wrap" style="transform: translateY(0)">
  <div class="slide"></div>
</div>
对应的css

 #slide-wrap {
    width: 5px;
    height: 60px;
    background-color: var(--background-color);
    position: absolute;
    right: 0;
    top: 30px;
    border-radius: 5px;
    transition: all 1s ease;
    opacity: 0;
}
#slide-wrap:hover {
  cursor: grab;
}
.slide {
  width: 5px;
  height: 60px;
  background-color: var(--background-color);
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 5px;
}
对应的基本结构与css已经ok,我们再看下控制滚动条

...
setScrollY() {
  const { slideWrap, slide, throttle, rowHeight, el, options } = this;
  const dom = options.touchCanvans ? el : slide;
  if (!options.touchCanvans) {
      slideWrap.style.opacity = 1;
  }
  let startY = 0; // 起始点
  let scrollEndIndex = -1; // 当滚动条滑到底部时,数据未完全加载完毕时
  const getSlideWrapStyleValue = () => {
      return slideWrap.style.transform ? slideWrap.style.transform.match(/\d/g).join('') * 1 : 0;
  }
  const move = (event) => {
      // console.log(event.clientY, 'event.clientY')
      let scrollY = event.clientY - startY;
      let transformY = getSlideWrapStyleValue();
      // console.log(transformY, 'transformY')
      if (scrollY < 0) {
          console.log('到顶了,不能继续上滑动了...')
          scrollY = 0;
          transformY = scrollY;
          scrollEndIndex = 0;
      } else {
          transformY = scrollY;
      }
      const limit = Math.floor((el.height - rowHeight) / rowHeight); // 最大限度展示可是区域条数
      // 如果拉到最低部了
      if (transformY >= rowHeight * limit - rowHeight * 2) {
          scrollEndIndex++
          transformY = rowHeight * limit - rowHeight * 2;
      }
      slideWrap.style.transform = `translateY(${transformY}px)`;
      // scrollEndIndex 滑到底部,数据还没有加载完毕
      this.startIndex = Math.floor(scrollY / rowHeight) + scrollEndIndex
      throttle(() => {
          this.setDataByPage()
      }, 500)();
  }
  const stop = (event) => {
      dom.onmousemove = null;
      dom.onmouseup = null;
      if (options.touchCanvans) {
          slideWrap.style.opacity = 0;
      }
  }
  dom.addEventListener("mousedown", (e) => {
      if (options.touchCanvans) {
          slideWrap.style.opacity = 1;
      }
      const transformY = getSlideWrapStyleValue();
      startY = e.clientY - transformY;
      dom.onmousemove = throttle(move, 200);
      dom.onmouseup = stop;
  });
}
我们看上面的代码,主要做的事件,有以下

1、监听dom的鼠标事件,通过鼠标的滑动,去控制滚动条的位置

2、根据滚动条的位置确定起始位置,并且需要控制判断滚动条达到底部的位置以及起始位置边界问题

3、根据滚动条位置,获取对应数据,然后重新渲染table

4、throttle做了一个简单的节流处理

...
throttle(callback, wait) {
  let timer = null;
  return function () {
      if (timer) return;
      timer = setTimeout(() => {
          callback.apply(this, arguments);
          timer = null;
      }, wait);
  };
}
好了我们最后看下结果

如何在canvans里面绘制自定义dom
其实在canvas里面所有的元素都是绘制的,但是如果在canvas里面绘制个input或者下拉框,或者是第三方UI组件,那基本上是很困难,那怎么办呢?

这时候需要我们移花接木,把需要自定义的内容div定位覆盖在canvas上,我们在之前基础上结合vue3,实现在canvas里面自定义dom

先看下新的布局结构

 <div id="app">
      <div class="content-table">
        <canvas id="canvans" width="600" height="300"></canvas>
        <div class="render-table">
          <!---操作--->
          <template v-if="tableData.length > 0">
            <div
              class="columns-options"
              v-for="(item, index) in tableData"
              :key="index"
              :style="setColumnsStyle(item, 'options')"
            >
              <a href="javascript:void(0)">编辑</a>
              <a href="javascript:void(0)">删除</a>
            </div>
          </template>
          <!---columns--->
          <template v-if="tableData.length > 0">
            <div
              class="columns-row"
              v-for="(item, index) in tableData"
              :style="setColumnsStyle(item, 'age')"
              :key="index"
            >
              <input type="text" v-model="item.age" style="width: 100px" />
            </div>
          </template>
        </div>
        <!---自定义滚动条-->
        <div id="slide-wrap" style="transform: translateY(0)">
          <div class="slide"></div>
        </div>
      </div>
    </div>
我们发现,我们在原有的结构中新增了render-table这样的一个自定义dom,我们的目标是需要将自己需要的控制的dom定位在canvas上,给人的错觉好像是在canvas上画的一样,比如说操作或者表单中需要自定义的项目

注意我们的render-table样式设置,这里我是写死的,如果通用组件,则需要动态设置top

.render-table {
  position: relative;
  top: -320px;
}
.render-table .columns-options a {
  display: inline-block;
  margin: 0 5px;
}
在body引入vue3

  <div id="app">
    ...
  </div>
<script type="importmap">
{
  "imports": {
    "vue": "https://cdn.bootcdn.net/ajax/libs/vue/3.2.41/vue.esm-browser.js"
  }
}
</script>
<script src="./index2.js"></script>
<script type="module">
  import { createApp, reactive, toRefs, onMounted } from "vue";
  createApp({
    setup() {
      const columns = [
        {
          label: "姓名",
          key: "name",
        },
        {
          label: "年龄",
          key: "age",
          render: true, // 新增一个标识标识这列需要自定义渲染
        },
        {
          label: "学校",
          key: "school",
        },
        {
          label: "分数",
          key: "source",
        },
        {
          label: "操作",
          slot: "options",
        },
      ];
      const mockData = [
        {
          name: "张三",
          id: 0,
          age: 0,
          school: "公众号:Web技术学苑",
          source: 800,
        },
      ];
      var tableData = new Array(30).fill(mockData[0]).map((v, index) => {
        const row = {
          ...v,
          id: index,
          name: `${v.name}-${index + 1}`,
          age: v.age + index + 1,
          source: v.source + index + 1,
        };
        return row;
      });
      const table = {
        rowHeight: 30,
        headerHight: 30,
        columns,
        tableData,
      };
      const state = reactive({
        columns,
        tableData: [],
      });
      onMounted(() => {
        const slideWrap = document.getElementById("slide-wrap");
        const slide = slideWrap.querySelector(".slide");
        const canvansDom = document.getElementById("canvans");
        // 获取canvans内部操作的数据
        const getCanvansData = (tableData) => {
          state.tableData = tableData;
        };
        const canvans = new CanvasTable(
          {
            el: canvansDom,
            slideWrap,
            slide,
            table,
            touchCanvans: true,
          },
          getCanvansData
        );
      });
      // 设置body自定义dom的位置
      const setColumnsStyle = (row, keyName) => {
        if (!row[`${keyName}_position`]) {
          return;
        }
        const [x, y] = row[`${keyName}_position`];
        return {
          position: "absolute",
          left: `${x}px`,
          top: `${y}px`,
        };
      };


      return {
        ...toRefs(state),
        setColumnsStyle,
      };
    },
  }).mount("#app");
</script>
我们主要分析一下几个方法






1、new CanvasTable为什么需要一个回调函数getCanvansData?

const getCanvansData = (tableData) => {
  state.tableData = tableData;
};
其实这个回调的作用主要是为了更新设置我们自定义的数据,因为当我们操作canvas上滑滚动时,我们也需要更新我们自己自定义的数据,自定义的dom最好和渲染canvas是同一份数据,这样就可以保持同一份数据一致性了。

2、怎么样让自己自定义的dom一一填充在canvas上?

这就归功于以下这个方法setColumnsStyle,我们的目标就是根据原始数据遍历生成dom,然后定位到canvas的位置上去,所以我们的目标就是设置对应dom的x与y

 const setColumnsStyle = (row, keyName) => {
  if (!row[`${keyName}_position`]) {
    return;
  }
  const [x, y] = row[`${keyName}_position`];
  return {
    position: "absolute",
    left: `${x}px`,
    top: `${y}px`,
  };
};
注意setColumnsStyle的第二个参数keyName,你想让哪个自定义,你需要写那个字段名称,我们自己构造了一个虚拟自断xxx_position,这个字段记录了自己当前canvas的准确位置

对应的html我们可以看下

  <!---操作--->
  <template v-if="tableData.length > 0">
    <div
      class="columns-options"
      v-for="(item, index) in tableData"
      :key="index"
      :style="setColumnsStyle(item, 'options')"
    >
      <a href="javascript:void(0)">编辑</a>
      <a href="javascript:void(0)">删除</a>
    </div>
  </template>
<!---columns--->
  <template v-if="tableData.length > 0">
    <div
      class="columns-row"
      v-for="(item, index) in tableData"
      :style="setColumnsStyle(item, 'age')"
      :key="index"
    >
      <input type="text" v-model="item.age" style="width: 60%" />
    </div>
  </template>
这个就像我们自己写自定义插槽一样,自定义对应dom。

我们需要看下index2.js

class CanvasTable {
    constructor(options = {}, callback) {
        this.options = options;
        const { el, slideWrap, slide, table: { rowHeight, columns, headerHight } } = options;
        ...
        this.callback = callback;
        this.init();
    }
    init() {
        // 初始化数据
        this.setDataByPage();
        // 纵向滚动条Y
        this.setScrollY();
    }
     setDataByPage() {
        const { el, rowHeight, options: { table: { tableData: sourceData = [] } }, callback } = this;
        ...
        this.tableData = tableData;
        callback(this.tableData)
        // 清除画布
        this.clearCanvans();
        // 绘制表头
        this.drawHeader();
        // 绘制body
        this.drawBody();
    }
    drawBody() {
        ...
        // 填充内容
        const columnsKeys = columns.map((v) => v.key || v.slot);
        //   ctx.fillText(tableData[0].name, 10, 48);
        for (let i = 0; i < tableData.length; i++) {
            columnsKeys.forEach((keyName, j) => {
                const x = 10 + colWidth * j;
                const y = 18 + rowHeight * (i + 1);
                if (tableData[i][keyName] && !columns[j].render) {
                    ctx.fillText(tableData[i][keyName], x, y);
                }
                tableData[i][`${keyName}_position`] = [x, y];
            });
        }
    }
}
主要是drawBody绘制填充内容,我们通过columns[j].render标识确定是否需要canvas绘制对应内容,如果columns中配置render: true则说明需要自己自定义dom,并且我们自定义了一个字段来记录每一个坐标

当我们能确定每一个字段对应显示的坐标时,我们就很好确定自定义dom位置了

所以最后的结果就是下面这样的



我们看下删除操作

 <template v-if="tableData.length > 0">
      <div
        class="columns-options"
        v-for="(item, index) in tableData"
        :key="index"
        :style="setColumnsStyle(item, 'options')"
      >
        <a href="javascript:void(0)">编辑</a>
        <a href="javascript:void(0)" @click="handleDel(item)">删除</a>
      </div>
</template>
handleDel,主要是调用了内部canvans的state.canvans.setDataByPage(item)方法,只需要在setDataByPage方法修改一行代码就可以删除操作了setDataByPage

setDataByPage(item) {
    ...
    if (item) {
        sourceData = sourceData.filter(v => v.id !== item.id);
    }
    const tableData = sourceData.slice(this.startIndex, this.endIndex);
    if (tableData.length === 0 || this.startIndex + limit > sourceData.length) {
        console.log('到底了')
        return;
    }
    this.tableData = tableData;
    callback(this.tableData)
    // 清除画布
    this.clearCanvans();
    // 绘制表头
    this.drawHeader();
    // 绘制body
    this.drawBody();
}
对应的删除操作

    ...
    const state = reactive({
        canvans: null,
        columns,
        tableData: [],
      });
      onMounted(() => {
        const slideWrap = document.getElementById("slide-wrap");
        const slide = slideWrap.querySelector(".slide");
        const canvansDom = document.getElementById("canvans");
        const getCanvansData = (tableData) => {
          state.tableData = tableData;
        };
        const canvans = new CanvasTable(
          {
            el: canvansDom,
            slideWrap,
            slide,
            table,
            touchCanvans: true,
          },
          getCanvansData
        );
        state.canvans = canvans;
      });
 // 删除功能
  const handleDel = (item) => {
    state.canvans.setDataByPage(item);
  };
大功告成,操作原数据,就可以删除对应的行了。



这个简易的canvas就实现基础table显示,自定义滚动条,以及自定义操作,还有在canvans中自定义渲染dom。

总得来说,用canvas去处理大数据table是一种不错的方案,像飞书的excel统计表就是用canvas绘制,用canvas绘制表,带来的业务挑战问题也会比较多,比如如下几个问题

1、能根据表头调整整列宽度吗?(我们用canvans画线的方式去做的,此时需要调整当前列所有元素的坐标)

2、表头可以自定义渲染,可以加筛选条件吗?

3、还有我需要添加全选功能,以及支持隐藏表头,以及自定义渲染对应表内部,比如我是通过定位的方式去显示我们对应canvas自定义的内容,除了这种方案,还有更好的办法吗?等等

面对复杂的业务需求,也许elementUI的table已经覆盖了我们业务场景很大的需求,包括虚拟列表滚动,当我们选择canvas这种技术方案试图提升大数据渲染性能时,带来的隐性技术成本也是巨大的。当然大佬除外,因为大佬完全可以手写一个类似excel的在线编辑表,我们在线webexcel也绝大部分是用canvas做的,性能上相比较dom方式是完全没得说。

总结
canvas实现一个简易的table,如何绘制table表头,以及表内容

如何手写个滚动条,并且滚动条边界控制,滑动画布,控制滚动条位置

canvas绘制的table如何自定义dom渲染,主要是采用定位方式,我们需要在columns中添加标识是否需要自定义渲染

结合vue3实现删除,将自定义dom渲染到canvas上

本文示例源码code example[1]

参考资料
[1]
code example: https://github.com/maicFir/lessonNote/tree/master/canvans/01-canvans-table

作者:Maic


欢迎关注微信公众号 :web技术学苑