在之前业务有幸接触过复杂的大数据业务渲染,所用的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 具体操作
canvas
dom - 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
也是画线的方式绘制表体的,不过是从第三根横线开始绘制,因为表头已经占用了两根横线了,所以我们看到是从第三根横线位置开始,竖线是将表头与表体一起绘制的,然后就是填充数据内容
所以我们看到canvans
绘制表就是下面这样的
自定义滚动条
这是一个比较关键的点,因为canvans
中绘制的内容不像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);
};
}
好了我们最后看下结果
dom
如何在canvans里面绘制自定义其实在canvans
里面所有的元素都是绘制的,但是如果在canvans
里面绘制个input
或者下拉框
,或者是第三方UI组件,那基本上是很困难,那怎么办呢?
这时候需要我们移花接木
,把需要自定义的内容div
定位覆盖在canvans
上,我们在之前基础上结合vue3
,实现在canvans
里面自定义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
定位在canvans
上,给人的错觉好像是在canvans
上画的一样,比如说操作
或者表单中需要自定义的项目
注意我们的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;
};
其实这个回调的作用主要是为了更新设置我们自定义的数据,因为当我们操作canvans
上滑滚动时,我们也需要更新我们自己自定义的数据,自定义的dom
最好和渲染canvans
是同一份数据,这样就可以保持同一份数据一致性了。
2、怎么样让自己自定义的dom
一一填充在canvans
上?
这就归功于以下这个方法setColumnsStyle
,我们的目标就是根据原始数据遍历生成dom
,然后定位到canvans
的位置上去,所以我们的目标就是设置对应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
,这个字段记录了自己当前canvans
的准确位置
对应的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
标识确定是否需要canvans
绘制对应内容,如果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);
};
大功告成,操作原数据,就可以删除对应的行了。
这个简易的canvans
就实现基础table显示,自定义滚动条,以及自定义操作,还有在canvans
中自定义渲染dom。
总得来说,用canvans
去处理大数据table
是一种不错的方案,像飞书的excel统计表
就是用canvans
绘制,用canvans
绘制表,带来的业务挑战问题也会比较多,比如如下几个问题
1、能根据表头调整整列宽度吗?(我们用canvans画线的方式去做的,此时需要调整当前列所有元素的坐标)
2、表头可以自定义渲染,可以加筛选条件吗?
3、还有我需要添加全选功能,以及支持隐藏表头,以及自定义渲染对应表内部,比如我是通过定位的方式去显示我们对应canvans
自定义的内容,除了这种方案,还有更好的办法吗?等等
面对复杂的业务需求,也许elementUI
的table已经覆盖了我们业务场景很大的需求,包括虚拟列表滚动,当我们选择canvans
这种技术方案试图提升大数据渲染性能时,带来的隐性技术成本也是巨大的。当然大佬除外,因为大佬完全可以手写一个类似excel
的在线编辑表,我们在线webexcel
也绝大部分是用canvans
做的,性能上相比较dom方式是完全没得说。
总结
canvans
实现一个简易的table
,如何绘制table表头,以及表内容如何手写个滚动条,并且
滚动条
边界控制,滑动画布,控制滚动条位置canvans
绘制的table
如何自定义dom
渲染,主要是采用定位方式,我们需要在columns
中添加标识是否需要自定义渲染结合
vue3
实现删除,将自定义dom
渲染到canvans
上本文示例源码code example