列表页面 是实际开发中最常见的场景,列表页面是一堆数据的集合,通过每一条记录可以进入对应的详细页面。开发列表主要需要考虑的技术点:
-
如何翻页:翻页过程中,数据的来源是服务器端还是客户端?
-
如何进行内容搜索:前端搜索 or 服务器端搜索(发送请求)
-
如何缓存数据:从内容页返回列表页,数据来自于前端缓存
-
如何进行页面的刷新:数据发生修改时,刷新缓存数据
store 的设计
页面的数据,以及操作的管理我们都会放在 store 里,所以我们先设计一个 store 模型:
const initialState = {
listItems: [], // array
keyword: '', // string
page: 1, // number
pageSize: 3, // number
total: 0, // number
byId: {}, // object
/*
** 数据请求相关 ⬇️
*/
fetchListPending: false, // boolean,请求中
fetchListError: null, // object,请求失败信息
listNeedReload: false, // boolean,是否需要重新请求数据
};
响应 redux 推崇的一种扁平化结构:我们在 listItems
中保存的是一组 id,而非全部数据,具体数据通过 byId
获取。
URL 的设计
为了增加用户体验,我们通常都会为每一个资源映射一个唯一的 URL,将当前页面和关键字 keyword 也作为 URL 的一部分:
/list/${page}?keyword=${XXX}
<Switch>
<Route path="/table/:page?">
<Table />
</Route>
<Route path="/user/:userId">
<Detail />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
实现
目录
├── App.js
├── src
├── store
├── action.js
├── reducer.js
└── store.js
└── pages
├── detail.js
└── table.js
UI 架构
基于 Ant Design
,我们主要用到 Input, Table, Pagination
三个组件,其中 Pagination
已经被 Table
封装自带了。
- table.js
import { Input, Table } from 'antd';
const { Search } = Input;
const { Column, ColumnGroup } = Table;
const TablePage = () => {
return (
<div>
<Search placeholder="Search..." style={{ width: '200px' }} />
<Table
style={{ width: '800px', margin: '50px auto' }}
rowKey="id"
pagination={{ position: 'bottomCenter' }}
>
<Column title="ID" dataIndex="id" key="id" />
<ColumnGroup title="Name">
<Column title="First Name" dataIndex="first_name" key="first_name" />
<Column title="Last Name" dataIndex="last_name" key="last_name" />
</ColumnGroup>
<Column title="Email" dataIndex="email" key="email" />
</Table>
<br />
</div>
);
};
export default TablePage;
- detail.js
function Detail() {
return (
<div className="detail-page">
<Link to="/table">Back to list</Link>
<ul>
<li>
<label>First name:</label>
<span></span>
</li>
<li>
<label>Last name:</label>
<span></span>
</li>
</ul>
</div>
);
}
export default Detail;
Store 实现(异步 Action)
我们用 REQ | RES( reqres.in/api/users?p… )作为测试数据,关于数据请求我们至少设置三种 action:
'FETCH_LIST_BEGIN'
:请求开始'FETCH_LIST_SUCCESS'
:请求成功'FETCH_LIST_ERROR'
:请求失败
我们还会用到的依赖:
- 用 axios 来发送请求
- 用 redux-thunk 处理异步 Action
- 用 redux-logger 帮我们打印派发 action 的日志
关于 异步 action 更多内容,这篇文章 做了详细介绍。
action.js
import axios from 'axios';
// 获取用户列表
export const fetchList =
(page = 1, pageSize = 3, keyword = '') =>
(dispatch) => {
dispatch({
type: 'FETCH_LIST_BEGIN',
});
return new Promise((resolve, reject) => {
const doRequest = axios.get(
`https://reqres.in/api/users?page=${page}&per_page=${pageSize}&q=${keyword}`,
);
doRequest.then(
(res) => {
dispatch({
type: 'FETCH_LIST_SUCCESS',
data: {
items: res.data.data,
page,
pageSize,
total: res.data.total,
},
});
resolve(res);
},
(err) => {
dispatch({
type: 'FETCH_LIST_ERROR',
data: { error: err },
});
reject(err);
},
);
});
};
// 获取用户具体信息
export const fetchUser = (id) => (dispatch) => {
dispatch({
type: 'FETCH_USER_BEGIN',
});
return new Promise((resolve, reject) => {
const doRequest = axios.get(`https://reqres.in/api/users/${id}`);
doRequest.then(
(res) => {
dispatch({
type: 'FETCH_USER_SUCCESS',
data: res.data.data,
});
resolve(res);
},
(err) => {
dispatch({
type: 'FETCH_USER_ERROR',
data: { error: err },
});
reject(err);
},
);
});
};
reducer.js
根据 action 处理 state
const initialState = {
items: [],
page: 1,
pageSize: 3,
total: 0,
byId: {},
fetchListPending: false,
fetchListError: null,
fetchUserPending: false,
fetchUserError: null,
listNeedReload: false,
};
// reducer
export default function reducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_LIST_BEGIN':
return {
...state,
fetchListPending: true,
fetchListError: null,
};
case 'FETCH_LIST_SUCCESS': {
const byId = {};
const items = [];
action.data.items.forEach((item) => {
items.push(item.id);
byId[item.id] = item;
});
return {
...state,
byId,
items,
page: action.data.page,
pageSize: action.data.pageSize,
total: action.data.total,
fetchListPending: false,
fetchListError: null,
};
}
case 'FETCH_LIST_ERROR':
return {
...state,
fetchListPending: false,
fetchListError: action.data,
};
case 'FETCH_USER_BEGIN':
return {
...state,
fetchUserPending: true,
fetchUserError: null,
};
case 'FETCH_USER_SUCCESS': {
return {
...state,
byId: {
...state.byId,
[action.data.id]: action.data,
},
fetchUserPending: false,
};
}
case 'FETCH_USER_ERROR':
return {
...state,
fetchUserPending: false,
fetchUserError: action.data,
};
default:
break;
}
return state;
}
store.js
create store + 设置中间件
import { createStore, applyMiddleware, compose } from 'redux';
import reducer from './reducer';
import thunk from 'redux-thunk';
const createLogger = require('redux-logger').createLogger;
const logger = createLogger({ collapsed: true });
// 设置调试工具
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
: compose;
// 设置中间件
const enhancer = composeEnhancers(applyMiddleware(thunk, logger));
// Create store
const store = createStore(reducer, enhancer);
export default store;
列表页
页面数据加载
前面写到的组件只能算是一个展示组件(负责 UI 的呈现),想要在组件中使用 store 就需要用一个容器组件包裹它。详细介绍看 这篇 --- Redux 结合 React 开发应用。
connect store
import { connect } from 'react-redux';
import { fetchList, fetchUser } from '../store/action';
const TablePage = (props) => {
return;
};
const mapStateToProps = function (state) {
return {
...state.table,
};
};
const mapDispatchToProps = { fetchList, fetchUser };
export default connect(mapStateToProps, mapDispatchToProps)(TablePage);
获取/处理数据
// something import ....
const TablePage = (props) => {
const { items, byId, fetchList, page, total, pageSize } = props;
// 处理数据
const getDataSource = () => {
if (!items) return [];
return items.map((id) => byId[id]);
};
// 获取数据
useEffect(() => {
fetchList(1);
}, []);
// 渲染 UI
return (
<div>
// ...
<Table
dataSource={getDataSource()}
style={{ width: '800px', margin: '50px auto' }}
rowKey="id"
pagination={{
current: page,
total: total,
pageSize: pageSize,
}}
>
<Column
title="ID"
dataIndex="id"
key="id"
render={(id) => <Link to={`/user/${id}`}>{id}</Link>}
/>
<ColumnGroup title="Name">
<Column title="First Name" dataIndex="first_name" key="first_name" />
<Column title="Last Name" dataIndex="last_name" key="last_name" />
</ColumnGroup>
<Column title="Email" dataIndex="email" key="email" />
</Table>
</div>
);
};
翻页
我们的翻页状态是可以保存在路由中的,刷新后依然停留在当前页码
import { useHistory, useParams } from 'react-router-dom';
const TablePage = (props) => {
let history = useHistory();
const { page: routerPage } = useParams();
const { page } = props;
useEffect(() => {
const initPage = routerPage || 1;
// 页码无变化时,不会重新请求
if (page !== initPage) fetchList(parseInt(initPage, 10));
// eslint-disable-next-line
}, []);
// 处理页码变化
const handlePageChange = (newPage) => {
history.push(`/table/${newPage}`);
fetchList(newPage);
};
return (
<div>
// ...
<Table
dataSource={getDataSource()}
pagination={{
current: page,
onChange: handlePageChange,
total: total,
pageSize: pageSize,
}}
>
// ...
</Table>
</div>
);
};
处理加载状态
-
第一次渲染时的全局 loading
// 页面还没有数据 或 数据为空 if (!items || !items.length) return 'loading...';
-
第一次渲染后的局部 loading
const { fetchListPending } = props; return <Table loading={fetchListPending} />;
更新数据缓存
当我们从内容页切回列表页,使用缓存数据:
if (page !== initPage || !getDataSource().length)
fetchList(parseInt(initPage, 10));
还记得我们 initState 中的 listNeedReload 字段吗,它是我们判断是否更新缓存的依据。
如果在内容页(detail.js)发生了修改数据的操作,应该同时设定 listNeedReload = true
useEffect(() => {
const initPage = routerPage || 1;
if (page !== initPage || !getDataSource().length || listNeedReload)
fetchList(parseInt(initPage, 10));
}, []);
错误处理
如果存在错误信息,就劫持页面不进行后续的渲染。
// pages/table.js
const { fetchListError } = porps;
if (fetchListError) {
return <div>{fetchListError.error.message}</div>;
}
table.js
import { useState, useEffect } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
import { connect } from 'react-redux';
import { Input, Table } from 'antd';
import { fetchList, fetchUser } from '../store/action/table';
const { Search } = Input;
const { Column, ColumnGroup } = Table;
const TablePage = (props) => {
let history = useHistory();
const { page: routerPage } = useParams();
const [search, setSearch] = useState('');
const {
items,
byId,
fetchList,
fetchListError,
fetchListPending,
page,
total,
pageSize,
listNeedReload,
} = props;
const getDataSource = () => {
if (!items) return [];
return items.map((id) => byId[id]);
};
useEffect(() => {
const initPage = routerPage || 1;
// 页码变化 || 未拉取过数据 || 需要 reload
if (page !== initPage || !getDataSource().length || listNeedReload)
fetchList(parseInt(initPage, 10));
// eslint-disable-next-line
}, []);
if (fetchListError) {
return <div>{fetchListError.error.message}</div>;
}
if (!items || !items.length) return 'loading...';
const handlePageChange = (newPage) => {
history.push(`/table/${newPage}`);
fetchList(newPage);
};
const handleSearch = (keyword) => {
fetchList(page, pageSize, keyword);
};
return (
<div>
<Search
placeholder="Search..."
style={{ width: '200px' }}
value={search}
onChange={(e) => setSearch(e.target.value)}
onSearch={handleSearch}
/>
<Table
dataSource={getDataSource()}
style={{ width: '800px', margin: '50px auto' }}
rowKey="id"
loading={fetchListPending}
pagination={{
current: page,
onChange: handlePageChange,
total: total,
pageSize: pageSize,
}}
>
<Column
title="ID"
dataIndex="id"
key="id"
render={(id) => <Link to={`/user/${id}`}>{id}</Link>}
/>
<ColumnGroup title="Name">
<Column title="First Name" dataIndex="first_name" key="first_name" />
<Column title="Last Name" dataIndex="last_name" key="last_name" />
</ColumnGroup>
<Column title="Email" dataIndex="email" key="email" />
</Table>
</div>
);
};
const mapStateToProps = function (state) {
return {
...state.table,
};
};
const mapDispatchToProps = { fetchList, fetchUser };
export default connect(mapStateToProps, mapDispatchToProps)(TablePage);
内容页
内容页和列表页存在两种数据关系:
- 简单数据:列表页数据包含了内容页数据,不需要重新获取数据(需要注意,直接进入内容页的情况)
- 复杂数据:内容页数据需要额外获取
其实第一种情况可以涵盖第二种情况了。
首先,判断 store 中是否存在 user 数据;如果不存在,发起 fetch 请求。
import { fetchUser } from '../store/action/table';
const Detail = (props) => {
const { byId, fetchUser } = props;
const user = byId ? byId[userId] : null;
useEffect(() => {
if (!user) fetchUser(userId);
}, []);
};
detail.js
import { useEffect } from 'react';
import { connect } from 'react-redux';
import { Link, useParams } from 'react-router-dom';
import { fetchUser } from '../store/action/table';
function Detail(props) {
const { userId } = useParams();
const { byId, fetchUserPending, fetchUser } = props;
const user = byId ? byId[userId] : null;
useEffect(() => {
if (!user) fetchUser(userId);
// eslint-disable-next-line
}, []);
if (!user || fetchUserPending) return 'loading...';
const { first_name, last_name } = user;
return (
<div className="detail-page">
<Link to="/table">Back to list</Link>
<ul>
<li>
<label>First name:</label>
<span>{first_name}</span>
</li>
<li>
<label>Last name:</label>
<span>{last_name}</span>
</li>
</ul>
</div>
);
}
function mapStateToProps(state) {
return {
...state.table,
};
}
const mapDispatchToProps = { fetchUser };
export default connect(mapStateToProps, mapDispatchToProps)(Detail);