✨✨✨目前成都的"小学生"大佬和作者一起开发了 Flowable 流程引擎组件(包含前端设计器与后端流程引擎)。

该组件与 Flowable 流程引擎深度融合,结合实际业务场景和使用方式,对属性编辑面板进行了重新设计,优化了用户体验。 增加了符合业务场景的流程校验与进度预览、引入富文本编辑器与代码编辑器。 结合后端引擎,可直接嵌入系统中使用。

详情请访问:https://www.bpmport.com/products ;

设计器预览:

  1. 编辑器:designer
  2. 预览与模拟:viewer
  3. DMN决策设计器:dmn

四. Modules

7. Modeling 基本建模方法

Diagram.js 提供的基础建模工厂 BaseModeling,注入了 EventBus, ElementFactory, CommandStack 模块。Bpmn.js 继承了 BaseModeling 并提供了新的方法。

该模块在自定义节点属性等方面经常使用

使用方式
const Modeling = this.bpmnModeler.get("modeling");

Modeling 初始化时会向 CommandStack 命令堆栈中注册对应的处理程序,以确保操作可恢复和取消。

Modeling 提供的方法主要是根据 handlers 来定义的,每个方法会触发对应的事件

// BaseModeling (diagram.js)
BaseModeling.prototype.getHandlers = function () {
    var BaseModelingHandlers = {
        'shape.append': AppendShapeHandler, // 形状可逆添加到源形状的处理程序
        'shape.create': CreateShapeHandler, // 形状可逆创建、添加到流程中的处理程序
        'shape.delete': DeleteShapeHandler, // 形状可逆移除的处理程序
        'shape.move': MoveShapeHandler, // 形状可逆移动的处理程序
        'shape.resize': ResizeShapeHandler, // 形状可逆变换大小的处理程序
        'shape.replace': ReplaceShapeHandler, // 通过添加新形状并删除旧形状来替换形状。 如果可能,将保持传入和传出连接
        'shape.toggleCollapse': ToggleShapeCollapseHandler, // 切换元素的折叠状态及其所有子元素的可见性
        'spaceTool': SpaceToolHandler, // 通过移动和调整形状、大小、连线锚点(巡航点)来添加或者删除空间
        'label.create': CreateLabelHandler, // 创建标签并附加到特定的模型元素上
        'connection.create': CreateConnectionHandler, // 创建连线,并显示到画布上
        'connection.delete': DeleteConnectionHandler, // 移除连线
        'connection.move': MoveConnectionHandler, // 实现连接的可逆移动的处理程序。 该处理程序与布局连接处理程序的不同之处在于它保留了连接布局
        'connection.layout': LayoutConnectionHandler, // 实现形状的可逆移动的处理程序
        'connection.updateWaypoints': UpdateWaypointsHandler, // 更新锚点(巡航点)
        'connection.reconnect': ReconnectConnectionHandler, // 重新建立连接关系
        'elements.create': CreateElementsHandler, // 元素可逆创建的处理程序
        'elements.move': MoveElementsHandler, // 元素可逆移动的处理程序
        'elements.delete': DeleteElementsHandler, // 元素可逆移除的处理程序
        'elements.distribute': DistributeElementsHandler, // 均匀分配元素布局的处理程序
        'elements.align': AlignElementsHandler, // 以某种方式对齐元素
        'element.updateAttachment': UpdateAttachmentHandler // 实现形状的可逆附着/分离的处理程序。
    }
    return BaseModelingHandlers;
}

// Modeling (bpmn.js)
var ModelingHandlers = BaseModeling.prototype.getHandlers.call(this);

ModelingHandlers['element.updateModdleProperties'] = UpdateModdlePropertiesHandler; // 实现元素上的扩展属性的可逆修改
ModelingHandlers['element.updateProperties'] = UpdatePropertiesHandler; // 实现元素上的属性的可逆修改
ModelingHandlers['canvas.updateRoot'] = UpdateCanvasRootHandler; // 可逆更新画布挂载节点
ModelingHandlers['lane.add'] = AddLaneHandler; // 可逆通道添加
ModelingHandlers['lane.resize'] = ResizeLaneHandler; // 通道可逆resize
ModelingHandlers['lane.split'] = SplitLaneHandler; // 通道可逆分隔
ModelingHandlers['lane.updateRefs'] = UpdateFlowNodeRefsHandler; // 可逆更新通道引用
ModelingHandlers['id.updateClaim'] = IdClaimHandler;
ModelingHandlers['element.setColor'] = SetColorHandler; // 可逆更新元素颜色
ModelingHandlers['element.updateLabel'] = UpdateLabelHandler; // 可逆更新元素label
提供方法
const Modeling = this.bpmnModeler.get("modeling");


