整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

css-in-js、React Redux经典案例:

css-in-js、React Redux经典案例:仿钉钉审批流

这几天帮朋友忙,用了一周时间,高仿了一个钉钉审批流。
这个东西会有不少朋友有类似需求,就分享出来,希望能有所帮助。为了方便朋友的使用,设计制作的时候,尽量做到节点配置可定制,减少集成成本。如果您的项目有审批流需求,这个项目可以直接拿过去使用。
React初学者也可以把本项目当做研读案例,学习并快速上手React项目。通过研读项目代码,您可以学到如何设计一个react项目架构,辅助理解react设计哲学,学习css-in-js在项目中的使用,并理解其优势。理解Redux这种immutable的状态管理好处等。
本文章只包含审批流设计部分,不包含表单的设计,表单的设计请参考作者另一个可视化前端项目RxDrag:

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

相关文章:

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

项目信息

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


这个项目非常典型,它足够小,不至于让文章太长;另外,它足够完整,涵盖了一个设计器的大部分内容,比如状态管理、物料管理、属性面板、撤销重做、画布缩放、皮肤切换、多语言管理、文件的导入导出等。
设计制作一个项目的时候,最好适当提高自己的要求,从利他的角度思考,比如:能够方便发布独立npm包,方便第三方引用;要考虑,代码怎么写,别人容易读。这样的要求,能让你设计的代码结构更合理,扩展性更好。时间久了,代码会越来越优雅。本项目也是这个思路下完成的,希望作者代码能够越来越好!
项目画布的css大部分复制了这个项目:github.com/StavinLi/Wo…
饮水思源,有了这个项目的借鉴,节省了大量时间,在此对项目作者深表谢意。
本文的代码取自项目代码仓库,但是为了理解的方便,做了少许简化。

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模仿钉钉审批流的大致原理,内容偏架构方面,细节介绍不多,毕竟篇幅所限,不明的地方欢迎联系作者。
文章对代码的表达还是有限,很多细节未能说明白,后期如果有朋友需要的话,可以考虑录个视频来讲解代码。

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

段时间有分享过Vue和React系列聊天实例精选,今天要给小伙伴们分享的是如何基于AngularJs全家桶技术来开发一个移动端聊天实例。

简述

angular+angular-cli+angular-router+ngrx/store+rxjs等技术开发实现的仿微信app聊天室实例项目。实现了下拉刷新、右键菜单、发送消息、表情(动图),图片、视频预览,红包打赏等功能。

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

技术实现

  • MVVM框架:angular8.0 + @angular/cli
  • 状态管理:ngrx/store + rxjs
  • 地址路由:@angular/router
  • 弹窗组件:wcPop
  • 打包工具:webpack 2.0
  • 环境配置:node.js + cnpm
  • 图片预览:previewImage
  • 轮播滑动:swiper

预览

<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 },
  },
  
  // 首页、联系人、我
  {
    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 来进行状态管理。这里不作详细介绍,想了解用法去官网查阅资料。

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: '登录成功,跳转中...', style: 'background:#378fe7;color:#fff;', time: 2, shadeClose: false,
                end: function () {
                    that.router.navigate(['/index'])
                }
            });
        }
    }
}

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

?? 最后

如果觉得这篇文章对你有帮助,点个「关注/转发」,让更多的人也能看到你的分享!

vue3.x越来越稳定及vite2.0的快速迭代推出,加上很多大厂相继推出了vue3的UI组件库,在2021年必然受到开发者的再一次热捧。

Vue3迭代更新频繁,目前star高达20.2K+

// 官网地址
https://v3.vuejs.org/

Vitejs目前的star达到15.7K+

// 官网地址
https://vitejs.dev/

项目介绍

vue3-webchat 基于vue3.x+vuex4+vue-router4+element-plus+v3layer+v3scroll等技术架构的仿微信PC端界面聊天实例。

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

技术栈

  • 使用技术:vue3.0+vuex4+vue-router4
  • UI组件库:element-plus(饿了么Vue3 pc端组件库)
  • 弹窗组件:V3Layer(基于Vue3自定义桌面端弹窗)
  • 滚动条组件:V3Scroll(基于Vue3自定义虚拟美化滚动条)
  • iconfont图标:阿里字体图标库

Vue3.x自定义弹窗组件

大家看到的所有弹窗功能,均是自己开发的vue3.0自定义弹窗V3Layer组件。

前段时间有过一篇详细的分享,这里就不作介绍了。感兴趣的话可以去看看。

vue3.0系列:Vue3自定义PC端弹窗组件V3Layer

Vue3.x自定义美化滚动条组件

为了使得项目效果一致,所有页面的滚动条均是采用vue3.0自定义组件实现。

v3scroll 一款轻量级的pc桌面端模拟滚动条组件。支持是否原生滚动条、自动隐藏、滚动条大小/层叠/颜色等功能。

大家感兴趣的话,可以去看看这篇分享。

Vue3.0系列:vue3定制美化滚动条组件v3scroll

vue.config.js项目配置

/**
 * Vue3.0项目配置
 */

const path=require('path')

module.exports={
    // 基本路径
    // publicPath: '/',

    // 输出文件目录
    // outputDir: 'dist',

    // assetsDir: '',

    // 环境配置
    devServer: {
        // host: 'localhost',
        // port: 8080,
        // 是否开启https
        https: false,
        // 编译完是否打开网页
        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拦截登录状态

vue3.0中使用全局路由钩子拦截登录状态。

router.beforeEach((to, from, next)=> {
    const token=store.state.token

    // 判断当前路由地址是否需要登录权限
    if(to.meta.requireAuth) {
        if(token) {
            next()
        }else {
            // 未登录授权
            V3Layer({
                content: '还未登录授权!', position: 'top', layerStyle: 'background:#fa5151', time: 2,
                onEnd: ()=> {
                    next({ path: '/login' })
                }
            })
        }
    }else {
        next()
    }
})

Vue3.x聊天模块

如上图:聊天编辑框部分支持文字+emoj表情、在光标处插入表情、多行文本内容。

编辑器抽离了一个公共的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>

另外还支持粘贴截图发送,通过监听paste事件,判断是否是图片类型,从而发送截图。

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)
            }
        }
    }
})

还支持拖拽图片至聊天区域进行发送。

<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开发仿微信/QQ聊天实战项目就分享到这里。

基于vue3.0+vant3移动端聊天实战|vue3聊天模板实例