Ant Design Pro 使用之 表单组件再封装

背景


使用 Ant Design Pro 开发有一段时间了,表单作为后台系统常见的功能当然很有必要封装一下,减少代码重复量。虽说 antd 的表单组件已经很不错了,但是使用上还是太麻烦了 ( 我就是懒 ) ,所以我就基于一些小小的约定封装了它的上层业务组件,更方便调用:

  • 常用表单场景主要分四类:搜索条件、详情页、弹出式窗口、其他混合型

  • 表单布局主要分三类:水平排列、垂直排列、复杂混合型

  • 弹窗类型分两类:模态对话框、屏幕边缘滑出的浮层面板 ( 抽屉 )

  • 封装尽可能不引入新的语法,兼容 antd 原有配置方式

  • 调用尽可能简单,减少重复关键字的使用。( 比如:getFieldDecorator )

基础表单组件


  • 组件定义

    import React, { Component } from 'react';
    import { Form } from 'antd';
    import PropTypes from 'prop-types';
    import { renderFormItem, fillFormItems, submitForm } from './extra';
    
    const defaultFormLayout = { labelCol: { span: 5 }, wrapperCol: { span: 15 } };
    
    /**
     * 基础表单
     */
    @Form.create({
      // 表单项变化时调用
      onValuesChange({ onValuesChange, ...restProps }, changedValues, allValues) {
        if (onValuesChange) onValuesChange(restProps, changedValues, allValues);
      },
    })
    class BaseForm extends Component {
      static propTypes = {
        layout: PropTypes.string,
        formLayout: PropTypes.object,
        hideRequiredMark: PropTypes.bool,
        dataSource: PropTypes.array,
        formValues: PropTypes.object,
        renderItem: PropTypes.func,
        onSubmit: PropTypes.func,
        // eslint-disable-next-line react/no-unused-prop-types
        onValuesChange: PropTypes.func,
      };
    
      static defaultProps = {
        layout: 'horizontal',
        formLayout: undefined,
        hideRequiredMark: false,
        dataSource: [],
        formValues: {},
        renderItem: renderFormItem,
        onSubmit: () => {},
        onValuesChange: undefined,
      };
    
      /**
       * 表单提交时触发
       *
       * @param e
       */
      onSubmit = e => {
        if (e) e.preventDefault(); // 阻止默认行为
        this.submit();
      };
    
      /**
       * 调用表单提交
       */
      submit = () => {
        const { form, formValues, onSubmit } = this.props;
        submitForm(form, formValues, onSubmit);
      };
    
      render() {
        const {
          children,
          layout,
          formLayout = layout === 'vertical' ? null : defaultFormLayout,
          hideRequiredMark,
          renderItem,
          form: { getFieldDecorator },
          formValues,
          dataSource,
        } = this.props;
        return (
          <Form layout={layout} onSubmit={this.onSubmit} hideRequiredMark={hideRequiredMark}>
            {children ||
              fillFormItems(dataSource, formValues).map(item =>
                renderItem(item, getFieldDecorator, formLayout)
              )}
          </Form>
        );
      }
    }
    
    export * from './extra';
    export default BaseForm;
    
  • 调用示例

    <BaseForm
      hideRequiredMark={false}
      layout="vertical"
      formLayout={null}
      dataSource={[
        { label: 'key1', name: 'name1', required: true },
        { label: 'key2', name: 'name2', required: true },
        { label: 'key3', name: 'name3' },
      ]}
      formValues={{ name2: 'default' }}
      onSubmit={() => {}}
      onValuesChange={() => {}}
      wrappedComponentRef={form => {
        this.form = form;
      }}
    />
    

比起 antd 表单组件的调用应该简洁不少吧

