canvas库 konva 实现腾讯文档 [甘特图视图]

3,176 阅读9分钟

简要说明

写文章时贴的代码还处于脱敏阶段,有些业务功能不参与其中。我会在后续完全脱敏后完善优化并开源代码。上一篇文章实现了日历视图,布局和功能相对简单一点,所以核心功能我用了一个class去实现。甘特图功能和布局相对复杂一点,且为了后续扩展,这块要将功能细化。可能开源的代码并不能够直接参与到你们的实际项目中,主要是为了让大家体会一个功能的实现的过程。

我先贴一下简易的效果图和布局和文件目录结构。

8317c98aac44ef92f64a0ac92c054658.png image.png image.png

结构分析

双图层模式

静态图层:

  1. 年月季周单独一个Group
  2. 列日期标题单独一个Group,主要是为了处理纵向滚动条滚动正文部分 不要让title也被offset掉。
  3. 渲染正文Group,滚动时控制offseX和offseY,这样就不用去设置layer涂层的位置,最小单位更新。

动态图层

  1. 横向|纵向滚动条单独一个Rect
  2. maker里程碑 | task每个独有一个Group

通过结构分配 然后初始化各个模块

image.png image.png image.png

这样初始化后 布局大致上有了纹路,接下来要做的就是在各个功能区填充对应的小功能。

渲染 年月tab区域

3b759ea47305adde43443cb8c7825579.png

如果是dom实现的话很简单 用konva的话 我们需要定义好结构

  • 灰色打底的Rect 满整个tab宽度
  • 白色选中背景的Rect 占用某一个选项宽度
  • 选项文字渲染
  • 点击事件,修改白色选中背景的Rect的位置并添加动画
export type Iunit = 'WEEK' | 'MONTH' | 'QUARTER' | 'YEAR';

export class DateRange {
  readonly container: Konva.Group;
  //  周|月|季|年
  unit: Iunit = 'MONTH';
  unitMap = new Map([
    ['WEEK', { name: '周', x: 28, index: 0 }],
    ['MONTH', { name: '月', x: 88, index: 1 }],
    ['QUARTER', { name: '季', x: 148, index: 2 }],
    ['YEAR', { name: '年', x: 208, index: 3 }],
  ]);
  constructor(private readonly core: Core) {
    this.unit = 'MONTH';
    this.container = new Konva.Group({
      x: this.core.config.containerWidth - 260,
    });
    const bgcolor = new Konva.Rect({
      width: 245,
      height: 30,
      fill: 'rgba(235,236,237,1)',
      opacity: 1,
      cornerRadius: 2
    })
    const activeBgColor = new Konva.Rect({
      x: 63,
      y: 3,
      width: 60,
      height: 24,
      // 白色
      fill: 'rgba(255,255,255,1)',
      opacity: 1,
      cornerRadius: 2
    });
    this.container.add(bgcolor, activeBgColor);

    const values = this.unitMap.values();
    while (true) {
      const iterator = values.next();
      if (iterator.done) {
        break;
      }
      const rect = new Konva.Rect({
        x: iterator.value.x - 20,
        y: 3,
        width: 50,
        height: 23,
        fill: 'transparent',
        // fill: 'red',
        cornerRadius: 3
      });
      const text = new Konva.Text({
        x: iterator.value.x,
        y: 8,
        width: 50,
        height: 20,
        fill: 'black',
        text: iterator.value.name,
        fontSize: 14
      })
      const itemGroup = new Konva.Group();
      itemGroup.on('click', () => {
        activeBgColor.to({
          x: iterator.value.index * 60 + 3,
          easing: Konva.Easings.StrongEaseOut,
          duration: 0.2
        })
      })
      itemGroup.add(rect, text);
      this.container.add(itemGroup);
    }
    //  鼠标进入
    this.container.on('mouseenter', () => this.core.cursor('pointer'));
    //  鼠标离开
    this.container.on('mouseleave', () => this.core.cursor('default'));
  }
}

渲染列标题日期和正文 ( 以月为标准进行渲染 )

0e7f4cee941dc52e2541fde240d3da62.png

