如何使用MUI开发一个可编辑的表格

620 阅读10分钟

背景

最近公司的项目需要做一个表格,用于记录一些配置信息,同时希望用户可以对这些配置进行修改并保存。为此,我需要使用 MUI 来开发一个能满足需求的组件。虽然 MUI 提供了表格组件,但是想要给用户一个不错的体验,还是需要做一些额外开发,并且很多地方需要自己探索才能找到解决方案

绘制一个表格

MUI 提供了一个表格组件 Table,但是我使用的是功能更为强大的 MUI-X 里面提供的 DataGrid 组件。MUI-X 有三个版本,一个免费版和两个付费版。由于预算有限,所以我们使用的是免费版,对于这个需求也是绰绰有余

和其他组件库类似,DataGrid 也需要定义每行的字段和数据,之后我们可以很容易的绘制出一张表格

const rows: any = [
  {
    name: '参数A',
    value1: 100,
    value2: 200,
    value3: 300,
    value4: 400,
    min: 0,
    max: 1000,
  },
  ...
]
  
const columns: GridColDef[] = [
  {
    field: 'name',
    headerName: '参数名',
    flex: 1
  },
  {
    field: 'value1',
    headerName: '配置1',
    flex: 1,
  },
  ...
  {
    field: 'range',
    headerName: '取值范围',
    flex: 1,
    renderCell: ({ row }) => {
      const { min, max } = row
      return <div>{`${min} - ${max}`}</div>
    },
  },
]
...
return (
  <DataGrid
    rows={rows}
    columns={columns}
    getRowId={(row) => row.name} // 数据没有id属性的话要指定一个属性为id,否则会报错
  />
)

一张最基本的表格就绘制出来了 企业微信截图_20241111171410.png

添加编辑功能

需求中要求用户可以对表单内容进行编辑,而 DataGrid 里面自带了属性可以使单元格可编辑。只需要在 columns 中对应的字段中设置 editable: true即可

const columns: GridColDef[] = [
  ...
  {
    field: 'value1',
    type: 'number',
    headerName: '配置1',
    flex: 1,
    editable: true, // 设置这个属性使字段可以编辑
  },
  ...
]

修改后的效果如下 chrome_iMwi8Rh70I.gif 可以看到,现在我们可以随意编辑单元格,然后点击其他地方或是按下回车就可以完成编辑。这样用户就可以非常方便的对数据进行修改。如果我们希望在编辑前后进行任何操作,比如调用接口,我们可以定义 onCellEditStartonCellEditStop 属性。比如我们现在希望用户点击其他地方,也就是单元格失焦的时候不退出编辑模式,只有通过回车才能进行保存,我们可以这样修改

<DataGrid
  rows={rows}
  columns={columns}
  getRowId={(row) => row.name}
  onCellEditStop={(params, e) => {
    // 按回车保存,按ESC退出,其他操作则阻止退出编辑模式
    switch (params.reason) {
      case GridCellEditStopReasons.enterKeyDown:
        updateData()
        break

      case GridCellEditStopReasons.escapeKeyDown:
        break

      default:
        e.defaultMuiPrevented = true
        break
    }
  }}
/>

修改后的效果如下 chrome_B6T2zOuYlc.gif 现在我们就实现了用户可编辑的功能,基本满足了项目的需求

行编辑模式

上面的例子绘制了一张表格,并且允许用户对数据进行编辑,看起来是不错的,不过有两个地方欠佳

通过上面示例我们可以看到,用户启用编辑模式是通过双击单元格来完成。这个操作虽然非常方便,但是给用户的提示却不明显。用户无法直观地了解到数据可以编辑,也不知道通过双击来编辑

除此之外,现在单元格每编辑一次就会调用一次接口更新数据,这也一定程度上增加了服务器的压力。既然每一行数据都是一个对象,那么我们更希望是用户编辑完一整行的数据再提交,这样比较符合用户的直觉,也可以减小服务器的压力

针对上面两个问题,我决定给每一行增加一些操作按钮,用来提示用户,并且将编辑模式由单元格编辑改为行编辑

增加操作列