弹出式表单组件


  • 组件定义

    import React, { PureComponent } from 'react';
    import ReactDOM from 'react-dom';
    import PropTypes from 'prop-types';
    import BaseComponent from '../BaseComponent';
    import BaseForm from '../BaseForm';
    
    const destroyFns = []; // 保存所有弹框的引用
    
    /**
     * 弹出式表单
     */
    class PopupForm extends PureComponent {
      static propTypes = {
        layout: PropTypes.string,
        formLayout: PropTypes.object,
        hideRequiredMark: PropTypes.bool,
        width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        title: PropTypes.string,
        root: PropTypes.object,
        okText: PropTypes.string,
        cancelText: PropTypes.string,
        onValuesChange: PropTypes.func,
        closeOnSubmit: PropTypes.bool,
        onClose: PropTypes.func,
      };
    
      static defaultProps = {
        layout: 'vertical',
        formLayout: null,
        hideRequiredMark: false,
        width: 720,
        title: undefined,
        root: undefined,
        okText: '确定',
        cancelText: '取消',
        onValuesChange: undefined,
        closeOnSubmit: true,
        onClose: undefined,
      };
    
      /**
       * 显示通过getInstance创建的组件
       *
       * @param formValues 表单初始值
       */
      static show(formValues) {
        const { instance } = this;
        if (instance) {
          const { root } = instance.props;
          if (root instanceof BaseComponent) {
            root.showPopup(this.name, true, formValues);
          }
        }
      }
    
      /**
       * 创建一个该类型表单组件的实例,配合show显示/关闭
       *
       * @param root  表单组件引用的父组件,用于统一管理表单组件的状态
       * @param props 组件属性
       * @returns {*}
       */
      static getInstance(root, props) {
        if (root instanceof BaseComponent) {
          const { forms = {} } = root.state || {};
          const form = forms[this.getFormName()] || {};
          this.instance = <this root={root} {...form} {...props} />;
          return this.instance;
        }
        return null;
      }
    
      /**
       * 接口方式创建并显示一个表单组件,独立于App容器之外
       *
       * @param props      组件属性
       * @param decorators 要给组件附加的高阶组件
       * @returns {*}
       */
      static open(props, decorators) {
        const Com = decorators ? [].concat(decorators).reduce((pre, item) => item(pre), this) : this;
        const div = document.createElement('div');
        const close = () => {
          const unmountResult = ReactDOM.unmountComponentAtNode(div);
          if (unmountResult && div.parentNode) {
            div.parentNode.removeChild(div);
          }
          const pos = destroyFns.findIndex(item => item === close);
          if (pos >= 0) destroyFns.splice(pos, 1);
        };
        // 使用DvaContainer作为新的根组件,保证子组件正常使用redux
        const rootContainer = window.g_plugins.apply('rootContainer', {
          initialValue: <Com {...props} visible onClose={close} />,
        });
        ReactDOM.render(rootContainer, div);
    
        destroyFns.push(close);
    
        // 返回一个对象,通过这个对象来显式关闭组件
        return { close };
      }
    
      /**
       * 销毁全部弹框
       */
      static destroyAll() {
        while (destroyFns.length) {
          const close = destroyFns.pop();
          if (close) close();
        }
      }
    
      /**
       * 获取表单名称,用于父组件对表单组件的控制,默认取组件类名
       *
       * @returns {string}
       */
      static getFormName() {
        return this.name;
      }
    
      /**
       * 表单提交时触发
       *
       * @param fieldsValue
       * @param form
       */
      onSubmit = (fieldsValue, form) => {
        const { onSubmit, closeOnSubmit = false } = this.props;
        if (closeOnSubmit === true) {
          // 表单提交时关闭当前组件
          this.close();
        }
        onSubmit(fieldsValue, form);
      };
    
      /**
       * 点击Ok按钮时触发
       *
       * @param e
       */
      onOk = e => {
        if (e) e.preventDefault(); // 阻止默认行为
        const { form: { submit } = {} } = this;
        if (submit) {
          // 通过子组件暴露的方法,显式提交表单
          submit();
        }
      };
    
      /**
       * 点击Cancel按钮时触发
       *
       * @param e
       */
      onCancel = e => {
        if (e) e.preventDefault(); // 阻止默认行为
        this.close();
      };
    
      /**
       * 关闭当前组件
       */
      close = () => {
        const { onClose, root } = this.props;
        const formName = this.constructor.getFormName();
        if (onClose) {
          onClose(formName);
        } else if (root instanceof BaseComponent) {
          // 对应getInstance创建的组件,由父组件控制
          root.showPopup(formName, false);
        }
      };
    
      /**
       * 绘制表单,可覆盖
       *
       * @returns {*}
       */
      renderForm = () => {
        const {
          children,
          layout,
          formLayout,
          hideRequiredMark,
          onValuesChange,
          formValues,
          ...restProps
        } = this.props;
    
        return (
          <BaseForm
            {...restProps}
            hideRequiredMark={hideRequiredMark}
            layout={layout}
            formLayout={formLayout}
            dataSource={this.getDataSource()}
            formValues={formValues}
            onSubmit={this.onSubmit}
            onValuesChange={onValuesChange}
            wrappedComponentRef={form => {
              this.form = form;
            }}
          />
        );
      };
    
      /**
       * 绘制组件主体内容,可覆盖
       *
       * @returns {PopupForm.props.children | *}
       */
      renderBody = () => {
        const { children } = this.props;
        return children || this.renderForm();
      };
    
      /**
       * 表单字段数据源,可覆盖
       *
       * @returns {undefined}
       */
      getDataSource = () => undefined;
    
      /**
       * 组件显示标题,可覆盖
       *
       * @returns {string}
       */
      getTitle = () => '';
    }
    
    export default PopupForm;
    
  • 这个是 基础组件 ,不能直接使用,具体的弹框 表现形式子类 实现,主要为 模态框抽屉

  • 调用方式和常规组件不一样,采用继承的方式实现具体的业务组件,通过组件的静态方法实现渲染和行为控制 ( 当然,要使用 JSX 也是可以的 )

  • API

    方法 说明
    getInstance 创建一个该类型表单组件的实例,配合 show 方法控制 显示 / 关闭
    show 显示通过 getInstance 创建的组件弹框
    open 接口方式创建并显示一个表单组件,独立于 App 容器之外。<br />返回一个对象,通过这个对象引用来显式关闭组件
    destroyAll 销毁所有通过 open 方法创建的组件弹框

