# 引言 這幾天幫朋友忙,用了一周時間,高仿了一個釘釘審批流。這個東西會有不少朋友有類似需求,就分享出來,希望能有所幫助。為了方便朋友的使用,設計製作的時候,儘量做到節點配置可定製,減少集成成本。如果您的項目有審批流需求,這個項目可以直接拿過去使用。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 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)。然後就可以在代碼中使用這些主題信息來定義組件樣式了:
編輯器外部傳入不同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模仿釘釘審批流的大致原理,內容偏架構方面,細節介紹不多,畢竟篇幅所限,不明的地方歡迎聯繫作者。
文章對代碼的表達還是有限,很多細節未能說明白,後期如果有朋友需要的話,可以考慮錄個視頻來講解代碼。