写点什么

用 React 仿钉钉审批流、工作流

作者:悠闲的水
  • 2023-08-06
    山东
  • 本文字数:9093 字

    阅读完需:约 30 分钟

用React仿钉钉审批流、工作流

引言

这几天帮朋友忙,用了一周时间,高仿了一个钉钉审批流。


这个东西会有不少朋友有类似需求,就分享出来,希望能有所帮助。为了方便朋友的使用,设计制作的时候,尽量做到节点配置可定制,减少集成成本。如果您的项目有审批流需求,这个项目可以直接拿过去使用。


React 初学者也可以把本项目当做研读案例,学习并快速上手 React 项目。通过研读项目代码,您可以学到如何设计一个 react 项目架构,辅助理解 react 设计哲学,学习 css-in-js 在项目中的使用,并理解其优势。理解 Redux 这种 immutable 的状态管理好处等。


本文章只包含审批流设计部分,不包含表单的设计,表单的设计请参考作者另一个可视化前端项目 RxDrag:


项目地址:https://github.com/codebdy/rxdrag

演示地址:https://rxdrag.vercel.app


相关文章:


《实战,一个高扩展、可视化低代码前端,详实、完整》《挑战零代码:可视化逻辑编排》

项目信息

项目地址:https://github.com/codebdy/dingflow


演示地址(要科学上网):https://dingflow.vercel.app/


运行快照:



这个项目非常典型,它足够小,不至于让文章太长;另外,它足够完整,涵盖了一个设计器的大部分内容,比如状态管理、物料管理、属性面板、撤销重做、画布缩放、皮肤切换、多语言管理、文件的导入导出等。


设计制作一个项目的时候,最好适当提高自己的要求,从利他的角度思考,比如:能够方便发布独立 npm 包,方便第三方引用;要考虑,代码怎么写,别人容易读。这样的要求,能让你设计的代码结构更合理,扩展性更好。时间久了,代码会越来越优雅。本项目也是这个思路下完成的,希望作者代码能够越来越好!


项目画布的 css 大部分复制了这个项目:https://github.com/StavinLi/Workflow-React


饮水思源,有了这个项目的借鉴,节省了大量时间,在此对项目作者深表谢意。


本文的代码取自项目代码仓库,但是为了理解的方便,做了少许简化。

UI 布局

分两部分理解界面布局,第一部分整体布局,理解了这部分,就知道自己业务相关的组件如何插入编辑器,能够理解作者这么设计代码架构是为了提高扩展性,方便第三方引入;第二部分是画布绘制,该项目以 div 树的方式组织审批流节点,理解了这部分有助于理解后面的数据结构。

整体布局


项目代码有两个主要目录:example 和 workflow-editor。workflow-editor 是编辑器核心,未来要作为独立的 npm package 来发布;example 是演示如何使用 workflow-editor 来把审批流集成入自己的项目。


上图把页面划分为 3 个区域,workflow-editor 包含全部③区域和②区域的部分通用组件;example 包含全部①区域的内容跟部分②区域的定制内容,并引用③的内容。


点击一个画布(也就是区域③)中的节点,会弹出属性设置面板,属性面板包含④⑤两部分:



弹出这个面板的抽屉(drawer)和它的标题④,包含在 workflow-editor 目录中,它内部的组件,就是⑤区域是在 example 中定义,通过接口注入进去的。


综上,编辑器通用的功能在 workflow-editor 中定义,差异化部分通过接口注入。

画布绘制

画布区是通过嵌套的 div 实现的,连线、箭头是通过 css 的 border、伪类 before 跟 after 实现的,这些 css 细节请参看源码,这里只介绍 div 的嵌套结构。

普通节点

像这样一组不含条件的普通节点:



它的 div 结构是这样的:



在一条直线路径上的节点,就这样层层嵌套,结束节点除外,它最后面。

条件节点

如果加上条件分支,同一级别的条件分支是水平排列的 div,分支内部的路径再次循环嵌套:



只要明白这些节点是一棵 div 树,不是扁平结构就可以了。

数据结构(DSL 定义)

UI 虽然是树形结构,但是项目内部的数据结构可以是树形,也可以是扁平的。


扁平的意思是,所用节点存在一个数组或者 map 里,通过 parentId 跟 childIds 等信息描述树形关系。


因为这个项目是帮朋友做的,他的后端是树形结构,跟 div 的结构一致。如果这个项目提供一个编辑器组件 WorkflowEditor,这个组件要有 value 跟 onChange 属性,如果是扁平结构,onChange 的时要转一下,如果做成受控组件,性能可能会有问题。


所以,最后选择了树形数据结构:


