中後臺項目 - 查詢表格業務最佳實踐

前言

查詢表格業務是中後臺系統最經常使用的業務系統之一,我相信該業務場景會在你的項目中會大量出現。既然該此場景在項目中大量的出現,因此對其進行必要的封裝會極大的提高業務的複用性以及項目的可維護性。如下是不採起封裝可能會帶來的問題。git

  • 會出現大量重複的業務代碼
  • 不一樣開發人員對公共業務實現的 方式/命名 不一樣
  • 在 store 層,不一樣的開發人員可能會定義不一樣的數據模型
  • 相同業務的代碼分散,不利於造成良好的開發規範

以上的幾點總結起來就是不利於項目的維護和造成規範。github

查詢表格業務 - 設計思路

該業務場景如此常見,全部相信你們都有本身的實現。因此這裏僅僅是提出一個設計思路,你能夠用來參考案而後考慮是否對你的項目有幫助。設計圖以下;redux

HOC 定義公共業務接口,預留插槽

這裏會在 HOC 中綁定到 Store小程序

const TableHoc = config => (WrappedComponent) => {
  const {
    store, // 綁定 store
    className,
    NoPager, // 是否須要外置翻頁器
    noNeedReloadPathname = [], // 不須要從新加載數據的返回頁面
    dealFormatData = data => data, // 清理列表數據方法
  } = config || {};

  @inject(store)
  @observer
  class BaseTable extends Component {
    static defaultProps = {
      fixClass: 'baseTable-wrapper',
    };

    static propTypes = {
      fixClass: PropTypes.string,
      className: PropTypes.string,
      location: PropTypes.object.isRequired,
      match: PropTypes.object.isRequired,
    };

    componentDidMount() {
      const {
        match: { params: { id } = {} },
        location: { pathname },
      } = this.props;
      /* eslint-disable */
      const {
        tableData: { count, needReload },
      } = this.props[store];

      const preLocation = window.RouterPathname.find((item) => item !== pathname); // [preLocation, curLocation]

      const noNeedReloadTag = !preLocation
        ? false
        : noNeedReloadPathname.some((item) => {
            return preLocation.startsWith(item);
          });

      // 數據沒有更新使用緩存數據
      if (count !== 0 && !needReload && noNeedReloadTag) {
        return null;
      }

      if (id) {
        // 若是根據路由獲取 id 則拿 id 進行調用
        this.props[store].getData({ id });
      } else {
        this.props[store].getData();
      }
      return null;
    }

    /**
     * 頂部搜索 接口
     * 具體實如今 store 中
     */
    handleSearch = (values) => {
      this.props[store].handleSearch(values); // eslint-disable-line
    };

    /**
     * 重置搜索 接口
     * 具體實如今 store 中
     */
    handleResetSearch = () => {
      this.props[store].handleResetSearch(); // eslint-disable-line
    };

    /**
     * 翻頁 接口
     * 具體實如今 store 中
     */
    handlePageChange = (page) => {
      this.props[store].handlePageChange(page); // eslint-disable-line
    };

    /**
     * 改變pageSize 接口
     * 具體實如今 store 中
     */
    handlePageSizeChange = (page, pageSize) => {
      this.props[store].handlePageSizeChange(page, pageSize); // eslint-disable-line
    };

    /**
     * 排序 接口
     * 具體實如今 store 中
     */
    handleSort = (data) => {
      this.props[store].handleSort(data); // eslint-disable-line
    };

    render() {
      const { fixClass } = this.props;
      // 傳遞 Store, 讓頁面可以調用 Store 中的自定義方法
      const Store = this.props[store]; // eslint-disable-line
      const { tableData: data } = Store;
      const tableData = toJS(data);
      const classes = classnames(fixClass, { [className]: className });

      const { loading, count, listItems, pageNo, pageSize, query } = tableData;

      const formatData = dealFormatData(listItems);

      return (
        <div className={classes}>
          <WrappedComponent
            loading={loading}
            query={query}
            tableData={formatData}
            handleSort={this.handleSort}
            handleSearch={this.handleSearch}
            handleResetSearch={this.handleResetSearch}
            store={Store}
            {...this.props}
          />

          {NoPager ? null : (
            <div className="pagWrapper">
              <Pagination
                showQuickJumper
                showSizeChanger
                showTotal={() => `共 ${count} 條`}
                onChange={this.handlePageChange}
                onShowSizeChange={this.handlePageSizeChange}
                current={pageNo}
                total={count}
                pageSize={pageSize}
              />
            </div>
          )}
        </div>
      );
    }
  }

  return BaseTable;
};
複製代碼

定義查詢表格通用數據接口

經過高階組件屬性代理:統一項目對於此類場景的具體調用方法。後端

  • 搜索
  • 篩選
  • 翻頁
  • 改變每頁條目
  • 排序
  • 重置
  • 配置可顯示列

預留插槽

經過傳入 hoc 一些用戶自定義處理方法api

