最近我们的产品 DMS(数据管理服务) 中,新增了一个新功能——在任务编排时可以增加条件分支。顾名思义,在条件分支中需要有一个条件表达式用于判断是否继续执行下面的分支。在这里,可选的条件项是指定有限的,如果只是一个简单的输入框,用户在使用的时候容易出错,而且也不易做错误提示。所以,我们设计了下面这个组件,以满足所有可能的条件表达式。如图:
图中的“条件组”表示是括号,假设我们要写一个 Key1 > 0 && (Key2 < 20 || Key3 > 10) 这样的表达式,那么就可以用下图表示:
接下来就跟随笔者一起来实现这样一个有意思的组件吧。
前期准备
技术栈说明
组件使用 React,UI 库使用的阿里集团 fusion 的 Next。读者可以根据自己的习惯使用任意 UI 库替换,或者不使用 UI 都可以。另外本文使用 immer 进行 immutable 处理。
所以 package.json 中的依赖项如下:
{"dependencies": {"@alifd/next": "^1.23.10","immer": "^9.0.3","react": "^16.4.1","react-dom": "^16.4.1"}}
定义数据结构
React 是基于数据做渲染的,每一次界面的变化都是由数据变化引起的。所以为组件,定义一个合适的数据结构至关重要。根据 DEMO 图片,我们不难想到以下数据结构:
// 为方便理解这里使用 TypeScript 定义,但组件源码中并未使用 TypeScriptenum Logics = {and = 'and',or = 'or',};interface Item { [key: string]: any }// 完整的数据结构interface Relation {ops: Logics;children: Array<Item & Relation>;}// 示例:const value: Relation = {ops: 'and', // 可选值:and, orchildren: [{key: 'Key1',op: '>',value: 0,},{ops: 'or',children: [{key: 'Key2',op: '<',value: 20,},{key: 'Key3',op: '>',value: 10,},],},],}
细心的读者发现对 Item 的数据定义是开放的,并没有固定结构内的具体 Key。那是因为这里的具体数据结构会根据业务的需求有所变化。在本文中仅用于示意,这里只有几个简单项的条件、关系符的选择,值也仅仅只是一个输入框。而在实际业务中,情况要复杂的多。条件项通常会是从服务端获取到的特定的内容,这些内容还可能存在关联或者互斥关系;关系符和值可能会跟随选择的条件不同而不同,最常见的,选择条件项可能有特定的数据类型。比如有数字、字符串、枚举值等,在遇到数字时关系符会是大于、小于、等于等;在遇到字符串时,关系符可能会是等于、包含、属于等,;遇到枚举时,值就可能是一个选择框,而选择值也会特定接口获取的。这些复杂的情况,在组件中很难罗列清楚。
编写过程
定义组件结构
根据上面的数据结构,可以轻松得到这是一个递归嵌套的结构,结构分为 Item 和 Group(Relation) 两种情况。所以相应的组件也可以分出 Item 和 Group 两个子组件。笔者暂且把这个组件命名为 RelationTree ,两个子组件分别命名为 RelationGroup 和 RelationItem。
RelationGroup 对应的是条件组,整个组件其实就是一个条件组;RelationItem 即每个组内的单项。考虑业务的复杂性(上一章节的最后有详细描述),条件、关系符和值部分的渲染是以函数的形式传入的。整体结构如图:
明确以上这些后,我们就可以着手用代码把整个组件的框架写出来了。
RelationTree.jsx
import React, { useState } from 'react';import RelationGroup, { defaultOpsValue } from './RelationGroup';import './RelationTree.less';const defaultRelation = {ops: defaultOpsValue,children: [{}],};function RelationTree({ value, setElementTerm }) {const [relations, setRelations] = useState(defaultRelation);return (<div className="vui-relation-tree">{/* 一个完整的 value 其实就是一个 RelationGroup */}<RelationGroup// 还未开始渲染任何控件,所以 pos 为空pos=""data={relations}setElementTerm={setElementTerm}/></div>);}export default RelationTree;
RelationGroup.jsx
import React from 'react';import { Select, Button } from '@alifd/next';import RelationItem from './RelationItem';const { Option } = Select;export const posSeparator = '_';export const defaultOpsValue = 'and';function RelationGroup({ data, pos, setElementTerm }) {const { children, ops } = data;const relationValue = ops || defaultOpsValue;return (<div className="vui-relation-group"><div className="relational"><Select className="relation-sign" value={relationValue}><Option value="and">且</Option><Option value="or">或</Option></Select></div><div className="conditions">{ children.map((record, i) => {const { children: list } = record;const newPos = getNewPos(pos, i);return list && list.length ? (// 包含 children 的项使用 RelationGroup 进行渲染<RelationGrouppos={newPos}key={newPos}data={record}setElementTerm={setElementTerm}/>) : (// 未包含 children 的使用 RelationItem 进行渲染<RelationItempos={newPos}key={newPos}data={record}setElementTerm={setElementTerm}/>);}) }<div className="operators"><Button type="normal" className="add-term">加条件</Button><Button type="normal" className="add-group">加条件组</Button></div></div></div>);}const getNewPos = (pos, i) => {// 如果当前项是整个 value (即组件的起始项)时,新位置即当前序号return pos ? `${pos}${posSeparator}${i}` : String(i);};export default RelationGroup;
RelationItem.jsx
import React from 'react';import { Button } from '@alifd/next';function RelationItem({ data, pos, setElementTerm }) {if (typeof setElementTerm !== 'function') {console.error('setElementTerm 属性必须设置,且必须是返回 ReactElement 的Function');return null;}return (<div className="vui-relation-item">{ setElementTerm(data, pos) }<Button type="normal" className="delete-term">删除</Button></div>);}export default RelationItem;
组件调用
有了前面的组件框架,就可以加上组件调用了,方便后面增加 CSS 和逻辑调试。
import ReactDOM from 'react-dom';import { Select, Input } from '@alifd/next';import RelationTree from './RelationTree/RelationTree';import '@alifd/next/index.css';import './index.less';const { Option } = Select;// 包含具体业务逻辑的 Termconst RelationTerm = ({ data }) => {const { key, op, value } = data;return (<div className="term"><span className="element"><Select placeholder="请选择条件项" value={key}><Option value="Key1">Key1</Option><Option value="Key2">Key2</Option><Option value="Key3">Key3</Option></Select></span><span className="comparison"><Select placeholder="请选择关系符" value={op}><Option value="==">等于</Option><Option value="!=">不等于</Option><Option value=">">大于</Option><Option value=">=">大于等于</Option><Option value="<">小于</Option><Option value="<=">小于等于</Option></Select></span><span className="value"><Input placeholder="请输入条件值" value={value} /></span></div>);}// 渲染函数const setElementTerm = (record, pos) => {return <RelationTerm data={record} />};const data = {ops: 'and',children: [{ key: 'Key1', op: '>', value: 0 },{ops: 'or',children: [{ key: 'Key2', op: '<', value: 20 },{ key: 'Key3', op: '>', value: 10 },],},],};ReactDOM.render(<RelationTree value={data} setElementTerm={setElementTerm} />,document.getElementById('root'));
CSS样式
执行前面写的组件调用,整个HTML 结构的出来了,据此可以把 CSS 完善起来。这个组件的 CSS 稍微有些复杂,复杂点主要在于几条线,如图:
HTML 中的 CSS 使用的都是盒模型,也就是 border 都是包裹在内容外面的。像这种高度和位置不跟元素边缘对齐的线条是无法直接实现的。这里我们需要使用一些非常规属性——伪类。笔者在这里主要使用了 ::before 这个伪类属性,来模拟实现图中所示的视觉效果。具体内容如下:
.vui-relation-tree {.vui-relation-group {.relational {width: 78px;padding: 20px 0 0 16px;// 表示一个组的竖线,图 ① 标记位置border-right: 1px solid #d9d9d9;}.conditions {> div {position: relative;// 实现每项中间位置显示的短横线,图 ②、③、④ 标记位置的横线&::before {content: "";display: inline-block;position: absolute;top: 0;left: 0px;width: 16px;height: 14px;border-bottom: 1px solid #d9d9d9;background-color: #fff;}// 第一项需要把竖线的前面一截盖住;图 ③ 标记位置上面“空白”区域&:first-child {&::before {left: -1px;width: 17px;}}&.vui-relation-group:before {top: 20px;}// 最后一项需要把竖线的后面一截盖住;图 ④ 标记位置下面“空白”区域&:last-child {&::before {top: inherit;bottom: 0;left: -1px;width: 17px;border-bottom: 0;border-top: 1px solid #d9d9d9;}}}}}}
其他的样式都是常规的简单样式,就不一一写出来说明了。
事件交互
最后我们把事件的交互逻辑补充完成,组件中需要完善的事件有:加条件、加条件组、删除(条件)、变更逻辑连接符、变更条件项内容。这些事件无论触发哪个,最终都是改变 state 中的数据,所以我们可以写一个方法来集中实现变更数据的逻辑:
/*** @param {object} data RelationTree 完整的 value* @param {string} pos 位置字符串,形如:0_0_1* @param {string} type 操作类型,如:addTerm, addGroup, changeOps(改变逻辑运算符 &&、||), changeTerm, deleteTerm* @param {string} record 变更的单项值*/// 如果使用了 immutable,只要将此方法里的内容改成 immutable 的写法即可const getNewValue = (data = {}, pos = '', type, record) => {if (!pos) {return record;}const arrPos = getArrPos(pos);const last = arrPos.length - 1;// 使用 immer 进行数据处理return produce(data, (draft) => {let prev = { data: draft, idx: 0 };// 暂存遍历到的当前条件组的数据let current = draft.children || [];// 根据 pos 遍历数据,pos 中的每一个数字代表它所在条件组的序号arrPos.forEach((strIdx, i) => {const idx = Number(strIdx);if (i === last) {switch (type) {case 'addTerm':case 'addGroup': // 加条件或条件组current.splice(idx + 1, 0, record);break;case 'deleteTerm': // 删除条件项current.splice(idx, 1);// 如果删除了组的最后一项,则删除整个组if (!current.length) {prev.data.splice(prev.idx, 1);}break;default: // 变更逻辑连接符或条件项内容current[idx] = record;}} else {prev = { data: current, idx };// 将下一个条件组的数据复制到 currentcurrent = (current[idx] && current[idx].children) || [];}});});};
这里重点讲解一下“changeTerm”,Term 是通过回调函数进行渲染的,但 Term 值的变化也需要触发整个组件的 onChange。具体实现如下:
- Term 的回调渲染函数执行的时候,将 onChange 作为参数传会到回调函数中。代码如下(RelationItem): ```jsx import React from ‘react’; import { Button } from ‘@alifd/next’;
function RelationItem({ data, pos, setElementTerm, onTermChange }) { // 此 value 入参必须是 { key: value } 格式的 const handleTermChange = (value) => { if (typeof onTermChange === ‘function’) { onTermChange(pos, { …data, …value }); } };
if (typeof setElementTerm !== ‘function’) { console.error(‘setElementTerm 属性必须设置,且必须是返回 ReactElement 的Function’); return null; }
return (
export default RelationItem;
2. 上面 RelationItem 中 onTermChange 实际在 RelationTree 中处理。代码如下(RelationTree):```jsximport React, { useState } from 'react';import RelationGroup, { defaultOpsValue } from './RelationGroup';import './RelationTree.less';const defaultRelation = {ops: defaultOpsValue,children: [{}],};function RelationTree({ value, setElementTerm }) {const [relations, setRelations] = useState(defaultRelation);const setOnChange = (pos, record, type) => {// getNewValue 就是本章节一开始讲解的方法;在此不赘述const value = getNewValue(relations, pos, type, record);if (typeof onChange === 'function') {onChange(value, type, record);}setRelations(value);};const handleTermChange = (pos, record) => {setOnChange(pos, record, 'changeTerm');};return (<div className="vui-relation-tree"><RelationGrouppos=""data={relations}setElementTerm={setElementTerm}onTermChange={handleTermChange}/></div>);}export default RelationTree;
- 调用组件时在回调函数里执行传入的 onChange 事件回调,并按要求传入入参({ key: value } 格式)。代码如下(RelationTerm): ```jsx import React from ‘react’; import { Select, Input } from ‘@alifd/next’;
const { Option } = Select;
const RelationTerm = ({ data, onChange }) => { const setOnChange = (params) => { if (typeof onChange === ‘function’) { // 执行传入的 onChange 回调,入参都是 { key: value } 格式 onChange(params); } };
const handleKeyChange = (val) => { setOnChange({ key: val }); };
const handleOpsChange = (val) => { setOnChange({ op: val }); };
const handleValueChange = (val) => { setOnChange({ value: val }); };
const { key, op, value } = data; return (
export default RelationTerm;
其他的事件很简单,都只要在在相应的节点上加上 onClick 或 onChange 属性,然后调用以上方法即可,详细代码请见“完整示例”。<a name="KB3zE"></a>## 完整示例仅 RelationTree.jsx、RelationGroup.jsx、RelationItem.jsx 三个文件是属于组件内部逻辑,RelationTerm.jsx 是业务逻辑,在调用组件时以入参的形式使用。<a name="JibM1"></a>### RelationTree.jsx```jsximport produce from 'immer';import React, { useEffect, useState } from 'react';import RelationGroup, { getArrPos, defaultOpsValue } from './RelationGroup';import './RelationTree.less';const defaultRelation = {ops: defaultOpsValue,children: [{}],};function RelationTree({ value, onChange, setElementTerm }) {const [relations, setRelations] = useState(defaultRelation);// console.log('relations', relations);if (value) {useEffect(() => {setRelations(value);}, [value]);}/*** @param {string} pos 位置字符串,形如:0_0_1* @param {object} record 变更的单项值* @param {string} type 操作类型,如:addTerm, addGroup, changeOps(改变逻辑运算符 &&、||), changeTerm, deleteTerm*/const setOnChange = (pos, record, type) => {const value = getNewValue(relations, pos, type, record);if (typeof onChange === 'function') {onChange(value, type, record);}setRelations(value);};const handleAddGroup = (pos, record) => {setOnChange(pos, record, 'addGroup');};const handleAddTerm = (pos, record) => {setOnChange(pos, record, 'addTerm');};const handleOpsChange = (pos, record) => {setOnChange(pos, record, 'changeOps');};const handleDeleteTerm = (pos, record) => {setOnChange(pos, record, 'deleteTerm');};const handleTermChange = (pos, record) => {setOnChange(pos, record, 'changeTerm');};return (<div className="vui-relation-tree"><RelationGrouppos=""data={relations}setElementTerm={setElementTerm}onAddGroup={handleAddGroup}onAddTerm={handleAddTerm}onOpsChange={handleOpsChange}onDeleteTerm={handleDeleteTerm}onTermChange={handleTermChange}/></div>);}/*** @param {object} data RelationTree 完整的 value* @param {string} pos 位置字符串,形如:0_0_1* @param {string} type 操作类型,如:addTerm, addGroup, changeOps(改变逻辑运算符 &&、||), changeTerm, deleteTerm* @param {string} record 变更的单项值*/const getNewValue = (data = {}, pos = '', type, record) => {if (!pos) {return record;}const arrPos = getArrPos(pos);const last = arrPos.length - 1;// 使用 immer 进行数据处理return produce(data, (draft) => {let prev = { data: draft, idx: 0 };// 暂存遍历到的当前条件组的数据let current = draft.children || [];// 根据 pos 遍历数据,pos 中的每一个数字代表它所在条件组的序号arrPos.forEach((strIdx, i) => {const idx = Number(strIdx);if (i === last) {switch (type) {case 'addTerm':case 'addGroup': // 加条件或条件组current.splice(idx + 1, 0, record);break;case 'deleteTerm': // 删除条件项current.splice(idx, 1);// 如果删除了组的最后一项,则删除整个组if (!current.length) {prev.data.splice(prev.idx, 1);}break;default: // 变更逻辑连接符或条件项内容current[idx] = record;}} else {prev = { data: current, idx };// 将下一个条件组的数据复制到 currentcurrent = (current[idx] && current[idx].children) || [];}});});}export default RelationTree;
RelationGroup.jsx
import React from 'react';import { Select, Button } from '@alifd/next';import RelationItem from './RelationItem';const { Option } = Select;export const posSeparator = '_';export const defaultOpsValue = 'and';function RelationGroup({ data, pos, setElementTerm, onAddGroup, onAddTerm, onOpsChange , onDeleteTerm, onTermChange }) {const getLastPos = () => {const arrPos = getArrPos(pos);const { children } = data;arrPos.push(children.length - 1)return arrPos.join(posSeparator);};const handleOpsChange = (value) => {if (typeof onOpsChange === 'function') {onOpsChange(pos, { ...data, ops: value });}};const handleAddTermClick = () => {const record = {};const pos = getLastPos();if (typeof onAddTerm === 'function') {onAddTerm(pos, record);}};const handleAddGroupClick = () => {const record = { ops: defaultOpsValue, children: [{}] };const pos = getLastPos();if (typeof onAddGroup === 'function') {onAddGroup(pos, record);}};const { children, ops } = data;const relationValue = ops || defaultOpsValue;return (<div className="vui-relation-group"><div className="relational"><Select className="relation-sign" value={relationValue} onChange={handleOpsChange}><Option value="and">且</Option><Option value="or">或</Option></Select></div><div className="conditions">{ children.map((record, i) => {const { children: list } = record;const newPos = getNewPos(pos, i);return list && list.length ? (<RelationGrouppos={newPos}key={newPos}data={record}setElementTerm={setElementTerm}onAddGroup={onAddGroup}onAddTerm={onAddTerm}onOpsChange={onOpsChange}onDeleteTerm={onDeleteTerm}onTermChange={onTermChange}/>) : (<RelationItempos={newPos}key={newPos}data={record}setElementTerm={setElementTerm}onDeleteTerm={onDeleteTerm}onTermChange={onTermChange}/>);}) }<div className="operators"><Button type="normal" className="add-term" onClick={handleAddTermClick}>加条件</Button><Button type="normal" className="add-group" onClick={handleAddGroupClick}>加条件组</Button></div></div></div>);}const getNewPos = (pos, i) => {// 如果当前项是整个 value (即组件的起始项)时,新位置即当前序号return pos ? `${pos}${posSeparator}${i}` : String(i);};export const getArrPos = (pos) => {return (pos && pos.split(posSeparator)) || [];};export default RelationGroup;
RelationItem.jsx
import React from 'react';import { Button } from '@alifd/next';function RelationItem({ data, pos, setElementTerm, onDeleteTerm, onTermChange }) {const handleDeleteTermClick = () => {if (typeof onDeleteTerm === 'function') {onDeleteTerm(pos, data);}}// 此 value 入参必须是 { key: value } 格式的const handleTermChange = (value) => {if (typeof onTermChange === 'function') {onTermChange(pos, { ...data, ...value });}};if (typeof setElementTerm !== 'function') {console.error('setElementTerm 属性必须设置,且必须是返回 ReactElement 的Function');return null;}return (<div className="vui-relation-item">{ setElementTerm(data, pos, handleTermChange) }<Button type="normal" onClick={handleDeleteTermClick} className="delete-term">删除</Button></div>);}export default RelationItem;
RelationTerm.jsx
import React from 'react';import { Select, Input } from '@alifd/next';const { Option } = Select;const RelationTerm = ({ data, onChange }) => {const setOnChange = (params) => {if (typeof onChange === 'function') {// 执行传入的 onChange 回调,入参都是 { key: value } 格式onChange(params);}};const handleKeyChange = (val) => {setOnChange({ key: val });};const handleOpsChange = (val) => {setOnChange({ op: val });};const handleValueChange = (val) => {setOnChange({ value: val });};const { key, op, value } = data;return (<div className="term"><span className="element"><Select placeholder="请选择条件项" value={key} onChange={handleKeyChange}><Option value="Key1">Key1</Option><Option value="Key2">Key2</Option><Option value="Key3">Key3</Option></Select></span><span className="comparison"><Select placeholder="请选择关系符" value={op} onChange={handleOpsChange}><Option value="==">等于</Option><Option value="!=">不等于</Option><Option value=">">大于</Option><Option value=">=">大于等于</Option><Option value="<">小于</Option><Option value="<=">小于等于</Option></Select></span><span className="value"><Input placeholder="请输入条件值" value={value} onChange={handleValueChange} /></span></div>);}export default RelationTerm;
index.jsx
import ReactDOM from 'react-dom';import RelationTree from './RelationTree/RelationTree';import RelationTerm from './RelationTerm';import '@alifd/next/index.css';import './index.less';const setElementTerm = (record, pos, onChange) => {return <RelationTerm data={record} onChange={onChange} />};ReactDOM.render(<RelationTree setElementTerm={setElementTerm} />,document.getElementById('root'));
总结
最后我们来简单总结下这个组件在设计上的几个关键点,笔者罗列如下:
- pos 采用每个节点所在数组的序号组成的字符串,形如“0_0_1”;通过此值,很方便就能定位到当前修改项在树形结构数据中的位置,极大简化了组件事件交互的代码逻辑。
- 巧妙的采用 ::before、:first-child、:last-child 几个伪类实现非常规盒模型视觉效果 —— 类方括号样式。
- 使用将 Term 以参数的形式传入的方式,完全剥离业务逻辑,最大限度的增强了可复用性。
以上是关于此组件的所有内容,读者若有其他想法或建议,欢迎留言交流,
