用React仿钉钉审批流
<h1 id="引言">引言</h1><p>这几天帮朋友忙,用了一周时间,高仿了一个钉钉审批流。<br>这个东西会有不少朋友有类似需求,就分享出来,希望能有所帮助。为了方便朋友的使用,设计制作的时候,尽量做到节点配置可定制,减少集成成本。如果您的项目有审批流需求,这个项目可以直接拿过去使用。<br>React初学者也可以把本项目当做研读案例,学习并快速上手React项目。通过研读项目代码,您可以学到如何设计一个react项目架构,辅助理解react设计哲学,学习css-in-js在项目中的使用,并理解其优势。理解Redux这种immutable的状态管理好处等。<br>本文章只包含审批流设计部分,不包含表单的设计,表单的设计请参考作者另一个可视化前端项目RxDrag:</p>
<blockquote>
<p>项目地址:https://github.com/codebdy/rxdrag<br>
演示地址:https://rxdrag.vercel.app</p>
</blockquote>
<p>相关文章:</p>
<blockquote>
<p>《实战,一个高扩展、可视化低代码前端,详实、完整》<br>
《挑战零代码:可视化逻辑编排》</p>
</blockquote>
<h1 id="项目信息">项目信息</h1>
<p>项目地址:https://github.com/codebdy/dingflow<br>演示地址:https://dingflow.vercel.app/<br>运行快照:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691217287943-72adc450-38c8-4112-a9a6-df3cb188887e.png#averageHue=%236598d9&clientId=u34b4fe99-a3f2-4&from=paste&height=750&id=ud7ef9559&originHeight=938&originWidth=1908&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=55534&status=done&style=none&taskId=ufb21b726-85cd-42ab-bbbd-3f0853d8f33&title=&width=1526.4" alt="image.png" loading="lazy"><br>这个项目非常典型,它足够小,不至于让文章太长;另外,它足够完整,涵盖了一个设计器的大部分内容,比如状态管理、物料管理、属性面板、撤销重做、画布缩放、皮肤切换、多语言管理、文件的导入导出等。<br>设计制作一个项目的时候,最好适当提高自己的要求,从利他的角度思考,比如:能够方便发布独立npm包,方便第三方引用;要考虑,代码怎么写,别人容易读。这样的要求,能让你设计的代码结构更合理,扩展性更好。时间久了,代码会越来越优雅。本项目也是这个思路下完成的,希望作者代码能够越来越好!<br>项目画布的css大部分复制了这个项目:https://github.com/StavinLi/Workflow-React<br>饮水思源,有了这个项目的借鉴,节省了大量时间,在此对项目作者深表谢意。<br>本文的代码取自项目代码仓库,但是为了理解的方便,做了少许简化。</p>
<h1 id="ui布局">UI布局</h1>
<p>分两部分理解界面布局,第一部分整体布局,理解了这部分,就知道自己业务相关的组件如何插入编辑器,能够理解作者这么设计代码架构是为了提高扩展性,方便第三方引入;第二部分是画布绘制,该项目以div树的方式组织审批流节点,理解了这部分有助于理解后面的数据结构。</p>
<h2 id="整体布局">整体布局</h2>
<p><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691237966838-b84f92a6-a0be-4b0e-a1b1-be157d8c42d4.png#averageHue=%2377aaec&clientId=u7c43d1f4-aca1-4&from=paste&height=489&id=u906668e8&originHeight=611&originWidth=1239&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=73307&status=done&style=none&taskId=uc6330a36-b325-431e-a9b3-ef0c1f0e552&title=&width=991.2" alt="image.png" loading="lazy"><br>项目代码有两个主要目录:example 和 workflow-editor。workflow-editor 是编辑器核心,未来要作为独立的npm package来发布;example 是演示如何使用workflow-editor来把审批流集成入自己的项目。<br>上图把页面划分为3个区域,workflow-editor 包含全部③区域和②区域的部分通用组件;example包含全部①区域的内容跟部分②区域的定制内容,并引用③的内容。<br>点击一个画布(也就是区域③)中的节点,会弹出属性设置面板,属性面板包含④⑤两部分:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691238945531-0eec3c55-6d3c-41da-b9fe-e2319d716d01.png#averageHue=%23969793&clientId=u7c43d1f4-aca1-4&from=paste&height=430&id=u004c9f37&originHeight=537&originWidth=1099&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=61011&status=done&style=none&taskId=u749e29af-0b33-4314-bfae-9effe0463cc&title=&width=879.2" alt="image.png" loading="lazy"><br>弹出这个面板的抽屉(drawer)和它的标题④,包含在workflow-editor目录中,它内部的组件,就是⑤区域是在example中定义,通过接口注入进去的。<br>综上,编辑器通用的功能在workflow-editor中定义,差异化部分通过接口注入。</p>
<h2 id="画布绘制">画布绘制</h2>
<p>画布区是通过嵌套的div实现的,连线、箭头是通过css的border、伪类before跟after实现的,这些css细节请参看源码,这里只介绍div的嵌套结构。</p>
<h3 id="普通节点">普通节点</h3>
<p>像这样一组不含条件的普通节点:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691241335213-8eba08ab-60eb-4620-af38-5e7d38709c5a.png#averageHue=%234d88d2&clientId=u7c43d1f4-aca1-4&from=paste&height=344&id=u337706f8&originHeight=698&originWidth=384&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=18946&status=done&style=none&taskId=u30dc410b-7522-4b69-8da2-158c719156d&title=&width=189.20001220703125" alt="image.png" loading="lazy"><br>它的div结构是这样的:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691242797251-3ba630e8-93a3-4a42-8dbb-cd0ae0d411b4.png#averageHue=%23abd290&clientId=u7c43d1f4-aca1-4&from=paste&height=252&id=uf37f5a48&originHeight=419&originWidth=565&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=11411&status=done&style=none&taskId=ud49354dc-6c84-420c-bc1b-88d7dbf1b61&title=&width=340" alt="image.png" loading="lazy"><br>在一条直线路径上的节点,就这样层层嵌套,结束节点除外,它最后面。</p>
<h3 id="条件节点">条件节点</h3>
<p>如果加上条件分支,同一级别的条件分支是水平排列的div,分支内部的路径再次循环嵌套:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691243876243-40f177d8-576d-470f-87eb-95fc02f334f3.png#averageHue=%23e9bc0b&clientId=u7c43d1f4-aca1-4&from=paste&height=346&id=u3d396c29&originHeight=433&originWidth=728&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=14756&status=done&style=none&taskId=u545109b4-f0bd-415d-8794-681500528bb&title=&width=582.4" alt="image.png" loading="lazy"><br>只要明白这些节点是一棵div树,不是扁平结构就可以了。</p>
<h1 id="数据结构dsl定义">数据结构(DSL定义)</h1>
<p>UI虽然是树形结构,但是项目内部的数据结构可以是树形,也可以是扁平的。<br>扁平的意思是,所用节点存在一个数组或者map里,通过parentId跟childIds等信息描述树形关系。<br>因为这个项目是帮朋友做的,他的后端是树形结构,跟div的结构一致。如果这个项目提供一个编辑器组件WorkflowEditor,这个组件要有value跟onChange属性,如果是扁平结构,onChange的时要转一下,如果做成受控组件,性能可能会有问题。<br>所以,最后选择了树形数据结构:</p>
<pre><code class="language-typescript">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;
}
</code></pre>
<h1 id="状态管理">状态管理</h1>
<p>如果是扁平结构,状态管理作者会首选Recoil,用起来简单,代码量小。但是,因为数据结构定义的树形,要是用Recoil做状态管理,需要扁平化处理,会出现上文说的转换问题。所以,最终选择了Redux作为状态管理工具。<br>作者只会基础的Redux库,所以代码会略显繁琐一点,即便这样,还是不想选mobx。因为这么小的编辑器项目,mobx的撤销、重做的工作量,要比Redux大。用Mobx的话,一般要采用comand模式做撤销重做,每个Command有正负操作,挺繁琐,工作量也大。而immutable的操作方式,可以保留状态快照,易于回溯,很容易就能完成撤销、重做功能。<br>状态定义:</p>
<pre><code class="language-typescript">//操作快照,用于撤销、重做
export interface ISnapshot {
//开始节点
startNode: IWorkFlowNode,
//是否校验过
validated?: boolean,
}
//错误消息
export interface IErrors {
: string | undefined
}
//状态
export interface IState {
//是否被修改,该标识用于提示是否需要保存
changeFlag: boolean,
//撤销快照列表
undoList: ISnapshot[],
//重做快照列表
redoList: ISnapshot[],
//开始节点
startNode: IWorkFlowNode,
//被选中的节点,用于弹出属性面板
selectedId?: string,
//是否校验过,如果校验过,后面加入的节点会自动校验
validated?: boolean,
//校验错误
errors: IErrors,
}
</code></pre>
<p>Redux处理这些树形结构的状态,需要递归处理,具体参看reducers部分代码。</p>
<h1 id="设计器架构">设计器架构</h1>
<p><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691284771070-91d4bad6-93c0-449c-a8a2-5ef74d7368cd.png#averageHue=%23f7f3e5&clientId=u48cf4383-1d67-4&from=paste&height=361&id=u0ff193b5&originHeight=451&originWidth=989&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=47892&status=done&style=none&taskId=u2a028b16-9959-4170-b8c9-8d13f3279f6&title=&width=791.2" alt="image.png" loading="lazy"></p>
<h2 id="引擎editorengine">引擎(EditorEngine)</h2>
<p>引擎(Engine)在作者的项目里是老演员了,这里依然扮演了一个重要角色,全名EditorEngine。编辑器的绝大多数业务逻辑,都在这部分实现,主要功能就是操作Redux store。源码文件在src/workflow-editor/classes目录下。</p>
<h3 id="节点物料">节点物料</h3>
<p>物料就是节点的定义,包括节点的图标、颜色、缺省配置等信息。把这些信息独立出来的好处,是让代码更容易扩展,方便后期添加新的节点类型。作者自己开源低代码前端RxDrag,也用了类似的设计方式,不过比这里的扩展性还要好,可以支持物料的热加载。这个项目比较简单,没有热加载需求,做到这种程度就够用了。<br>物料定义代码:</p>
<pre><code class="language-typescript">//国际化翻译函数,外部注入,这里使用的是@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
}
</code></pre>
<p>审批流节点相对比较固定,目前只有四个主要节点类型。后面有可能会有扩展,但是频率会非常低。所以物料虽然定义了接口,但是实现基本上还是以预定义实现为主。预定义节点代码:</p>
<pre><code class="language-typescript">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,
},
]
</code></pre>
<p>这份配置代码保存在引擎(EditorEngine)中,渲染画布跟物料面板会使用这些配置。物料面板是指这里:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691287629462-71016bbd-9822-4592-bdbb-23212f17d652.png#averageHue=%237ac7dd&clientId=u48cf4383-1d67-4&from=paste&height=218&id=ude06a088&originHeight=462&originWidth=905&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=26023&status=done&style=none&taskId=ua6ac2060-ad6d-44e9-9462-427d3bbf562&title=&width=427" alt="image.png" loading="lazy"><br>就是点击“添加”按钮弹出的选择面板。</p>
<h3 id="物料ui配置">物料UI配置</h3>
<p>跟物料相关的还有一些内容:节点的内容区①;校验规则、校验后的错误消息②;节点配置面板③。<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691288063936-019d5e25-13df-4610-b61a-f9b3ce91a708.png#averageHue=%23a6a7a1&clientId=u48cf4383-1d67-4&from=paste&height=337&id=ue560ca46&originHeight=421&originWidth=1202&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=65243&status=done&style=none&taskId=u2298ceea-6e43-4cce-9326-0a66e9616b1&title=&width=961.6" alt="image.png" loading="lazy"><br>这些内容根据物料的不同而不同,并且跟具体业务强相关。就是说,不同的项目,这些内容是不一样的。如果要把编辑器跟具体项目集成,那么这部分内容就要做成可注入的。<br>把要注入的内容抽出来,独立定义为物料UI(IMaterialUI),具体代码:</p>
<pre><code class="language-typescript">//物料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 {
: IMaterialUI<any> | undefined
}
</code></pre>
<p>在example目录(该目录放具体项目强相关内容),依据这个物料UI约定,定义业务相关的ui元素,注入进设计器。目前的实现:</p>
<pre><code class="language-typescript">export const materialUis: IMaterialUIs = {
//发起人物料UI
: {
//节点内容区,只实现了空逻辑,具体过几天实现
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
}
},
//办理人节点
: {
//节点内容区
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
}
},
//条件分支节点的分支子节点
: {
//节点内容区
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
}
},
//通知人节点
: {
viewContent: (node: IWorkFlowNode<INotifierSettings>, { t }) => {
return <ContentPlaceholder text={t("pleaseChooseNotifier")} />
},
settersPanel: NotifierPanel,
},
//发起人节点
: {
viewContent: (node: IWorkFlowNode<IStartSettings>, { t }) => {
return <ContentPlaceholder text={t("allMember")} />
},
settersPanel: StartPanel,
},
}
</code></pre>
<p>这份代码游离于设计器之外,要根据具体项目的业务规则进行修改,这里并没有完全完成。</p>
<h3 id="多语言配置">多语言配置</h3>
<p>多语言使用的是@rxdrag/locales,相关的react封装在src/workflow-editor/react-locales目录下。没有@rxdrag/react-lacales,因为react版本跟朋友项目的react版本不兼容。<br>通过钩子useTranslate拿到t函数,把t函数注入到引擎供物料定义等场景使用。<br>项目其他部分的翻译,直接使用useTranslate实现。多语言资源系统预定义了一部分,也可以通过编辑器的props传入locales,补充或覆盖已有的多语言资源。</p>
<h2 id="钩子-react-hooks">钩子 React Hooks</h2>
<p>引擎订阅Redux store的数据变化,通过一系列钩子来把这些数据变化推送给相应的react组件,这些钩子在目录src/workflow-editor/hooks下。这些钩子,相当于是状态的监听器。<br>比如起始节点的监听,它hook代码是这样:</p>
<pre><code class="language-typescript">//获取起始节点
export function useStartNode() {
const = useState<IWorkFlowNode>()
const engine = useEditorEngine()
//引擎起始节点变化事件处理函数
const handleStartNodeChange = useCallback((startNode: IWorkFlowNode) => {
setStartNode(startNode)
}, [])
useEffect(() => {
//订阅起始节点变化事件
const unsub = engine?.subscribeStartNodeChange(handleStartNodeChange)
return unsub
}, )
//初始化时,先拿到最新数据
useEffect(() => {
setStartNode(engine?.store.getState().startNode)
}, )
return startNode
}
</code></pre>
<p>现在redux有很多辅助库,用上这些辅助库的话可能不太需要这些钩子了,作者不是很熟悉这些库,代码量也不大,就这么写了。如果是大一点的项目,优先考虑的是Recoil,也就没有动力再去研究这些辅助库了。</p>
<h1 id="主题管理">主题管理</h1>
<p>antd5支持css-in-js了,虽然跟mui相比,在这方面还有不小差距,但是勉强够用了。主题皮肤的切换,就是基于antd的这个特性。<br>通过props把antd的theme token传入设计器,设计器根据这个,使用styled-components库定义符合相应主题的组件。<br>antd的theme token属性用不了全部,为了简化接口,摘了一部分有用的独立出来,没有直接使用token的好处是,以后扩展自己的配色方案更方便些。接口定义:</p>
<pre><code class="language-typescript">//只是摘取了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'
}
</code></pre>
<p>在编辑器最外层加一个styled-components的主题配置:</p>
<pre><code class="language-typescript">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
}
}, )
...
return <ThemeProvider theme={theme}>
...
</ThemeProvider>
})
</code></pre>
<p>添加typescript的声明文件styled.d.ts用于IDE的智能提示,文件代码:</p>
<pre><code class="language-typescript">// import original module declarations
import 'styled-components';
import { IDefaultTheme } from './theme';
// and extend them!
declare module 'styled-components' {
export interface DefaultTheme extends IDefaultTheme {
}
}
</code></pre>
<p>给IDE(作者用的VSCode)安装styled-components相关插件(作者用的是vscode-styled-components)。然后就可以在代码中使用这些主题信息来定义组件样式了:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691296322785-b075721c-3f98-482c-b165-57af858e7736.png#averageHue=%2320201f&clientId=u48cf4383-1d67-4&from=paste&height=374&id=u08f4273c&originHeight=467&originWidth=1069&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=41384&status=done&style=none&taskId=u27482de7-8596-4cd2-861d-1e12590a428&title=&width=855.2" alt="image.png" loading="lazy"><br>编辑器外部传入不同theme mode,来切换不同的皮肤主题,具体效果请参考在线演示。<br>BTW,最近网上在传阅一篇文章,那个谁谁谁不用css-in-js了,说是影响性能等等。看了后有两个困惑:<br>1、什么时候前端的性能变得那么重要了,显示器有能力展示出这种性能差异吗?人类真的能识别并感受到这种性能差异吗?<br>2、css-in-js如火如荼,使用面也够逛,如果一点优点看不到,不妨问问自己,为什么看不到它的优点,是不是触到了自己的知识盲点?<br>欢迎明白的大佬留言指点。</p>
<h1 id="编辑器组件接口">编辑器组件接口</h1>
<p>整个审批流编辑器独立在目录src/workflow-editor中,以后会抽时间把这个目录发布为一个单独的npm package。<br>编辑器对外提供两个组件:FlowEditorScope,FlowEditorCanvas。<br>前者负责接收各种配置资源,比如物料、物料ui、多语言资源、主题定义等,根据这个些配置生成一个EditorEngine对象,并把这个对象通过context下发。<br>理论上,FlowEditorScope内的所有子组件,都可以通过EditorEngine来操作编辑器。FlowEditorCanvas是画布区,流程图的所有UI,都在这里面。<br>通常思路,会把这两个合并为一个FlowEditor组件,外部只引用一次就可以。这样的话,集成的灵活性会丧失一些。这里保持分开,使用方法请参考expample目录。<br>FlowEditorCanvas 通过context拿到资源,所以没有props,除了className跟style。<br>FlowEditorScope的定义如下:</p>
<pre><code class="language-typescript">export const FlowEditorScope = memo((props: {
//当前主题模式
mode?: 'dark' | 'light',
//主题定义
themeToken?: IThemeToken,
children?: React.ReactNode,
//当前语言
lang?: string,
//多语言资源
locales?: ILocales,
//自定义物料
materials?: INodeMaterial[],
//所有物料的Ui配置,包括自定义物料跟预定义物料
materialUis?: IMaterialUIs,
}) => {
//实现代码省略
...
})
</code></pre>
<h1 id="导入导出json">导入、导出JSON</h1>
<p>以前做导出,直接做一个a标签,模拟a标签的点击触发下载动作,导入是用file组件。现在可以使用window.showOpenFilePicker跟window.showSaveFilePicker直接打开、保存文件。文件操作代码在src/workflow-editor/utils目录下。<br>导入导出JSON功能,基于这个通用方法,封装成两个钩子:useImport、useExport。在src/workflow-editor/hooks目录下,代码比较简单,读者自行翻看吧。</p>
<h1 id="优化体验">优化体验</h1>
<p>钉钉审批流设计的挺经典,足够简洁,能适应绝大多数审批场景。只是有些用户体验方面的细节,不是非常完美,这方面作者做了一点优化。具体的优化点有以下三处:</p>
<h2 id="zoom工具栏浮动">zoom工具栏浮动</h2>
<p>原版的zoom工具栏是隐形浮动的,在这个位置:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691301033222-9bb66224-345f-4f5e-a0a6-ed6c90fc813e.png#averageHue=%23eff1f5&clientId=u48cf4383-1d67-4&from=paste&height=198&id=u3eec9d23&originHeight=686&originWidth=1406&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=37274&status=done&style=none&taskId=u51815ea1-5b43-4d31-a356-e73c2ea06e4&title=&width=405.3999938964844" alt="image.png" loading="lazy"><br>这种隐形工具栏,在画布滚动时,有时会跟画布元素重叠,出现这样的效果:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691118217880-19a89ad2-d035-4071-ae6d-24c8a4b2e471.png#averageHue=%23d1e5ea&clientId=u6f1e4183-b772-4&from=paste&height=242&id=gGITI&originHeight=302&originWidth=439&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=15157&status=done&style=none&taskId=uda806709-ba0a-47a7-b4fb-4917f69fc74&title=&width=351.2" alt="image.png" loading="lazy"><br>这种效果用户也能明白,但是总感觉有种廉价感。<br>所以,这部分作者做成了浮动工具条,当画布没有滚动的时候,跟原版一样是隐形的,当画布滚动时,就会浮现出来,元素重叠时变成这样的效果:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691118440393-466ca03a-9fa0-4fb5-8f3b-43c53e58267b.png#averageHue=%2396d1eb&clientId=u6f1e4183-b772-4&from=paste&height=170&id=Jgw7f&originHeight=213&originWidth=378&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=6643&status=done&style=none&taskId=uefff967b-f5de-4f07-b638-7d2ef87f8e8&title=&width=302.4" alt="image.png" loading="lazy"><br>具体运作,请参考在线演示。</p>
<h2 id="鼠标拖动画布">鼠标拖动画布</h2>
<p>原版的画布滚动,只能通过点击滚动条实现,每次移动画布都要去找滚动条,用起来十分不便,这个也是作者最在意的地方。希望实现的效果是,鼠标悬浮在画布空白处,鼠标光标显示grab(展开的手掌)效果,鼠标按下时显示未grabbing(抓取的小手)效果,拖动时直接移动画布。有了这个功能,会极大提高用户体验。<br>在线演示已经实现了这个效果。实现代码在src/workflow-editor/FlowEditor/FlowEditorCanvas.tsx文件中。</p>
<h2 id="撤销重做">撤销、重做</h2>
<p>一个编辑器,如果有撤销、重做功能,能够非常有效的防止用户误操作,提高用户体验。原版中不存在这个功能,作者决定加上。使用immutable的状态管理方式,加这样的功能非常简单,增加不了多少工作量。<br>在画布左侧跟缩放工具栏对称的地方,加了一个迷你工具栏:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691302419657-ba758cef-c4ec-4ddd-9d19-32600c8c748f.png#averageHue=%2379b5f7&clientId=u48cf4383-1d67-4&from=paste&height=437&id=u3e982adc&originHeight=546&originWidth=1162&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=28037&status=done&style=none&taskId=ud51a9317-7a87-4628-896a-ea1764b964c&title=&width=929.6" alt="image.png" loading="lazy"><br>画布滚动的时候,这个工具栏同样会浮现出来:<br><img src="https://cdn.nlark.com/yuque/0/2023/png/33567963/1691302472735-389d7cfc-85f4-466d-b8c3-ed04df629752.png#averageHue=%23f2f3f6&clientId=u48cf4383-1d67-4&from=paste&height=388&id=u555ffaa5&originHeight=485&originWidth=1110&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=20222&status=done&style=none&taskId=u74ded92f-6dd3-47b0-90ef-3f152ada578&title=&width=888" alt="image.png" loading="lazy"><br>具体实现方式,请参考源码。</p>
<h2 id="遗留问题">遗留问题</h2>
<p>zoom实现方式是基于transform:scale(x) css样式实现的,放大画布时,会出现画布内的元素超出滚动区域的问题,为了解决这个问题,加了css样式:transform-origin: 50% 0px 0px ,但是这又出现了一个新问题,就是每次缩放画布,画布会闪烁一下,滚回起始点。<br>这个问题作者很在意,但是由于css样式不是很熟悉,这个问题一直没解决,有解决方案的朋友欢迎留言指点,十分感谢。</p>
<h1 id="总结">总结</h1>
<p>本文介绍了用React模仿钉钉审批流的大致原理,内容偏架构方面,细节介绍不多,毕竟篇幅所限,不明的地方欢迎联系作者。<br>文章对代码的表达还是有限,很多细节未能说明白,后期如果有朋友需要的话,可以考虑录个视频来讲解代码。</p><br><br>
来源:https://www.cnblogs.com/idlewater/p/17609435.html
頁:
[1]