Plait 框架介绍
一款开源、现代化的绘图框架,用于构建一体化的白板工具产品,比如:思维导图、流程图、自由画笔等等。
Plait 采用插件机制支持具体的业务功能开发,目前已经支持了基础的思维导图插件和流程图插件,基础功能演示传送到:摊牌了,我们在做画图框架!!!_哔哩哔哩_bilibili
Github 地址:github.com/worktile/pl…
欢迎 Star 支持,大家有任何技术上或者交互上问题欢迎提 Issue。
前言
抽象在软件开发过程中是一种常见的现象,我的理解: 抽象是一种表达方式 ,随着抽象概念的引入,代码的编写需要围绕新的概念进行,日常沟通的颗粒度也需要围绕新的概念对齐。
抽象也应当属于软件架构的一部分,当代码写起来很别扭、当代码阅读起来逻辑感觉比较混乱时就是需要抽象和封装的时候, 抽象的引入会让封装容易立的住,更容易交流沟通 ,就如同起「名字」,我们可以赋予「名字」以特定的意义,一说到这个「名字」大家里面形成一种共识,然后可以「名字」延伸别的东西,这样事情再沟通起来就会变的简单。
在 Plait 框架中就有一些抽象的影子,这些抽象有的是参考其它的开源作品引入的,有的是根据业务需要自己设计的,我自己会有一种感觉:「抽象」或者「抽象的概念」是值得不断凝练、不断琢磨的,尤其是当发现有些些「抽象概念」、「抽象逻辑」可以很好的表达业务时总是莫名的会有些激动,所以才有了这样一个简单的分享。
本次分享是一个宽泛的话题,不涉及具体前端知识,也不涉及后端知识,但是会以 Plait 框架为载体,给大家介绍下 Plait 框架中的一些「抽象」,主要包含两个部分:
- getPoints 抽象
- PointPlacement 抽象
- Vector 抽象
一、getPoints 中的抽象
第一个案例是拿一个开源作品中的抽象概念(开源库 xyflow: github.com/xyflow/xyfl… )来介绍,这部分内容主要来源于公司小伙伴 @huanhuanwa 对 xyflow 代码中 「默认正交连线实现」 的源码分析,我觉得也是一个比较好的抽象表达,所以拿来介绍。
它所实现的业务场景如下图所示:
xyflow 默认正交连线路径示意
简单说就是确定节点间的连线路径,在 xyflow 中对应 getPoints 函数。
为什么要抽象?
这是 getPoints 一个基础实现,不涉及数学算法,实现思路就是区分场景,确定每种场景下的路径,但是它要处理的场景仍然很多,如果写不好会有无数个分支判断,而现在 xyflow 的实现大致把它划归到了三种情况,我觉得这就是一种较好的抽象。
下面我们直接看它的抽象,看看它是如何抽象一些具象概念,然后根据这些概念将逻辑划归到三种情况的。
三种情况
情况一:同轴不同向
同轴不同向
情况二:同轴同向
同轴同向
情况三:同轴不同向
同轴不同向
抽象概念
下面是一些简单的抽象概念,区分的情况以及概念的抽象大家可以不用过于纠结细节,除非你真正对这块实现感兴趣的或者想参考这块,否则你只需要简单了解它的抽象思路即可。
- Position
- handleDirections
handleDirections
3. gapped
gapped 点示意
4. dir/dirAccessor/currDir
四种 dir 情况
5. defaultCenterX, defaultCenterY/defaultOffsetX, defaultOffsetY
中心点示意
个人感觉只有第4点,dir/dirAccessor/currDir 有点不太好理解,其它的抽象及命名还是比较好理解的,通过这样的方式可以有效的简化逻辑,将复杂的分问题划归到极少的几种情况。
简化版代码实现
function getPoints({
source,
sourcePosition = Position.Bottom,
target,
targetPosition = Position.Top,
center,
offset,
}: {
source: XYPosition;
sourcePosition: Position;
target: XYPosition;
targetPosition: Position;
center: Partial<XYPosition>;
offset: number;
}): [XYPosition[], number, number, number, number] {
const sourceDir = handleDirections[sourcePosition];
const targetDir = handleDirections[targetPosition];
const sourceGapped: XYPosition = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset };
const targetGapped: XYPosition = { x: target.x + targetDir.x * offset, y: target.y + targetDir.y * offset };
const dir = getDirection({...});
const dirAccessor = dir.x !== 0 ? 'x' : 'y';
const currDir = dir[dirAccessor];
let points: XYPosition[] = [];
let centerX, centerY;
const sourceGapOffset = { x: 0, y: 0 };
const targetGapOffset = { x: 0, y: 0 };
const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({...});
// opposite handle positions, default case
if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) {
} else {
// sourceTarget means we take x from source and y from target, targetSource is the opposite
const sourceTarget: XYPosition[] = [{ x: sourceGapped.x, y: targetGapped.y }];
const targetSource: XYPosition[] = [{ x: targetGapped.x, y: sourceGapped.y }];
// this handles edges with same handle positions
if (dirAccessor === 'x') {
} else {
}
if (sourcePosition === targetPosition) {
}
// these are conditions for handling mixed handle positions like Right -> Bottom for example
if (sourcePosition !== targetPosition) {
}
}
const pathPoints = [
source,
{ x: sourceGapped.x + sourceGapOffset.x, y: sourceGapped.y + sourceGapOffset.y },
...points,
{ x: targetGapped.x + targetGapOffset.x, y: targetGapped.y + targetGapOffset.y },
target,
];
return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY];
}
二、PointPlacement 抽象
这个就很简单,比较容易解释了,PointPlacement 是做思维导图(@plait/mind)业务时实现的抽象。
PointPlacement 介绍:
-
对应于获取特定的点位坐标
-
对应场景(基于确定的点位坐标找相对的点位坐标):
-
找思维导图节点连线中的关键点
-
找概要括弧线中的关键点
-
找节点展开收起的中心点
-
…
下面是三种情况的示意图:红色表达确定的点(思维导图边框上的点)、黑色表达要找的相对的点
为什么要抽象?
光看上图的场景感觉找到这些关键点其实不复杂,但是实际情况是每一个场景都需要做很多的条件判断,因为我们要支持多种布局:上、下、左、右、缩进布局。
抽象概念
-
PointPlacement
-
HorizontalPlacement
-
VerticalPlacement
-
getPointByPlacement – 根据点位获取坐标点
-
transformPlacement – 基于布局做点位变换(屏蔽布局的影响)
-
moveXOfPoint、moveYOfPoint – 移动坐标(如上图:红色点 -> move -> 黑色点)
PointPlacement: 代表点位
类型定义:
export type PointPlacement = [HorizontalPlacement, VerticalPlacement];
export enum HorizontalPlacement {
left = 'left',
center = 'center',
right = 'right'
}
export enum VerticalPlacement {
top = 'top',
middle = 'middle',
bottom = 'bottom'
}
使用示意
export function drawLogicLink(roughSVG: RoughSVG, ..., isHorizontal: boolean) {
// ① 确定起始点位
const beginPlacement: PointPlacement = [HorizontalPlacement.right, VerticalPlacement.middle];
// ② 根据布局变换起始点位
transformPlacement(beginPlacement, linkDirection);
// ③ 获取坐标点
let beginPoint = getPointByPlacement(parentClient, beginPlacement);
const beginBufferDistance = (parent.hGap + node.hGap) / 3;
const endBufferDistance = -(parent.hGap + node.hGap) / 2;
let curve: Point[] = [
beginPoint2,
// ④ 根据坐标点和偏移量计算偏移后坐标
moveXPoint(beginPoint2, beginBufferDistance, linkDirection),
moveXPoint(endPoint, endBufferDistance, linkDirection),
endPoint
];
const underlineEnd = moveXPoint(endPoint, nodeClient.width, linkDirection);
const underline: Point[] = hasUnderlineShape && isHorizontal ? [underlineEnd, underlineEnd, underlineEnd] : [];
const points = pointsOnBezierCurves([...straightLine, ...curve, ...underline]);
return roughSVG.curve(points as any, { stroke, strokeWidth });
}
整个实现不会再牵扯上、下、左、右布局的条件判断,将这部分逻辑揉入 transformPlacement 和 moveXPoint 等函数中。
小结
通过 PointPlacement 的抽象可以有效的简化代码的编写,查找点位时不再需要根据不同的布局做复杂的逻辑判断了,一些复杂的转换过程被封装到通用的函数中了。
三、Vector 抽象
向量是在坐流程图(@plait/draw)业务时抽象的概念,现在也会用在其它插件中
向量解释:
-
表达方向(它也可以表达前面说的方向)
-
left(-1, 0)
-
top(0,1)
-
表达斜率
-
基于屏幕坐标系
const vector1 = [P2.x - P1.x, P2.y - P2.y];
// vector1 = [180, 180]
// 表示方向是右下
const veector2 = [P2.x - P1.x, P2.y - P2.y];
// vector2 = [160, -260]
// 表示方向是右上
为什么抽象?
- 一些场景下可以简化计算
- 一些场景下向量是解决问题的必要手段
向量表达
-
Vector 向量
-
VectorComponent 向量距离
-
VectorDirectionComponent 向量分量距离
-
rotateVectorAnti90 函数
-
getDirectionByVector 函数
-
getPointByVectorComponent 函数
-
getPointByVectorDirectionComponent 函数
向量距离、向量分量距离示意:
应用场景
- 确定正交连线方向
- 获取线段上的点
1、确定正交连线方向
直线:
基于边构建详细,基于顺时针的方向
斜线:
曲线:
定位步骤:
- 确定向量逻辑
- 逆旋转 90(rotateVectorAnti90)
- Vector -> 方向(getDirectionByVector)
2、获取线段上的点
向量距离是已知常量
已知向量距离,基于 getPointByVectorComponent 函数就可以求解
已知向量分量(Y 方向分量)距离,基于 getPointByVectorDirectionComponent 函数可求解
结束
架构要是一个有体系的整体,对外 API 统一,内部抽象统一,并且随着业务功能的不断叠加,内部的抽象要有从扩张到收窄的趋势,不能任由复杂度不断叠加。
我的观点之一就是,一般函数的抽取尽量不要包含过多的分支判断,尤其是分支嵌套分支。
谢谢阅读!