通过效果图来看。我们只需要渲染可视区域的几个Rect节点即可完成,但是如果仅仅如此的话,后面我们判断task的y坐标和hover添加task不太好确定。于是我使用konva devtool谷歌插件调试腾讯文档的konva节点 ,得到的结果是其实是渲染的一个区域的多个rect,只不过隐藏了边的颜色,外加渲染了几条竖线日期线。我们也依葫芦画瓢来准备参数。

  1. 所以我们要渲染一个类似table出来,准备好所需参数
    
    
  export class Config {
      //  列数量
      columnCount = 0;
      //  container的 offsetX
      offsetX = 0;
      offsetY = 0;
      //  行高
      rowHeight = 33;
      //  列宽
      columnWidth = 60;
      //  行数量
      rowCount = 40;
      //  画布的宽度
      containerWidth = 1080;
      //  画布的高度
      containerHeight = 600;
      //  开始时间
      startDate = "2024-08-22";
      //  结束时间
      endDate = "2025-09-25";
      //  挂载节点
      container = '.container';
      //  模式
      mode: 'edit' | 'read' = 'edit'
    
      constructor(config?: Partial<Config>) {
        Object.assign(this, config);
        this.columnCount = DatePostion.calculateColumnCount(this.startDate, this.endDate);
      }
    
      update(config: Partial<Config>) {
        Object.assign(this, config);
      }


}
       

2.绘制一个表格出来很简单,但是我们要结合滚动条滚动的位置来渲染并且只渲染可视区域的节点,需要自己准备一些辅助函数实现。render这个class专门处理渲染相关的逻辑

image.png 最关键就是render中这个getDrawConfig函数,这个函数可以在 存在offsetX 和 offsetY | containerHeight | containerWidth的限制情况下得到 可视区域中应该存在哪些单元格。
  getDrawConfig函数,({ initRowCount }: Partial<{ initRowCount?: number }>) {
    const {
      offsetX,
      offsetY,
      containerHeight,
      containerWidth: width,
      startDate,
      endDate,
      columnCount
    } = this.config;

    const containerWidth = width - 20;

    const rowHeight = () => this.config.rowHeight;
    const columnWidth = () => this.config.columnWidth;
    const rowCount = initRowCount || this.config.rowCount;

    const rowStartIndex = getRowStartIndexForOffset({
      itemType: "row",
      rowHeight,
      columnWidth,
      rowCount,
      columnCount,
      instanceProps: this.instanceProps,
      offset: offsetY,
    });
    const rowStopIndex = getRowStopIndexForStartIndex({
      startIndex: rowStartIndex,
      rowCount,
      rowHeight,
      columnWidth,
      scrollTop: offsetY,
      containerHeight,
      instanceProps: this.instanceProps,
    });
    const columnStartIndex = getColumnStartIndexForOffset({
      itemType: "column",
      rowHeight,
      columnWidth,
      rowCount,
      columnCount,
      instanceProps: this.instanceProps,
      offset: offsetX,
    });

    const columnStopIndex = getColumnStopIndexForStartIndex({
      startIndex: columnStartIndex,
      columnCount,
      rowHeight,
      columnWidth,
      scrollLeft: offsetX,
      containerWidth,
      instanceProps: this.instanceProps,
    });

    const items = [];
    if (columnCount > 0 && rowCount) {
      for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) {
        for (
          let columnIndex = columnStartIndex;
          columnIndex <= columnStopIndex;
          columnIndex++
        ) {
          const width = getColumnWidth(columnIndex, this.instanceProps);
          const x = getColumnOffset({
            index: columnIndex,
            rowHeight,
            columnWidth,
            instanceProps: this.instanceProps,
          });
          const height = getRowHeight(rowIndex, this.instanceProps);
          const y = getRowOffset({
            index: rowIndex,
            rowHeight,
            columnWidth,
            instanceProps: this.instanceProps,
          });
          const date = this.getDateFromX(x);
          items.push(
            {
              x,
              y,
              width,
              height,
              rowIndex,
              columnIndex,
              date: date.date,
              title: date.title,
              name: `container_cell ${date.date}`,
              isWeak: this.isWeekend(new Date(date.date)),
              key: itemKey({ rowIndex, columnIndex }),
            }
          );
        }
      }
    }

    // this.config.update({ columnCount });
    this.columnStartIndex = columnStartIndex;
    this.columnStopIndex = columnStopIndex;
    this.itemCells = items;

  }

调用这个函数。得到所有单元格数据。拿到数据后我们通过专门的渲染函数进行渲染列标题和单元格。

