如何设计表单业务组件

254 阅读6分钟

如何设计表单业务组件

前言

在前端项目开发中,组件设计是必不可少的环节,项目中会包含各种业务型组件和功能型组件。而随着产品业务的不断迭代升级,开发的组件通常会遇到以下几类问题

  • 组件参数与后端api设计不统一
  • 组件拆分不够单一
  • 扩展性和维护性欠缺 本文将以React框架为例结合自身的组件设计经验通过实际的业务案例讲解如何解决这几类问题,

业务分析

产品经理给到的需求是:创建活动的表单项新增参与活动地理位置限制,需满足以下功能点:

  • 地理位置限制可以 开启或者关闭
  • 开启后可以支持城市限制:即能满足选择省,市,区,并且支持多选
  • 开启后可以支持距离限制:即在地图上点击地图选中经纬度坐标,设置该坐标范围内一定距离,并且支持设置多个位置坐标
  • 地图可以支持根据关键词检索位置坐标
  • 表单校验,当选中城市限制必须选中一个城市,无最大上限;当选中距离限制后必须选中一个位置坐标,最多20个,并且距离范围在100~10000米之间
  • 方便后续其他业务场景复用

组件最终实现的效果图 2.gif

组件设计

组件设计原则

单一职责 功能简单单一,尽可能服务于一个职责,确保组件的最细颗粒度,有利于复用。

通用性组件既要服务于业务,又能从业务中抽离出来封装通用型组件。

封装 良好的组件封装应该隐藏内部细节和实现意义,并通过props和回调事件来控制行为和输出。

组合能将多个单一的功能组件组合成为复杂的组件,类似于"分而治之"的思想。

组件拆解

基于产品需求和组件设计原则进行拆分和代码设计,这里我们将拆分以下6个组件

3.png

  • 省/市/区下拉列表组件<CitySelect/>,地图组件<Map/>,关键词检索位置信息组件<LocationSearch/>,都能单独使用,也能聚合在一起:(组合原则)
  • 每个组件功能简单单一,服务于自身的逻辑 (单一职责原则)
  • 省/市/区下拉列表组件<CitySelect/>,地图组件<Map/>,关键词检索位置信息组件<LocationSearch/>内部封装了调用腾讯地图api能力,引入组件的时候不需要关注内部的实现细节就能实现功能 (封装原则)
  • 省/市/区下拉列表组件,地图组件,关键词检索位置信息组件不仅能满足自身的业务,也能抽离出来,基于antdesign ui库封装开源给其他开发者使用 (通用性)

代码设计

我们以ReactTypescriptAntdesign为例设计组件的伪代码 地图组件 Map.tsx

import React from "react";
interface MapProps { }
const Map = (props: MapProps) => {
    return <div></div>
}

关键词检索 LocationSearch.tsx

import React from "react";
interface LocationSearchProps { }
const LocationSearch = (props: LocationSearchProps) => {
    return <div></div>
}

省市区下拉列表 CitySelect.tsx

import React from "react";
interface CitySelectProps { }
const CitySelect = (props: CitySelectProps) => {
    return <div></div>
}

地图弹框 LocationModal.tsx

import React from "react";
interface LocationModalProps { }
const LocationModal = (props: LocationModalProps) => {
    return <div>
	    <LocationSearchProps />
	    <Map />
    </div>
}

经纬度输入 LocationInput.tsx

import React from "react";
interface LocationInputProps { }
const LocationInput = (props: LocationModalProps) => {
	return <div>
		<LocationModal />
		<Map />
	</div>
}

地理位置限制 LocationSetting.tsx

import React from "react";
import { Radio, Form, RadioChangeEvent } from "antd";

// 组件value
export type LocationSettingValue = any


export interface LocationSettingProps {
    value?: any;
    onChange?(value: LocationSettingValue): void;
}

export const LocationSetting = (props: LocationSettingProps) => {
    const { value, onChange } = props

    // 组件value进行数据初始化绑定
    const componentValue = typeof serverDataParse === "function" ? serverDataParse(value)?.componentData : value

    const handleSettingTypeChange = (e: RadioChangeEvent) => {
        const radioValue = e.target.value;
        typeof onChange === "function" && onChange(radioValue)
    };

    return <div>
        <Radio.Group onChange={handleSettingTypeChange} value={value}>
            <Radio value={0}>不限</Radio>
            <Radio value={1}>城市限制</Radio>
            <Radio value={2}>距离限制</Radio>
        </Radio.Group>
        {componentValue === 1 < CitySelect /> }
        {componentValue === 2 < LocationInput /> }
    </div>
};

组件值适配api接口

在后端的领域模型设计中,定义的数据结构和所需字段不一定会按照前端ui交互设计,所以就需要抽象一层代码来进行适配,以地理位置限制组件为例, ** 后端api设计的json数据结构为**

type ServerDataProps = {
  "is_limit": true, // 是否开启地理位置限制
  "site_info": {
    "ad_info": [
      {
        "ad_level": "string",  // 城市级别,省/市/区
        "ad_name": "string",   // 城市名称
        "ad_code": "string"    // 城市区划码
      }
    ],
    "surround": [
      {
        "location": {
          "lng": "string",   // 经度
          "lat": "string"    // 纬度
        },
        "distance": 0       // 距离限制
      }
    ]
  }
}