这个其实没啥技术含量,就是在 columns 里面加一个对象,增加几个按钮,做一些状态管理,实现几个 onClick 函数就可以了,直接上代码

const columns = [
  ...
  {
    field: 'actions',
    headerName: '操作',
    width: 180,
    sortable: false,
    renderCell: (params) => {
      const id = params.id as string
      const { error } = params.row
      return (
        <Flex className='h-full gap-2' alignCenter>
          {editingRows.includes(id) ? (
            <>
              <Button
                variant='outlined'
                onClick={() => handleCancelClick(id)}
              >
                取消
              </Button>
              <Button
                variant='contained'
                onClick={() => handleSaveClick(id)}
              >
                保存
              </Button>
            </>
          ) : (
            <Button variant='outlined' onClick={() => handleEditClick(id)}>
              编辑
            </Button>
          )}
        </Flex>
      )
    },
  },
]

const [editingRows, setEditingRows] = useState<string[]>([])

const handleEditClick = (rowId: string) => {
  setEditingRows((rows) => {
    return [...rows, rowId]
  })
}

const handleCancelClick = (rowId: string) => {
  setEditingRows((rows) => {
    return rows.filter((key) => key !== rowId)
  })
}

const handleSaveClick = (rowId: string) => {
  updateData()
  setEditingRows((rows) => {
    return rows.filter((key) => key !== rowId)
  })
}

Flex 组件是我项目中自定义的组件,类似于 antd 里面的 Flex 组件,可以根据自己的组件自行替换。修改后的效果如下 3.gif 现在我们在表格行添加了按钮,让用户可以感知到如何编辑和保存数据了

启用行编辑

虽然我们添加了编辑保存按钮,但是可以看到我们点击按钮的时候,单元格的状态并没有发生变化。这是因为我们目前的编辑模式还是单元格模式,这也是 DataGrid 默认的编辑模式,所以我们需要启用行编辑模式,并且让单元格的状态随着用户的操作变化

首先在 DataGrid 中设置 editMode 属性

<DataGrid
 ...
 editMode='row'
 ...
/>

随后我们需要在用户操作的时候控制行的编辑状态。DataGrid 里面提供了 useGridApiRef 这个hook,用于管理表格的状态和数据,所以我们调用这个hook并将返回的ref赋给 DataGrid 组件

const apiRef = useGridApiRef()
...
return (
  <DataGrid
    ref={apiRef}
    ...
  />
)

然后我们通过 apiRef.current.startRowEditMode()apiRef.current.stopRowEditMode() 这两个方法就可以控制行的编辑状态了,整理后的代码如下

...
// 记录当前已修改的数据
const [dirtyRows, setDirtyRows] = useState<any>(rows)

// 开始编辑行
const startRowEdit = (rowId: string) => {
  updateRowEditMode(rowId)
}

// 结束编辑行
const stopRowEdit = (rowId: string, ignoreChanges?: boolean) => {
  updateRowEditMode(rowId, true, ignoreChanges)
}

// 更新行编辑状态
const updateRowEditMode = (
  rowId: string,
  stop?: boolean,
  ignoreChanges?: boolean
) => {
  if (stop) {
    apiRef.current.stopRowEditMode({
      id: rowId,
      ignoreModifications: ignoreChanges, // 是否忽略改动,取消时要忽略
    })
    return
  }

  apiRef.current.startRowEditMode({
    id: rowId,
  })
}
  
// 将该行加入正在编辑的行中
const insertEditRow = (rowId: string) => {
  updateEditingRows(rowId, true)
}

// 将该行从正在编辑的行中移除
const removeEditRow = (rowId: string) => {
  updateEditingRows(rowId, false)
}

// 更新正在编辑的行
const updateEditingRows = (rowId: string, insert: boolean) => {
  setEditingRows((rows) => {
    return insert ? [...rows, rowId] : rows.filter((key) => key !== rowId)
  })
}
  
// 开始编辑
const handleEditClick = (rowId: string) => {
  startRowEdit(rowId)
  insertEditRow(rowId)
}

// 取消编辑,恢复数据
const handleCancelClick = (rowId: string) => {
  stopRowEdit(rowId, true)
  removeEditRow(rowId)
}
  
