用canvas畫了個table,手寫滾動條
在之前業(yè)務(wù)有幸接觸過復(fù)雜的大數(shù)據(jù)業(yè)務(wù)渲染,所用的table居然是用canvas以及虛擬列表的方式實(shí)現(xiàn),也有看到飛書的統(tǒng)計(jì)信息表就是canvas繪制,一直沒太明白為什么要用canvas去做,今天記錄一下如何用canvas繪制一個table的簡易版,希望看完在項(xiàng)目中能帶來一些思考和幫助。
正文開始...
在開始本文之前,主要是從以下方向去思考:
1、canvas繪制table必須滿足我們常規(guī)table方式
2、因?yàn)閠able內(nèi)容是顯示在畫布中,那如何實(shí)現(xiàn)滾動條控制,canvas是固定高的
3、內(nèi)容的分頁顯示需要自定義滾動條,也就是需要自己實(shí)現(xiàn)一個滾動條
4、如何在canvas中擴(kuò)展類似vue插槽能力
5、在canvas中的列表事件操作,比如刪除,編輯等。
canvas畫個table
首先我們確定一個普通的表就是header和body組成,在html中,我們直接用thead與tbody以及tr,td就可以輕松畫出一個表,或者用div也可以布局一個table出來
那在canvas中,就需要自己繪制了head與body了
我們把table主要分成兩部分
thead表頭,在canvas畫布我們是以左側(cè)頂點(diǎn)為起始點(diǎn)的一個逆向的x,y坐標(biāo)系
我們看下對應(yīng)的代碼,我們把預(yù)先html基本結(jié)構(gòu)以及部分mock數(shù)據(jù)自己先模擬一份
<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: "學(xué)校",key: "school"},{label: "分?jǐn)?shù)",key: "source"},{label: "操作",key: "options"}];
const mockData = [
{
name: "張三",
id: 0,
age: 0,
school: "公眾號:Web技術(shù)學(xué)苑",
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 // 點(diǎn)擊事件默認(rèn)作用在canvans上
});
</script>
我們看到CanvasTable最主要的幾個參數(shù)就是下面幾個
el 具體操作canvasdom
slideWrap 自定義滾動條
slide 自定義滾動內(nèi)部
table 畫布表格需要的一些參數(shù)數(shù)據(jù)
我們再來看下引入的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畫布環(huán)境
this.rowHeight = rowHeight; // 表col的高度
this.headerHight = headerHight; // 表頭高度
this.slideWrap = slideWrap; // 自定義滑塊容器
this.slide = slide; // 自定義滑塊
this.columns = columns; // 表列
this.tableData = []; // canvans渲染的數(shù)據(jù)
this.startIndex = 0; // 數(shù)據(jù)起始位
this.endIndex = 0; // 數(shù)據(jù)末尾索引
this.init();
}
...
}
我們看到constructor主要是一些canvas對應(yīng)元素以及對應(yīng)自定義滾動條
在constructor還有調(diào)用init方法,init方法主要是做了兩件事
1、一個是初始化根據(jù)數(shù)據(jù)填充畫布內(nèi)容,setDataByPage方法
2、canvas事件,根據(jù)內(nèi)部滾動設(shè)置渲染canvas內(nèi)容,setScrollY縱向Y軸自定義滾動條
init() {
// 初始化數(shù)據(jù)
this.setDataByPage();
// 縱向滾動條Y
this.setScrollY();
}
setDataByPage 設(shè)置數(shù)據(jù)
...
setDataByPage() {
const { el, rowHeight, options: { table: { tableData: sourceData = [] } } } = this;
const limit = Math.floor((el.height - rowHeight) / rowHeight); // 最大限度展示可是區(qū)域條數(shù)
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();
}
其實(shí)上面這段代碼非常簡單
1、根據(jù)canvas高度以及col的高度確定顯示最大的可視區(qū)域row的limit
2、確認(rèn)起始末尾索引endIndex,根據(jù)起始索引startIndex對原數(shù)據(jù)sourceData進(jìn)行slice操作,本質(zhì)上就是前端做了一個假分頁
3、每次設(shè)置數(shù)據(jù)要清除畫布,重置畫布寬高,重新繪制
clearCanvans() {
// 當(dāng)寬高重新設(shè)置時,就會重新繪制
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);
// 繪制表頭文字內(nèi)容
for (let index = 0; index < columns.length + 1; index++) {
if (columns[index]) {
ctx.fillText(columns[index].label, index * colWidth + 10, 18);
}
}
}
回顧下上面繪制的那張圖,其實(shí)就是繪制兩條橫線,然后根據(jù)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();
}
// 填充內(nèi)容
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);
}
});
}
}
我們會發(fā)現(xiàn),body也是畫線的方式繪制表體的,不過是從第三根橫線開始繪制,因?yàn)楸眍^已經(jīng)占用了兩根橫線了,所以我們看到是從第三根橫線位置開始,豎線是將表頭與表體一起繪制的,然后就是填充數(shù)據(jù)內(nèi)容
所以我們看到canvas繪制表就是下面這樣的
自定義滾動條
這是一個比較關(guān)鍵的點(diǎn),因?yàn)閏anvas中繪制的內(nèi)容不像dom渲染的,如果是dom結(jié)構(gòu),父級容器給固定高度,那么子級容器超過就會溢出隱藏,但是canvans溢出內(nèi)容,高度固定,所以畫布的多余數(shù)據(jù)部分會被直接隱藏,所以這也是為什么需要我們自己模擬寫個滾動條的原因
對應(yīng)的html
<!---自定義滾動條-->
<div id="slide-wrap" style="transform: translateY(0)">
<div class="slide"></div>
</div>
對應(yīng)的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;
}
對應(yīng)的基本結(jié)構(gòu)與css已經(jīng)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; // 起始點(diǎn)
let scrollEndIndex = -1; // 當(dāng)滾動條滑到底部時,數(shù)據(jù)未完全加載完畢時
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('到頂了,不能繼續(xù)上滑動了...')
scrollY = 0;
transformY = scrollY;
scrollEndIndex = 0;
} else {
transformY = scrollY;
}
const limit = Math.floor((el.height - rowHeight) / rowHeight); // 最大限度展示可是區(qū)域條數(shù)
// 如果拉到最低部了
if (transformY >= rowHeight * limit - rowHeight * 2) {
scrollEndIndex++
transformY = rowHeight * limit - rowHeight * 2;
}
slideWrap.style.transform = `translateY(${transformY}px)`;
// scrollEndIndex 滑到底部,數(shù)據(jù)還沒有加載完畢
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、監(jiān)聽dom的鼠標(biāo)事件,通過鼠標(biāo)的滑動,去控制滾動條的位置
2、根據(jù)滾動條的位置確定起始位置,并且需要控制判斷滾動條達(dá)到底部的位置以及起始位置邊界問題
3、根據(jù)滾動條位置,獲取對應(yīng)數(shù)據(jù),然后重新渲染table
4、throttle做了一個簡單的節(jié)流處理
...
throttle(callback, wait) {
let timer = null;
return function () {
if (timer) return;
timer = setTimeout(() => {
callback.apply(this, arguments);
timer = null;
}, wait);
};
}
好了我們最后看下結(jié)果
如何在canvans里面繪制自定義dom
其實(shí)在canvas里面所有的元素都是繪制的,但是如果在canvas里面繪制個input或者下拉框,或者是第三方UI組件,那基本上是很困難,那怎么辦呢?
這時候需要我們移花接木,把需要自定義的內(nèi)容div定位覆蓋在canvas上,我們在之前基礎(chǔ)上結(jié)合vue3,實(shí)現(xiàn)在canvas里面自定義dom
先看下新的布局結(jié)構(gòu)
<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>
我們發(fā)現(xiàn),我們在原有的結(jié)構(gòu)中新增了render-table這樣的一個自定義dom,我們的目標(biāo)是需要將自己需要的控制的dom定位在canvas上,給人的錯覺好像是在canvas上畫的一樣,比如說操作或者表單中需要自定義的項(xiàng)目
注意我們的render-table樣式設(shè)置,這里我是寫死的,如果通用組件,則需要動態(tài)設(shè)置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, // 新增一個標(biāo)識標(biāo)識這列需要自定義渲染
},
{
label: "學(xué)校",
key: "school",
},
{
label: "分?jǐn)?shù)",
key: "source",
},
{
label: "操作",
slot: "options",
},
];
const mockData = [
{
name: "張三",
id: 0,
age: 0,
school: "公眾號:Web技術(shù)學(xué)苑",
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內(nèi)部操作的數(shù)據(jù)
const getCanvansData = (tableData) => {
state.tableData = tableData;
};
const canvans = new CanvasTable(
{
el: canvansDom,
slideWrap,
slide,
table,
touchCanvans: true,
},
getCanvansData
);
});
// 設(shè)置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為什么需要一個回調(diào)函數(shù)getCanvansData?
const getCanvansData = (tableData) => {
state.tableData = tableData;
};
其實(shí)這個回調(diào)的作用主要是為了更新設(shè)置我們自定義的數(shù)據(jù),因?yàn)楫?dāng)我們操作canvas上滑滾動時,我們也需要更新我們自己自定義的數(shù)據(jù),自定義的dom最好和渲染canvas是同一份數(shù)據(jù),這樣就可以保持同一份數(shù)據(jù)一致性了。
2、怎么樣讓自己自定義的dom一一填充在canvas上?
這就歸功于以下這個方法setColumnsStyle,我們的目標(biāo)就是根據(jù)原始數(shù)據(jù)遍歷生成dom,然后定位到canvas的位置上去,所以我們的目標(biāo)就是設(shè)置對應(yīng)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的第二個參數(shù)keyName,你想讓哪個自定義,你需要寫那個字段名稱,我們自己構(gòu)造了一個虛擬自斷xxx_position,這個字段記錄了自己當(dāng)前canvas的準(zhǔn)確位置
對應(yīng)的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>
這個就像我們自己寫自定義插槽一樣,自定義對應(yīng)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() {
// 初始化數(shù)據(jù)
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() {
...
// 填充內(nèi)容
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繪制填充內(nèi)容,我們通過columns[j].render標(biāo)識確定是否需要canvas繪制對應(yīng)內(nèi)容,如果columns中配置render: true則說明需要自己自定義dom,并且我們自定義了一個字段來記錄每一個坐標(biāo)
當(dāng)我們能確定每一個字段對應(yīng)顯示的坐標(biāo)時,我們就很好確定自定義dom位置了
所以最后的結(jié)果就是下面這樣的
我們看下刪除操作
<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,主要是調(diào)用了內(nèi)部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();
}
對應(yīng)的刪除操作
...
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);
};
大功告成,操作原數(shù)據(jù),就可以刪除對應(yīng)的行了。
這個簡易的canvas就實(shí)現(xiàn)基礎(chǔ)table顯示,自定義滾動條,以及自定義操作,還有在canvans中自定義渲染dom。
總得來說,用canvas去處理大數(shù)據(jù)table是一種不錯的方案,像飛書的excel統(tǒng)計(jì)表就是用canvas繪制,用canvas繪制表,帶來的業(yè)務(wù)挑戰(zhàn)問題也會比較多,比如如下幾個問題
1、能根據(jù)表頭調(diào)整整列寬度嗎?(我們用canvans畫線的方式去做的,此時需要調(diào)整當(dāng)前列所有元素的坐標(biāo))
2、表頭可以自定義渲染,可以加篩選條件嗎?
3、還有我需要添加全選功能,以及支持隱藏表頭,以及自定義渲染對應(yīng)表內(nèi)部,比如我是通過定位的方式去顯示我們對應(yīng)canvas自定義的內(nèi)容,除了這種方案,還有更好的辦法嗎?等等
面對復(fù)雜的業(yè)務(wù)需求,也許elementUI的table已經(jīng)覆蓋了我們業(yè)務(wù)場景很大的需求,包括虛擬列表滾動,當(dāng)我們選擇canvas這種技術(shù)方案試圖提升大數(shù)據(jù)渲染性能時,帶來的隱性技術(shù)成本也是巨大的。當(dāng)然大佬除外,因?yàn)榇罄型耆梢允謱懸粋€類似excel的在線編輯表,我們在線webexcel也絕大部分是用canvas做的,性能上相比較dom方式是完全沒得說。
總結(jié)
canvas實(shí)現(xiàn)一個簡易的table,如何繪制table表頭,以及表內(nèi)容
如何手寫個滾動條,并且滾動條邊界控制,滑動畫布,控制滾動條位置
canvas繪制的table如何自定義dom渲染,主要是采用定位方式,我們需要在columns中添加標(biāo)識是否需要自定義渲染
結(jié)合vue3實(shí)現(xiàn)刪除,將自定義dom渲染到canvas上
本文示例源碼code example[1]
參考資料
[1]
code example: https://github.com/maicFir/lessonNote/tree/master/canvans/01-canvans-table
作者:Maic
歡迎關(guān)注微信公眾號 :web技術(shù)學(xué)苑