用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é)苑