// 保存编辑,更新数据
const handleSaveClick = (rowId: string) => {
  stopRowEdit(rowId)
  removeEditRow(rowId)
}

return (
  <DataGrid
   ...
   rows={dirtyRows}
   ...
  />
)

修改后的效果如下 4.gif 现在我们已经实现了行编辑的效果,不过除了点击编辑外,现在用户任意点击单元格都会让这一行进入编辑态,这并不是我们希望看到的。我们需要让这个行为可控,所以我们需要监控行的编辑状态,并且对异常情况做出控制。与单元格编辑类似,DataGrid 提供了 onRowEditStartonRowEditStop 属性来对行编辑状态进行控制。所以我们需要移除先前定义的 onCellEditStop 属性,改为上面提到的两个

// 开始行编辑
const handleRowEditStart = (params: GridRowEditStartParams, e: MuiEvent) => {
  // 只有双击触发行编辑
  if (params.reason === GridRowEditStartReasons.cellDoubleClick) {
    insertEditRow(params.id as string)
    return
  }
  // 禁用所有其他触发方式
  e.defaultMuiPrevented = true
}

// 结束行编辑
const handleRowEditStop = (params: GridRowEditStopParams, e: MuiEvent) => {
  // 只有ESC退出行编辑
  if (params.reason === GridRowEditStopReasons.escapeKeyDown) {
    stopRowEdit(params.id as string, true)
    removeEditRow(params.id as string)
    return
  }
  // 禁用其他所有退出方式
  e.defaultMuiPrevented = true
}
...
return (
  <DataGrid
    ...
    onRowEditStart={handleRowEditStart}
    onRowEditStop={handleRowEditStop}
    // 移除onCellEditStop
  />
)

修改后的效果如下 5.gif 可以看到,现在只有双击可以进入编辑模式,并且只有ESC可以退出编辑模式。到目前为止,我们已经实现了通过行编辑模式更新数据的功能

数据校验

在调用接口前对数据进行校验必不可少,在这个表格中我们有一列叫做【取值范围】,就是指定用户输入值的范围,超出这个范围我们应该对用户进行提示并且阻止其更新数据。虽然 DataGrid 提供了 preProcessEditCellProps来在用户编辑时进行校验,但是我们需要进行额外的处理

首先我们在 columns 里面的对象中加入 preProcessEditCellProps 属性

const columns = [
  ...
  {
    field: 'value1',
    ...
    preProcessEditCellProps: (params) =>
      handleBeforeEditCellStop(params, 'value1'),
  }
  ...
]

// 编辑确认前的操作
const handleBeforeEditCellStop = (
  params: GridPreProcessEditCellProps,
  key: string
) => {
  const { value } = params.props

  // 更新表格数据
  setDirtyRows((rows: any) => {
    return rows.map((row: any) => {
      let hasError = false
      if (row.name !== params.id) {
        return row
      }

      // 判断当前行的所有输入是否合法,决定是否可以保存
      ;['value1', 'value2', 'value3', 'value4'].map((field) => {
        const value = field === key ? params.props : row[field]
        hasError = hasError || value < row.min || value > row.max
      })
       return {
        ...row,
        [key]: value,
        error: hasError, // 设置错误属性
      }
    })
  })

  return { ...params.props, [key]: value }
}

随后加入错误数据的样式

const columns = [
  ...
  {
    field: 'value1',
    ...
    cellClassName: (params) => getCellClassName(params),
    ...
  }
  ...
]

// 校验数据
const getCellClassName = (params: GridCellParams<any, any>) => {
  const { min, max } = params.row
  const hasError = params.value < min || params.value > max
  return hasError ? 'cell-error' : ''
}
.cell-error
  border: 1px solid red !important

最后判断当行内有非法数据时不能进行保存

const columns = [
  ...
  {
    field: 'actions',
    ...
    renderCell: (params) => {
      const { error } = params.row
      return (
        <Flex className='h-full gap-2' alignCenter>
          ...
              <Button
                variant='contained'
                disabled={error}
                onClick={() => handleSaveClick(id)}
              >
                保存
              </Button>
          ...
        </Flex>
      )
    },
  },
]