模态框式表单组件


  • 组件定义

    import React from 'react';
    import { Modal } from 'antd';
    import PropTypes from 'prop-types';
    import PopupForm from '../PopupForm';
    
    /**
     * 模态框式表单
     */
    class ModalForm extends PopupForm {
      static propTypes = {
        layout: PropTypes.string,
        formLayout: PropTypes.object,
        hideRequiredMark: PropTypes.bool,
        width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        title: PropTypes.string,
        root: PropTypes.object,
        okText: PropTypes.string,
        cancelText: PropTypes.string,
        onValuesChange: PropTypes.func,
        closeOnSubmit: PropTypes.bool,
      };
    
      static defaultProps = {
        layout: 'horizontal',
        formLayout: undefined,
        hideRequiredMark: false,
        width: 640,
        title: undefined,
        root: undefined,
        okText: '确定',
        cancelText: '取消',
        onValuesChange: undefined,
        closeOnSubmit: true,
      };
    
      render() {
        const { children, title, width, visible, okText, cancelText, ...restProps } = this.props;
    
        return visible ? (
          <Modal
            title={title || this.getTitle()}
            width={width}
            visible
            okText={okText}
            onOk={this.onOk}
            cancelText={cancelText}
            onCancel={this.onCancel}
            {...restProps}
            destroyOnClose
          >
            {this.renderBody()}
          </Modal>
        ) : null;
      }
    }
    
    export default ModalForm;
    
  • 调用示例

    class Demo1 extends ModalForm {
      getTitle = () => '模态框式表单';
    
      getDataSource = () => [
        { label: 'key1', name: 'name1', required: true },
        { label: 'key2', name: 'name2', required: true },
        { label: 'key3', name: 'name3' },
      ];
    }
    
    <Button type="primary" onClick={() => Demo1.open({ title: '覆盖表单标题' })}>
      新增
    </Button>
    