表单组件value定义的数据结构

type CityValue = Array<{ ad_code: string; ad_level: string; ad_name: string }>;
interface CitySelectValue {
    cityValue: CityValue;
}
interface LocationValue {
    range: number;
    longitude: string;
    latitude: string;
}
interface LocationInputValue {
    locationValue: Array<LocationValue>;
}
interface LocationSettingValue extends CitySelectValue, LocationInputValue {
    type: 0 | 1 | 2;
}

可以看到后端定义的数据结构和表单的value值有很大的差异,所以在设计组件的这里就需要抽象一层value值的适配。

  • 当通过api接口获取数据的时候需要将接口数据转换为组件数据,当表单组件接收到value 的时候调用改方法进行转换
  • 当通过api接口保存数据的时候需要将组件数据转换为接口数据,当组件执行onChange事件的时候到调用该方法进行转换

表单组件封装完整代码

注:该表单组件为封装的伪代码,部分功能未完全实现

import React from "react";
import { Radio, Form, RadioChangeEvent } from "antd";

// 组件value
export type LocationSettingValue = any

// 后端数据结构
export type ServerDataProps = any

export interface LocationSettingProps {
    value?: any;
    onChange?(value: LocationSettingValue): void;
    serverDataParse?(value: any): any
    componentDataParse?(value: any): any
}

export const LocationSetting = (props: LocationSettingProps) => {
    const { value, onChange, serverDataParse, componentDataParse } = props

    // 组件value进行数据初始化绑定
    const componentValue = typeof serverDataParse === "function" ? serverDataParse(value)?.componentData : value

    const handleSettingTypeChange = (e: RadioChangeEvent) => {
        const radioValue = e.target.value;

        // todo 这里回调的组件value还需要包含 CitySelectValue 和 LocationInputValue,
        const changeValue = typeof componentDataParse === "function" ? componentDataParse(radioValue) : radioValue
        typeof onChange === "function" && onChange(changeValue)
    };

    return <div>
        <Radio.Group onChange={handleSettingTypeChange} value={componentValue}>
            <Radio value={0}>不限</Radio>
            <Radio value={1}>城市限制</Radio>
            <Radio value={2}>距离限制</Radio>
        </Radio.Group>
        {componentValue === 1 < CitySelect /> }
        {componentValue === 2 < LocationInput /> }
    </div>
};

/**
 * @description 将服务端数据结构转化为表单组件数据结构
 * @param serverData 
 * @returns 
 */
export const serverDataParse = (serverData: ServerDataProps): { componentData: LocationSettingValue } => {
    // todo 
    return {
        componentData: {  // 组件数据

        }
    }
}

/**
 * @description 将组件数据结构转化为表单组件数据结构
 * @param componentData 
 * @returns 
 */
export const componentDataParse = (componentData: LocationSettingValue): { serverData: ServerDataProps } => {
    // todo 
    return {
        serverData: {  // 后端接口数据结构

        }
    }
}

/**
 * @description 表单验证
 * @param rule 
 * @param value 
 * @param callback 
 * @returns 
 */
const locationSettingValidator = (rule: any, value: LocationSettingValue, callback: (msg?: string) => void) => {
    if (!value) {
        return callback('请配置地理位置限制');
    } else {
        // todo
    }
    return callback();
};

// 测试组件
const TestDemo = (props: any) => {
    return <Form.Item
        name="event_location"
        label="地理位置限制"
        rules={[
            { required: true, message: '' },
            { validator: locationSettingValidator, validateTrigger: 'onBlur' }
        ]}
    >
        <LocationSetting serverDataParse={serverDataParse} componentDataParse={componentDataParse} />
    </Form.Item>
}

业务迭代

表单业务组件设计

思考:基于上面6的个组件,我们最终只组合出了一个表单组件<LocationSetting />,但如果产品业务扩展,支持比如要支持单独的<LocationSearch /><CitySelect /> 表单组件复用到其他业务场景应该怎么扩展呢? 参考antdesign设计的表单,不难发现,一个基本的表单组件应该具备以下属性

type FormValue = any
interface FormItemProps {
  style?: React.CSSProperties
  className?: string
  disabled?: boolean;
  value?: FormValue;
  onChange?(value: FormValue): void;
}

所以在设计表单组件的时候:

  • 组件属性命名和回调方法应该尽可能和Antdesign表单写法保持统一
  • 表单型的组件都可以预留 FormItemProps 接口属性
  • 每个表单组件都可以封装一个validator表单校验函数
  • 表单组件通过onChange函数监听值的更改
  • 当组件value与后端api数据结构不一致的时候,可以新增serverDataParse,componentDataParse属性进行数据格式的转换

总结归纳

最后,我们总结一下要想设计出复用性较强的业务组件,可以按照以下方式进行设计:

  • 梳理业务需求,将功能按照最细的颗粒度划分
  • 梳理业务边界,哪些是差异性的业务,哪些是通用型的业务
  • 参考组件设计原则
  • 提前考虑扩展性,稳定性,用户体验等方面