// 获取当前拥有的处理程序
Modeling.getHandlers()

/**
 * 更新元素的label标签,同时触发 element.updateLabel 事件
 * @param element: ModdleElement
 * @param newLabel: ModdleElement 新的标签元素
 * @param newBounds: {x: number;y: number; width: number; height: number} 位置及大小
 * @param hints?:{} 提示信息
 */
Modeling.updateLabel(element, newLabel, newBounds, hints);

/**
 * 创建新的连接线,触发 connection.create 事件
 * 会在内部调用 createConnection() 方法(Modeling.prototype.createConnection -- in diagram.js)
 * @param source:ModdleElement 源元素
 * @param target:ModdleElement 目标元素
 * @param attrs?: {} 属性,未传时会根据规则替换成对应的对象,主要包含连线类型 type
 * @param hints?: {} 
 * @return Connection 连线实例
 */
Modeling.connect(source, target, attrs, hints)

/**
 * 更新元素扩展属性,同时触发 element.updateModdleProperties
 * @param element 目标元素
 * @param moddleElement 元素扩展属性对应的实例
 * @param properties 属性
 */
Modeling.updateModdleProperties(element, moddleElement, properties)

/**
 * 更新元素属性,同时触发 element.updateProperties
 * @param element 目标元素
 * @param properties 属性
 */
Modeling.connect(element, properties)

/**
 * 泳道(通道)事件,会触发对应的事件 lane.resize
 */
Modeling.resizeLane(laneShape, newBounds, balanced)

/**
 * 泳道(通道)事件,会触发对应的事件 lane.add
 */
Modeling.addLane(targetLaneShape, location)

/**
 * 泳道(通道)事件,会触发对应的事件 lane.split
 */
Modeling.splitLane(targetLane, count)

/**
 * 将当前图转换为协作图
 * @return Root
 */
Modeling.makeCollaboration()

/**
 * 将当前图转换为一个过程
 * @return Root
 */
Modeling.makeProcess()

/**
 * 修改目标元素color,同时触发 element.setColor 事件
 * @param elements: ModdleElment || ModdleElement[] 目标元素
 * @param colors:{[key: string]: string} svg对应的css颜色属性对象
 */
Modeling.setColor(elements, colors)
BaseModeling 提供方法

BaseModelingdiagram.js 提供的基础方法,也可以直接调用未被 bpmn.js 覆盖的方法。

// 向命令堆栈注册处理程序
Modeling.registerHandlers(commandStack)

// 移动 Shape 元素到新元素下, 触发shape.move
Modeling.moveShape(shape, delta, newParent, newParentIndex, hints)

// 移动多个 Shape 元素到新元素下, 触发 elements.move
Modeling.moveElements(shapes, delta, target, hints)

// 移动 Connection 元素到新元素下, 触发 connection.move
Modeling.moveConnection(connection, delta, newParent, newParentIndex, hints)

// 移动 Connection 元素到新元素下, 触发 connection.move
Modeling.layoutConnection(connection, hints)


/**
 * 创建新的连线实例,触发 connection.create
 * @param source: ModdleElement
 * @param target: ModdleElement
 * @param parentIndex?: number
 * @param connection: ModdleElement | Object 连线实例或者配置的属性对象
 * @param parent:ModdleElement 所在的元素的父元素 通常为 Root
 * @param hints: {}
 * @return Connection 新的连线实例
 */
Modeling.createConnection(source, target, parentIndex, connection, parent, hints)

/**
 * 创建新的图形实例,触发 shape.create
 * @param shape
 * @param position
 * @param target
 * @param parentIndex
 * @param hints
 * @return Shape 新的图形实例
 */
Modeling.createShape(shape, position, target, parentIndex, hints)

/**
 * 创建多个元素实例,触发 elements.create
 * @param
 * @param
 * @return Elements 实例数组
 */
