React项目如何在不提前全量查询的前提下仅为有培训数据的用户展示展开图标
我花了不少时间才梳理清楚这个问题,它本质上属于设计策略范畴,和功能实现关联度较低。
我有一个展示全量用户列表的表格视图,表格每行左侧配有一个图标,点击可展开或收起名为UsersTrainingSummary的组件,该组件用于展示用户的特定数据摘要,效果如下图所示:
以下是该视图组件的完整代码:
import React from 'react'; import { Card, CardContent, CardHeader, Chip, Divider, Grid, Typography } from '@material-ui/core'; import { gridSpacing } from '../../../store/constant'; import TableContainer from '@material-ui/core/TableContainer'; import Table from '@material-ui/core/Table'; import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import MuiTableCell from '@material-ui/core/TableCell'; import TableBody from '@material-ui/core/TableBody'; import { makeStyles, withStyles } from '@material-ui/core/styles'; import {Link} from "react-router-dom"; import IconButton from '@material-ui/core/IconButton'; import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp'; import moment from 'moment'; import UsersTrainingSummary from './UsersTrainningSummary'; import useLocalStorage from 'react-use-localstorage'; import { useQuery } from '@apollo/client'; import ALL_USERS from '../../../graphql/AllUsers'; import Loader from '../../../component/Loader/Loader'; const useStyles = makeStyles({ table: { minWidth: 350, }, }); const TableCell = withStyles({ root: { borderBottom: "none" } })(MuiTableCell); function createData(userId, username, firstName, lastName, dateJoined, lastLogin, email, isActive, trainings ) { return { userId, username, firstName, lastName, dateJoined, lastLogin, email,isActive, trainings }; } const UserDashboard = () => { const classes = useStyles(); const [storage, setStorage] = useLocalStorage('orgId'); const orgId = storage const { data, error , loading} = useQuery(ALL_USERS, { variables: { orgId: Number(orgId) }, }); function Row(props){ const { row } = props; const [open, setOpen] = React.useState(false); return ( <React.Fragment> <TableRow key={row.userId}> <TableCell> <IconButton aria-label="expand row" size="small" onClick={() => setOpen(!open)}> {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} </IconButton> </TableCell> <Link to={{pathname: `/users/${row?.userId}`, state: { user: row}}} style={{ textDecoration: 'none' }}> <TableCell className={classes.root} hover={true} component="th" scope="row">{row?.username} </TableCell> </Link> <TableCell>{row.firstName}</TableCell> <TableCell>{row.lastName}</TableCell> <TableCell>{moment(row?.dateJoined).format('MM/DD/YYYY')}</TableCell> <TableCell>{moment(row?.lastLogin).format('MM/DD/YYYY')}</TableCell> <TableCell>{row?.email}</TableCell> <TableCell>{row?.isActive? <React.Fragment>Yes</React.Fragment> : <React.Fragment>No</React.Fragment>}</TableCell> </TableRow> <TableRow> <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}> {open && <UsersTrainingSummary userId={row.userId} trainings={row.trainings}/>} </TableCell> </TableRow> </React.Fragment> ) } if(data) { let userList = data.organization?.user const rows = []; if (userList) { userList.map((user) => { rows.push(createData(user.id, user.username, user.firstName, user.lastName, user.dateJoined, user.lastLogin, user.email, user.isActive, user.trainings)) }) } return ( <Grid container spacing={gridSpacing}> <Grid item xs={12}> <Card> <CardHeader title={ <Typography component="div" className="card-header"> List of all trainees </Typography> } /> <Divider/> <CardContent className="p-0"> <TableContainer> <Table className={classes.table} aria-label="simple table"> <TableHead> <TableRow> <TableCell></TableCell> <TableCell>Username</TableCell> <TableCell>First Name</TableCell> <TableCell>Last Name</TableCell> <TableCell>Date Joined</TableCell> <TableCell>Last Login</TableCell> <TableCell>Email</TableCell> <TableCell>Is Active</TableCell> </TableRow> </TableHead> <TableBody> {rows?.length > 0 && rows.map((row) => ( <Row key={row?.userId} row={row}/> ))} </TableBody> </Table> </TableContainer> </CardContent> </Card> </Grid> </Grid> ); } else return ( <Loader />) } export default UserDashboard
由于数据库关联表数据量极大,若一次性拉取所有用户的摘要数据,对应查询的响应速度会非常慢。而绝大多数场景下仅需查看个别用户的培训数据,因此我采用了懒加载策略:仅当用户点击箭头图标时才会渲染UsersTrainingSummary组件,在该组件内部发起GraphQL查询拉取后端数据,并完成折叠组件的渲染逻辑。
我现在想要实现的效果是:仅为实际存在可展示培训数据的用户显示左侧展开图标,避免用户点击后出现空数据提示影响使用体验。但判断用户是否有数据需要先执行查询,全量查询所有用户的培训数据会导致性能大幅下降,性能损耗远大于体验优化的收益,我不愿意采用该方案。
请问是否有其他方案可以实现该需求?
可行解决方案
方案1:后端追加轻量计数字段(最优,推荐)
直接修改现有的ALL_USERS GraphQL查询,在用户返回字段中新增一个trainingCount整数字段,后端仅需要返回关联培训表的COUNT统计值即可。COUNT查询只要走索引,性能损耗几乎可以忽略,不会明显拖慢原有用户列表接口的响应速度。
前端拿到该字段后,仅需要判断row.trainingCount > 0时渲染展开按钮,否则隐藏即可,修改代码量极小:
// Row组件内图标部分调整为以下逻辑 <TableCell> {row.trainingCount > 0 && ( <IconButton aria-label="expand row" size="small" onClick={() => setOpen(!open)}> {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} </IconButton> )} </TableCell>
该方案前后端改造加起来不足20行代码,性价比最高。
方案2:前端缓存查询状态(无需修改后端)
如果暂时没有后端接口调整权限,可以用本地缓存的方式做渐进式优化:
用户第一次点击某行展开按钮时,正常发起查询,拿到结果后把用户ID -> 是否有培训数据的映射存在本地状态/缓存中,后续渲染行时,只要缓存中存在该用户的标记,就对应显示/隐藏展开按钮。
未被点击过的用户暂时还是显示展开按钮,仅在用户点击一次后更新状态,虽然做不到100%提前隐藏,但也能避免用户重复点到无数据的行,同时完全不影响原有性能。
方案3:可视区域预查询(适合分页/虚拟滚动场景)
如果你的表格做了分页或者虚拟滚动,同一时间可视区域内仅展示几十条用户数据,可以监听滚动事件,仅预查询当前可视区域内用户的培训数据状态,每次查询量极小,性能压力可以忽略,也能实现提前显示/隐藏展开按钮的效果。
内容的提问来源于stack exchange,提问作者Rafael Santos

