Plait 框架介绍
一款开源、现代化的绘图框架,用于构建一体化的白板工具产品,比如:思维导图、流程图、自由画笔等等。
Plait 采用插件机制支持具体的业务功能开发,目前已经支持了基础的思维导图插件和流程图插件,基础功能演示传送到:摊牌了,我们在做画图框架!!!_哔哩哔哩_bilibili
Github 地址:github.com/worktile/pl…
欢迎 Star 支持,大家有任何技术上或者交互上问题欢迎提 Issue。

前言

抽象在软件开发过程中是一种常见的现象,我的理解: 抽象是一种表达方式 ,随着抽象概念的引入,代码的编写需要围绕新的概念进行,日常沟通的颗粒度也需要围绕新的概念对齐。

抽象也应当属于软件架构的一部分,当代码写起来很别扭、当代码阅读起来逻辑感觉比较混乱时就是需要抽象和封装的时候, 抽象的引入会让封装容易立的住,更容易交流沟通 ,就如同起「名字」,我们可以赋予「名字」以特定的意义,一说到这个「名字」大家里面形成一种共识,然后可以「名字」延伸别的东西,这样事情再沟通起来就会变的简单。

在 Plait 框架中就有一些抽象的影子,这些抽象有的是参考其它的开源作品引入的,有的是根据业务需要自己设计的,我自己会有一种感觉:「抽象」或者「抽象的概念」是值得不断凝练、不断琢磨的,尤其是当发现有些些「抽象概念」、「抽象逻辑」可以很好的表达业务时总是莫名的会有些激动,所以才有了这样一个简单的分享。

本次分享是一个宽泛的话题,不涉及具体前端知识,也不涉及后端知识,但是会以 Plait 框架为载体,给大家介绍下 Plait 框架中的一些「抽象」,主要包含两个部分:

  1. getPoints 抽象
  2. PointPlacement 抽象
  3. Vector 抽象

一、getPoints 中的抽象

第一个案例是拿一个开源作品中的抽象概念(开源库 xyflow: github.com/xyflow/xyfl… )来介绍,这部分内容主要来源于公司小伙伴 @huanhuanwa 对 xyflow 代码中 「默认正交连线实现」 的源码分析,我觉得也是一个比较好的抽象表达,所以拿来介绍。

它所实现的业务场景如下图所示:

画图框架 Plait 中的抽象

xyflow 默认正交连线路径示意

简单说就是确定节点间的连线路径,在 xyflow 中对应 getPoints 函数。

为什么要抽象?

这是 getPoints 一个基础实现,不涉及数学算法,实现思路就是区分场景,确定每种场景下的路径,但是它要处理的场景仍然很多,如果写不好会有无数个分支判断,而现在 xyflow 的实现大致把它划归到了三种情况,我觉得这就是一种较好的抽象。

下面我们直接看它的抽象,看看它是如何抽象一些具象概念,然后根据这些概念将逻辑划归到三种情况的。

三种情况

情况一:同轴不同向

画图框架 Plait 中的抽象

同轴不同向

情况二:同轴同向

画图框架 Plait 中的抽象

同轴同向

情况三:同轴不同向

画图框架 Plait 中的抽象

同轴不同向

抽象概念

下面是一些简单的抽象概念,区分的情况以及概念的抽象大家可以不用过于纠结细节,除非你真正对这块实现感兴趣的或者想参考这块,否则你只需要简单了解它的抽象思路即可。

  1. Position
  2. handleDirections

画图框架 Plait 中的抽象

handleDirections

3. gapped

画图框架 Plait 中的抽象

gapped 点示意

4. dir/dirAccessor/currDir

画图框架 Plait 中的抽象

四种 dir 情况

5. defaultCenterX, defaultCenterY/defaultOffsetX, defaultOffsetY

画图框架 Plait 中的抽象

中心点示意

个人感觉只有第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 介绍:

  1. 对应于获取特定的点位坐标

  2. 对应场景(基于确定的点位坐标找相对的点位坐标):

  3. 找思维导图节点连线中的关键点

  4. 找概要括弧线中的关键点

  5. 找节点展开收起的中心点

下面是三种情况的示意图:红色表达确定的点(思维导图边框上的点)、黑色表达要找的相对的点

画图框架 Plait 中的抽象

画图框架 Plait 中的抽象

画图框架 Plait 中的抽象

为什么要抽象?

光看上图的场景感觉找到这些关键点其实不复杂,但是实际情况是每一个场景都需要做很多的条件判断,因为我们要支持多种布局:上、下、左、右、缩进布局。

抽象概念

  1. PointPlacement

  2. HorizontalPlacement

  3. VerticalPlacement

  4. getPointByPlacement – 根据点位获取坐标点

  5. transformPlacement – 基于布局做点位变换(屏蔽布局的影响)

  6. moveXOfPoint、moveYOfPoint – 移动坐标(如上图:红色点 -> move -> 黑色点)

PointPlacement: 代表点位

画图框架 Plait 中的抽象

类型定义:

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)业务时抽象的概念,现在也会用在其它插件中

向量解释:

  1. 表达方向(它也可以表达前面说的方向)

  2. left(-1, 0)

  3. top(0,1)

  4. 表达斜率

  5. 基于屏幕坐标系

画图框架 Plait 中的抽象

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]
// 表示方向是右上

为什么抽象?

  1. 一些场景下可以简化计算
  2. 一些场景下向量是解决问题的必要手段

向量表达

  1. Vector 向量

  2. VectorComponent 向量距离

  3. VectorDirectionComponent 向量分量距离

  4. rotateVectorAnti90 函数

  5. getDirectionByVector 函数

  6. getPointByVectorComponent 函数

  7. getPointByVectorDirectionComponent 函数

向量距离、向量分量距离示意:

画图框架 Plait 中的抽象

应用场景

  1. 确定正交连线方向
  2. 获取线段上的点

1、确定正交连线方向

直线:

画图框架 Plait 中的抽象

基于边构建详细,基于顺时针的方向

斜线:

画图框架 Plait 中的抽象

曲线:

画图框架 Plait 中的抽象

定位步骤:

  1. 确定向量逻辑
  2. 逆旋转 90(rotateVectorAnti90)
  3. Vector -> 方向(getDirectionByVector)

2、获取线段上的点

画图框架 Plait 中的抽象

向量距离是已知常量

已知向量距离,基于 getPointByVectorComponent 函数就可以求解

画图框架 Plait 中的抽象

已知向量分量(Y 方向分量)距离,基于 getPointByVectorDirectionComponent 函数可求解

结束

架构要是一个有体系的整体,对外 API 统一,内部抽象统一,并且随着业务功能的不断叠加,内部的抽象要有从扩张到收窄的趋势,不能任由复杂度不断叠加。

我的观点之一就是,一般函数的抽取尽量不要包含过多的分支判断,尤其是分支嵌套分支。

谢谢阅读!