React 最佳实践:完成一个列表需求叭

986 阅读5分钟

列表页面 是实际开发中最常见的场景,列表页面是一堆数据的集合,通过每一条记录可以进入对应的详细页面。开发列表主要需要考虑的技术点:

  1. 如何翻页:翻页过程中,数据的来源是服务器端还是客户端?

  2. 如何进行内容搜索:前端搜索 or 服务器端搜索(发送请求)

  3. 如何缓存数据:从内容页返回列表页,数据来自于前端缓存

  4. 如何进行页面的刷新:数据发生修改时,刷新缓存数据

    table-2.png

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 架构

table-1.png

基于 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);

React 最佳实践