Modeling.createElements(elements, position, parent, parentIndex, hints)

/**
 * 为元素创建 label 实例, 触发 label.create
 * @param labelTarget: ModdleElement 目标元素
 * @param position: { x: number; y: number }
 * @param label:ModdleElement label 实例
 * @param parent: ModdleElement
 * @return Label
 */
Modeling.createLabel(labelTarget, position, label, parent)

/**
 * 将形状附加到给定的源,在源和新创建的形状之间绘制连接。触发 shape.append
 * @param source: ModdleElement
 * @param shape: ModdleElement | Object
 * @param position: { x: number; y: number }
 * @param target: ModdleElement
 * @param hints
 * @return Shape 形状实例
 */
Modeling.appendShape(source, shape, position, target, hints)

/**
 * 移除元素,触发 elements.delete
 * @param elements: ModdleElement[]
 */
Modeling.removeElements(elements)

/**
 * 不太了解
 */
Modeling.distributeElements(groups, axis, dimension)

/**
 * 移除元素, 触发 shape.delete
 * @param shape: ModdleElement
 * @param hints?: object
 */
Modeling.removeShape(shape, hints)

/**
 * 移除连线, 触发 connection.delete
 * @param connection: ModdleElement
 * @param hints?: object
 */
Modeling.removeConnection(connection, hints)

/**
 * 更改元素类型(替换元素),触发 shape.replace
 * @param oldShape:ModdleElement
 * @param newShape:ModdleElement
 * @param hints?: object
 * @return Shape 替换后的新元素实例
 */
Modeling.replaceShape(oldShape, newShape, hints)

/**
 * 对其选中元素,触发 shape.replace
 * @param elements: ModdleElement[]
 * @param alignment: Alignment
 * @return
 */
Modeling.alignElements(elements, alignment)

/**
 * 调整形状元素大小,触发 shape.resize
 * @param shape: ModdleElement
 * @param newBounds
 * @param minBounds
 * @param hints?: object
 */
Modeling.resizeShape(shape, newBounds, minBounds, hints)

/**
 * 切换元素展开/收缩模式,触发 shape.toggleCollapse
 * @param shape?: ModdleElement
 * @param hints?: object=
 */
Modeling.toggleCollapse(shape, hints)

// 连线调整的方法
Modeling.reconnect(connection, source, target, dockingOrPoints, hints)

Modeling.reconnectStart(connection, newSource, dockingOrPoints, hints)

Modeling.reconnectEnd(connection, newTarget, dockingOrPoints, hints)

Modeling.connect(source, target, attrs, hints)

8. Draw 绘制模块

基础的元素绘制方法,由 diagram.js 提供基础模块,源码如下:

// diagram.js/lib/draw/index.js
import DefaultRenderer from './DefaultRenderer';
import Styles from './Styles';

export default {
  __init__: [ 'defaultRenderer' ],
  defaultRenderer: [ 'type', DefaultRenderer ],
  styles: [ 'type', Styles ]
};

其中 DefaultRenderer 为默认元素绘制方法,继承 BaseRenderer ,自身包含 CONNECTION_STYLE --连线默认样式, FRAME_TYLE -- 框架默认样式SHAPE_STYLE -- 元素默认样式 三个样式属性。

Styles 为样式管理组件,包含 cls -- 根据属性、样式名等来定义样式, style -- 根据属性计算样式computeStyle -- 样式计算方法 三个方法。

BaseRenderer 是一个抽象类,只定义了方法和绘制时的触发事件,没有定义方法的具体实现。