例如:緩存

  • 傳入映射函數,兼容表格 column 的展現
  • 傳入數據清理函數,清洗後端返回的數據,經過屬性代理傳播

定義公共業務模型

⚠️ 本文是基於mobx進行數據流管理。redux管理的是純JavaScript對象,應該更容易實現公共模型的抽離。bash

class TableModel {
  constructor({ pageSize = 10 } = {}) {
    this.tableData = {
      loading: false, // 加載數據狀態
      count: 0, // 數據條目
      pageNo: 1, // 當前頁碼
      pageSize, // 單頁數據條目
      listItems: [], // 數據條目 id 集合
      byId: {}, // 數據條目的映射
      query: {}, // 其餘請求參數對象
      errorMessage: undefined, // 錯誤信息
      needReload: false, 數據是否須要從新加載,用於數據緩存優化
    };
  }
  
  // 獲取請求參數
  getParams(data) {
    return {
      pageNo: this.pageNo,
      pageSize: this.pageSize,
      ...this.query,
      ...data,
    };
  }
}
複製代碼

該模型是比較好的實踐,具備廣泛通用性;app

  • 經過 listItems 和 byId。扁平化數據集合。
  • query 拓展可選的請求參數。
  • needReload,能夠控制是否須要從新拉去數據

定義項目Store

class Table {
  @observable
  tableData;

  /**
   * more observable to add
   */

  constructor(Model) {
    this.tableModel = new Model(); // 以前定義的模型
    this.tableData = this.tableModel.tableData;
  }

  @action
  handleSearch(values) {
    const params = Object.assign(values, { pageNo: 1 });
    this.getData(this.tableModel.getParams(params));
  }

  @action
  handleResetSearch() {
    this.getData({
      pageNo: 1,
      grade: undefined,
      name: undefined,
      startTime: undefined,
      endTime: undefined,
    });
  }

  @action
  handlePageChange(pageNo) {
    this.getData(this.tableModel.getParams({ pageNo }));
  }

  @action
  handlePageSizeChange(pageNo, pageSize) {
    this.getData(this.tableModel.getParams({ pageNo, pageSize }));
  }

  @action
  getData({
    name = undefined,
    grade = undefined,
    pageNo = 1,
    pageSize = 10,
    startTime = undefined,
    endTime = undefined,
  } = {}) {
    this.tableData.loading = true;
    api
      .initTableData({
        params: {
          name,
          grade,
          pageNo,
          itemsPerPage: pageSize,
          startTime,
          endTime,
        },
      })
      .then((resp) => {
        const { count, items: listItems } = resp;
        const byId = listItems.map(item => item.id);

        this.tableData = {
          loading: false,
          pageNo: pageNo || this.tableData.pageNo,
          pageSize: pageSize || this.tableData.pageSize,
          count,
          listItems,
          byId,
          errorMessage: undefined,
          needReload: false,
          query: {
            grade,
            name,
            startTime,
            endTime,
          },
        };
      });
  }

  /**
   * more action to add
   */

}
複製代碼

頁面組件

這裏的頁面組件固然是做爲一個容器組件,內部一般包含;函數

  • 搜索表單
  • 列表
  • 外部翻頁器
  • other

組件開發的一種思想,展現性組件對於同一調用一般會有不一樣實現。基於下降組件的耦合度,一般只會定義調用接口具體實現由外部實現。

這裏的頁面組件會實現除公共業務之外的全部實現,同時也能夠拓展其餘store不調用定義好的業務。

搜索表單

  • 表單接受 query, query 會填充到表單
  • 搜索回調 返回搜索參數
  • 重置回調

列表

  • 接受listItems 數據集合
  • 跳轉回調
  • 打開 modal 回調
  • other 自定義回調

外部翻頁器

若是你自定義了列表,而且內部沒有封裝翻頁器,就是用外部翻頁器。

// 可使用緩存數據的返回頁面
const noNeedReloadPathname = ['/form/baseForm', '/detail/baseDetail/'];

// dealFormatData -> 清理列表數據方法
@TableHoc({ store: 'TableStore', dealFormatData, noNeedReloadPathname })
class SearchTable extends Component {
  static defaultProps = {
    titleValue: ['本次推廣專屬小程序二維碼', '本次推廣專屬小程序連接'],
  };

  static propTypes = {
    loading: PropTypes.bool,
    tableData: PropTypes.array, // 表格數據
    query: PropTypes.object, // 表單查詢信息
    titleValue: PropTypes.array, // 彈窗提示
    store: PropTypes.object, // @TableHoc 高階組件中綁定的 mobx store 對象
    routerData: PropTypes.object.isRequired, // 路由數據
    history: PropTypes.object.isRequired, // router history
    handleSearch: PropTypes.func.isRequired, // @TableHoc 表單搜索接口
    handleResetSearch: PropTypes.func.isRequired, // @TableHoc 表單重置接口
  };

  constructor(props) {
    super(props);
    this.state = {
      visibleModal: false,
      record: {},
    };
  }

