做了蛮多的后台管理页面, 几乎都是 table+弹窗表单. 所以总结一下自己 CRUD 的套路
假设这次要做一个关于文章 Post 的增删改查的需求. 可以先查看最终的 demo 示例 来看看效果
类型定义
这里的示例使用 typescript 3.7
/** 文章 */
export interface Post {
/** 主键 */
id: number
/** 标题 */
title: string
/** 内容 */
content: string
/** 状态 */
status: PostStatus
/** 排序字段 */
order: number
/** 创建时间, 时间戳 */
createdAt: number
/** 更新时间, 时间戳 */
updatedAt: number
}
/** 文章状态 */
enum PostStatus {
/** 草稿 */
Draft = 0,
/** 已发布 */
Published = 1,
}
假设所有后端接口都满足 API
这个类型的定义
/** 后端接口 */
export interface API<Response = unknown> {
(...args: any[]): Promise<Response>
}
基本数据分页展示
首先, 我们要有一个获取文章列表的接口, 并且可以进行分页查询.
那么就来模拟一个接口, 取名为getPosts
. 类型如下
export interface GetPostsDto {
/** @default 1 */
page?: number
/** @default 20 */
pageSize?: number
}
export interface APIPagination {
page: number
total: number
pageSize: number
}
export interface TableListResponse<T = unknown> {
list: T[]
pagination: APIPagination
}
// getPosts 的类型
type GetPosts = (dto?: GetPostsDto) => Promise<TableListResponse<Post>>
其中 DTO, 这个词语的解释可以参考 Data Transfer Object, 我是因为看了 nestjs 的文档才用的
接口有了就可以写页面了
装好 antd 以后, 引入 Table 组件, 然后先一股脑地定义好 columns
const columns: ColumnProps<Post>[] = [
{ dataIndex: 'id', title: 'id' },
{ dataIndex: 'title', title: 'title' },
{ dataIndex: 'content', title: 'content' },
{ dataIndex: 'status', title: 'status' },
{ dataIndex: 'order', title: 'order' },
{ dataIndex: 'createdAt', title: 'createdAt' },
{ dataIndex: 'updatedAt', title: 'updatedAt' },
{
render: () => (
<Space>
<span>编辑</span>
<span style={{ color: 'red' }}>删除</span>
</Space>
),
},
]
export default function App() {
return (
<div className='App'>
<h1>antd table curd</h1>
<Table rowKey='id' dataSource={[]} columns={columns}></Table>
</div>
)
}
这样 table 就渲染出来了
接下来就想着怎么调用getPosts
接口. 重新来看看 getPosts 的类型
type GetPosts = (dto?: GetPostsDto) => Promise<TableListResponse<Post>>
我们需要一个 state 来存储查询参数GetPostsDto
const [query, setQuery] = React.useState<GetPostsDto>({})
同时, 需要一个 state 来存储接口返回的数据 TableListResponse<Post>
const [data, setData] = React.useState<TableListResponse<Post>>({
list: [],
pagination: {
page: 1,
pageSize: 20,
total: 0,
},
})
加个 loading
const [loading, setLoading] = React.useState(false)
那就可以在 React.useEffect
调用接口了
React.useEffect(() => {
let isCurrent = true
setLoading(true)
getPosts(query)
.then((res) => isCurrent && setData(res))
.finally(() => isCurrent && setLoading(false))
return () => {
// 防止组件已经卸载的时候, 还会对已经卸载的组件setState
isCurrent = false
}
// query每次变化的时候都会重新调用接口
}, [query])
这样的话, table 里就已经有数据了, 大概长这样
然后, 我们要在切换分页的时候, 重新发起请求调用接口. 只需要监听 table 的 onChange 函数, setQuery 即可(因为 query 每次变化的时候都会重新调用接口)
onChange={(pagination) => {
setQuery({
page: pagination.current || 1,
pageSize: pagination.pageSize || 20
});
}}
当然, 如果只是关心分页的变化的话, 也可以在 Table 组件的 pagination 配置里监听分页的 onChange 函数, 而不是整个 table 的 onChange 函数
到这里, 页面的基本展示就完成了, 可以访问codesandbox.io/s/smoosh-ba…来查看在线的 demo
顶部搜索表单
假设我们可以根据文章标题来进行模糊搜索, 需要在 table 上方添加一个输入框
更新搜索参数 GetPostsDto
的类型定义为
export interface GetPostsDto {
/** @default 1 */
page?: number
/** @default 20 */
pageSize?: number
title?: string
}
接着使用 antd 的 Form 组件来创建一个业务表单组件, 取名为 SearchForm
interface FormValues {
title?: string
}
export function SearchForm(props: {
onSubmit: (values: FormValues) => any
onReset: (values: FormValues) => any
}) {
const { onSubmit, onReset } = props
const [form] = Form.useForm<FormValues>()
const handleReset = () => {
form.resetFields()
onReset({ title: undefined })
}
return (
<Form form={form} layout='inline' onFinish={onSubmit}>
<Form.Item name='title' label='标题'>
<Input placeholder='文章标题' />
</Form.Item>
<Button htmlType='submit' type='primary'>
搜索
</Button>
<Button htmlType='button' onClick={handleReset}>
重置
</Button>
</Form>
)
}
我们希望用户点击搜索或者重置的时候, 都重新发起请求来刷新 table 的数据. 显然我们又需要修改 query
这个 state
<SearchForm
onSubmit={(values) =>
setQuery((prev) => ({
...prev,
...values,
page: 1, // 重置分页
}))
}
onReset={(values) =>
setQuery((prev) => ({
...prev,
...values,
page: 1, // 重置分页
}))
}
/>
注意这里, 我们用了 setQuery 传递了一个函数, 同时结合展开运算符, 达到了 Class Component 里 this.setState 合并更新对象的效果, 参考React 文档
因为, 我们不希望点击搜索传递了title
参数时, 就把之前可能已经存在的 pageSize
等参数丢掉
同理, table 里的 onChange 函数也要进行同样的操作, 不能因为切换分页就把可能已经存在的title
参数丢了
onChange={(pagination) => {
setQuery((prev) => ({
...prev,
page: pagination.current || 1,
pageSize: pagination.pageSize || 20
}));
}}
这个时候给表格大概是长这样的 查看在线 demo codesandbox.io/s/great-bla…
表单校验
接着, 来思考一个有趣的问题. 假设这个 title 的输入框, 用户输入一个超长的字符串, 那么前端要做一些限制吗? 不同的应用可能有不同的答案
- 像谷歌的搜索框, 我试了最多只能输入 2048 个字符, 因为它会把这个搜索的字符串加到 url 里, url 显然是有长度限制的(具体看实现), 所以也很合理.B 站的搜索框也做了类似的处理, 但是限制在了 100 个字符
- 阿里云的用户中心里, 对于订单号这个 input, 前端并没有做长度上的校验/过滤, 而是直接丢给后端, 然后后管返回系统异常前端弹窗提示
- 我平时的工作里, 后台管理系统中, 写过直接崩掉用户的这次操作, 给用户提示字符过长之类的
个人来看的话, 我觉得直接限制用户的输入会比较好. 例如: “输入框输入 n 个字符串就不能再输入”, “数字 id 输入框就只能输入数字”, “合理的情况下使用可以选择的控件而不是输入框(Select, Picker, 带搜索的 Select 等)”. 而不是说等用户进行了非法的输入以后, 才去做校验给用户标红提示或者弹信息提示
输入了, 但是没有点击搜索
假设一个用户更新了输入框, 但是没有点击搜索按钮, 这时候用户点击下一页等时候, 我应该带上视觉上已经更新了的 title 参数吗?
纠结过一下后我还是觉得这是用户傻逼, 你不点击搜索来提交我为什么要带, 而且带的话我是不是又要考虑一下表单校验怎么处理? 但是还是得看产品选择怎么搞了
筛选和排序
假设我们可以根据文章状态来在表格列进行筛选, 并且可以根据 Post 的 order 字段来排序
更新搜索参数 GetPostsDto
的类型定义为
export interface GetPostsDto {
/** @default 1 */
page?: number
/** @default 20 */
pageSize?: number
title?: string
/** 0升序 1降序 */
order?: 0 | 1
status?: PostStatus
}
可以看到, 对于接口来说, 没有什么不同的, 就是加了两个字段而已; 那么对于前端来讲, 也没什么不同的, 就是搜索参数来自于不同的 UI 控件而已, 对于到代码还是那一句 setQuery
更新 columns status 那一栏
{
dataIndex: "status",
title: "status",
filters: [
{ text: "0", value: 0 },
{ text: "1", value: 1 },
],
filterMultiple: false,
},
同时, 对应的 table 的 onChange 函数也更新一下. 同时因为这里使用了 antd 的 table, 得对它给我们的一些数据结构进行一下处理, 让它符合接口的规范
onChange={(pagination, filters) => {
setQuery((prev) => ({
...prev,
page: pagination.current || 1,
pageSize: pagination.pageSize || 20,
status:
filters.status && filters.status.length > 0 ? Number(filters.status[0]) : undefined,
}))
}}
排序也差不多
{ dataIndex: 'order', title: 'order', sorter: true },
onChange={(pagination, filters, sorter) => {
setQuery((prev) => ({
...prev,
page: pagination.current || 1,
pageSize: pagination.pageSize || 20,
status:
filters.status && filters.status.length > 0 ? Number(filters.status[0]) : undefined,
order:
!Array.isArray(sorter) && !!sorter.order && sorter.field === 'order'
? ({ ascend: 0, descend: 1 } as const)[sorter.order]
: undefined,
}))
}}
这时候数据结构就有点恶心了, 要转来转去. 没办法, ui 要用的数据结构和接口要用的数据结构, 用途都不一样那就很难一致. 至于多列排序也是类似的, 就是处理的 sorter 变成一个数组而已
这个时候表格大概是长这个样子的
查看在线 demo codesandbox.io/s/async-moo…
从 url 获取参数初始化查询条件
url 参数我们在任何组件都可以拿, 但是消费这些 url 参数的, 是query
这个 state, 对应到 UI 上, 就有可能是顶部的SearchForm
, table 里列的 sorter 和 filter, 所以拿 url 参数这个动作, 最好是直接在页面组件里搞也就是现在示例用的 App.tsx
这里安装qs这个库, 用于 url querysring 的解析和序列化
yarn add qs
yarn add -D @types/qs
先写一个函数, 获取最初的查询条件
function getDefaultQuery() {
// 先不考虑服务端渲染
const urlSearchParams = qs.parse(window.location.search, {
ignoreQueryPrefix: true,
})
const { page, pageSize, title, status, order } = urlSearchParams
const dto: GetPostsDto = {}
if (typeof page === 'string') {
dto.page = validIntOrUndefiend(page) || 1
}
if (typeof pageSize === 'string') {
dto.pageSize = validIntOrUndefiend(pageSize) || 20
}
if (typeof title === 'string') {
dto.title = title
}
if (typeof status === 'string') {
dto.status = validIntOrUndefiend(status)
}
if (typeof order === 'string') {
const orderNum = validIntOrUndefiend(order)
dto.order = orderNum ? (clamp(orderNum, 0, 1) as 0 | 1) : undefined
}
return dto
}
声明多一个叫 defaultQuery
的 state, 用getDefaultQuery
来初始化它
再用defaultQuery
来初始化query
const [defaultQuery, setDefaultQuery] = React.useState<GetPostsDto>(
getInitialQuery
)
const [query, setQuery] = React.useState<GetPostsDto>(defaultQuery)
为什么要加多一个 defaultQuery
呢? 因为要把它传给SearchForm
, 来同步初始化表单的值
;<SearchForm
defaultQuery={defaultQuery}
//...
/>
export function SearchForm(props: {
onSubmit: (values: FormValues) => any
onReset: (values: FormValues) => any
defaultQuery?: GetPostsDto
}) {
const { onSubmit, onReset, defaultQuery } = props
const [form] = Form.useForm<FormValues>()
const handleReset = () => {
form.resetFields()
onReset({ title: undefined })
}
React.useEffect(() => {
if (!defaultQuery) {
return
}
const { title } = defaultQuery
if (title) {
form.setFieldsValue({ title })
}
}, [form, defaultQuery])
return (
<Form form={form} layout='inline' onFinish={onSubmit}>
<Form.Item name='title' label='标题'>
<Input placeholder='文章标题' maxLength={10} />
</Form.Item>
<Button htmlType='submit' type='primary'>
搜索
</Button>
<Button htmlType='button' onClick={handleReset}>
重置
</Button>
</Form>
)
}
对于 filter 和 sorter, antd 的 columns 提供了对应的受控属性, 将它传进去就好了
{
dataIndex: 'status',
title: 'status',
filters: [
{ text: '0', value: 0 },
{ text: '1', value: 1 },
],
filterMultiple: false,
filteredValue: query.status === undefined ? undefined : [query.status.toString()],
},
{
dataIndex: 'order',
title: 'order',
sorter: true,
sortOrder:
query.order === undefined
? undefined
: ({ 0: 'ascend', 1: 'descend' } as const)[query.order],
},
但是很重要的一点是, 必须将 columns 移入 App 组件内了, 因为 columns 依赖了 query 这个 state, 必须放进去才能每次都获取最新的 query
同步 query 到 url
反向操作, 在 query 每次变化的时候都将其同步到 url
React.useEffect(() => {
const { protocol, host, pathname } = window.location
const newurl = `${protocol}//${host}${pathname}?${qs.stringify(query)}`
window.history.replaceState(null, '', newurl)
// query每次变化的时候同步参数到url
}, [query])
这里直接使用 window.history 的 api, 实际项目里, 比如你用 react-router 的就用 react-router 的 api 就行了
其实这个功能我做得比较少, 除非产品明确要求不然我都是不做...不过做了会对用户会比较友好
查看在线 demo codesandbox.io/s/cool-cook…
弹窗表单
CRUD 中的 Read 已经搞得差不多, 接着看看剩下来的增删改
但是开工前先思考一个问题, 现在增删改就有三个弹窗了, 而且你永远不知道产品会在一个页面下塞下多少个弹窗
假设一个弹窗对应一个 visible
的 state 和一个 Modal
组件, 如果有个 n 个弹窗, 我们是不是要像下面那样写 n 次呢?
function Page() {
const [visible1, setVisible1] = React.useState(false)
const [visible2, setVisible2] = React.useState(false)
const [visible3, setVisible3] = React.useState(false)
const [visiblen, setVisiblen] = React.useState(false)
return (
<>
<Modal title='Modal1' visible={visible1}></Modal>
<Modal title='Modal2' visible={visible2}></Modal>
<Modal title='Modal3' visible={visible3}></Modal>
<Modal title='Modaln' visible={visible4}></Modal>
</>
)
}
如果每个弹窗还都要加上一个 loading 状态等等的话, 那么 Page 里的 state 就太多了
但是没写过的话光这样看是看不出什么鬼来的, 所以还是先写吧
Create
首先还是要有一个接口啊, 假设我们有一个叫 createPost
的接口, 类型定义如下:
type CreatePostDto = {
title: string
content: string
status: PostStatus
order: number
}
type CreatePost = (
dto: CreatePostDto
) => Promise<{
id: number
}>
根据 antd 里的文档, 一个弹窗的里的新建表单可以这样搞, 那就哐哐哐照抄
创建一个叫 CreateForm
的组件
export function CreatForm(props: {
visible: boolean
onCreate: (dto: CreatePostDto) => void
onCancel: () => void
loading: boolean
}) {
const { visible, onCancel, onCreate, loading } = props
const [form] = Form.useForm()
const handleSubmit = () => {
form.validateFields().then((values) => {
onCreate(values as CreatePostDto)
})
}
// 重置表单
React.useEffect(() => {
if (!visible) {
return
}
form.resetFields()
}, [visible, form])
return (
<Modal
title='Create Post'
visible={visible}
onCancel={onCancel}
onOk={handleSubmit}
okButtonProps={{ loading }}
>
<Form form={form} labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item
name='title'
label='title'
rules={[
{
required: true,
message: 'title is required',
},
]}
>
<Input></Input>
</Form.Item>
<Form.Item
name='content'
label='content'
rules={[
{
required: true,
message: 'content is required',
},
]}
>
<Input.TextArea></Input.TextArea>
</Form.Item>
<Form.Item
name='status'
label='status'
initialValue={PostStatus.Draft}
required
>
<Radio.Group>
<Radio value={PostStatus.Draft}>draft</Radio>
<Radio value={PostStatus.Published}>published</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name='order'
label='order'
rules={[
{
required: true,
message: 'order is required',
},
]}
initialValue={1}
>
<InputNumber min={1}></InputNumber>
</Form.Item>
</Form>
</Modal>
)
}
然后在页面组件引入,并且声明相关状态以及绑定事件
const [createVisible, setCreateVisible] = React.useState(false)
const [createLoading, setCreateLoading] = React.useState(false)
//...
<CreatForm
visible={createVisible}
onCreate={async (values: CreatePostDto) => {
setCreateLoading(true)
try {
await createPost(values)
message.success('创建成功')
// 刷新列表
setQuery((prev) => ({
...prev,
}))
setCreateVisible(false)
} catch (e) {
message.error('创建失败')
} finally {
setCreateLoading(false)
}
}}
onCancel={() => setCreateVisible(false)}
loading={createLoading}
/>
查看在线 demo codesandbox.io/s/tender-tu…
注意我们的接口都是模拟的, 每次刷新页面数据都会重新生成
Update
接下来就是编辑了, 照样先看接口类型, 我们叫它updatePost
type UpdatePostDto = Partial<Post> & { id: number }
type UpdatePost = (dto: CreatePostDto) => Promise<void>
更新文章 id 是必传的, 其他字段不传就不更新
一般来讲, 我们的创建表单和编辑表单都是可以复用同一个组件的. 同时我们也需要当前编辑的 Post 数据来初始化表单
将CreateForm
重命名为PostForm
interface FormValues {
title: string
content: string
status: PostStatus
order: number
}
export function PostForm(props: {
visible: boolean
title: string
loading: boolean
onCancel: () => void
onCreate?: (dto: CreatePostDto) => void
onUpdate?: (dto: UpdatePostDto) => void
record?: Post
}) {
const {
visible,
onCancel,
onCreate,
onUpdate,
loading,
record,
title,
} = props
const [form] = Form.useForm<FormValues>()
const handleSubmit = () => {
form.validateFields().then((values) => {
if (record) {
onUpdate &&
onUpdate({
...values,
id: record.id,
} as UpdatePostDto)
} else {
onCreate && onCreate(values as CreatePostDto)
}
})
}
// 初始化表单
React.useEffect(() => {
form.setFieldsValue({
title: record?.title,
content: record?.content,
status: record ? record.status : PostStatus.Draft,
order: record?.order || 1,
})
}, [record, form])
return (
<Modal
title={title}
visible={visible}
onCancel={onCancel}
onOk={handleSubmit}
okButtonProps={{ loading }}
>
<Form form={form} labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item
name='title'
label='title'
rules={[
{
required: true,
message: 'title is required',
},
]}
>
<Input></Input>
</Form.Item>
<Form.Item
name='content'
label='content'
rules={[
{
required: true,
message: 'content is required',
},
]}
>
<Input.TextArea></Input.TextArea>
</Form.Item>
<Form.Item name='status' label='status' required>
<Radio.Group>
<Radio value={PostStatus.Draft}>draft</Radio>
<Radio value={PostStatus.Published}>published</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name='order'
label='order'
rules={[
{
required: true,
message: 'order is required',
},
]}
>
<InputNumber min={1}></InputNumber>
</Form.Item>
</Form>
</Modal>
)
}
props 的变更: 增加 onUpdate
, record
,title
, 将onCreate
变成 optional 的
同时注意到, 我们将比表单初始化的工作放在React.useEffect
来做了, 因为 Form.Item
的initialValue
属性和非受控 input 的defaultValue
一样, 在组件第一次渲染之后就没用了影响不到后续的更新
然后在页面组件里, 照之前的 CreateForm 来一套就好了
<PostForm
title='Update Post'
record={selectedRecord}
visible={updateVisible}
onUpdate={async (values: UpdatePostDto) => {
setUpdateLoading(true)
try {
await updatePost(values)
message.success('编辑成功')
// 刷新列表
setQuery((prev) => ({
...prev,
}))
setUpdateVisible(false)
} catch (e) {
message.error('编辑失败')
} finally {
setUpdateLoading(false)
}
}}
onCancel={() => setUpdateVisible(false)}
loading={updateLoading}
/>
注意到这里需要一个 record 属性, 也就是当前编辑的 Post, 我们需要一个声明一个 state 来保存它
const [selectedRecord, setSelectedRecord] = React.useState<Post>()
触发事件的时候:
{
title: '操作',
render: (_, record) => (
<Space>
<span
style={{ cursor: 'pointer' }}
onClick={() => {
setSelectedRecord(record)
setUpdateVisible(true)
}}
>
编辑
</span>
<span style={{ color: 'red', cursor: 'pointer' }}>删除</span>
</Space>
),
},
这样就搞定了,查看在线 demo, codesandbox.io/s/happy-hai…
编辑的时候需要额外调用接口
这也是个比较常见的需求, 有时候一些额外的字段在表格的 list 接口可能并没有, 需要调用额外的接口去拿. 如果是这种情况的话, 我们的 PostForm
的 props 一样可以保持不变, 根据传进来的record
也就是当前 Post 的信息去调用接口, 然后再设置表单的值就可以了
Delete
接下来搞删除, 假设我们的接口叫 deletePost
, 类型如下:
type DeletePost = (id: number) => Promise<void>
删除的话, 这里我们使用 antd 的Modal.confirm
. 而且这里 onOk 返回一个 Promise 的话可以给按钮加 loading, 这样=我们就不用再声明多一个 loading 状态了
function handleDelete(record: Post, onSuccess: () => void) {
Modal.confirm({
title: 'Delete Post',
content: <p>确定删除 {record.title} 吗?</p>,
onOk: async () => {
try {
await deletePost(record.id)
message.success('删除成功')
onSuccess()
} catch (e) {
message.error('删除失败')
}
},
})
}
事件绑定:
{
title: '操作',
render: (_, record) => (
<Space>
<span
style={{ cursor: 'pointer' }}
onClick={() => {
setSelectedRecord(record)
setUpdateVisible(true)
}}
>
编辑
</span>
<span
style={{ color: 'red', cursor: 'pointer' }}
onClick={() =>
handleDelete(record, () =>
setQuery((prev) => {
const prevPage = prev.page || 1
return {
...prev,
page: data.list.length === 1 ? clamp(prevPage - 1, 1, prevPage) : prevPage,
}
})
)
}
>
删除
</span>
</Space>
),
}
这里有个稍微要注意的地方, 就是当前页面只有最后一条数据了, 如果我们删除了这一条数据还传原来的页码过去, 那么用户看到的就是没数据的页面, 会有点奇怪, 所以把页码减了一页
查看在线 demo, codesandbox.io/s/beautiful…
我挺喜欢 Modal.confirm 这个语法糖的, 对于这种不需要填表单的操作, 是很方便的
批量操作
假设产品跟我们讲, 需要一个批量发布文章的按钮, 那么我们需要一个批量更改文章状态的接口. 假设它叫 batchUpdatePostsStatus
类型定义如下:
type BatchUpdatePostsStatusDto = {
ids: number[]
status: PostStatus
}
type BatchUpdatePostsStatus = (dto: BatchUpdatePostsDto) => Promise<void>
其实我们像之前删除那样子搞就好了, 但是为了把事情搞得复杂一点, 产品说在批量发布的时候, 必须需要加上一个备注. 所以我们得像创建和编辑那样, 搞一个弹窗表单了.
BatchUpdatePostsStatusDto
的类型更新为
type BatchUpdatePostsStatusDto = {
ids: number[]
status: PostStatus
/** 备注 */
remark: string
}
创建表单
interface FormValues {
status: PostStatus
remark: string
}
export function BatchUpdatePostsStatusForm(props: {
visible: boolean
loading: boolean
records: Post[]
onCancel: () => void
onSubmit: (dto: BatchUpdatePostsStatusDto) => Promise<void>
}) {
const { visible, onCancel, onSubmit, loading, records } = props
const [form] = Form.useForm<FormValues>()
const handleSubmit = () => {
form.validateFields().then(async (values) => {
await onSubmit({
...values,
ids: records.map((item) => item.id),
} as BatchUpdatePostsStatusDto)
// 更新完重置表单
form.resetFields()
})
}
return (
<Modal
title='批量更新文章状态'
visible={visible}
onCancel={onCancel}
onOk={handleSubmit}
okButtonProps={{ loading }}
>
<Form form={form} labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
<Form.Item
name='remark'
label='remark'
rules={[
{
required: true,
message: 'remark is required',
},
]}
>
<Input.TextArea placeholder='填写备注'></Input.TextArea>
</Form.Item>
<Form.Item
name='status'
label='status'
required
initialValue={PostStatus.Draft}
>
<Radio.Group>
<Radio value={PostStatus.Draft}>draft 0</Radio>
<Radio value={PostStatus.Published}>published 1</Radio>
</Radio.Group>
</Form.Item>
</Form>
</Modal>
)
}
添加所需要的状态, 包括多选的 row
const [selectedRows, setSelectedRows] = React.useState<Post[]>([])
const [batchUpdateStatusVisible, setBatchUpdateStatusVisible] = React.useState(
false
)
const [batchUpdateStatusLoading, setBatchUpdateStatusLoading] = React.useState(
false
)
渲染表单
<BatchUpdatePostsStatusForm
// @see https://ant.design/components/form-cn/#FAQ
forceRender
visible={batchUpdateStatusVisible}
records={selectedRows}
loading={batchUpdateStatusLoading}
onCancel={() => {
setBatchUpdateStatusVisible(false)
setSelectedRows([])
}}
onSubmit={async (values: BatchUpdatePostsStatusDto) => {
setBatchUpdateStatusLoading(true)
try {
await batchUpdatePostsStatus(values)
message.success('批量编辑成功')
// 刷新列表
setQuery((prev) => ({
...prev,
}))
setBatchUpdateStatusVisible(false)
setSelectedRows([])
} catch (e) {
message.error('批量编辑失败')
} finally {
setBatchUpdateStatusLoading(false)
}
}}
/>
绑定事件
<Button
type='primary'
disabled={selectedRows.length <= 0}
onClick={() => {
setBatchUpdateStatusVisible(true)
}}
>
批量更新文章状态
</Button>
<Table
rowSelection={{
selectedRowKeys: selectedRows.map((item) => item.id),
onChange: (_, rows) => setSelectedRows(rows),
}}
/>
好了反正就是来一套, 查看线上 demo,codesandbox.io/s/proud-dar…
现在 App.tsx 这个文件内容有大概 317 行了, 下一步来看看能不能在写法上优化一下 (不过我觉着还好, 起码挺工整的...)
提取接口获取数据逻辑至外部
来观察一下现在的 App 组件
function App() {
const [defaultQuery] = React.useState<GetPostsDto>(getDefaultQuery)
const [query, setQuery] = React.useState<GetPostsDto>(defaultQuery)
const [data, setData] = React.useState<TableListResponse<Post>>({
list: [],
pagination: {
page: 1,
pageSize: 20,
total: 0,
},
})
const [loading, setLoading] = React.useState(false)
const [selectedRecord, setSelectedRecord] = React.useState<Post>()
const [selectedRows, setSelectedRows] = React.useState<Post[]>([])
const [createVisible, setCreateVisible] = React.useState(false)
const [createLoading, setCreateLoading] = React.useState(false)
const [updateVisible, setUpdateVisible] = React.useState(false)
const [updateLoading, setUpdateLoading] = React.useState(false)
const [
batchUpdateStatusVisible,
setBatchUpdateStatusVisible,
] = React.useState(false)
const [
batchUpdateStatusLoading,
setBatchUpdateStatusLoading,
] = React.useState(false)
const columns: ColumnProps<Post>[] = [
{ dataIndex: 'id', title: 'id' },
{ dataIndex: 'title', title: 'title' },
{ dataIndex: 'content', title: 'content' },
{
dataIndex: 'status',
title: 'status',
filters: [
{ text: '0', value: 0 },
{ text: '1', value: 1 },
],
filterMultiple: false,
filteredValue:
query.status === undefined ? undefined : [query.status.toString()],
},
{
dataIndex: 'order',
title: 'order',
sorter: true,
sortOrder:
query.order === undefined
? undefined
: ({ 0: 'ascend', 1: 'descend' } as const)[query.order],
},
{ dataIndex: 'createdAt', title: 'createdAt', sorter: true },
{ dataIndex: 'updatedAt', title: 'updatedAt' },
{
title: '操作',
render: (_, record) => (
<Space>
<span
style={{ cursor: 'pointer' }}
onClick={() => {
setSelectedRecord(record)
setUpdateVisible(true)
}}
>
编辑
</span>
<span
style={{ color: 'red', cursor: 'pointer' }}
onClick={() =>
handleDelete(record, () =>
setQuery((prev) => {
const prevPage = prev.page || 1
return {
...prev,
page:
data.list.length === 1
? clamp(prevPage - 1, 1, prevPage)
: prevPage,
}
})
)
}
>
删除
</span>
</Space>
),
},
]
React.useEffect(() => {
let isCurrent = true
setLoading(true)
getPosts(query)
.then((res) => isCurrent && setData(res))
.finally(() => isCurrent && setLoading(false))
return () => {
// 防止组件已经卸载的时候, 还会对已经卸载的组件setState
isCurrent = false
}
// query每次变化的时候都会重新调用接口
}, [query])
React.useEffect(() => {
const { protocol, host, pathname } = window.location
const newurl = `${protocol}//${host}${pathname}?${qs.stringify(query)}`
window.history.replaceState(null, '', newurl)
// query每次变化的时候同步参数到url
}, [query])
return (
<div>
<h1>antd table crud</h1>
<SearchForm
defaultQuery={defaultQuery}
onSubmit={(values) =>
setQuery((prev) => ({
...prev,
...values,
page: 1, // 重置分页
}))
}
onReset={(values) =>
setQuery((prev) => ({
...prev,
...values,
page: 1, // 重置分页
}))
}
/>
<div style={{ margin: '15px 0' }}>
<Space>
<Button type='primary' onClick={() => setCreateVisible(true)}>
Create
</Button>
<Button
type='primary'
disabled={selectedRows.length <= 0}
onClick={() => {
setBatchUpdateStatusVisible(true)
}}
>
批量更新文章状态
</Button>
</Space>
</div>
<Table
rowKey='id'
dataSource={data.list}
columns={columns}
loading={loading}
pagination={{ ...antdPaginationAdapter(data.pagination) }}
onChange={(pagination, filters, sorter) => {
setQuery((prev) => ({
...prev,
page: pagination.current || 1,
pageSize: pagination.pageSize || 20,
status:
filters.status && filters.status.length > 0
? Number(filters.status[0])
: undefined,
order:
!Array.isArray(sorter) &&
!!sorter.order &&
sorter.field === 'order'
? ({ ascend: 0, descend: 1 } as const)[sorter.order]
: undefined,
}))
}}
rowSelection={{
selectedRowKeys: selectedRows.map((item) => item.id),
onChange: (_, rows) => setSelectedRows(rows),
}}
></Table>
<PostForm
title='Create Post'
visible={createVisible}
onCreate={async (values: CreatePostDto) => {
setCreateLoading(true)
try {
await createPost(values)
message.success('创建成功')
// 刷新列表
setQuery((prev) => ({
...prev,
}))
setCreateVisible(false)
} catch (e) {
message.error('创建失败')
} finally {
setCreateLoading(false)
}
}}
onCancel={() => setCreateVisible(false)}
loading={createLoading}
/>
<PostForm
title='Update Post'
record={selectedRecord}
visible={updateVisible}
onUpdate={async (values: UpdatePostDto) => {
setUpdateLoading(true)
try {
await updatePost(values)
message.success('编辑成功')
// 刷新列表
setQuery((prev) => ({
...prev,
}))
setUpdateVisible(false)
} catch (e) {
message.error('编辑失败')
} finally {
setUpdateLoading(false)
}
}}
onCancel={() => setUpdateVisible(false)}
loading={updateLoading}
/>
<BatchUpdatePostsStatusForm
visible={batchUpdateStatusVisible}
records={selectedRows}
loading={batchUpdateStatusLoading}
onCancel={() => {
setBatchUpdateStatusVisible(false)
setSelectedRows([])
}}
onSubmit={async (values: BatchUpdatePostsStatusDto) => {
setBatchUpdateStatusLoading(true)
try {
await batchUpdatePostsStatus(values)
message.success('批量编辑成功')
// 刷新列表
setQuery((prev) => ({
...prev,
}))
setBatchUpdateStatusVisible(false)
setSelectedRows([])
} catch (e) {
message.error('批量编辑失败')
} finally {
setBatchUpdateStatusLoading(false)
}
}}
/>
</div>
)
}
首先, 获取根据 query 获取 table 数据这个套路是很固定的, 我们完全可以把它提取到 App 组件外面, 形成一个叫 usePosts
的函数
function usePosts(defaultQuery: GetPostsDto) {
const [query, setQuery] = React.useState<GetPostsDto>(defaultQuery)
const [data, setData] = React.useState<TableListResponse<Post>>({
list: [],
pagination: {
page: 1,
pageSize: 20,
total: 0,
},
})
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
let isCurrent = true
setLoading(true)
getPosts(query)
.then((res) => isCurrent && setData(res))
.finally(() => isCurrent && setLoading(false))
return () => {
// 防止组件已经卸载的时候, 还会对已经卸载的组件setState
isCurrent = false
}
// query每次变化的时候都会重新调用接口
}, [query])
return {
query,
setQuery,
data,
loading,
}
}
然后, 把 App 组件里相关的代码删掉, 换成这一句
const { data, query, setQuery, loading } = usePosts(defaultQuery)
但是如果是这样的话, 我们的usePosts
也仅仅只能用于文章的增删改查. 同一个项目里, 接口的数据结构应该是一致的. 再看一下上面的 usePosts, 有类型标注的地方, 已经在提示我们要怎么进行抽象了, 这时候就需要使用泛型了
export function useTableListQuery<
Query extends { page?: number; pageSize?: number },
Entity
>(
api: (query: Query) => Promise<TableListResponse<Entity>>,
defaultQuery: Query
) {
const [query, setQuery] = React.useState<Query>(defaultQuery)
const [data, setData] = React.useState<TableListResponse<Entity>>({
list: [],
pagination: {
page: 1,
pageSize: 20,
total: 0,
},
})
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
let isCurrent = true
setLoading(true)
api(query)
.then((res) => isCurrent && setData(res))
.finally(() => isCurrent && setLoading(false))
return () => {
// 防止组件已经卸载的时候, 还会对已经卸载的组件setState
isCurrent = false
}
// query每次变化的时候都会重新调用接口
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
return {
query,
setQuery,
data,
loading,
}
}
上面这里我们提取了一个叫 useTableListQuery
的函数, 它接受两个参数: 一个是调用后端接口的函数, 一个是默认的查询参数. 逻辑上和 usePosts
没有任何区别
然后将 App 组件里的相关代码修改成下面这样就好了 👇
const { data, query, setQuery, loading } = useTableListQuery(
getPosts,
defaultQuery
)
同理, 同步query
状态到 url 参数这个逻辑也可以提取到外面:
export function useStateSyncToUrl<T>(state: T, options?: qs.IStringifyOptions) {
const optionsRef = React.useRef(options)
React.useEffect(() => {
const { protocol, host, pathname } = window.location
const newurl = `${protocol}//${host}${pathname}?${qs.stringify(
state,
optionsRef.current
)}`
window.history.replaceState(null, '', newurl)
// state每次变化的时候同步参数到url
}, [state])
}
查看在线 demo, codesandbox.io/s/infallibl…
抽象弹窗表单逻辑
一路写下来我们可以发现, 这些弹窗表单的逻辑都很像: 点击按钮 -> 弹窗 -> 填写表单 -> 提交接口 -> 接口调用成功 -> 刷新 table 数据, 收起弹窗 既然套路比较统一了的话, 我觉得是可以也值得抽象的, 当然如果有例外的就特殊处理就好了
统一的 visible 和 loading 状态
像弹窗这种交互, 本身就是中断用户的其它操作, 让用户将精力聚焦于弹窗本身, 所以上面的一堆 visible 状态可以统一使用一个
我们有创建, 编辑, 批量更新文章状态这三种业务/操作, 为其定义一个类型
type ModalActionType = '' | 'create' | 'update' | 'batchUpdateStatus'
其中, 空字符串表示不进行任何操作
接着定义相关的 state:
const [modalActionType, setModalActionType] = React.useState<ModalActionType>(
''
)
这样的话, 判断创建弹窗表单是否展示可以用
visible = {modalActionType === 'create'}
打开创建弹窗表单可以用
setModalActionType('create')
关闭弹窗可以用
setModalActionType('')
编辑和批量更新文章状态同理
接着是 loading, 当然可以按照上面的 visible 那样来搞一遍, 但是我觉着没必要, 直接统一用一个就好了:
const [modalActionLoading, setModalActionLoading] = React.useState(false)
确定, 取消按钮的事件绑定
来看看确定按钮的事件绑定, 最主要的区别还是调用的接口的不同; 其次是接口成功之后的操作可能不同, 比如有多选表格的, 要把这个多选记录清掉 我们可以尝试来写一个工厂函数, 统一不变的, 可变的用参数传递
type ModalActionFactory = <
API extends (...args: any[]) => Promise<unknown>
>(options: {
api: API
successMessage?: string
errorMessage?: string
}) => (...args: Parameters<API>) => Promise<void>
const clean = () => {
setSelectedRecord(undefined)
setSelectedRows([])
}
const handleModalCancel = () => {
setModalActionType('')
clean()
}
const modalActionFactory: ModalActionFactory = (options) => {
const {
api,
successMessage = '操作成功',
errorMessage = '操作失败',
} = options
return async (...args: any[]) => {
setModalActionLoading(true)
try {
await api(args)
message.success(successMessage)
// 刷新列表
setQuery((prev) => ({
...prev,
}))
handleModalCancel()
} catch (e) {
message.error(errorMessage)
} finally {
setModalActionLoading(false)
}
}
}
如上所示, modalActionFactory 接收接口 api 的参数等, 返回了一个函数, 这个函数被添加了接口调用成功和失败以后的处理逻辑, 这样, 组件里就可以这样写:
<PostForm
title='Create Post'
visible={modalActionType === 'create'}
onCreate={modalActionFactory({
api: createPost,
successMessage: '创建成功',
errorMessage: '创建失败',
})}
onCancel={handleModalCancel}
loading={modalActionLoading}
/>
这样的话, 看起来就会比较统一了, 少写一点模板代码. 坏处就是, 如果碰到一些比较特殊的情况, 这个 modalActionFactory 的封装很可能满足不了, 这种情况的话, 我都是建议直接另外写就好了
查看线上 demo codesandbox.io/s/jolly-eul…
Hooks vs Class Components
可以看到上面的例子, 都是用 hooks 来写的. 都要 2021 了, 不想纠结说用哪个更好了, 哪个下班快就用哪个
hooks 逻辑复用有优势, class 代码组织上会让人觉得更有条理更工整, 这是我的感受
如果上面的例子用 class 来写的话, 最关键的点是如何做到:
React.useEffect(() => {
let isCurrent = true
setLoading(true)
api(query)
.then((res) => isCurrent && setData(res))
.finally(() => isCurrent && setLoading(false))
return () => {
// 防止组件已经卸载的时候, 还会对已经卸载的组件setState
isCurrent = false
}
// query每次变化的时候都会重新调用接口
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
我能想到的有两个:
componentDidMount + componentDidUpdate
this.setState({ query }, () => api.then(() => {/** logic */}))
其次是, 如果用 class 写, 我基本不会想去提取use-table-list-query
这样的逻辑, 宁愿在每一个页面都写一次. 因为提取这样的逻辑的话, 很大可能就是用 hoc, hoc 套来套去的, 不太想用
hooks 直接 use 会直观很多, 但是如果 use 得多了, 或者函数组件内部定义了大量的 const xxx = yyy 之类的变量/子函数, 代码结构上看起来也会感觉挺乱的. 反正还是那句话, 哪个下班快就用哪个