用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技术学苑