抽屉式表单组件


  • 组件定义

    import React from 'react';
    import { Drawer, Button } from 'antd';
    import PropTypes from 'prop-types';
    import PopupForm from '../PopupForm';
    
    /**
     * 抽屉式表单
     */
    class DrawerForm extends PopupForm {
      static propTypes = {
        layout: PropTypes.string,
        formLayout: PropTypes.object,
        hideRequiredMark: PropTypes.bool,
        width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        title: PropTypes.string,
        root: PropTypes.object,
        okText: PropTypes.string,
        cancelText: PropTypes.string,
        onValuesChange: PropTypes.func,
        closeOnSubmit: PropTypes.bool,
        closable: PropTypes.bool,
      };
    
      static defaultProps = {
        layout: 'vertical',
        formLayout: null,
        hideRequiredMark: false,
        width: 720,
        title: undefined,
        root: undefined,
        okText: '确定',
        cancelText: '取消',
        onValuesChange: undefined,
        closeOnSubmit: false,
        closable: false,
      };
    
      /**
       * 绘制组件按钮
       *
       * @returns {*}
       */
      renderFooter = () => {
        const { okText, cancelText } = this.props;
        return (
          <div
            style={{
              position: 'absolute',
              left: 0,
              bottom: 0,
              width: '100%',
              borderTop: '1px solid #e9e9e9',
              padding: '10px 16px',
              background: '#fff',
              textAlign: 'right',
            }}
          >
            {cancelText ? (
              <Button onClick={this.onCancel} style={{ marginRight: 8 }}>
                {cancelText}
              </Button>
            ) : null}
            {okText ? (
              <Button onClick={this.onOk} type="primary">
                {okText}
              </Button>
            ) : null}
          </div>
        );
      };
    
      render() {
        const { children, title, width, visible, closable, formLayout, ...restProps } = this.props;
    
        return visible ? (
          <Drawer
            title={title || this.getTitle()}
            width={width}
            visible
            closable={closable}
            onClose={this.onCancel}
            {...restProps}
            destroyOnClose
          >
            <div style={{ paddingBottom: 75 }}>{this.renderBody()}</div>
            {this.renderFooter()}
          </Drawer>
        ) : null;
      }
    }
    
    export default DrawerForm;
    
  • 调用示例

    class Demo1 extends DrawerForm {
      getTitle = () => '模态框式表单';
    
      getDataSource = () => [
        { label: 'key1', name: 'name1', required: true },
        { label: 'key2', name: 'name2', required: true },
        { label: 'key3', name: 'name3' },
      ];
    }
    
    <Button type="primary" onClick={() => Demo1.open({ title: '覆盖表单标题' })}>
      新增
    </Button>
    