  get columns() {
    return [
      {
        title: '建立時間',
        dataIndex: 'createdAt',
        key: 'createdAt',
      },
      {
        title: '地區',
        dataIndex: 'address',
        key: 'address',
      },
      {
        title: '學校',
        dataIndex: 'school',
        key: 'school',
      },
      {
        title: '年級',
        dataIndex: 'grade',
        key: 'grade',
      },
      {
        title: '班級',
        dataIndex: 'className',
        key: 'className',
      },
      {
        title: '用戶數',
        dataIndex: 'registerNumber',
        key: 'registerNumber',
      },
      {
        title: '訂單金額',
        dataIndex: 'totalPayMoney',
        key: 'totalPayMoney',
      },
      {
        title: '個人收益',
        dataIndex: 'totalShare',
        key: 'totalShare',
      },
      {
        title: '操做',
        dataIndex: 'action',
        key: 'action',
        width: 155,
        render: (text, record) => {
          const shareStyle = {
            width: 70,
            color: '#1574D4',
            marginRight: 5,
            cursor: 'pointer',
          };
          const detailStyle = {
            width: 70,
            color: '#1574D4',
            marginLeft: 5,
            cursor: 'pointer',
          };
          return (
            <div className="operations-orgGo">
              <span style={shareStyle} onClick={() => this.handleOpenShareModal(record)}>
                當即分享
              </span>
              <span style={detailStyle} onClick={() => this.redirectToDetail(record)}>
                查看詳情
              </span>
            </div>
          );
        },
      },
    ];
  }

  redirectToCreatePromotion = () => {
    const {
      history: { push },
    } = this.props;
    push({ pathname: '/form/baseForm' });
  };

  redirectToDetail = (record) => {
    const {
      history: { push },
    } = this.props;
    push({ pathname: `/detail/baseDetail/${record.id}` });
  };

  handleOpenShareModal = (record) => {
    this.setState({
      visibleModal: true,
      record,
    });
    const { store } = this.props;
    store.getWeiCode({ promotionId: record.id, record });
  };

  handleCloseShareModal = () => {
    const { store } = this.props;
    this.setState(
      {
        visibleModal: false,
        record: {},
      },
      () => store.delWeiCode(),
    );
  };

  handleReset = () => {
    const { handleResetSearch } = this.props;
    handleResetSearch();
  };

  handleSearch = (value) => {
    const { timeLimit = [undefined, undefined], grade } = value;
    let { queryCond: name } = value;
    const startTime = timeLimit[0] && timeLimit[0].format('YYYY-MM-DD HH:mm:ss');
    const endTime = timeLimit[1] && timeLimit[1].format('YYYY-MM-DD HH:mm:ss');
    name = name ? name.replace(/^(\s|\u00A0)+/, '').replace(/(\s|\u00A0)+$/, '') : undefined;

    const { handleSearch } = this.props;
    handleSearch({
      startTime,
      endTime,
      name,
      grade: grade || undefined,
    });
  };

  render() {
    const { visibleModal, record } = this.state;

    const {
      routerData: { config },
      titleValue,
      loading,
      tableData,
      query,
    } = this.props;

    return (
      <WithBreadcrumb config={config}>
        <Helmet>
          <title>查詢表格 - SPA</title>
          <meta name="description" content="SPA" />
        </Helmet>
        <div className="table-search-wrapper">
          <ModuleLine title="查詢表格">
            <Button
              size="middle"
              type="primary"
              className="promotionBtn"
              onClick={this.redirectToCreatePromotion}
            >
              新增
            </Button>
          </ModuleLine>

          <SearchForm
            handleReset={this.handleReset}
            onSubmit={this.handleSearch}
            initialValue={query}
          />
        </div>

        <Table
          bordered
          className="self-table-wrapper"
          loading={loading}
          dataSource={tableData}
          pagination={false}
          columns={this.columns}
        />
        <ShareModal
          key="base-table-modal"
          width={600}
          record={record}
          showTitle={false}
          titleDownImg="保存"
          recordType="string"
          visible={visibleModal}
          titleValue={titleValue}
          handleClose={this.handleCloseShareModal}
        />
      </WithBreadcrumb>
    );
  }
}
複製代碼

總結

總結一下,這裏管理查詢列表的全部抽象和模塊功能:

  • hoc 中定義公共業務接口,而且做爲中間層實現一些業務插槽,好比進行數據清理。
  • 定義公共模型。抽離該模塊的公共狀態,使用扁平化數據利於數據緩存。
  • 基於公共模型拓展該模塊的多有業務和狀態。
  • 展現型組件,只是更具數據進行展現。業務處理基於回調傳遞給容器組件,容器組件決定是容器內部實現仍是公共業務實現。
  • 容器組件,組合公共業務組件和自定義組件。而且能夠拓展其餘Store。

我在項目中的具體實踐

clone項目,查看項目的 表格頁 -> 查詢表格

相關文章
相關標籤/搜索