最终的效果如下 6.gif

总结

通过以上步骤,我们通过 MUI-X DataGrid 组件实现了一个可编辑的表格。虽然大部分都是用的组件自带的属性来实现,但是由于 MUI-X 文档给出的示例比较少,不是很完善,有些功能需要通过翻阅API文档或是询问AI才能了解。当然实现这样一个表格的方式可能有很多种,这里只是给出一种比较符合我的项目的方法,有些地方可能也不是很完善。欢迎各位小伙伴提供其他的方案

完整代码

EditableTable.tsx

import Button from '@mui/material/Button';
import { Flex } from '@/components'
import {
  DataGrid,
  GridCellParams,
  GridColDef,
  GridPreProcessEditCellProps,
  GridRowEditStartParams,
  GridRowEditStartReasons,
  GridRowEditStopParams,
  GridRowEditStopReasons,
  MuiEvent,
  useGridApiRef,
} from '@mui/x-data-grid'
import { zhCN } from '@mui/x-data-grid/locales'
import { FC, useState } from 'react'
import './style.sass'

const EditableTable: FC = () => {
  const rows: any = [
    {
      name: '参数A',
      value1: 100,
      value2: 200,
      value3: 300,
      value4: 400,
      min: 0,
      max: 1000,
    },
    {
      name: '参数B',
      value1: 50,
      value2: 170,
      value3: 350,
      value4: 904,
      min: 0,
      max: 2000,
    },
    {
      name: '参数C',
      value1: 5,
      value2: 37,
      value3: 60,
      value4: 90,
      min: 0,
      max: 100,
    },
  ]

  const columns: GridColDef[] = [
    {
      field: 'name',
      headerName: '参数名',
      flex: 1,
    },
    {
      field: 'value1',
      type: 'number',
      headerName: '配置1',
      cellClassName: (params) => getCellClassName(params),
      flex: 1,
      editable: true,
      preProcessEditCellProps: (params) =>
        handleBeforeEditCellStop(params, 'value1'),
    },
    {
      field: 'value2',
      type: 'number',
      headerName: '配置2',
      cellClassName: (params) => getCellClassName(params),
      flex: 1,
      editable: true,
      preProcessEditCellProps: (params) =>
        handleBeforeEditCellStop(params, 'value2'),
    },
    {
      field: 'value3',
      type: 'number',
      headerName: '配置3',
      cellClassName: (params) => getCellClassName(params),
      flex: 1,
      editable: true,
      preProcessEditCellProps: (params) =>
        handleBeforeEditCellStop(params, 'value3'),
    },
    {
      field: 'value4',
      type: 'number',
      headerName: '配置4',
      cellClassName: (params) => getCellClassName(params),
      flex: 1,
      editable: true,
      preProcessEditCellProps: (params) =>
        handleBeforeEditCellStop(params, 'value4'),
    },
    {
      field: 'range',
      headerName: '取值范围',
      flex: 1,
      renderCell: ({ row }) => {
        const { min, max } = row
        return <div>{`${min} - ${max}`}</div>
      },
    },
    {
      field: 'actions',
      headerName: '操作',
      width: 180,
      sortable: false,
      renderCell: (params) => {
        const id = params.id as string
        const { error } = params.row
        return (
          <Flex className='h-full gap-2' alignCenter>
            {editingRows.includes(id) ? (
              <>
                <Button
                  variant='outlined'
                  onClick={() => handleCancelClick(id)}
                >
                  取消
                </Button>
                <Button
                  variant='contained'
                  disabled={error}
                  onClick={() => handleSaveClick(id)}
                >
                  保存
                </Button>
              </>
            ) : (
              <Button variant='outlined' onClick={() => handleEditClick(id)}>
                编辑
              </Button>
            )}
          </Flex>
        )
      },
    },
  ]

  const [originRows, setOriginRows] = useState<any>(rows)
  const [dirtyRows, setDirtyRows] = useState<any>(rows)
  const [editingRows, setEditingRows] = useState<string[]>([])

  // MUI-X DataGrid提供的ref,可以对表格进行操作
  const apiRef = useGridApiRef()

  /**
   * 根据取值范围判断值是否合法
   */
  const getCellClassName = (params: GridCellParams<any, any>) => {
    const { min, max } = params.row
    const hasError = params.value < min || params.value > max
    return hasError ? 'cell-error' : ''
  }

  /**
   * 处理单元格停止编辑前的动作
   */
  const handleBeforeEditCellStop = (
    params: GridPreProcessEditCellProps,
    key: string
  ) => {
    const { value } = params.props

    // 更新表格数据,否则无法判断输入是否合法
    setDirtyRows((rows: any) => {
      return rows.map((row: any) => {
        let hasError = false
        if (row.name !== params.id) {
          return row
        }

        // 判断当前行的所有输入是否合法,决定是否可以保存
        ;['value1', 'value2', 'value3', 'value4'].forEach((field) => {
          const value = field === key ? params.props.value : row[field]
          hasError = hasError || value < row.min || value > row.max
        })

        return {
          ...row,
          [key]: value,
          error: hasError,
        }
      })
    })

    return { ...params.props, [key]: value }
  }

  /**
   * 开始编辑行
   */
  const startRowEdit = (rowId: string) => {
    updateRowEditMode(rowId)
  }

  /**
   * 结束编辑行
   */
  const stopRowEdit = (rowId: string, ignoreChanges?: boolean) => {
    updateRowEditMode(rowId, true, ignoreChanges)
  }

  /**
   * 将当前行插入正在编辑的行中
   */
  const insertEditRow = (rowId: string) => {
    updateEditingRows(rowId, true)
  }

  /**
   * 将当前行从正在编辑的行中移除
   */
  const removeEditRow = (rowId: string) => {
    updateEditingRows(rowId, false)
  }

  /**
   * 更新行编辑状态
   */
  const updateRowEditMode = (
    rowId: string,
    stop?: boolean,
    ignoreChanges?: boolean
  ) => {
    if (stop) {
      apiRef.current.stopRowEditMode({
        id: rowId,
        ignoreModifications: ignoreChanges,
      })
      return
    }

    apiRef.current.startRowEditMode({
      id: rowId,
    })
  }

  /**
   * 更新正在编辑的行
   */
  const updateEditingRows = (rowId: string, insert: boolean) => {
    setEditingRows((rows) => {
      return insert ? [...rows, rowId] : rows.filter((key) => key !== rowId)
    })
  }

  /**
   * 处理行开始编辑
   */
  const handleRowEditStart = (params: GridRowEditStartParams, e: MuiEvent) => {
    if (params.reason === GridRowEditStartReasons.cellDoubleClick) {
      insertEditRow(params.id as string)
      return
    }

    e.defaultMuiPrevented = true
  }

  /**
   * 处理行结束编辑
   */
  const handleRowEditStop = (params: GridRowEditStopParams, e: MuiEvent) => {
    if (params.reason === GridRowEditStopReasons.escapeKeyDown) {
      stopRowEdit(params.id as string, true)
      removeEditRow(params.id as string)
      return
    }

    e.defaultMuiPrevented = true
  }

  /**
   * 处理编辑点击
   */
  const handleEditClick = (rowId: string) => {
    startRowEdit(rowId)
    insertEditRow(rowId)
  }

  /**
   * 处理取消点击
   */
  const handleCancelClick = (rowId: string) => {
    stopRowEdit(rowId, true)
    removeEditRow(rowId)
    setDirtyRows([...originRows])
  }

  /**
   * 处理保存点击
   */
  const handleSaveClick = (rowId: string) => {
    stopRowEdit(rowId)
    removeEditRow(rowId)
    setOriginRows([...dirtyRows])
  }

  return (
    <Flex className='gap-4' column>
      <Container>
        <DataGrid
          apiRef={apiRef}
          rows={dirtyRows}
          columns={columns}
          editMode='row'
          getRowId={(row) => row.name}
          pageSizeOptions={[]}
          localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
          disableColumnMenu
          disableRowSelectionOnClick
          onRowEditStart={handleRowEditStart}
          onRowEditStop={handleRowEditStop}
        />
      </Container>
    </Flex>
  )
}

export default EditableTable

style.sass

.cell-error
  border: 1px solid red !important