Styles 样式管理(diagram.js

根据源码的思路,这个模块只推荐重写,即修改默认的类名与样式配置。

// diagram.js/lib/draw/Styles.js
import { isArray, assign, reduce } from 'min-dash';

/**
 * A component that manages shape styles
 */
export default function Styles() {

  var defaultTraits = {
    'no-fill': {
      fill: 'none'
    },
    'no-border': {
      strokeOpacity: 0.0
    },
    'no-events': {
      pointerEvents: 'none'
    }
  };
  var self = this;

  /**
   * Builds a style definition from a className, a list of traits and an object of additional attributes.
   *
   * @param  {string} className
   * @param  {Array<string>} traits
   * @param  {Object} additionalAttrs
   *
   * @return {Object} the style defintion
   */
  this.cls = function(className, traits, additionalAttrs) {
    var attrs = this.style(traits, additionalAttrs);
    return assign(attrs, { 'class': className });
  };

  /**
   * Builds a style definition from a list of traits and an object of additional attributes.
   *
   * @param  {Array<string>} traits
   * @param  {Object} additionalAttrs
   *
   * @return {Object} the style defintion
   */
  this.style = function(traits, additionalAttrs) {
    if (!isArray(traits) && !additionalAttrs) {
      additionalAttrs = traits;
      traits = [];
    }
    var attrs = reduce(traits, function(attrs, t) {
      return assign(attrs, defaultTraits[t] || {});
    }, {});
    return additionalAttrs ? assign(attrs, additionalAttrs) : attrs;
  };

  this.computeStyle = function(custom, traits, defaultStyles) {
    if (!isArray(traits)) {
      defaultStyles = traits;
      traits = [];
    }
    return self.style(traits || [], assign({}, defaultStyles, custom || {}));
  };
}
DefaultRenderer 默认绘制方法(diagram.js

源码位置:diagram-js/lib/draw/DefaultRenderer.js

继承了 diagram.js/BaseRenderer ,注入 eventBus styles 模块,并且默认绘制方法的处理优先级最低,在有其他绘制方法的时候会被覆盖。

BaseRenderer 提供了一个抽象基类,并且提供了 canRender() , getShapePath(), getConnecttionPath(), drawShape(), DrawConnection() 五个抽象方法,定义了方法触发时刻。

eventBus.on([ 'render.shape', 'render.connection' ], renderPriority, function(evt, context) {
    var type = evt.type,
        element = context.element,
        visuals = context.gfx;
    if (self.canRender(element)) {
        if (type === 'render.shape') {
            return self.drawShape(visuals, element);
        } else {
            return self.drawConnection(visuals, element);
        }
    }
});
eventBus.on([ 'render.getShapePath', 'render.getConnectionPath'], renderPriority, function(evt, element) {
    if (self.canRender(element)) {
        if (evt.type === 'render.getShapePath') {
            return self.getShapePath(element);
        } else {
            return self.getConnectionPath(element);
        }
    }
});

DefaultRenderer 重写了以上五个方法(canRender() 直接返回了 true, 表示任何情况都可以绘制和渲染元素),实现默认元素和样式的解析渲染。

方法说明:

  1. canRender(): 判断方法,返回一个布尔值,为真时表示可以继续解析元素属性(位置、大小、形状等)或者继续渲染属性。
  2. getShapePath(shape): 元素(默认是方形元素)属性解析方法。
  3. getConnectionPath(connection): 连线属性解析方法。
  4. drawShape(visuals, element): 元素(默认是方形元素)绘制方法。
  5. drawConnection(visuals, connection): 连线绘制方法。

--------------------------------- 分割线 -------------------------------------

bpmn.js 继承 diagram.js/BaseRenderer 定义了一个 BpmnRender 类,并针对 bpmn 2.0 流程需要的其他元素做了新的处理。

bpmn.js 为了实现 bpmn 2.0 流程图的支持,不仅重新定义了新的渲染方法类 BpmnRenderer, TextRender, PathMap,以保证图形元素的正常解析,以及 label 的便捷添加修改。

import BpmnRenderer from './BpmnRenderer';
import TextRenderer from './TextRenderer';
import PathMap from './PathMap';

export default {
  __init__: [ 'bpmnRenderer' ],
  bpmnRenderer: [ 'type', BpmnRenderer ],
  textRenderer: [ 'type', TextRenderer ],
  pathMap: [ 'type', PathMap ]
};
BpmnRenderer 流程元素绘制方法

支持 bpmn 2.0 的流程元素的基础绘制方法,继承 BaseRender,注入了 config, eventBus, styles, pathMap, canvas, textRenderer 模块。源码位于 bpmn-js/lib/draw/BpmnRenderer.js,共1900+行(其中1200+行都在定义绘制各种元素的方法)。

BpmnRenderer 只实现了基类的4个抽象方法(getConnectionPath() 方法没有使用,由此可见其实 bpmn-js 内部的连线元素也是当做了 shape 类型来进行处理的,毕竟有个箭头,也可能存在折线的情况),并且没有新增方法。但是在 canRender() 方法里判断了需要渲染的元素是否属于 bpmn:BaseElement 类型。

BpmnRenderer.prototype.canRender = function(element) {
      return is(element, 'bpmn:BaseElement'); // 从解析文件 bpmn.json 其实可以发现,所有需要渲染的元素最终都继承了 Bpmn:BaseElement
};

getShapePath() 方法中,对属于 bpmnEvent(事件类节点,例如开始和结束等事件,显示为圆形)bpmn:Activity(任务类节点,包含子流程类型的节点,显示为圆角矩形)bpmn:Gateway(网关类型,显示为菱形) 三个大类型的节点定义的对应的路径获取方法,其他类型则沿用与 diagram.js/DefaultRenderer.js 里面使用的 getRectPath() 方法。

drawShape()drawConnection() 方法则是判断了需要渲染的元素类型,调用对应的 handler() 方法也处理(也就是上面说的那1200+行代码),通过 handlers 对象(所有 handler() 方法的集合,以各类型的类型名作为 key),可以发现可显示的元素一共有60种:

0: "bpmn:Event"
1: "bpmn:StartEvent"
2: "bpmn:MessageEventDefinition"
3: "bpmn:TimerEventDefinition"
4: "bpmn:EscalationEventDefinition"
5: "bpmn:ConditionalEventDefinition"
6: "bpmn:LinkEventDefinition"
7: "bpmn:ErrorEventDefinition"
8: "bpmn:CancelEventDefinition"
9: "bpmn:CompensateEventDefinition"
10: "bpmn:SignalEventDefinition"
11: "bpmn:MultipleEventDefinition"
12: "bpmn:ParallelMultipleEventDefinition"
13: "bpmn:EndEvent"
14: "bpmn:TerminateEventDefinition"
15: "bpmn:IntermediateEvent"
16: "bpmn:IntermediateCatchEvent"
17: "bpmn:IntermediateThrowEvent"
18: "bpmn:Activity"
19: "bpmn:Task"
20: "bpmn:ServiceTask"
21: "bpmn:UserTask"
22: "bpmn:ManualTask"
23: "bpmn:SendTask"
24: "bpmn:ReceiveTask"
25: "bpmn:ScriptTask"
26: "bpmn:BusinessRuleTask"
27: "bpmn:SubProcess"
28: "bpmn:AdHocSubProcess"
29: "bpmn:Transaction"
30: "bpmn:CallActivity"
31: "bpmn:Participant"
32: "bpmn:Lane"
33: "bpmn:InclusiveGateway"
34: "bpmn:ExclusiveGateway"
35: "bpmn:ComplexGateway"
36: "bpmn:ParallelGateway"
37: "bpmn:EventBasedGateway"
38: "bpmn:Gateway"
39: "bpmn:SequenceFlow"
40: "bpmn:Association"
41: "bpmn:DataInputAssociation"
42: "bpmn:DataOutputAssociation"
43: "bpmn:MessageFlow"
44: "bpmn:DataObject"
45: "bpmn:DataObjectReference"
46: "bpmn:DataInput"
47: "bpmn:DataOutput"
48: "bpmn:DataStoreReference"
49: "bpmn:BoundaryEvent"
50: "bpmn:Group"
51: "label"
52: "bpmn:TextAnnotation"
53: "ParticipantMultiplicityMarker"
54: "SubProcessMarker"
55: "ParallelMarker"
56: "SequentialMarker"
57: "CompensationMarker"
58: "LoopMarker"
59: "AdhocMarker"

具体的实现方法,有兴趣的童鞋可以自行查看。

要实现自定义 renderer ,本质也是定义一个自己的渲染函数类,继承 BaseRenderer,然后修改原型链上的方法,使生成的渲染方法实例每次调用 drawShape() 或者 drawConnection() 等方法的时候都调用自定义的绘制方法(这里必须重新实现基类 BaseRenderer 的几个方法,否则不生效)。

TextRenderer 文本元素绘制方法

源码位于 bpmn-js/lib/draw/TextRenderer.js,主要实现了文字元素(即 Label 标签)的渲染与显示,通过获取绑定节点的位置和大小,在对应的位置生成一个 text 标签来显示文本。可通过重写该函数类来实现自定义的文本位置控制。

PathMap SVG元素路径对象

包含 BpmnRenderer 所需的SVG路径的函数,内部有一个 pathMap 对象,保存了所有的元素的 svg 路径、默认大小。

9. AlignElements 元素对齐

diagram.js 模块,注入模块 Modeling。主要用作元素对齐。

会按照元素对齐方向的边界对齐。

使用:

const AlignElements = this.bpmnModeler.get("alignElements");

/**
 * Executes the alignment of a selection of elements
 * 执行元素选择的对齐
 *
 * @param  {Array} elements 通常为节点元素
 * @param  {string} type 可用:left|right|center|top|bottom|middle
 */
AlignElements.trigger(Elements, type);

改写:

// index.js
import CustomElements from './CustomElements';

export default {
  __init__: [ 'customElements' ],
  customElements: [ 'type', CustomElements ]
};

// CustomElements.js
import inherits from 'inherits';
import AlignElements from 'diagrem-js/lib/features/align-elements/AlignElements';

export default function CustomElements(modeling) {
  this._modeling = modeling;
}

inherits(CustomElements, AlignElements);

CustomElements.$inject = [ 'modeling' ];

CustomElements.prototype.trigger = function(elements, type) {
    // 对齐逻辑
}

10. AttachSupport 依附支持

diagram.js 模块,注入模块 injector, eventBus, canvas, rules, modeling,依赖规则模块 rulesModule。主要用作元素移动期间的绑定关系和预览。

基础逻辑模块,不推荐更改,也不提供直接使用的方法。

11. AutoPlace 元素自动放置

将元素自动放置到合适位置(默认在后方,正后方存在元素时向右下偏移)并调整连接的方法。通常在点击 contentPad 创建新元素的时候触发。

注入基础模块 eventBusmodeling

默认会初始化一个放置后选中元素的方法。

使用:

const AutoPlace = this.bpmnModeler.get("autoPlace");

/**
 * Append shape to source at appropriate position.
 * 将形状添加的源对应的合适位置
 * 会触发 autoPlace.start autoPlace autoPlace.end 三个事件
 *
 * @param {djs.model.Shape} source ModdleElement
 * @param {djs.model.Shape} shape ModdleElement
 *
 * @return {djs.model.Shape} appended shape
 */
AutoPlace.append(source, shape, hints);

12. AutoResize 元素大小调整

一个自动调整大小的组件模块,用于在创建或移动子元素接近父边缘的情况下扩展父元素。

注入模块 eventBus, elementRegistry, modeling, rules

暂时没找到怎么直接调用

重写/禁用:

// 重写
// index.js
import AutoResize from './AutoResize';

export default {
  __init__: [ 'autoResize' ],
  autoResize: [ 'type', AutoResize ]
  // 禁用直接设置 autoResize: [ 'type', "" ] 或者 autoResize: [ 'type', () => false ]
};

// AutoResize.js
export default function AutoResize(eventBus, elementRegistry, modeling, rules) {
    // ...
}

AutoResize.$inject = [
  'eventBus',
  'elementRegistry',
  'modeling',
  'rules'
];

inherits(AutoResize, CommandInterceptor); // CommandInterceptor 向commandStack中插入命令的原型。

13. AutoScroll 画布滚动

画布自动扩展滚动的方法,如果当前光标点靠近边框,则开始画布滚动。 当当前光标点移回到滚动边框内时取消或手动取消。

依赖于 DraggingModule ,注入模块 eventBus, canvas

函数原型上传入了 config ,但打印为 undefined

使用与方法:

const AutoScroll = this.modeler.get("autoScroll");

/**
 * Starts scrolling loop.
 * 开始滚动
 * Point is given in global scale in canvas container box plane.
 *
 * @param  {Object} point { x: X, y: Y }
 */
AutoScroll.startScroll(point);

// 停止滚动
AutoScroll.stopScroll();

/**
 * 覆盖默认配置
 * @param {Object} options 
 * options.scrollThresholdIn: [ 20, 20, 20, 20 ],
 * options.scrollThresholdOut: [ 0, 0, 0, 0 ],
 * options.scrollRepeatTimeout: 15,
 * options.scrollStep: 10
 */
AutoScroll.setOptions(options);

本文首发于“掘金”
作者:MiyueFE
链接:https://juejin.cn/post/6925304166638174216
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