中後臺專案 - 查詢表格業務最佳實踐

小崽兒發表於2019-04-02

前言

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

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

以上的幾點總結起來就是不利於專案的維護和形成規範。

查詢表格業務 - 設計思路

該業務場景如此常見,所有相信大家都有自己的實現。所以這裡僅僅是提出一個設計思路,你可以用來參考案然後考慮是否對你的專案有幫助。設計圖如下;

中後臺專案 - 查詢表格業務最佳實踐

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 一些使用者自定義處理方法

例如:

  • 傳入對映函式,相容表格 column 的展示
  • 傳入資料清理函式,清洗後端返回的資料,通過屬性代理傳播

定義公共業務模型

⚠️ 本文是基於mobx進行資料流管理。redux管理的是純JavaScript物件,應該更容易實現公共模型的抽離。

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,
    };
  }
}
複製程式碼

該模型是比較好的實踐,具有普遍通用性;

  • 通過 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專案,檢視專案的 表格頁 -> 查詢表格

相關文章