有一种页面在后台系统中比较常见:页面分上下两部分,上部分是 input、select、时间等查询项,下部分是查询项对应的表格数据。包含 增删改查 ,例如点击 新建 进行新增操作。就像这样:

本篇将对 ant 的表格进行封装。效果如下:

spug 中 Table 封装的分析

我们选择 spug 比较简单的模块( 角色管理 )进行分析。

进入角色管理模块入口,发现表格区封装到模块当前目录的 Table.js 中:

// spug\src\pages\system\role\index.js
import ComTable from './Table';
export default observer(function () {
  return (
    <AuthDiv auth="system.role.view">
      <Breadcrumb>
        <Breadcrumb.Item>首页</Breadcrumb.Item>
        <Breadcrumb.Item>系统管理</Breadcrumb.Item>
        <Breadcrumb.Item>角色管理</Breadcrumb.Item>
      </Breadcrumb>
      {/* 查询区域 */}
      <SearchForm>
        <SearchForm.Item span={8} title="角色名称">
          <Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder="请输入"/>
        </SearchForm.Item>
      </SearchForm>
      {/* 将表格区域封装到了 Table.js 中 */}
      <ComTable/>
    </AuthDiv>

查阅 Table.js 发现表格使用的是 components 中的 TableCard

// spug\src\pages\system\role\Table.js
import { TableCard, ... } from 'components';
@observer
class ComTable extends React.Component {
  render() {
    return (
      <TableCard
        rowKey="id"
        title="角色列表"
        loading={store.isFetching}
        dataSource={store.dataSource}
        onReload={store.fetchRecords}
        actions={[
          <AuthButton type="primary" icon={<PlusOutlined/>} onClick={() => store.showForm()}>新建</AuthButton>
        pagination={{
          showSizeChanger: true,
          showLessItems: true,
          showTotal: total => `共 ${total} 条`,
          pageSizeOptions: ['10', '20', '50', '100']
        columns={this.columns}/>
export default ComTable

进一步跟进不难发现 TableCard.js 就是 spug 中 封装好的 Table 组件。

Tip: vscode 搜索 TableCard, 发现有 17 处,推测至少有 16 个模块使用的这个封装好的 Table 组件

表格封装的组件

下面我们来分析spug 中表格分装组件:TableCard。

TableCard 从界面上分三部分:头部表格主体(包含分页器)、Footer。请看代码:

// spug\src\components\TableCard.js
  return (
    <div ref={rootRef} className={styles.tableCard} style={{ ...props.customStyles }}>
      {/* 头部。例如表格标题 */}
      <Header
        title={props.title}
        columns={columns}
        actions={props.actions}
        fields={fields}
        rootRef={rootRef}
        defaultFields={defaultFields}
        onFieldsChange={handleFieldsChange}
        onReload={props.onReload} />
      {/* 表格主体,包含分页。如果没数据分页器页不会显示 */}
      <Table
        tableLayout={props.tableLayout}
        scroll={props.scroll}
        rowKey={props.rowKey}
        loading={props.loading}
        columns={columns.filter((_, index) => fields.includes(index))}
        dataSource={props.dataSource}
        rowSelection={props.rowSelection}
        expandable={props.expandable}
        size={props.size}
        onChange={props.onChange}
        // 分页器
        pagination={props.pagination} />
      {/* Footer 根据props.selected 来显示,里面显示`选择了几项...` */}
      {selected.length ? <Footer selected={selected} actions={batchActions} /> : null}

头部分三部分,左侧是表格的标题,中间是是一些操作,例如新增、批量删除等,右侧是表格的操作。如下图所示:

右侧表格操作也有三部分:刷新表格、列展示、表格全屏。

Tip:表格刷新很简单,就是调用父组件的 reload 重新发请求。

表格全屏也很简单,利用的是浏览器原生支持的功能。

  // 全屏操作。使用浏览器自带全屏功能
  function handleFullscreen() {
    // props.rootRef.current 是表格组件的原始 Element
    // fullscreenEnabled 属性提供了启用全屏模式的可能性。当它的值是 false 的时候,表示全屏模式不可用(可能的原因有 "fullscreen" 特性不被允许,或全屏模式不被支持等)。
    if (props.rootRef.current && document.fullscreenEnabled) {
      // 如果处在全屏。
      // fullscreenElement 返回当前文档中正在以全屏模式显示的Element节点,如果没有使用全屏模式,则返回null.
      if (document.fullscreenElement) {
        document.exitFullscreen()
      } else {
        props.rootRef.current.requestFullscreen()

比如取消描述信息,表格中将不会显示该列。效果如下图所示:

这个过程不会发送请求。

整个逻辑如下:

  • 父组件会给 <Header> 组件传入 columns、fields、onFieldsChange、defaultFields等属性方法。
  • <Header
            title={props.title}
            columns={columns}
            actions={props.actions}
            fields={fields}
            rootRef={rootRef}
            defaultFields={defaultFields}
            onFieldsChange={handleFieldsChange}
            onReload={props.onReload} />
    
  • 绿框的 checkbox 由传入的 columns 决定
  • 列展示由传入的 columns 和 fields 决定,当选中的个数(fields)等于 columns 的个数,则全选
  • 重置主要针对 fields,页面一进来就会取到默认选中字段。
  • 表格主体就是调用 antd 中的 Table 组件:

    : antd 中的 Table 有许多属性,这里只对外暴露有限个 antd 表格属性,这种做法不是很好。

    <Table
            // 表格元素的 table-layout 属性,例如可以实现`固定表头/列`
            tableLayout={props.tableLayout}
            // 表格是否可滚动
            scroll={props.scroll}
            // 表格行 key 的取值,可以是字符串或一个函数。spug 中 `rowKey="id"` 重现出现在 29 个文件中。
            rowKey={props.rowKey}
            // 加载中的 loading 效果
            loading={props.loading}
            // 表格的列。用户可以选择哪些列不显示
            columns={columns.filter((_, index) => fields.includes(index))}
            // 数据源
            dataSource={props.dataSource}
            // 表格行是否可选择,配置项(object)。可以不传
            rowSelection={props.rowSelection}
            // 展开功能的配置。可以不传
            expandable={props.expandable}
            // 表格大小 default | middle | small
            size={props.size}
            // 分页、排序、筛选变化时触发
            onChange={props.onChange}
            // 分页器,参考配置项或 pagination 文档,设为 false 时不展示和进行分页
            pagination={props.pagination} />
    

    根据父组件的 selected 决定是否显示 Footer:

    {/* selected 来自 props,在 Footer 组件中显示选中了多少项等信息,spug 中没有使用到 */}
    {selected.length ? <Footer selected={selected} actions={batchActions} /> : null}
    

    Footer 主要显示已选择...,spug 中出现得很少:

    function Footer(props) {
      const actions = props.actions || [];
      const length = props.selected.length;
      return length > 0 ? (
        <div className={styles.tableFooter}>
          <div className={styles.left}>已选择 <span>{length}</span> 项</div>
          <Space size="middle">
            {actions.map((item, index) => (
              <React.Fragment key={index}>{item}</React.Fragment>
          </Space>
      ) : null
    

    TableCard.js

    spug 中表格封装的完整代码如下:

    // spug\src\components\TableCard.js
    import React, { useState, useEffect, useRef } from 'react';
    import { Table, Space, Divider, Popover, Checkbox, Button, Input, Select } from 'antd';
    import { ReloadOutlined, SettingOutlined, FullscreenOutlined, SearchOutlined } from '@ant-design/icons';
    import styles from './index.module.less';
    // 从缓存中取得之前设置的列。记录要隐藏的字段。比如之前将 `状态` 这列隐藏
    let TableFields = localStorage.getItem('TableFields')
    TableFields = TableFields ? JSON.parse(TableFields) : {}
    function Search(props) {
      // ...
    // 已选择多少项。
    function Footer(props) {
      const actions = props.actions || [];
      const length = props.selected.length;
      return length > 0 ? (
        <div className={styles.tableFooter}>
          <div className={styles.left}>已选择 <span>{length}</span> 项</div>
          <Space size="middle">
            {actions.map((item, index) => (
              <React.Fragment key={index}>{item}</React.Fragment>
          </Space>
      ) : null
    function Header(props) {
      // 表格所有的列
      const columns = props.columns || [];
      // 例如创建、批量删除等操作
      const actions = props.actions || [];
      // 选中列,也就是表格要显示的列
      const fields = props.fields || [];
      // 取消或选中某列时触发
      const onFieldsChange = props.onFieldsChange;
      // 列展示组件
      const Fields = () => {
        return (
          // value - 指定选中的选项 string[]
          // onChange- 变化时的回调函数 function(checkedValue)。
          // 例如取消`状态`这列的选中
          <Checkbox.Group value={fields} onChange={onFieldsChange}>
            {/* 展示所有的列 */}
            {columns.map((item, index) => (
              // 注:值的选中是根据索引来的,因为 columns 是数组,是有顺序的。
              <Checkbox value={index} key={index}>{item.title}</Checkbox>
          </Checkbox.Group>
      // 列展示 - 全选或取消全部
      function handleCheckAll(e) {
        if (e.target.checked) {
          // 例如:[0, 1, 2, 3]
          // console.log('columns', columns.map((_, index) => index))
          onFieldsChange(columns.map((_, index) => index))
        } else {
          onFieldsChange([])
      // 全屏操作。使用浏览器自带全屏功能
      function handleFullscreen() {
        // props.rootRef.current 是表格组件的原始 Element
        // fullscreenEnabled 属性提供了启用全屏模式的可能性。当它的值是 false 的时候,表示全屏模式不可用(可能的原因有 "fullscreen" 特性不被允许,或全屏模式不被支持等)。
        if (props.rootRef.current && document.fullscreenEnabled) {
          // 如果处在全屏。
          // fullscreenElement 返回当前文档中正在以全屏模式显示的Element节点,如果没有使用全屏模式,则返回null.
          if (document.fullscreenElement) {
            // console.log('退出全屏')
            document.exitFullscreen()
          } else {
            // console.log('全屏该元素')
            props.rootRef.current.requestFullscreen()
      // 头部分左右两部分:表格标题 和 options。options 又分两部分:操作项(例如新建、批量删除)、表格操作(刷新表格、表格列显隐控制、表格全屏控制)
      return (
        <div className={styles.toolbar}>
          <div className={styles.title}>{props.title}</div>
          <div className={styles.option}>
            {/* 新建、删除等项 */}
            <Space size="middle" style={{ marginRight: 10 }}>
              {actions.map((item, index) => (
                // 这种用法有意思
                <React.Fragment key={index}>{item}</React.Fragment>
            </Space>
            {/* 如果有新建等按钮就得加一个分隔符 | */}
            {actions.length ? <Divider type="vertical" /> : null}
            {/* 表格操作:刷新表格、表格列显隐控制、表格全屏控制 */}
            <Space className={styles.icons}>
              {/* 刷新表格 */}
              <ReloadOutlined onClick={props.onReload} />
              {/* 控制表格列的显示,比如让`状态`这列隐藏 */}
              <Popover
                arrowPointAtCenter
                destroyTooltipOnHide={{ keepParent: false }}
                // 头部:列展示、重置
                title={[
                  <Checkbox
                    key="1"
                    // 全选状态。选中的列数 === 表格中定义的列数
                    checked={fields.length === columns.length}
                    // 在实现全选效果时,你可能会用到 indeterminate 属性。
                    // 设置 indeterminate 状态,只负责样式控制
                    indeterminate={![0, columns.length].includes(fields.length)}
                    onChange={handleCheckAll}>列展示</Checkbox>,
                  // 重置展示最初的列,也就是页面刚进来时列展示的状态。localStorage 会记录对表格列展示的状态。
                  <Button
                    key="2"
                    type="link"
                    style={{ padding: 0 }}
                    onClick={() => onFieldsChange(props.defaultFields)}>重置</Button>
                overlayClassName={styles.tableFields}
                // 触发方式是 click
                trigger="click"
                placement="bottomRight"
                // 卡片内容
                content={<Fields />}>
                <SettingOutlined />
              </Popover>
              {/* 表格全屏控制 */}
              <FullscreenOutlined onClick={handleFullscreen} />
            </Space>
    function TableCard(props) {
      // 定义一个 ref,用于表格的全屏控制
      const rootRef = useRef();
      // Footer 组件中使用
      const batchActions = props.batchActions || [];
      // Footer 组件中使用
      const selected = props.selected || [];
      // 记录要展示的列
      // 例如全选则是 [0, 1, 2, 3 ...],空数组表示不展示任何列
      const [fields, setFields] = useState([]);
      // 用于列展示中的重置功能。页面一进来就会将选中的列进行保存
      const [defaultFields, setDefaultFields] = useState([]);
      // 用于保存传入的表格的列数据
      const [columns, setColumns] = useState([]);
      useEffect(() => {
        // _columns - 传入的列数据 
        let [_columns, _fields] = [props.columns, []];
        // `角色名称`这种功能 props.children 是空。
        if (props.children) {
          if (Array.isArray(props.children)) {
            _columns = props.children.filter(x => x.props).map(x => x.props)
          } else {
            _columns = [props.children.props]
        // 隐藏字段。有 hide 属性的是要隐藏的字段。如果有 tKey 字段,隐藏字段则以缓存的为准
        let hideFields = _columns.filter(x => x.hide).map(x => x.title)
        // tKey 是表格标识,比如这个表要隐藏 `状态` 字段,另一个表格要隐藏 `地址` 字段,与表格初始列展示对应。
        // 如果表格有唯一标识(tKey),再看TableFields(来自localStorage)中是否有数据,如果没有则更新缓存
        if (props.tKey) {
          if (TableFields[props.tKey]) {
            hideFields = TableFields[props.tKey]
          } else {
            TableFields[props.tKey] = hideFields
            localStorage.setItem('TableFields', JSON.stringify(TableFields))
        // Array.prototype.entries() 方法返回一个新的数组迭代器对象,该对象包含数组中每个索引的键/值对。
        for (let [index, item] of _columns.entries()) {
          // 比如之前将 `状态` 这列隐藏,输出:hideFields ['状态']
          // console.log('hideFields', hideFields)
          if (!hideFields.includes(item.title)) _fields.push(index)
        setFields(_fields);
        // 将传入的列数据保存在 state 中
        setColumns(_columns);
        // 记录初始展示的列
        setDefaultFields(_fields);
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [])
      // 列展示的操作。
      function handleFieldsChange(fields) {
        // 更新选中的 fields
        setFields(fields)
        // tKey 就是一个标识,可以将未选中的fields存入 localStorage。比如用户取消了 `状态` 这列的展示,只要没有清空缓存,下次查看表格中仍旧不会显示`状态`这列
        // 将列展示状态保存到缓存
        if (props.tKey) {
          TableFields[props.tKey] = columns.filter((_, index) => !fields.includes(index)).map(x => x.title)
          localStorage.setItem('TableFields', JSON.stringify(TableFields))
          // 隐藏三列("频率","描述","操作"),输入: {"hi":["备注信息"],"cb":[],"cg":[],"cc":[],"sa":[],"mi":["频率","描述","操作"]}
          // console.log(localStorage.getItem('TableFields'))
      // 分为三部分:Header、Table和 Footer。
      return (
        <div ref={rootRef} className={styles.tableCard}>
          {/* 头部。 */}
          <Header
            // 表格标题。例如`角色列表`
            title={props.title}
            // 表格的列
            columns={columns}
            // 操作。例如新增、批量删除等操作
            actions={props.actions}
            // 不隐藏的列
            fields={fields}
            rootRef={rootRef}
            defaultFields={defaultFields}
            // 所选列变化时触发
            onFieldsChange={handleFieldsChange}
            onReload={props.onReload} />
          {/* antd 的 Table 组件 */}
          <Table
            // 表格元素的 table-layout 属性,例如可以实现`固定表头/列`
            tableLayout={props.tableLayout}
            // 表格是否可滚动
            scroll={props.scroll}
            // 表格行 key 的取值,可以是字符串或一个函数。spug 中 `rowKey="id"` 重现出现在 29 个文件中。
            rowKey={props.rowKey}
            // 加载中的 loading 效果
            loading={props.loading}
            // 表格的列。用户可以选择哪些列不显示
            columns={columns.filter((_, index) => fields.includes(index))}
            // 数据源
            dataSource={props.dataSource}
            // 表格行是否可选择,配置项(object)。可以不传
            rowSelection={props.rowSelection}
            // 展开功能的配置。可以不传
            expandable={props.expandable}
            // 表格大小 default | middle | small
            size={props.size}
          // 分页、排序、筛选变化时触发
            onChange={props.onChange}
            // 分页器,参考配置项或 pagination 文档,设为 false 时不展示和进行分页
            pagination={props.pagination} />
          {/* selected 来自 props,在 Footer 组件中显示选中了多少项等信息,spug 中没有使用到 */}
          {selected.length ? <Footer selected={selected} actions={batchActions} /> : null}
    // spug 没有用到
    TableCard.Search = Search;
    export default TableCard
    

    myspug 中 Table 封装的实现

    配置 mobx

    笔者这里验证效果时需要使用状态管理器 mobx,目前项目会报如下 2 种错误:

    Support for the experimental syntax 'decorators' isn't currently enabled (10:1):
    
    src\pages\system\role\Table.js
      Line 10:  Parsing error: This experimental syntax requires enabling one of the following parser plugin(s): "decorators", "decorators-legacy". (10:0)
    

    这里需要两处修改即可:

  • config-overrides.js 中增加 addDecoratorsLegacy 的支持
  • 项目根目录新建 .babelrc 文件
  • Tip: 具体细节请看 这里

    至此 mobx 仍有问题,经过一番折腾,最终才验证表格成功。

    笔者在表格中使用一个变量(store.isFetching)控制 loading 效果,但页面一直显示加载效果。而加载完毕将 isFetching 置为 false 的语句也执行了,怀疑是 store.isFetching 变量没有同步到组件。折腾了一番...,最后将 mobx和 mobx-react 包版本改成和 spug 中相同:

    -    "mobx": "^6.7.0",
    -    "mobx-react": "^7.6.0",
    +    "mobx": "^5.15.7",
    +    "mobx-react": "^6.3.1",
    

    期间无意发现我的组件加载完毕后输出两次

    componentDidMount(){
      // 执行2次
      console.log('hi')
    

    删除 <React.StrictMode>

    笔者在新建页面(角色管理)中验证封装的表格组件,效果如下:

    有关导航的配置,路由、mock数据、样式都无需讲解,这里主要说一下表格模块的封装(TableCard.js)和表格的使用(store.jsTable.js)。

    TableCard.js

    前面我们已经分析过了 spug 中表格的封装,这里与之类似,不在冗余。

    // myspug\src\components\TableCard.js
     import React, { useState, useEffect, useRef } from 'react';
     import { Table, Space, Divider, Popover, Checkbox, Button, Input, Select } from 'antd';
     import { ReloadOutlined, SettingOutlined, FullscreenOutlined, SearchOutlined } from '@ant-design/icons';
     import styles from './index.module.less';
     // 从缓存中取得之前设置的列。记录要隐藏的字段。比如之前将 `状态` 这列隐藏
     let TableFields = localStorage.getItem('TableFields')
     TableFields = TableFields ? JSON.parse(TableFields) : {}
     // 已选择多少项。
     function Footer(props) {
       const actions = props.actions || [];
       const length = props.selected.length;
       return length > 0 ? (
         <div className={styles.tableFooter}>
           <div className={styles.left}>已选择 <span>{length}</span> 项</div>
           <Space size="middle">
             {actions.map((item, index) => (
               <React.Fragment key={index}>{item}</React.Fragment>
           </Space>
       ) : null
     function Header(props) {
       const columns = props.columns || [];
       const actions = props.actions || [];
       // 选中列,也就是表格要显示的列
       const fields = props.fields || [];
       const onFieldsChange = props.onFieldsChange;
       // 列展示组件
       const Fields = () => {
         return (
           // value - 指定选中的选项 string[]
           // onChange- 变化时的回调函数 function(checkedValue)。
           // 例如取消`状态`这列的选中
           <Checkbox.Group value={fields} onChange={onFieldsChange}>
             {/* 展示所有的列 */}
             {columns.map((item, index) => (
               // 注:值的选中是根据索引来的,因为 columns 是数组,是有顺序的。
               <Checkbox value={index} key={index}>{item.title}</Checkbox>
           </Checkbox.Group>
       // 列展示 - 全选或取消全部
       function handleCheckAll(e) {
         if (e.target.checked) {
           // 例如:[0, 1, 2, 3]
           // console.log('columns', columns.map((_, index) => index))
           onFieldsChange(columns.map((_, index) => index))
         } else {
           onFieldsChange([])
       // 全屏操作。使用浏览器自带全屏功能
       function handleFullscreen() {
         // props.rootRef.current 是表格组件的原始 Element
         // fullscreenEnabled 属性提供了启用全屏模式的可能性。当它的值是 false 的时候,表示全屏模式不可用(可能的原因有 "fullscreen" 特性不被允许,或全屏模式不被支持等)。
         if (props.rootRef.current && document.fullscreenEnabled) {
           // 如果处在全屏。
           // fullscreenElement 返回当前文档中正在以全屏模式显示的Element节点,如果没有使用全屏模式,则返回null.
           if (document.fullscreenElement) {
             // console.log('退出全屏')
             document.exitFullscreen()
           } else {
             // console.log('全屏该元素')
             props.rootRef.current.requestFullscreen()
       // 头部分左右两部分:表格标题 和 options。options 又分两部分:操作项(例如新建、批量删除)、表格操作(刷新表格、表格列显隐控制、表格全屏控制)
       return (
         <div className={styles.toolbar}>
           <div className={styles.title}>{props.title}</div>
           <div className={styles.option}>
             {/* 新建、删除等项 */}
             <Space size="middle" style={{ marginRight: 10 }}>
               {actions.map((item, index) => (
                 // 这种用法有意思
                 <React.Fragment key={index}>{item}</React.Fragment>
             </Space>
             {/* 如果有新建等按钮就得加一个分隔符 | */}
             {actions.length ? <Divider type="vertical" /> : null}
             {/* 表格操作:刷新表格、表格列显隐控制、表格全屏控制 */}
             <Space className={styles.icons}>
               {/* 刷新表格 */}
               <ReloadOutlined onClick={props.onReload} />
               {/* 控制表格列的显示,比如让`状态`这列隐藏 */}
               <Popover
                 arrowPointAtCenter
                 destroyTooltipOnHide={{ keepParent: false }}
                 // 头部:列展示、重置
                 title={[
                   <Checkbox
                     key="1"
                     // 全选状态。选中的列数 === 表格中定义的列数
                     checked={fields.length === columns.length}
                     // 在实现全选效果时,你可能会用到 indeterminate 属性。
                     // 设置 indeterminate 状态,只负责样式控制
                     indeterminate={![0, columns.length].includes(fields.length)}
                     onChange={handleCheckAll}>列展示</Checkbox>,
                   // 重置展示最初的列,也就是页面刚进来时列展示的状态。localStorage 会记录对表格列展示的状态。
                   <Button
                     key="2"
                     type="link"
                     style={{ padding: 0 }}
                     onClick={() => onFieldsChange(props.defaultFields)}>重置</Button>
                 overlayClassName={styles.tableFields}
                 // 触发方式是 click
                 trigger="click"
                 placement="bottomRight"
                 // 卡片内容
                 content={<Fields />}>
                 <SettingOutlined />
               </Popover>
               {/* 表格全屏控制 */}
               <FullscreenOutlined onClick={handleFullscreen} />
             </Space>
     function TableCard(props) {
       // 定义一个 ref,用于表格的全屏控制
       const rootRef = useRef();
       // Footer 组件中使用
       const batchActions = props.batchActions || [];
       // Footer 组件中使用
       const selected = props.selected || [];
       // 记录要展示的列
       // 例如全选则是 [0, 1, 2, 3 ...],空数组表示不展示任何列
       const [fields, setFields] = useState([]);
       const [defaultFields, setDefaultFields] = useState([]);
       // 用于保存传入的表格的列数据
       const [columns, setColumns] = useState([]);
       useEffect(() => {
         // _columns - 传入的列数据 
         let [_columns, _fields] = [props.columns, []];
         if (props.children) {
           if (Array.isArray(props.children)) {
             _columns = props.children.filter(x => x.props).map(x => x.props)
           } else {
             _columns = [props.children.props]
         // 隐藏字段。有 hide 属性的是要隐藏的字段。如果有 tKey 字段,隐藏字段则以缓存的为准
         let hideFields = _columns.filter(x => x.hide).map(x => x.title)
         // tKey 是表格标识,比如这个表要隐藏 `状态` 字段,另一个表格要隐藏 `地址` 字段,与表格初始列展示对应。
         // 如果表格有唯一标识(tKey),再看TableFields(来自localStorage)中是否有数据,如果没有则更新缓存
         if (props.tKey) {
           if (TableFields[props.tKey]) {
             hideFields = TableFields[props.tKey]
           } else {
             TableFields[props.tKey] = hideFields
             localStorage.setItem('TableFields', JSON.stringify(TableFields))
         // Array.prototype.entries() 方法返回一个新的数组迭代器对象,该对象包含数组中每个索引的键/值对。
         for (let [index, item] of _columns.entries()) {
           // 比如之前将 `状态` 这列隐藏,输出:hideFields ['状态']
           // console.log('hideFields', hideFields)
           if (!hideFields.includes(item.title)) _fields.push(index)
         setFields(_fields);
         // 将传入的列数据保存在 state 中
         setColumns(_columns);
         // 记录初始展示的列
         setDefaultFields(_fields);
         // eslint-disable-next-line react-hooks/exhaustive-deps
       }, [])
       // 列展示的操作。
       function handleFieldsChange(fields) {
         // 更新选中的 fields
         setFields(fields)
         // tKey 就是一个标识,可以将未选中的fields存入 localStorage。比如用户取消了 `状态` 这列的展示,只要没有清空缓存,下次查看表格中仍旧不会显示`状态`这列
         // 将列展示状态保存到缓存
         if (props.tKey) {
           TableFields[props.tKey] = columns.filter((_, index) => !fields.includes(index)).map(x => x.title)
           localStorage.setItem('TableFields', JSON.stringify(TableFields))
           // 隐藏三列("频率","描述","操作"),输入: {"hi":["备注信息"],"cb":[],"cg":[],"cc":[],"sa":[],"mi":["频率","描述","操作"]}
           // console.log(localStorage.getItem('TableFields'))
       // 分为三部分:Header、Table和 Footer。
       return (
         <div ref={rootRef} className={styles.tableCard}>
           {/* 头部。 */}
           <Header
             // 表格标题。例如`角色列表`
             title={props.title}
             // 表格的列
             columns={columns}
             // 操作。例如新增、批量删除等操作
             actions={props.actions}
             // 不隐藏的列
             fields={fields}
             rootRef={rootRef}
             defaultFields={defaultFields}
             // 所选列变化时触发
             onFieldsChange={handleFieldsChange}
             onReload={props.onReload} />
           {/* antd 的 Table 组件 */}
           <Table
             // 表格元素的 table-layout 属性,例如可以实现`固定表头/列`
             tableLayout={props.tableLayout}
             // 表格是否可滚动
             scroll={props.scroll}
             // 表格行 key 的取值,可以是字符串或一个函数。spug 中 `rowKey="id"` 重现出现在 29 个文件中。
             rowKey={props.rowKey}
             // 加载中的 loading 效果
             loading={props.loading}
             // 表格的列。用户可以选择哪些列不显示
             columns={columns.filter((_, index) => fields.includes(index))}
             // 数据源
             dataSource={props.dataSource}
             // 表格行是否可选择,配置项(object)。可以不传
             rowSelection={props.rowSelection}
             // 展开功能的配置。可以不传
             expandable={props.expandable}
             // 表格大小 default | middle | small
             size={props.size}
             // 分页、排序、筛选变化时触发
             onChange={props.onChange}
             // 分页器,参考配置项或 pagination 文档,设为 false 时不展示和进行分页
             pagination={props.pagination} />
           {/* selected 来自 props,在 Footer 组件中显示选中了多少项等信息,spug 中没有使用到 */}
           {selected.length ? <Footer selected={selected} actions={batchActions} /> : null}
     // spug 没有用到,我们也删除
    //  TableCard.Search = Search;
     export default TableCard
    
    Table.js

    这里是表格的使用,与 antd Table 类似,主要是 columns(列) 和 dataSource(数据源):

    // myspug\src\pages\system\role\Table.js
    import React from 'react';
    import { observer } from 'mobx-react';
    import { Modal, Popover, Button, message } from 'antd';
    // PlusOutlined:antd 2.2.8 找到 
    import { PlusOutlined } from '@ant-design/icons';
    import { TableCard, } from '@/components';
    import store from './store';
    @observer
    class ComTable extends React.Component {
      componentDidMount() {
        store.fetchRecords()
      columns = [{
        title: '角色名称',
        dataIndex: 'name',
        title: '关联账户',
        render: info => 0
        title: '描述信息',
        dataIndex: 'desc',
        ellipsis: true
        title: '操作',
        width: 400,
        render: info => (
          '编辑按钮'
      render() {
        return (
          <TableCard
            rowKey="id"
            title="角色列表"
            loading={store.isFetching}
            dataSource={store.dataSource}
            // 刷新表格
            onReload={store.fetchRecords}
            actions={[
              <Button type="primary" icon={<PlusOutlined />}>新增</Button>
            pagination={{
              showSizeChanger: true,
              showLessItems: true,
              showTotal: total => `共 ${total} 条`,
              pageSizeOptions: ['10', '20', '50', '100']
            columns={this.columns} />
    export default ComTable
    
    store.js

    状态管理。例如表格的数据的请求,控制表格 loading 效果的 isFetching:

    // myspug\src\pages\system\role\store.js
    import { observable, computed, } from 'mobx';
    import http from '@/libs/http';
    class Store {
      @observable records = [];
      @observable isFetching = false;
      @computed get dataSource() {
        let records = this.records;
        return records
      fetchRecords = () => {
        // 加载中
        this.isFetching = true;
        http.get('/api/account/role/')
          .then(res => {
            this.records = res
          .finally(() => this.isFetching = false)
    export default new Store()
    

    分页请求数据

    spug 中的表格数据是一次性加载出来的,点击上下翻页不会发请求给后端。配合表格上方的过滤条件,体验不错,因为无需请求,数据都在前端。就像这样:

    但是如果数据量很大,按照常规做法,翻页、查询等操作都需要从后端重新请求数据。

    要实现表格翻页时重新请求数据也很简单,使用 antd Table 的 onChange 属性(分页、排序、筛选变化时触发)即可。

    前面我们已经在 TableCard.js 中增加了该属性(即onChange={props.onChange}

    下面我们将角色管理页面的表格改为分页请求数据:

    首先我们回顾下目前这种一次请求表格所有数据,纯前端分页效果。请看代码:

      render() {
        return (
          <TableCard
            rowKey="id"
            title="角色列表"
            loading={store.isFetching}
            // 后端的数据源
            dataSource={store.dataSource}
            onReload={store.fetchRecords}
            actions={[
              <Button type="primary" icon={<PlusOutlined />}>新增</Button>
            // 分页器
            pagination={{
              showSizeChanger: true,
              showLessItems: true,
              showTotal: total => `共 ${total} 条`,
              pageSizeOptions: ['10', '20', '50', '100']
            columns={this.columns} />
    

    只需要给表格传入数据源(dataSource),antd Table 自动完成前端分页效果。

    接着我们修改代码如下:

  • Table.js - 给表格增加了 onChange 对应的回调以及给分页器增加 total 属性
  • store.js - 定义新状态 current、total
  • // myspug\src\pages\system\role\Table.js
    import { TableCard, } from '@/components';
    import store from './store';
    @observer
    class ComTable extends React.Component {
      componentDidMount() {
        store.fetchRecords()
      columns = [...];
      handleTableChange = ({current}, filters, sorter) => {
        store.current = current
        store.tableOptions = {
          // 排序:好像只支持单个排序
          sortField: sorter.field,
          sortOrder: sorter.order,
          ...filters
        store.fetchRecords();
      render() {
        return (
          <TableCard
            rowKey="id"
            title="角色列表"
            loading={store.isFetching}
            // 后端的数据源
            dataSource={store.dataSource}
            onReload={store.fetchRecords}
            onChange={this.handleTableChange}
            // 分页器
            pagination={{
              showSizeChanger: true,
              showLessItems: true,
              showTotal: total => `共 ${total} 条`,
              pageSizeOptions: ['10', '20', '50', '100'],
              // 如果不传 total,则以后端返回数据条数作为 total 的值
              total: store.total,
              // 如果不传,则默认是第一条,如果需要默认显示第3条,则必须传
              current: store.current,
            columns={this.columns} />
    export default ComTable
    
    // myspug\src\pages\system\role\store.js
    class Store {
      // 默认第1页
      @observable current = 1;
      // 总共多少页
      @observable total = '';
      // 其他参数,例如排序、过滤等等
      @observable tableOptions = {}
      fetchRecords = () => {
        const realParams = {current: this.current, ...this.tableOptions}
        this.isFetching = true;
        http.get('/api/account/role/', {params: realParams})
          .then(res => {
            // 可以这么赋值
            // ({data: this.records, total: this.pagination.total} = res)
            this.total = res.total
            this.records = res.data
          .finally(() => this.isFetching = false)
    export default new Store()
    

    最终效果如下图所示:

    Tip:本地 mock 模拟数据如下

    const getNum = () => String(+new Date()).slice(-3)
    // 注:第三个参数必须不能是对象,否则 getNum 不会重新执行
    Mock.mock(/\/api\/account\/role\/.*/, 'get', function () {
        return {
            "data": {
                data: new Array(10).fill(0).map((item, index) => ({
                    "id": index + getNum(), "name": 'name' + index + getNum(), "desc": null,
                total: 10000,
            , "error": ""
    

    create-react-app 组件为什么加载两次

    试试删除 <React.StrictMode>(官网说:这仅适用于开发模式。生产模式下生命周期不会被调用两次)

    疑惑:笔者验证表格时使用了 mobx,表格没渲染出来,删除 <React.StrictMode> 后表格正常,不知是否是 <React.StrictMode> 的副作用。

    spug 中表格的不足

    antd Table 组件某些属性无法使用:spug 中表格是对 antd Table 组件的封装,但是现在封装的组件对外的接口只提供了 antd Table 中有限的几个属性。例如上文提到的翻页请求后端数据需要使用 antd Table 中的 onChange 属性就没有提供出来

    头部一定会有:不需要都不行

    其他章节请看:

    react 高效高质量搭建后台系统 系列