稀土掘金技术社区 02月02日
canvas库 konva 实现腾讯文档 [甘特图视图]
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文详细介绍了如何使用 Konva 框架实现高性能甘特图。文章首先分析了甘特图的结构,采用双图层模式,分为静态图层和动态图层。静态图层负责渲染年月、列日期标题等,动态图层处理滚动条和里程碑等。文章重点讲解了如何通过 Konva 渲染年月 Tab 区域,以及如何渲染列标题日期和正文。为了提高性能,文章提出了优化滚动渲染性能的策略,包括在滚动时禁用用户交互,以及在滚动过程中只渲染一行的单元格,并在拖动结束时渲染所有单元格。

🗓️ 甘特图采用双图层结构,静态图层处理年月、列标题,动态图层处理滚动条和任务。

🎨 使用 Konva 渲染年月 Tab 区域,通过 Rect 和 Text 元素,结合点击事件实现切换效果。

📊 列标题和正文渲染采用类似表格的方式,但只渲染可视区域的节点,并通过辅助函数计算可视区域的单元格。

🚀 优化滚动渲染性能,在滚动时禁用用户交互,并只渲染一行的单元格,拖动结束时再渲染所有单元格,减少节点绘制。

原创 我是热心市民 2025-01-30 09:01 重庆

点击关注公众号,“技术干货”及时达!

点击关注公众号,“技术干货” 及时达!

简要说明

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

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

结构分析

双图层模式

静态图层:

    年月季周单独一个 Group

    列日期标题单独一个 Group,主要是为了处理纵向滚动条滚动正文部分 不要让 title 也被 offset 掉。

    渲染正文 Group,滚动时控制 offseX 和 offseY,这样就不用去设置 layer 涂层的位置,最小单位更新。

动态图层

    横向|纵向滚动条单独一个 Rect

    maker 里程碑 | task 每个独有一个 Group

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

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

渲染 年月 tab 区域

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

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'));
}
}

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

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

    所以我们要渲染一个类似 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);
}
}

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

最关键就是 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;
}

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

  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)',
}))
})
}

渲染图如下:

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

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

    先看下横向滚动条的实现,确定滚动条的宽度,图中有实现。

   // 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 节点。

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

   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 占比也高了点。因为在一帧中所做的事情太多了,这块我们要去优化。

优化滚动渲染性能

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

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

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

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

    拖动结束:调用原来的绘制函数,生成所有的 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 });

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

maker 和 task 渲染

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

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

没有权限时 实例化 :

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

纵向滚动条

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

发布订阅

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

调用方使用 :

    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);
})

结束

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

点击关注公众号,“技术干货” 及时达!

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Konva 甘特图 性能优化 前端渲染
相关文章