9732464a6706e244ad50f82ee3e6351a_720.png
  draw() {
    const { containerGroup, headerTextGroup } = this.staticLayer;
    containerGroup.removeChildren();
    headerTextGroup.removeChildren();
    this.getDrawConfig({});
    const items = this.itemCells;
    const columnStartIndex = this.columnStartIndex;
    const columnStopIndex = this.columnStopIndex;
    for (let index = 0; index <= (columnStopIndex - columnStartIndex); index++) {
      const colindex = items[index];
      const text = new Konva.Text({
        x: colindex.x + colindex.width / 2,
        y: -14,
        text: colindex.date.slice(-4),
        fontSize: 12,
        fill: 'rabg(0,0,0,1)',
        name: 'text',
        key: colindex.key,
      })
      text.setAttr('offsetX', text.width() / 2)     // 设置 offsetX 为文本宽度的一半,确保文字居中
      headerTextGroup.add(text)
    }

    items.forEach(({ x,
      y,
      width,
      height,
      rowIndex,
      columnIndex,
      name,
      key,
      isWeak,
      date
    }) => {
      containerGroup.add(new RectBorderNode({
        x,
        y,
        date,
        height,
        width,
        hitStrokeWidth: 1,
        strokeWidth: 0.2,
        fill: isWeak ? 'rgba(243,245,247,1)' : '#FFF',
        name,
        key,
        listening: false,
        strokeBottomColor: '#C0C4C9',
        strokeTopColor: '#C0C4C9',
        strokeRightColor: 'rgba(201,192,196 , 0.3)',
        strokeLeftColor: 'rgba(201,192,196 , 0.3)',
      }))
    })
  }

渲染图如下:

de0b597d39d4275af2357a0d2799f9dd.png

但是我们知道腾讯文档显示出来的就是竖线,没有呈现表格,那我们要做的就是设置rect为白色,然后单独渲染几条竖线出来 让视觉看起来正常就行。代码中会有呈现。然后效果图。

4c34d1f3093c13d693f297adc984e055.png

完成了基础的渲染。接下来就要完成动态的部分了,横线滚动条滚动变更区域单元格。

  1. 先看下横向滚动条的实现,确定滚动条的宽度,图中有实现。
image.png
   // dragmove更新表格 并重新绘制
  verticalBarRect.on('dragmove', (event) => {
      const scrollbarX = event.target.x();
      // 根据滚动条位置计算内容的滚动位置
      const scrollRatio = scrollbarX / maxScrollbarX;
      const tempScrollLeft = scrollRatio * maxScroll;
      // 更新内容的 x 位置
      this.core.moveOffsetX(tempScrollLeft);
    });
    verticalBarRect.on("dragend", () => {
      this.render.draw();
    })
    
   //core.ts
   moveOffsetX(offsetX: number) {
    // 更新内容的 x 位置
    this.config.update({ offsetX })
    this.staticLayer.containerGroup.x(-offsetX);
    this.staticLayer.headerTextGroup.x(-offsetX);
    this.render.scrollX();
  }
  
  //. render.ts
   scrollX() {
    this. draw()函数重新渲染。
    我们再来看下();
    this.makerManager.update();
    this.taskManager.moveX();
  }

当我们滚动然后调用更新。this.core.moveOffsetX(tempScrollLeft);会触发 draw()函数重新渲染。 我们再来看下 draw 函数的实现。 其逻辑是 在重新渲染之前先销毁所有的cell单元格 然后再add所有的cell Rect节点。

image.png

我们模拟一个动画。持续滚动 => 持续更新渲染。看看表现

   setTimeout(() => {
      this.request();
    }, 200);
  }

  private x = 0;
  request() {
    requestAnimationFrame(() => {
      if (this.config.offsetX > 800) {
        return;
      }
      this.x += 2;
      batchDrawManager.batchDraw(() => this.moveOffsetX(this.x))
      this.request();
    })
  }

通过录制火焰图,我发现会存在 Partially Presented Frame的情况。cpu占比也高了点。因为在一帧中所做的事情太多了,这块我们要去优化。

a9c4ced597686d18af4bab481eae4bf0_720.png

优化滚动渲染性能

1. 第一点我首先想到的是看konva官网有没有提到优化手段,刚好存在 listening : false ,于是实践。

image.png