export enum NodeType {  //开始节点  start = "start",  //审批人  approver = "approver",  //抄送人?  notifier = "notifier",  //处理人?  audit = "audit",  //路由(条件节点),下面包含分支节点  route = "route",  //分支节点  branch = "branch",}
//审批流节点export interface IWorkFlowNode<Config = unknown>{ id: string //名称 name?: string //string可以用于自定义节点,暂时用不上 nodeType: NodeType | string //描述 desc?: string //子节点 childNode?: IWorkFlowNode //配置 config?: Config}
//条件根节点,下面包含各分支节点export interface IRouteNode extends IWorkFlowNode { //分支节点 conditionNodeList: IBranchNode[]}
//条件分支的子节点,分支节点export interface IBranchNode extends IWorkFlowNode { //条件配置部分还没定义,可能会放入config}
//审批流,代表一张审批流图export interface IWorkflow { //审批流Id flowId: string; //审批流名称 name?:string; //开始节点 childNode: IWorkFlowNode;}
复制代码

状态管理

如果是扁平结构,状态管理作者会首选 Recoil,用起来简单,代码量小。但是,因为数据结构定义的树形,要是用 Recoil 做状态管理,需要扁平化处理,会出现上文说的转换问题。所以,最终选择了 Redux 作为状态管理工具。


作者只会基础的 Redux 库,所以代码会略显繁琐一点,即便这样,还是不想选 mobx。因为这么小的编辑器项目,mobx 的撤销、重做的工作量,要比 Redux 大。用 Mobx 的话,一般要采用 comand 模式做撤销重做,每个 Command 有正负操作,挺繁琐,工作量也大。而 immutable 的操作方式,可以保留状态快照,易于回溯,很容易就能完成撤销、重做功能。


状态定义:


//操作快照,用于撤销、重做export interface ISnapshot {  //开始节点  startNode: IWorkFlowNode,  //是否校验过  validated?: boolean,}
//错误消息export interface IErrors { [nodeId: string]: string | undefined}
//状态export interface IState { //是否被修改,该标识用于提示是否需要保存 changeFlag: boolean, //撤销快照列表 undoList: ISnapshot[], //重做快照列表 redoList: ISnapshot[], //开始节点 startNode: IWorkFlowNode, //被选中的节点,用于弹出属性面板 selectedId?: string, //是否校验过,如果校验过,后面加入的节点会自动校验 validated?: boolean, //校验错误 errors: IErrors,}
复制代码


Redux 处理这些树形结构的状态,需要递归处理,具体参看 reducers 部分代码。

设计器架构

引擎(EditorEngine)

引擎(Engine)在作者的项目里是老演员了,这里依然扮演了一个重要角色,全名 EditorEngine。编辑器的绝大多数业务逻辑,都在这部分实现,主要功能就是操作 Redux store。源码文件在 src/workflow-editor/classes 目录下。

节点物料

物料就是节点的定义,包括节点的图标、颜色、缺省配置等信息。把这些信息独立出来的好处,是让代码更容易扩展,方便后期添加新的节点类型。作者自己开源低代码前端 RxDrag,也用了类似的设计方式,不过比这里的扩展性还要好,可以支持物料的热加载。这个项目比较简单,没有热加载需求,做到这种程度就够用了。


物料定义代码:


//国际化翻译函数,外部注入,这里使用的是@rxdrag/locales的实现(通过react hooks转了一下)export type Translate = (msg: string) => string | undefined
//物料上下文export interface IContext { //翻译 t: Translate}
//节点物料export interface INodeMaterial<Context extends IContext = IContext> { //颜色 color: string //标题 label: string //图标 icon?: React.ReactElement //默认配置 defaultConfig?: { nodeType: NodeType | string } //创建一个默认节点,跟defaultCofig只选一个 createDefault?: (context: Context) => IWorkFlowNode //从物料面板隐藏,比如发起人节点、条件分支内的分支节点 hidden?: boolean}
复制代码


审批流节点相对比较固定,目前只有四个主要节点类型。后面有可能会有扩展,但是频率会非常低。所以物料虽然定义了接口,但是实现基本上还是以预定义实现为主。预定义节点代码:


export const defaultMaterials: INodeMaterial[] = [  //发起人节点  {    //标题,引擎会通过国际化t函数翻译    label: "promoter",    //颜色    color: "rgb(87, 106, 149)",    //引擎会直接去defaultConfig来生成一个节点,会克隆一份defaultConfig数据保证immutable    defaultConfig: {      //默认配置,可以把类型上移一层,但是如果增加其它默认属性的话,不利于扩展      nodeType: NodeType.start,    },    //不在物料板显示    hidden: true,  },  //审批人节点  {    color: "#ff943e",    label: "approver",    icon: sealIcon,    defaultConfig: {      nodeType: NodeType.approver,    },  },  //通知人节点  {    color: "#4ca3fb",    label: "notifier",    icon: notifierIcon,    defaultConfig: {      nodeType: NodeType.notifier,    },  },  {    color: "#fb602d",    label: "dealer",    icon: dealIcon,    defaultConfig: {      nodeType: NodeType.audit,    },  },  //条件节点  {    color: "#15bc83",    label: "routeNode",    icon: routeIcon,    //条件分支内部的分支节点需要动态创建ID,所以通过函数来实现    createDefault: ({ t }) => {      return {        id: createUuid(),        nodeType: NodeType.route,        conditionNodeList: [          {            id: createUuid(),            nodeType: NodeType.branch,            name: t?.("condition") + "1"          },          {            id: createUuid(),            nodeType: NodeType.branch,            name: t?.("condition") + "2"          }        ]      }    },
}, //分支节点 { label: "condition", color: "", defaultConfig: { nodeType: NodeType.branch, }, //不在物料板显示 hidden: true, },]
复制代码


这份配置代码保存在引擎(EditorEngine)中,渲染画布跟物料面板会使用这些配置。物料面板是指这里:



就是点击“添加”按钮弹出的选择面板。

物料 UI 配置

跟物料相关的还有一些内容:节点的内容区①;校验规则、校验后的错误消息②;节点配置面板③。



这些内容根据物料的不同而不同,并且跟具体业务强相关。就是说,不同的项目,这些内容是不一样的。如果要把编辑器跟具体项目集成,那么这部分内容就要做成可注入的。


把要注入的内容抽出来,独立定义为物料 UI(IMaterialUI),具体代码:


//物料UI配置export interface IMaterialUI<FlowNode extends IWorkFlowNode, Config = any, Context extends IContext = IContext> {  //节点内容区  viewContent?: (node: FlowNode, context: Context) => React.ReactNode  //属性面板设置组件  settersPanel?: React.FC<{ value: Config, onChange: (value: Config) => void }>  //校验失败返回错误消息,成功返回ture  validate?: (node: FlowNode, context: Context) => string | true | undefined}
//物料UI的一个map,用于组件间通过props传递物料UI,key是节点类型export interface IMaterialUIs { [nodeType: string]: IMaterialUI<any> | undefined}
复制代码


在 example 目录(该目录放具体项目强相关内容),依据这个物料 UI 约定,定义业务相关的 ui 元素,注入进设计器。目前的实现:


export const materialUis: IMaterialUIs = {  //发起人物料UI  [NodeType.approver]: {    //节点内容区,只实现了空逻辑,具体过几天实现    viewContent: (node: IWorkFlowNode<IApproverSettings>, { t }) => {      return <ContentPlaceholder secondary text={t("pleaseChooseApprover")} />    },    //属性面板    settersPanel: ApproverPanel,    //校验,目前仅实现了空校验,其它校验过几天实现    validate: (node: IWorkFlowNode<IApproverSettings>, { t }) => {      if (!node.config) {        return (t("noSelectedApprover"))      }      return true    }  },  //办理人节点  [NodeType.audit]: {    //节点内容区    viewContent: (node: IWorkFlowNode<IAuditSettings>, { t }) => {      return <ContentPlaceholder secondary text={t("pleaseChooseDealer")} />    },    //属性面板    settersPanel: AuditPanel,    //校验函数    validate: (node: IWorkFlowNode<IApproverSettings>, { t }) => {      if (!node.config) {        return t("noSelectedDealer")      }      return true    }  },  //条件分支节点的分支子节点  [NodeType.branch]: {    //节点内容区    viewContent: (node: IWorkFlowNode<IConditionSettings>, { t }) => {      return <ContentPlaceholder text={t("pleaseSetCondition")} />    },    //属性面板    settersPanel: ConditionPanel,    //校验函数    validate: (node: IWorkFlowNode<IApproverSettings>, { t }) => {      if (!node.config) {        return t("noSetCondition")      }      return true    }  },  //通知人节点  [NodeType.notifier]: {    viewContent: (node: IWorkFlowNode<INotifierSettings>, { t }) => {      return <ContentPlaceholder text={t("pleaseChooseNotifier")} />    },    settersPanel: NotifierPanel,  },  //发起人节点  [NodeType.start]: {    viewContent: (node: IWorkFlowNode<IStartSettings>, { t }) => {      return <ContentPlaceholder text={t("allMember")} />    },    settersPanel: StartPanel,  },}
复制代码


这份代码游离于设计器之外,要根据具体项目的业务规则进行修改,这里并没有完全完成。

多语言配置

多语言使用的是 @rxdrag/locales,相关的 react 封装在 src/workflow-editor/react-locales 目录下。没有 @rxdrag/react-lacales,因为 react 版本跟朋友项目的 react 版本不兼容。


通过钩子 useTranslate 拿到 t 函数,把 t 函数注入到引擎供物料定义等场景使用。


项目其他部分的翻译,直接使用 useTranslate 实现。多语言资源系统预定义了一部分,也可以通过编辑器的 props 传入 locales,补充或覆盖已有的多语言资源。

钩子 React Hooks

引擎订阅 Redux store 的数据变化,通过一系列钩子来把这些数据变化推送给相应的 react 组件,这些钩子在目录 src/workflow-editor/hooks 下。这些钩子,相当于是状态的监听器。


比如起始节点的监听,它 hook 代码是这样:


//获取起始节点export function useStartNode() {  const [startNode, setStartNode] = useState<IWorkFlowNode>()  const engine = useEditorEngine()
//引擎起始节点变化事件处理函数 const handleStartNodeChange = useCallback((startNode: IWorkFlowNode) => { setStartNode(startNode) }, [])
useEffect(() => { //订阅起始节点变化事件 const unsub = engine?.subscribeStartNodeChange(handleStartNodeChange) return unsub }, [handleStartNodeChange, engine])
//初始化时,先拿到最新数据 useEffect(() => { setStartNode(engine?.store.getState().startNode) }, [engine?.store])
return startNode}
复制代码


现在 redux 有很多辅助库,用上这些辅助库的话可能不太需要这些钩子了,作者不是很熟悉这些库,代码量也不大,就这么写了。如果是大一点的项目,优先考虑的是 Recoil,也就没有动力再去研究这些辅助库了。

主题管理

antd5 支持 css-in-js 了,虽然跟 mui 相比,在这方面还有不小差距,但是勉强够用了。主题皮肤的切换,就是基于 antd 的这个特性。


通过 props 把 antd 的 theme token 传入设计器,设计器根据这个,使用 styled-components 库定义符合相应主题的组件。


antd 的 theme token 属性用不了全部,为了简化接口,摘了一部分有用的独立出来,没有直接使用 token 的好处是,以后扩展自己的配色方案更方便些。接口定义:


//只是摘取了antd token的一些属性,后面还可以再根据需要扩展export interface IThemeToken {  colorBorder?: string;  colorBorderSecondary?: string;  colorBgContainer?: string;  colorText?: string;  colorTextSecondary?: string;  colorBgBase?: string;  colorPrimary?: string;}//styled-components 的typescript使用export interface IDefaultTheme{  token?: IThemeToken  mode?: 'dark' | 'light'}
复制代码


在编辑器最外层加一个 styled-components 的主题配置:


import { ThemeProvider } from "styled-components";...
export const FlowEditorScopeInner = memo((props: { mode?: 'dark' | 'light', themeToken?: IThemeToken, children?: React.ReactNode, materials?: INodeMaterial[], materialUis?: IMaterialUIs,}) => { ... const theme: { token: IThemeToken, mode?: 'dark' | 'light' } = useMemo(() => { return { token: themeToken || token, mode } }, [mode, themeToken, token]) ...return <ThemeProvider theme={theme}> ... </ThemeProvider>})
复制代码


添加 typescript 的声明文件 styled.d.ts 用于 IDE 的智能提示,文件代码:


// import original module declarationsimport 'styled-components';import { IDefaultTheme } from './theme';

// and extend them!declare module 'styled-components' { export interface DefaultTheme extends IDefaultTheme { }}
复制代码


给 IDE(作者用的 VSCode)安装 styled-components 相关插件(作者用的是 vscode-styled-components)。然后就可以在代码中使用这些主题信息来定义组件样式了:



编辑器外部传入不同 theme mode,来切换不同的皮肤主题,具体效果请参考在线演示。


BTW,最近网上在传阅一篇文章,那个谁谁谁不用 css-in-js 了,说是影响性能等等。看了后有两个困惑:


1、什么时候前端的性能变得那么重要了,显示器有能力展示出这种性能差异吗?人类真的能识别并感受到这种性能差异吗?


2、css-in-js 如火如荼,使用面也够逛,如果一点优点看不到,不妨问问自己,为什么看不到它的优点,是不是触到了自己的知识盲点?


欢迎明白的大佬留言指点。

编辑器组件接口

整个审批流编辑器独立在目录 src/workflow-editor 中,以后会抽时间把这个目录发布为一个单独的 npm package。


编辑器对外提供两个组件:FlowEditorScope,FlowEditorCanvas。


前者负责接收各种配置资源,比如物料、物料 ui、多语言资源、主题定义等,根据这个些配置生成一个 EditorEngine 对象,并把这个对象通过 context 下发。


理论上,FlowEditorScope 内的所有子组件,都可以通过 EditorEngine 来操作编辑器。FlowEditorCanvas 是画布区,流程图的所有 UI,都在这里面。


通常思路,会把这两个合并为一个 FlowEditor 组件,外部只引用一次就可以。这样的话,集成的灵活性会丧失一些。这里保持分开,使用方法请参考 expample 目录。


FlowEditorCanvas 通过 context 拿到资源,所以没有 props,除了 className 跟 style。


FlowEditorScope 的定义如下:


export const FlowEditorScope = memo((props: {  //当前主题模式  mode?: 'dark' | 'light',  //主题定义  themeToken?: IThemeToken,  children?: React.ReactNode,  //当前语言  lang?: string,  //多语言资源  locales?: ILocales,  //自定义物料  materials?: INodeMaterial[],  //所有物料的Ui配置,包括自定义物料跟预定义物料  materialUis?: IMaterialUIs,}) => {  //实现代码省略  ...})
复制代码

导入、导出 JSON

以前做导出,直接做一个 a 标签,模拟 a 标签的点击触发下载动作,导入是用 file 组件。现在可以使用 window.showOpenFilePicker 跟 window.showSaveFilePicker 直接打开、保存文件。文件操作代码在 src/workflow-editor/utils 目录下。


导入导出 JSON 功能,基于这个通用方法,封装成两个钩子:useImport、useExport。在 src/workflow-editor/hooks 目录下,代码比较简单,读者自行翻看吧。

优化体验

钉钉审批流设计的挺经典,足够简洁,能适应绝大多数审批场景。只是有些用户体验方面的细节,不是非常完美,这方面作者做了一点优化。具体的优化点有以下三处:

zoom 工具栏浮动

原版的 zoom 工具栏是隐形浮动的,在这个位置:



这种隐形工具栏,在画布滚动时,有时会跟画布元素重叠,出现这样的效果:



这种效果用户也能明白,但是总感觉有种廉价感。


所以,这部分作者做成了浮动工具条,当画布没有滚动的时候,跟原版一样是隐形的,当画布滚动时,就会浮现出来,元素重叠时变成这样的效果:



具体运作,请参考在线演示。

鼠标拖动画布

原版的画布滚动,只能通过点击滚动条实现,每次移动画布都要去找滚动条,用起来十分不便,这个也是作者最在意的地方。希望实现的效果是,鼠标悬浮在画布空白处,鼠标光标显示 grab(展开的手掌)效果,鼠标按下时显示未 grabbing(抓取的小手)效果,拖动时直接移动画布。有了这个功能,会极大提高用户体验。


在线演示已经实现了这个效果。实现代码在 src/workflow-editor/FlowEditor/FlowEditorCanvas.tsx 文件中。

撤销、重做

一个编辑器,如果有撤销、重做功能,能够非常有效的防止用户误操作,提高用户体验。原版中不存在这个功能,作者决定加上。使用 immutable 的状态管理方式,加这样的功能非常简单,增加不了多少工作量。


在画布左侧跟缩放工具栏对称的地方,加了一个迷你工具栏:



画布滚动的时候,这个工具栏同样会浮现出来:



具体实现方式,请参考源码。

遗留问题

zoom 实现方式是基于 transform:scale(x) css 样式实现的,放大画布时,会出现画布内的元素超出滚动区域的问题,为了解决这个问题,加了 css 样式:transform-origin: 50% 0px 0px ,但是这又出现了一个新问题,就是每次缩放画布,画布会闪烁一下,滚回起始点。


这个问题作者很在意,但是由于 css 样式不是很熟悉,这个问题一直没解决,有解决方案的朋友欢迎留言指点,十分感谢。

总结

本文介绍了用 React 模仿钉钉审批流的大致原理,内容偏架构方面,细节介绍不多,毕竟篇幅所限,不明的地方欢迎联系作者。


文章对代码的表达还是有限,很多细节未能说明白,后期如果有朋友需要的话,可以考虑录个视频来讲解代码。


用户头像

悠闲的水

关注

还未添加个人签名 2023-03-06 加入

还未添加个人简介

评论

发布
暂无评论
用React仿钉钉审批流、工作流_前端_悠闲的水_InfoQ写作社区