搜索表单组件


  • 组件定义

    import React, { Component } from 'react';
    import { Form } from 'antd';
    import PropTypes from 'prop-types';
    import { submitForm } from '../BaseForm';
    
    /**
     * 搜索表单
     */
    @Form.create({
      // 表单项变化时调用
      onValuesChange({ onValuesChange, ...restProps }, changedValues, allValues) {
        if (onValuesChange) onValuesChange(restProps, changedValues, allValues);
      },
    })
    class SearchForm extends Component {
      static propTypes = {
        root: PropTypes.object,
        onSearch: PropTypes.func,
        layout: PropTypes.string,
        render: PropTypes.func,
      };
    
      static defaultProps = {
        root: undefined,
        onSearch: undefined,
        layout: 'inline',
        render: undefined,
      };
    
      constructor(props) {
        super(props);
        const { root } = this.props;
        if (root) root.searchForm = this;
      }
    
      /**
       * 调用搜索
       *
       * @param formValues
       */
      search = formValues => {
        const { onSearch } = this.props;
        if (onSearch) onSearch(formValues);
      };
    
      /**
       * 重置表单并搜索
       */
      reset = (searchOnReset = true) => {
        const { form, formValues } = this.props;
        form.resetFields();
        if (searchOnReset === true) this.search(formValues);
      };
    
      /**
       * 表单提交时触发
       *
       * @param e
       */
      onSubmit = e => {
        if (e) e.preventDefault();
        const { form, formValues } = this.props;
        submitForm(form, formValues, this.search);
      };
    
      render() {
        const { render, hideRequiredMark, layout } = this.props;
        return (
          <Form hideRequiredMark={hideRequiredMark} layout={layout} onSubmit={this.onSubmit}>
            {render ? render(this.props) : null}
          </Form>
        );
      }
    }
    
    export default SearchForm;
    
  • 调用示例

    import React, { Component, Fragment } from 'react';
    import { Form, Button, Col, Input, Row, message } from 'antd';
    import SearchForm from '@/components/SearchForm';
    import { renderFormItem } from '@/components/BaseForm';
    
    export default class Demo extends Component {
      search = data => message.success(`搜索提交:${JSON.stringify(data)}`);
    
      renderSearchForm = ({ form: { getFieldDecorator } }) => (
        <Fragment>
          <Row>
            <Button icon="plus" type="primary">
              新增
            </Button>
          </Row>
          <Row style={{ marginTop: 16 }}>
            <Col span={18}>
              <Form.Item label="条件1">
                {getFieldDecorator('param1')(<Input placeholder="请输入" />)}
              </Form.Item>
              {renderFormItem({ label: '条件2', name: 'param2' }, getFieldDecorator)}
              {renderFormItem({ label: '条件3', name: 'param3' }, getFieldDecorator)}
            </Col>
            <Col span={6} style={{ textAlign: 'right' }}>
              <span>
                <Button type="primary" htmlType="submit">
                  查询
                </Button>
                <Button style={{ marginLeft: 8 }} onClick={() => this.searchForm.reset()}>
                  重置并提交
                </Button>
                <Button style={{ marginLeft: 8 }} onClick={() => this.searchForm.reset(false)}>
                  只重置
                </Button>
              </span>
            </Col>
          </Row>
        </Fragment>
      );
    
      render() {
        return (
          <SearchForm
            root={this}
            onSearch={this.search}
            render={this.renderSearchForm}
            searchOnReset={false}
          />
        );
      }
    }
    

遇到的问题


在实际使用的过程中,弹框表单的子组件中可能会包含 被 connect 的组件,光使用 antd 的弹框组件包裹就会报错:

Uncaught Error: Could not find "store" in either the context or props of "Connect(Demo)". Either wrap the root component in a <Provider>, or explicitly pass "store" as a prop to "Connect(Demo)".

解决办法就是使用 reduxProvider 组件包裹一下

ReactDOM.render(
  // 使用 Provider 使子组件能从上下文中访问 store
  // 注意 react-redux 版本要和 dva 中引用的版本一致,否则子组件使用 @connect 会出错
  // eslint-disable-next-line no-underscore-dangle
  <Provider store={window.g_app._store}>
    <Com {...props} visible onClose={close} />
  </Provider>,
  div
);

具体调用位置在上面 PopupForm.open 中,该代码已经按 dva 提供的方式进行解决了。

最后


完整代码已经传到 CodeSandbox ,点击查看 antd 版 或者 antd pro 版


转载请注明出处:https://www.jianshu.com/p/c7120bf2e4f8

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,670评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,928评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,926评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,238评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,112评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,138评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,545评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,232评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,496评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,596评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,369评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,226评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,600评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,906评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,185评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,516评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,721评论 2 335