整合營銷服務(wù)商

          電腦端+手機端+微信端=數(shù)據(jù)同步管理

          免費咨詢熱線:

          css-in-js、React Redux經(jīng)典案例:

          css-in-js、React Redux經(jīng)典案例:仿釘釘審批流

          這幾天幫朋友忙,用了一周時間,高仿了一個釘釘審批流。
          這個東西會有不少朋友有類似需求,就分享出來,希望能有所幫助。為了方便朋友的使用,設(shè)計制作的時候,盡量做到節(jié)點配置可定制,減少集成成本。如果您的項目有審批流需求,這個項目可以直接拿過去使用。
          React初學者也可以把本項目當做研讀案例,學習并快速上手React項目。通過研讀項目代碼,您可以學到如何設(shè)計一個react項目架構(gòu),輔助理解react設(shè)計哲學,學習css-in-js在項目中的使用,并理解其優(yōu)勢。理解Redux這種immutable的狀態(tài)管理好處等。
          本文章只包含審批流設(shè)計部分,不包含表單的設(shè)計,表單的設(shè)計請參考作者另一個可視化前端項目RxDrag:

          項目地址:github.com/codebdy/rxd…
          演示地址:rxdrag.vercel.app

          相關(guān)文章:

          《實戰(zhàn),一個高擴展、可視化低代碼前端,詳實、完整》
          《挑戰(zhàn)零代碼:可視化邏輯編排》

          項目信息

          項目地址:github.com/codebdy/din…
          演示地址:dingflow.vercel.app/
          運行快照:


          這個項目非常典型,它足夠小,不至于讓文章太長;另外,它足夠完整,涵蓋了一個設(shè)計器的大部分內(nèi)容,比如狀態(tài)管理、物料管理、屬性面板、撤銷重做、畫布縮放、皮膚切換、多語言管理、文件的導入導出等。
          設(shè)計制作一個項目的時候,最好適當提高自己的要求,從利他的角度思考,比如:能夠方便發(fā)布獨立npm包,方便第三方引用;要考慮,代碼怎么寫,別人容易讀。這樣的要求,能讓你設(shè)計的代碼結(jié)構(gòu)更合理,擴展性更好。時間久了,代碼會越來越優(yōu)雅。本項目也是這個思路下完成的,希望作者代碼能夠越來越好!
          項目畫布的css大部分復(fù)制了這個項目:github.com/StavinLi/Wo…
          飲水思源,有了這個項目的借鑒,節(jié)省了大量時間,在此對項目作者深表謝意。
          本文的代碼取自項目代碼倉庫,但是為了理解的方便,做了少許簡化。

          UI布局

          分兩部分理解界面布局,第一部分整體布局,理解了這部分,就知道自己業(yè)務(wù)相關(guān)的組件如何插入編輯器,能夠理解作者這么設(shè)計代碼架構(gòu)是為了提高擴展性,方便第三方引入;第二部分是畫布繪制,該項目以div樹的方式組織審批流節(jié)點,理解了這部分有助于理解后面的數(shù)據(jù)結(jié)構(gòu)。

          整體布局


          項目代碼有兩個主要目錄:example 和 workflow-editor。workflow-editor 是編輯器核心,未來要作為獨立的npm package來發(fā)布;example 是演示如何使用workflow-editor來把審批流集成入自己的項目。
          上圖把頁面劃分為3個區(qū)域,workflow-editor 包含全部③區(qū)域和②區(qū)域的部分通用組件;example包含全部①區(qū)域的內(nèi)容跟部分②區(qū)域的定制內(nèi)容,并引用③的內(nèi)容。
          點擊一個畫布(也就是區(qū)域③)中的節(jié)點,會彈出屬性設(shè)置面板,屬性面板包含④⑤兩部分:


          彈出這個面板的抽屜(drawer)和它的標題④,包含在workflow-editor目錄中,它內(nèi)部的組件,就是⑤區(qū)域是在example中定義,通過接口注入進去的。
          綜上,編輯器通用的功能在workflow-editor中定義,差異化部分通過接口注入。

          畫布繪制

          畫布區(qū)是通過嵌套的div實現(xiàn)的,連線、箭頭是通過css的border、偽類before跟after實現(xiàn)的,這些css細節(jié)請參看源碼,這里只介紹div的嵌套結(jié)構(gòu)。

          普通節(jié)點

          像這樣一組不含條件的普通節(jié)點:


          它的div結(jié)構(gòu)是這樣的:


          在一條直線路徑上的節(jié)點,就這樣層層嵌套,結(jié)束節(jié)點除外,它最后面。

          條件節(jié)點

          如果加上條件分支,同一級別的條件分支是水平排列的div,分支內(nèi)部的路徑再次循環(huán)嵌套:


          只要明白這些節(jié)點是一棵div樹,不是扁平結(jié)構(gòu)就可以了。

          數(shù)據(jù)結(jié)構(gòu)(DSL定義)

          UI雖然是樹形結(jié)構(gòu),但是項目內(nèi)部的數(shù)據(jù)結(jié)構(gòu)可以是樹形,也可以是扁平的。
          扁平的意思是,所用節(jié)點存在一個數(shù)組或者map里,通過parentId跟childIds等信息描述樹形關(guān)系。
          因為這個項目是幫朋友做的,他的后端是樹形結(jié)構(gòu),跟div的結(jié)構(gòu)一致。如果這個項目提供一個編輯器組件WorkflowEditor,這個組件要有value跟onChange屬性,如果是扁平結(jié)構(gòu),onChange的時要轉(zhuǎn)一下,如果做成受控組件,性能可能會有問題。
          所以,最后選擇了樹形數(shù)據(jù)結(jié)構(gòu):

          export enum NodeType {
            //開始節(jié)點
            start="start",
            //審批人
            approver="approver",
            //抄送人?
            notifier="notifier",
            //處理人?
            audit="audit",
            //路由(條件節(jié)點),下面包含分支節(jié)點
            route="route",
            //分支節(jié)點
            branch="branch",
          }
          
          //審批流節(jié)點
          export interface IWorkFlowNode<Config=unknown>{
            id: string
            //名稱
            name?: string
            //string可以用于自定義節(jié)點,暫時用不上
            nodeType: NodeType | string 
            //描述
            desc?: string
            //子節(jié)點
            childNode?: IWorkFlowNode
            //配置
            config?: Config
          }
          
          //條件根節(jié)點,下面包含各分支節(jié)點
          export interface IRouteNode extends IWorkFlowNode {
            //分支節(jié)點
            conditionNodeList: IBranchNode[]
          }
          
          //條件分支的子節(jié)點,分支節(jié)點
          export interface IBranchNode extends IWorkFlowNode {
            //條件配置部分還沒定義,可能會放入config
          }
          
          //審批流,代表一張審批流圖
          export interface IWorkflow {
            //審批流Id
            flowId: string;
            //審批流名稱
            name?:string;
            //開始節(jié)點
            childNode: IWorkFlowNode;
          }

          狀態(tài)管理

          如果是扁平結(jié)構(gòu),狀態(tài)管理作者會首選Recoil,用起來簡單,代碼量小。但是,因為數(shù)據(jù)結(jié)構(gòu)定義的樹形,要是用Recoil做狀態(tài)管理,需要扁平化處理,會出現(xiàn)上文說的轉(zhuǎn)換問題。所以,最終選擇了Redux作為狀態(tài)管理工具。
          作者只會基礎(chǔ)的Redux庫,所以代碼會略顯繁瑣一點,即便這樣,還是不想選mobx。因為這么小的編輯器項目,mobx的撤銷、重做的工作量,要比Redux大。用Mobx的話,一般要采用comand模式做撤銷重做,每個Command有正負操作,挺繁瑣,工作量也大。而immutable的操作方式,可以保留狀態(tài)快照,易于回溯,很容易就能完成撤銷、重做功能。
          狀態(tài)定義:

          //操作快照,用于撤銷、重做
          export interface ISnapshot {
            //開始節(jié)點
            startNode: IWorkFlowNode,
            //是否校驗過
            validated?: boolean,
          }
          
          //錯誤消息
          export interface IErrors {
            [nodeId: string]: string | undefined
          }
          
          //狀態(tài)
          export interface IState {
            //是否被修改,該標識用于提示是否需要保存
            changeFlag: boolean,
            //撤銷快照列表
            undoList: ISnapshot[],
            //重做快照列表
            redoList: ISnapshot[],
            //開始節(jié)點
            startNode: IWorkFlowNode,
            //被選中的節(jié)點,用于彈出屬性面板
            selectedId?: string,
            //是否校驗過,如果校驗過,后面加入的節(jié)點會自動校驗
            validated?: boolean,
            //校驗錯誤
            errors: IErrors,
          }

          Redux處理這些樹形結(jié)構(gòu)的狀態(tài),需要遞歸處理,具體參看reducers部分代碼。

          設(shè)計器架構(gòu)

          引擎(EditorEngine)

          引擎(Engine)在作者的項目里是老演員了,這里依然扮演了一個重要角色,全名EditorEngine。編輯器的絕大多數(shù)業(yè)務(wù)邏輯,都在這部分實現(xiàn),主要功能就是操作Redux store。源碼文件在src/workflow-editor/classes目錄下。

          節(jié)點物料

          物料就是節(jié)點的定義,包括節(jié)點的圖標、顏色、缺省配置等信息。把這些信息獨立出來的好處,是讓代碼更容易擴展,方便后期添加新的節(jié)點類型。作者自己開源低代碼前端RxDrag,也用了類似的設(shè)計方式,不過比這里的擴展性還要好,可以支持物料的熱加載。這個項目比較簡單,沒有熱加載需求,做到這種程度就夠用了。
          物料定義代碼:

          //國際化翻譯函數(shù),外部注入,這里使用的是@rxdrag/locales的實現(xiàn)(通過react hooks轉(zhuǎn)了一下)
          export type Translate=(msg: string)=> string | undefined
          
          //物料上下文
          export interface IContext {
            //翻譯
            t: Translate
          }
          
          //節(jié)點物料
          export interface INodeMaterial<Context extends IContext=IContext> {
            //顏色
            color: string
            //標題
            label: string
            //圖標
            icon?: React.ReactElement
            //默認配置
            defaultConfig?: { nodeType: NodeType | string }
            //創(chuàng)建一個默認節(jié)點,跟defaultCofig只選一個
            createDefault?: (context: Context)=> IWorkFlowNode
            //從物料面板隱藏,比如發(fā)起人節(jié)點、條件分支內(nèi)的分支節(jié)點
            hidden?: boolean
          }

          審批流節(jié)點相對比較固定,目前只有四個主要節(jié)點類型。后面有可能會有擴展,但是頻率會非常低。所以物料雖然定義了接口,但是實現(xiàn)基本上還是以預(yù)定義實現(xiàn)為主。預(yù)定義節(jié)點代碼:

          export const defaultMaterials: INodeMaterial[]=[
            //發(fā)起人節(jié)點
            {
              //標題,引擎會通過國際化t函數(shù)翻譯
              label: "promoter",
              //顏色
              color: "rgb(87, 106, 149)",
              //引擎會直接去defaultConfig來生成一個節(jié)點,會克隆一份defaultConfig數(shù)據(jù)保證immutable
              defaultConfig: {
                //默認配置,可以把類型上移一層,但是如果增加其它默認屬性的話,不利于擴展
                nodeType: NodeType.start,
              },
              //不在物料板顯示
              hidden: true,
            },
            //審批人節(jié)點
            {
              color: "#ff943e",
              label: "approver",
              icon: sealIcon,
              defaultConfig: {
                nodeType: NodeType.approver,
              },
            },
            //通知人節(jié)點
            {
              color: "#4ca3fb",
              label: "notifier",
              icon: notifierIcon,
              defaultConfig: {
                nodeType: NodeType.notifier,
              },
            },
            {
              color: "#fb602d",
              label: "dealer",
              icon: dealIcon,
              defaultConfig: {
                nodeType: NodeType.audit,
              },
            },
            //條件節(jié)點
            {
              color: "#15bc83",
              label: "routeNode",
              icon: routeIcon,
              //條件分支內(nèi)部的分支節(jié)點需要動態(tài)創(chuàng)建ID,所以通過函數(shù)來實現(xiàn)
              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"
                    }
                  ]
                }
              },
          
            },
            //分支節(jié)點
            {
              label: "condition",
              color: "",
              defaultConfig: {
                nodeType: NodeType.branch,
              },
              //不在物料板顯示
              hidden: true,
            },
          ]

          這份配置代碼保存在引擎(EditorEngine)中,渲染畫布跟物料面板會使用這些配置。物料面板是指這里:


          就是點擊“添加”按鈕彈出的選擇面板。

          物料UI配置

          跟物料相關(guān)的還有一些內(nèi)容:節(jié)點的內(nèi)容區(qū)①;校驗規(guī)則、校驗后的錯誤消息②;節(jié)點配置面板③。


          這些內(nèi)容根據(jù)物料的不同而不同,并且跟具體業(yè)務(wù)強相關(guān)。就是說,不同的項目,這些內(nèi)容是不一樣的。如果要把編輯器跟具體項目集成,那么這部分內(nèi)容就要做成可注入的。
          把要注入的內(nèi)容抽出來,獨立定義為物料UI(IMaterialUI),具體代碼:

          //物料UI配置
          export interface IMaterialUI<FlowNode extends IWorkFlowNode, Config=any, Context extends IContext=IContext> {
            //節(jié)點內(nèi)容區(qū)
            viewContent?: (node: FlowNode, context: Context)=> React.ReactNode
            //屬性面板設(shè)置組件
            settersPanel?: React.FC<{ value: Config, onChange: (value: Config)=> void }>
            //校驗失敗返回錯誤消息,成功返回ture
            validate?: (node: FlowNode, context: Context)=> string | true | undefined
          }
          
          //物料UI的一個map,用于組件間通過props傳遞物料UI,key是節(jié)點類型
          export interface IMaterialUIs {
            [nodeType: string]: IMaterialUI<any> | undefined
          }

          在example目錄(該目錄放具體項目強相關(guān)內(nèi)容),依據(jù)這個物料UI約定,定義業(yè)務(wù)相關(guān)的ui元素,注入進設(shè)計器。目前的實現(xiàn):

          export const materialUis: IMaterialUIs={
            //發(fā)起人物料UI
            [NodeType.approver]: {
              //節(jié)點內(nèi)容區(qū),只實現(xiàn)了空邏輯,具體過幾天實現(xiàn)
              viewContent: (node: IWorkFlowNode<IApproverSettings>, { t })=> {
                return <ContentPlaceholder secondary text={t("pleaseChooseApprover")} />
              },
              //屬性面板
              settersPanel: ApproverPanel,
              //校驗,目前僅實現(xiàn)了空校驗,其它校驗過幾天實現(xiàn)
              validate: (node: IWorkFlowNode<IApproverSettings>, { t })=> {
                if (!node.config) {
                  return (t("noSelectedApprover"))
                }
                return true
              }
            },
            //辦理人節(jié)點
            [NodeType.audit]: {
              //節(jié)點內(nèi)容區(qū)
              viewContent: (node: IWorkFlowNode<IAuditSettings>, { t })=> {
                return <ContentPlaceholder secondary text={t("pleaseChooseDealer")} />
              },
              //屬性面板
              settersPanel: AuditPanel,
              //校驗函數(shù)
              validate: (node: IWorkFlowNode<IApproverSettings>, { t })=> {
                if (!node.config) {
                  return t("noSelectedDealer")
                }
                return true
              }
            },
            //條件分支節(jié)點的分支子節(jié)點
            [NodeType.branch]: {
              //節(jié)點內(nèi)容區(qū)
              viewContent: (node: IWorkFlowNode<IConditionSettings>, { t })=> {
                return <ContentPlaceholder text={t("pleaseSetCondition")} />
              },
              //屬性面板
              settersPanel: ConditionPanel,
              //校驗函數(shù)
              validate: (node: IWorkFlowNode<IApproverSettings>, { t })=> {
                if (!node.config) {
                  return t("noSetCondition")
                }
                return true
              }
            },
            //通知人節(jié)點
            [NodeType.notifier]: {
              viewContent: (node: IWorkFlowNode<INotifierSettings>, { t })=> {
                return <ContentPlaceholder text={t("pleaseChooseNotifier")} />
              },
              settersPanel: NotifierPanel,
            },
            //發(fā)起人節(jié)點
            [NodeType.start]: {
              viewContent: (node: IWorkFlowNode<IStartSettings>, { t })=> {
                return <ContentPlaceholder text={t("allMember")} />
              },
              settersPanel: StartPanel,
            },
          }

          這份代碼游離于設(shè)計器之外,要根據(jù)具體項目的業(yè)務(wù)規(guī)則進行修改,這里并沒有完全完成。

          多語言配置

          多語言使用的是@rxdrag/locales,相關(guān)的react封裝在src/workflow-editor/react-locales目錄下。沒有@rxdrag/react-lacales,因為react版本跟朋友項目的react版本不兼容。
          通過鉤子useTranslate拿到t函數(shù),把t函數(shù)注入到引擎供物料定義等場景使用。
          項目其他部分的翻譯,直接使用useTranslate實現(xiàn)。多語言資源系統(tǒng)預(yù)定義了一部分,也可以通過編輯器的props傳入locales,補充或覆蓋已有的多語言資源。

          鉤子 React Hooks

          引擎訂閱Redux store的數(shù)據(jù)變化,通過一系列鉤子來把這些數(shù)據(jù)變化推送給相應(yīng)的react組件,這些鉤子在目錄src/workflow-editor/hooks下。這些鉤子,相當于是狀態(tài)的監(jiān)聽器。
          比如起始節(jié)點的監(jiān)聽,它hook代碼是這樣:

          //獲取起始節(jié)點
          export function useStartNode() {
            const [startNode, setStartNode]=useState<IWorkFlowNode>()
            const engine=useEditorEngine()
          
            //引擎起始節(jié)點變化事件處理函數(shù)
            const handleStartNodeChange=useCallback((startNode: IWorkFlowNode)=> {
              setStartNode(startNode)
            }, [])
          
            useEffect(()=> {
              //訂閱起始節(jié)點變化事件
              const unsub=engine?.subscribeStartNodeChange(handleStartNodeChange)
              return unsub
            }, [handleStartNodeChange, engine])
          
            //初始化時,先拿到最新數(shù)據(jù)
            useEffect(()=> {
              setStartNode(engine?.store.getState().startNode)
            }, [engine?.store])
          
            return startNode
          }

          現(xiàn)在redux有很多輔助庫,用上這些輔助庫的話可能不太需要這些鉤子了,作者不是很熟悉這些庫,代碼量也不大,就這么寫了。如果是大一點的項目,優(yōu)先考慮的是Recoil,也就沒有動力再去研究這些輔助庫了。

          主題管理

          antd5支持css-in-js了,雖然跟mui相比,在這方面還有不小差距,但是勉強夠用了。主題皮膚的切換,就是基于antd的這個特性。
          通過props把antd的theme token傳入設(shè)計器,設(shè)計器根據(jù)這個,使用styled-components庫定義符合相應(yīng)主題的組件。
          antd的theme token屬性用不了全部,為了簡化接口,摘了一部分有用的獨立出來,沒有直接使用token的好處是,以后擴展自己的配色方案更方便些。接口定義:

          //只是摘取了antd token的一些屬性,后面還可以再根據(jù)需要擴展
          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相關(guān)插件(作者用的是vscode-styled-components)。然后就可以在代碼中使用這些主題信息來定義組件樣式了:


          編輯器外部傳入不同theme mode,來切換不同的皮膚主題,具體效果請參考在線演示。
          BTW,最近網(wǎng)上在傳閱一篇文章,那個誰誰誰不用css-in-js了,說是影響性能等等。看了后有兩個困惑:
          1、什么時候前端的性能變得那么重要了,顯示器有能力展示出這種性能差異嗎?人類真的能識別并感受到這種性能差異嗎?
          2、css-in-js如火如荼,使用面也夠逛,如果一點優(yōu)點看不到,不妨問問自己,為什么看不到它的優(yōu)點,是不是觸到了自己的知識盲點?
          歡迎明白的大佬留言指點。

          編輯器組件接口

          整個審批流編輯器獨立在目錄src/workflow-editor中,以后會抽時間把這個目錄發(fā)布為一個單獨的npm package。
          編輯器對外提供兩個組件:FlowEditorScope,F(xiàn)lowEditorCanvas。
          前者負責接收各種配置資源,比如物料、物料ui、多語言資源、主題定義等,根據(jù)這個些配置生成一個EditorEngine對象,并把這個對象通過context下發(fā)。
          理論上,F(xiàn)lowEditorScope內(nèi)的所有子組件,都可以通過EditorEngine來操作編輯器。FlowEditorCanvas是畫布區(qū),流程圖的所有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配置,包括自定義物料跟預(yù)定義物料
            materialUis?: IMaterialUIs,
          })=> {
            //實現(xiàn)代碼省略
            ...
          })

          導入、導出JSON

          以前做導出,直接做一個a標簽,模擬a標簽的點擊觸發(fā)下載動作,導入是用file組件。現(xiàn)在可以使用window.showOpenFilePicker跟window.showSaveFilePicker直接打開、保存文件。文件操作代碼在src/workflow-editor/utils目錄下。
          導入導出JSON功能,基于這個通用方法,封裝成兩個鉤子:useImport、useExport。在src/workflow-editor/hooks目錄下,代碼比較簡單,讀者自行翻看吧。

          優(yōu)化體驗

          釘釘審批流設(shè)計的挺經(jīng)典,足夠簡潔,能適應(yīng)絕大多數(shù)審批場景。只是有些用戶體驗方面的細節(jié),不是非常完美,這方面作者做了一點優(yōu)化。具體的優(yōu)化點有以下三處:

          zoom工具欄浮動

          原版的zoom工具欄是隱形浮動的,在這個位置:


          這種隱形工具欄,在畫布滾動時,有時會跟畫布元素重疊,出現(xiàn)這樣的效果:


          這種效果用戶也能明白,但是總感覺有種廉價感。
          所以,這部分作者做成了浮動工具條,當畫布沒有滾動的時候,跟原版一樣是隱形的,當畫布滾動時,就會浮現(xiàn)出來,元素重疊時變成這樣的效果:


          具體運作,請參考在線演示。

          鼠標拖動畫布

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

          撤銷、重做

          一個編輯器,如果有撤銷、重做功能,能夠非常有效的防止用戶誤操作,提高用戶體驗。原版中不存在這個功能,作者決定加上。使用immutable的狀態(tài)管理方式,加這樣的功能非常簡單,增加不了多少工作量。
          在畫布左側(cè)跟縮放工具欄對稱的地方,加了一個迷你工具欄:


          畫布滾動的時候,這個工具欄同樣會浮現(xiàn)出來:


          具體實現(xiàn)方式,請參考源碼。

          遺留問題

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

          總結(jié)

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

          原文鏈接:https://juejin.cn/post/7263858443329191996

          段時間有分享過Vue和React系列聊天實例精選,今天要給小伙伴們分享的是如何基于AngularJs全家桶技術(shù)來開發(fā)一個移動端聊天實例。

          簡述

          angular+angular-cli+angular-router+ngrx/store+rxjs等技術(shù)開發(fā)實現(xiàn)的仿微信app聊天室實例項目。實現(xiàn)了下拉刷新、右鍵菜單、發(fā)送消息、表情(動圖),圖片、視頻預(yù)覽,紅包打賞等功能。

          https://angular.cn/
          https://github.com/angular/angular

          技術(shù)實現(xiàn)

          • MVVM框架:angular8.0 + @angular/cli
          • 狀態(tài)管理:ngrx/store + rxjs
          • 地址路由:@angular/router
          • 彈窗組件:wcPop
          • 打包工具:webpack 2.0
          • 環(huán)境配置:node.js + cnpm
          • 圖片預(yù)覽:previewImage
          • 輪播滑動:swiper

          預(yù)覽

          <script src="https://lf6-cdn-tos.bytescm.com/obj/cdn-static-resource/tt_player/tt.player.js?v=20160723"></script>

          NG主模板app.component.html

          <div class="weChatIM__panel clearfix">
              <div class="we__chatIM-wrapper flexbox flex__direction-column">
                  <!-- 頂部 -->
                  <header-bar></header-bar>
                  
                  <!-- 主頁面 -->
                  <div class="wcim__container flex1">
                      <router-outlet></router-outlet>
                  </div>
          
                  <!-- 底部 -->
                  <tab-bar></tab-bar>
              </div>
          </div>

          NG路由配置app-routing.module.ts

          /*
           *  angular/router路由配置
           */
          
          import { NgModule } from '@angular/core'
          import { Routes, RouterModule } from '@angular/router'
          
          // 引入路由驗證
          import { Auth } from '../views/auth/auth'
          
          // 引入頁面組件
          import { NotFoundComponent } from '../components/404'
          import { IndexComponent } from '../views/index'
          import { GroupChatComponent } from '../views/chat/group-chat'
          // ...
          
          export const routes: Routes=[
            {
              path: '', redirectTo: 'index', pathMatch: 'full',
              data: { showHeader: true, showTabBar: true },
            },
            
            // 首頁、聯(lián)系人、我
            {
              path: 'index', component: IndexComponent, canActivate: [Auth],
              data: { showHeader: true, showTabBar: true },
            },
            
            {
              path: 'chat/group-chat', component: GroupChatComponent, canActivate: [Auth]
            },
          
            // 404
            {
              path: '**', component: NotFoundComponent,
            },
          
            // ...
          ];
          
          @NgModule({
            // imports: [RouterModule.forRoot(routes)],
            imports: [RouterModule.forRoot(routes, { useHash: true })],  //開啟hash模式
            exports: [RouterModule],
            providers: [Auth]
          })
          
          export class AppRoutingModule {}

          AngularJs中是通過 ngrx/store 來進行狀態(tài)管理。這里不作詳細介紹,想了解用法去官網(wǎng)查閱資料。

          https://ngrx.io/
          https://github.com/ngrx/platform

          NG登錄/注冊表單驗證

          export class LoginComponent implements OnInit {
              private formField={
                  tel: '',
                  pwd: ''
              }
          
              handleSubmit(){
                  let that=this
          
                  if(!this.formField.tel){
                      wcPop({ content: '手機號不能為空!', style: 'background:#eb5a5c;color:#fff;', time: 2 });
                  }else if(!checkTel(this.formField.tel)){
                      wcPop({ content: '手機號格式不正確!', style: 'background:#eb5a5c;color:#fff;', time: 2 });
                  }else if(!this.formField.pwd){
                      wcPop({ content: '密碼不能為空!', style: 'background:#eb5a5c;color:#fff;', time: 2 });
                  }else{
                      this.store.dispatch(new actions.setToken(getToken(64)))
                      this.store.dispatch(new actions.setUser(this.formField.tel))
          
                      wcPop({
                          content: '登錄成功,跳轉(zhuǎn)中...', style: 'background:#378fe7;color:#fff;', time: 2, shadeClose: false,
                          end: function () {
                              that.router.navigate(['/index'])
                          }
                      });
                  }
              }
          }

          「vue+mint-ui仿微信移動端app聊天實例」

          ?? 最后

          如果覺得這篇文章對你有幫助,點個「關(guān)注/轉(zhuǎn)發(fā)」,讓更多的人也能看到你的分享!

          vue3.x越來越穩(wěn)定及vite2.0的快速迭代推出,加上很多大廠相繼推出了vue3的UI組件庫,在2021年必然受到開發(fā)者的再一次熱捧。

          Vue3迭代更新頻繁,目前star高達20.2K+

          // 官網(wǎng)地址
          https://v3.vuejs.org/

          Vitejs目前的star達到15.7K+

          // 官網(wǎng)地址
          https://vitejs.dev/

          項目介紹

          vue3-webchat 基于vue3.x+vuex4+vue-router4+element-plus+v3layer+v3scroll等技術(shù)架構(gòu)的仿微信PC端界面聊天實例。

          以上是仿制微信界面聊天效果,同樣也支持QQ皮膚。

          技術(shù)棧

          • 使用技術(shù):vue3.0+vuex4+vue-router4
          • UI組件庫:element-plus(餓了么Vue3 pc端組件庫)
          • 彈窗組件:V3Layer(基于Vue3自定義桌面端彈窗)
          • 滾動條組件:V3Scroll(基于Vue3自定義虛擬美化滾動條)
          • iconfont圖標:阿里字體圖標庫

          Vue3.x自定義彈窗組件

          大家看到的所有彈窗功能,均是自己開發(fā)的vue3.0自定義彈窗V3Layer組件。

          前段時間有過一篇詳細的分享,這里就不作介紹了。感興趣的話可以去看看。

          vue3.0系列:Vue3自定義PC端彈窗組件V3Layer

          Vue3.x自定義美化滾動條組件

          為了使得項目效果一致,所有頁面的滾動條均是采用vue3.0自定義組件實現(xiàn)。

          v3scroll 一款輕量級的pc桌面端模擬滾動條組件。支持是否原生滾動條、自動隱藏、滾動條大小/層疊/顏色等功能。

          大家感興趣的話,可以去看看這篇分享。

          Vue3.0系列:vue3定制美化滾動條組件v3scroll

          vue.config.js項目配置

          /**
           * Vue3.0項目配置
           */
          
          const path=require('path')
          
          module.exports={
              // 基本路徑
              // publicPath: '/',
          
              // 輸出文件目錄
              // outputDir: 'dist',
          
              // assetsDir: '',
          
              // 環(huán)境配置
              devServer: {
                  // host: 'localhost',
                  // port: 8080,
                  // 是否開啟https
                  https: false,
                  // 編譯完是否打開網(wǎng)頁
                  open: false,
                  
                  // 代理配置
                  // proxy: {
                  //     '^/api': {
                  //         target: '<url>',
                  //         ws: true,
                  //         changeOrigin: true
                  //     },
                  //     '^/foo': {
                  //         target: '<other_url>'
                  //     }
                  // }
              },
          
              // webpack配置
              chainWebpack: config=> {
                  // 配置路徑別名
                  config.resolve.alias
                      .set('@', path.join(__dirname, 'src'))
                      .set('@assets', path.join(__dirname, 'src/assets'))
                      .set('@components', path.join(__dirname, 'src/components'))
                      .set('@layouts', path.join(__dirname, 'src/layouts'))
                      .set('@views', path.join(__dirname, 'src/views'))
              }
          }

          Vue3引入/注冊公共組件

          // 引入餓了么ElementPlus組件庫
          import ElementPlus from 'element-plus'
          import 'element-plus/lib/theme-chalk/index.css'
          
          // 引入vue3彈窗組件v3layer
          import V3Layer from '../components/v3layer'
          
          // 引入vue3滾動條組件v3scroll
          import V3Scroll from '@components/v3scroll'
          
          // 引入公共組件
          import WinBar from '../layouts/winbar.vue'
          import SideBar from '../layouts/sidebar'
          import Middle from '../layouts/middle'
          
          import Utils from './utils'
          
          const Plugins=app=> {
              app.use(ElementPlus)
              app.use(V3Layer)
              app.use(V3Scroll)
          
              // 注冊公共組件
              app.component('WinBar', WinBar)
              app.component('SideBar', SideBar)
              app.component('Middle', Middle)
          
              app.provide('utils', Utils)
          }
          
          export default Plugins

          項目中主面板毛玻璃效果(虛化背景)

          <!-- //虛化背景(毛玻璃) -->
          <div class="vui__bgblur">
              <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="100%" height="100%" class="blur-svg" viewBox="0 0 1920 875" preserveAspectRatio="none">
              <filter id="blur_mkvvpnf"><feGaussianBlur in="SourceGraphic" stdDeviation="50"></feGaussianBlur></filter>
              <image :xlink:href="store.state.skin" x="0" y="0" width="100%" height="100%" externalResourcesRequired="true" xmlns:xlink="http://www.w3.org/1999/xlink" style="filter:url(#blur_mkvvpnf)" preserveAspectRatio="none"></image>
              </svg>
              <div class="blur-cover"></div>
          </div>

          Vue3攔截登錄狀態(tài)

          vue3.0中使用全局路由鉤子攔截登錄狀態(tài)。

          router.beforeEach((to, from, next)=> {
              const token=store.state.token
          
              // 判斷當前路由地址是否需要登錄權(quán)限
              if(to.meta.requireAuth) {
                  if(token) {
                      next()
                  }else {
                      // 未登錄授權(quán)
                      V3Layer({
                          content: '還未登錄授權(quán)!', position: 'top', layerStyle: 'background:#fa5151', time: 2,
                          onEnd: ()=> {
                              next({ path: '/login' })
                          }
                      })
                  }
              }else {
                  next()
              }
          })

          Vue3.x聊天模塊

          如上圖:聊天編輯框部分支持文字+emoj表情、在光標處插入表情、多行文本內(nèi)容。

          編輯器抽離了一個公共的Editor.vue組件。

          <template>
              <div
                  ref="editorRef"
                  class="editor"
                  contentEditable="true"
                  v-html="editorText"
                  @click="handleClick"
                  @input="handleInput"
                  @focus="handleFocus"
                  @blur="handleBlur"
                  style="user-select:text;-webkit-user-select:text;">
              </div>
          </template>

          另外還支持粘貼截圖發(fā)送,通過監(jiān)聽paste事件,判斷是否是圖片類型,從而發(fā)送截圖。

          editorRef.value.addEventListener('paste', function(e) {
              let cbd=e.clipboardData
              let ua=window.navigator.userAgent
              if(!(e.clipboardData && e.clipboardData.items)) return
          
              if(cbd.items && cbd.items.length===2 && cbd.items[0].kind==="string" && cbd.items[1].kind==="file" &&
                  cbd.types && cbd.types.length===2 && cbd.types[0]==="text/plain" && cbd.types[1]==="Files" &&
                  ua.match(/Macintosh/i) && Number(ua.match(/Chrome\/(\d{2})/i)[1]) < 49){
                  return;
              }
              for(var i=0; i < cbd.items.length; i++) {
                  var item=cbd.items[i]
                  // console.log(item)
                  // console.log(item.kind)
                  if(item.kind=='file') {
                      var blob=item.getAsFile()
                      if(blob.size===0) return
                      // 讀取圖片記錄
                      var reader=new FileReader()
                      reader.readAsDataURL(blob)
                      reader.onload=function() {
                          var _img=this.result
          
                          // 返回圖片給父組件
                          emit('pasteFn', _img)
                      }
                  }
              }
          })

          還支持拖拽圖片至聊天區(qū)域進行發(fā)送。

          <div class="ntMain__cont" @dragenter="handleDragEnter" @dragover="handleDragOver" @drop="handleDrop">
              // ...
          </div>
          const handleDragEnter=(e)=> {
              e.stopPropagation()
              e.preventDefault()
          }
          const handleDragOver=(e)=> {
              e.stopPropagation()
              e.preventDefault()
          }
          const handleDrop=(e)=> {
              e.stopPropagation()
              e.preventDefault()
              // console.log(e.dataTransfer)
          
              handleFileList(e.dataTransfer)
          }
          // 獲取拖拽文件列表
          const handleFileList=(filelist)=> {
              let files=filelist.files
              if(files.length >=2) {
                  v3layer.message({icon: 'error', content: '暫時支持拖拽一張圖片', shade: true, layerStyle: {background:'#ffefe6',color:'#ff3838'}})
                  return false
              }
              for(let i=0; i < files.length; i++) {
                  if(files[i].type !='') {
                      handleFileAdd(files[i])
                  }else {
                      v3layer.message({icon: 'error', content: '目前不支持文件夾拖拽功能', shade: true, layerStyle: {background:'#ffefe6',color:'#ff3838'}})
                  }
              }
          }

          大家如果感興趣可以自己去試試哈。

          ok,基于vue3+element-plus開發(fā)仿微信/QQ聊天實戰(zhàn)項目就分享到這里。

          基于vue3.0+vant3移動端聊天實戰(zhàn)|vue3聊天模板實例


          主站蜘蛛池模板: 国产美女精品一区二区三区| 91一区二区在线观看精品| 久久婷婷色一区二区三区| 97久久精品无码一区二区天美| 杨幂AV污网站在线一区二区| 精品一区二区久久| 亚洲AV成人精品日韩一区18p| 蜜桃传媒视频麻豆第一区| 亚洲AV无码一区二区三区在线观看 | 亚拍精品一区二区三区| 中文字幕aⅴ人妻一区二区| 亚洲国产成人久久综合一区77| 国产成人精品一区二区三在线观看| 日本免费一区二区三区四区五六区| 国产午夜精品一区理论片飘花| 国产在线一区观看| 国产伦精品一区二区三区免费迷| 亚洲色一区二区三区四区| 亚洲一区二区三区久久久久| 日韩亚洲AV无码一区二区不卡 | 精品国产乱子伦一区二区三区| 亚洲bt加勒比一区二区| 亚洲福利视频一区| 国产精品视频一区二区三区经| 亚洲va乱码一区二区三区| 精品人妻中文av一区二区三区| 亚洲天堂一区在线| 亚洲日韩AV无码一区二区三区人| 亚洲一区二区三区在线观看网站| 无码播放一区二区三区| 国产成人欧美一区二区三区| 成人一区二区三区视频在线观看| 精品国产精品久久一区免费式| 国产AV一区二区精品凹凸| 国产精品xxxx国产喷水亚洲国产精品无码久久一区 | 亚欧成人中文字幕一区| 无码人妻一区二区三区一| 狠狠综合久久av一区二区| 午夜福利国产一区二区| 国产在线一区二区| 亚洲性无码一区二区三区 |