思路:dragmove第一次执行的时候将layer层和Group listening = false 因为在滚动的过程中 我们也不需要参与到节点的用户交互。在 dragend滚动结束的时候再将 listening = true设置回来。

2.性能问题肯定跟节点数量有关,那我们能不能从节点数量出发优化呢?当然是可以的。既然在滚动过程 甘特图其实是不参与用户交互的。那么在这中间怎么渲染都行 只要视觉保持一致就行。

  1. 滚动开始: 重新实现一个函数,不要生成所有单元格,只需要生成一行的单元格,然后我利用一行的单元格,来生成一行的Rect 只需要把Rect的两边的高度跟Group的高度把持一致即可,这样我们保持视觉统一的情况下,又大大的减少了节点的绘制。
  2. 拖动结束: 调用原来的绘制函数,生成所有的cell。保持视觉和交互一致。

只是新增了animationDraw函数,this.getDrawConfig({ initRowCount: 1 });只生成一行数据。


    verticalBarRect.on('dragmove', (event) => {
      const scrollbarX = event.target.x();
      // 根据滚动条位置计算内容的滚动位置
      const scrollRatio = scrollbarX / maxScrollbarX;
      const tempScrollLeft = scrollRatio * maxScroll;
      // 更新内容的 x 位置
      this.core.moveOffsetX(tempScrollLeft);
    });
    verticalBarRect.on("dragend", () => {
      this.render.draw();
    })
    
   //。core.ts
   moveOffsetX(offsetX: number) {
    // 更新内容的 x 位置
    this.config.update({ offsetX })
    this.staticLayer.containerGroup.x(-offsetX);
    this.staticLayer.headerTextGroup.x(-offsetX);
    this.render.scrollX();
  }
  //。render.ts
   scrollX() {
    this.animationDraw();
    this.makerManager.update();
    this.taskManager.moveX();
  }
  
  
  animationDraw() {
    const {
      staticLayer: { containerGroup, headerTextGroup },
      columnStartIndex,
      columnStopIndex,
      itemCells: items,
    } = this;
    containerGroup.destroyChildren();
    headerTextGroup.destroyChildren();
    this.getDrawConfig({ initRowCount: 1 });

优化后。再执行一下动画,看看性能分析,明显好多了,符合自己的预期了。

image.png

maker 和 task 渲染

这块儿用几个class来管理。滚动时只需要调用manager中的update方法 会执行所有maker中的update更新x坐标。

image.png image.png

task也是一样。需要一个manager来管理。但是与maker不同的是。task的时间跨度需要支持可以拖动更新的。作为一个sdk,我们需要设计权限配置,当 mode = 'edit'才可以被更改。

image.png

没有权限时 实例化 :

image.png

存在权限时 实例化:ResizeTask 实现在task基础上 扩展resize功能。

image.png

纵向滚动条

这块的功能不复杂。这个滚动条不需要过多的讲解。滚动更新 offsetY 的值,然后调用taskManager中moveY函数,更新每一个task的y坐标即可。但是我们知道y坐标变化。cells也应该重新渲染 但是我们为什么不去重新渲染呢? 还是一样的优化思路。我们只在滚动结束后更新渲染。视觉上就像没有更新过cells一样。性能不用担心。

发布订阅

一个合格的sdk应该向调用方暴露一些接口,方便调用者知道sdk的进度和接收事件。这里我举个例子。

image.png image.png

调用方使用 :

    const gantt = new Gantt()
    gantt.API.on("tapTask", (params) => {
      console.log('params', params);

    })
    gantt.API.on("rightMenuTask", (params) => {
      console.log('params', params);

    })

补充

其实要充分实现这个功能,还有很多需要补充和优化的点。比如最上面提到的Config配置类,应该由调用者传入。 还有 父子节点关联, 都需要扩展。

    const gantt = new Gantt({
       mode : 'edit',
       startDate : '2024-10-30'
    })
       
    gantt.API.setData({
       makers:[{ startDate : '2024-10-30' }],
       tasks:[
          {...}
       ]
    })
    
    gantt.API.on("tapTask", (params) => {
      console.log('params', params);

    })
    gantt.API.on("rightMenuTask", (params) => {
      console.log('params', params);

    })

结束

本次文章我主要想讲解一下功能分析和性能优化,写文章不是我的强项,有不懂的可以留言。源码过几天整理后更新在文章后面,可以自己再去追加功能。