用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/
運行快照:
image.png
這個項目非常典型,它足夠小,不至於讓文章太長;另外,它足夠完整,涵蓋了一個設計器的大部分內容,比如狀態管理、物料管理、屬性面板、撤銷重做、畫布縮放、皮膚切換、多語言管理、文件的導入導出等。
設計製作一個項目的時候,最好適當提高自己的要求,從利他的角度思考,比如:能夠方便發佈獨立npm包,方便第三方引用;要考慮,代碼怎麼寫,別人容易讀。這樣的要求,能讓你設計的代碼結構更合理,擴展性更好。時間久了,代碼會越來越優雅。本項目也是這個思路下完成的,希望作者代碼能夠越來越好!
項目畫布的css大部分複製了這個項目:https://github.com/StavinLi/Workflow-React
飲水思源,有了這個項目的借鑑,節省了大量時間,在此對項目作者深表謝意。
本文的代碼取自項目代碼倉庫,但是爲了理解的方便,做了少許簡化。

UI佈局

分兩部分理解界面佈局,第一部分整體佈局,理解了這部分,就知道自己業務相關的組件如何插入編輯器,能夠理解作者這麼設計代碼架構是爲了提高擴展性,方便第三方引入;第二部分是畫布繪製,該項目以div樹的方式組織審批流節點,理解了這部分有助於理解後面的數據結構。

整體佈局

image.png
項目代碼有兩個主要目錄:example 和 workflow-editor。workflow-editor 是編輯器核心,未來要作爲獨立的npm package來發布;example 是演示如何使用workflow-editor來把審批流集成入自己的項目。
上圖把頁面劃分爲3個區域,workflow-editor 包含全部③區域和②區域的部分通用組件;example包含全部①區域的內容跟部分②區域的定製內容,並引用③的內容。
點擊一個畫布(也就是區域③)中的節點,會彈出屬性設置面板,屬性面板包含④⑤兩部分:
image.png
彈出這個面板的抽屜(drawer)和它的標題④,包含在workflow-editor目錄中,它內部的組件,就是⑤區域是在example中定義,通過接口注入進去的。
綜上,編輯器通用的功能在workflow-editor中定義,差異化部分通過接口注入。

畫布繪製

畫布區是通過嵌套的div實現的,連線、箭頭是通過css的border、僞類before跟after實現的,這些css細節請參看源碼,這裏只介紹div的嵌套結構。

普通節點

像這樣一組不含條件的普通節點:
image.png
它的div結構是這樣的:
image.png
在一條直線路徑上的節點,就這樣層層嵌套,結束節點除外,它最後面。

條件節點

如果加上條件分支,同一級別的條件分支是水平排列的div,分支內部的路徑再次循環嵌套:
image.png
只要明白這些節點是一棵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部分代碼。

設計器架構

image.png

引擎(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)中,渲染畫布跟物料面板會使用這些配置。物料面板是指這裏:
image.png
就是點擊“添加”按鈕彈出的選擇面板。

物料UI配置

跟物料相關的還有一些內容:節點的內容區①;校驗規則、校驗後的錯誤消息②;節點配置面板③。
image.png
這些內容根據物料的不同而不同,並且跟具體業務強相關。就是說,不同的項目,這些內容是不一樣的。如果要把編輯器跟具體項目集成,那麼這部分內容就要做成可注入的。
把要注入的內容抽出來,獨立定義爲物料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 declarations
import '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)。然後就可以在代碼中使用這些主題信息來定義組件樣式了:
image.png
編輯器外部傳入不同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工具欄是隱形浮動的,在這個位置:
image.png
這種隱形工具欄,在畫布滾動時,有時會跟畫布元素重疊,出現這樣的效果:
image.png
這種效果用戶也能明白,但是總感覺有種廉價感。
所以,這部分作者做成了浮動工具條,當畫布沒有滾動的時候,跟原版一樣是隱形的,當畫布滾動時,就會浮現出來,元素重疊時變成這樣的效果:
image.png
具體運作,請參考在線演示。

鼠標拖動畫布

原版的畫布滾動,只能通過點擊滾動條實現,每次移動畫布都要去找滾動條,用起來十分不便,這個也是作者最在意的地方。希望實現的效果是,鼠標懸浮在畫布空白處,鼠標光標顯示grab(展開的手掌)效果,鼠標按下時顯示未grabbing(抓取的小手)效果,拖動時直接移動畫布。有了這個功能,會極大提高用戶體驗。
在線演示已經實現了這個效果。實現代碼在src/workflow-editor/FlowEditor/FlowEditorCanvas.tsx文件中。

撤銷、重做

一個編輯器,如果有撤銷、重做功能,能夠非常有效的防止用戶誤操作,提高用戶體驗。原版中不存在這個功能,作者決定加上。使用immutable的狀態管理方式,加這樣的功能非常簡單,增加不了多少工作量。
在畫布左側跟縮放工具欄對稱的地方,加了一個迷你工具欄:
image.png
畫布滾動的時候,這個工具欄同樣會浮現出來:
image.png
具體實現方式,請參考源碼。

遺留問題

zoom實現方式是基於transform:scale(x) css樣式實現的,放大畫布時,會出現畫布內的元素超出滾動區域的問題,爲了解決這個問題,加了css樣式:transform-origin: 50% 0px 0px ,但是這又出現了一個新問題,就是每次縮放畫布,畫布會閃爍一下,滾回起始點。
這個問題作者很在意,但是由於css樣式不是很熟悉,這個問題一直沒解決,有解決方案的朋友歡迎留言指點,十分感謝。

總結

本文介紹了用React模仿釘釘審批流的大致原理,內容偏架構方面,細節介紹不多,畢竟篇幅所限,不明的地方歡迎聯繫作者。
文章對代碼的表達還是有限,很多細節未能說明白,後期如果有朋友需要的話,可以考慮錄個視頻來講解代碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章