AI 编程时代手工匠人代码打造 React 项目实战 (五):分页 & 筛选 & 阶段性思考

前置工作

使用 pnpm i @faker-js/faker 安装 faker.js 来填充一些数据

编码流程

1.分页

我们用 faker.js 来给我们的 db 填充一些数据

tsx 复制代码
// src/mock/faker.ts
import { fakerZH_CN as faker } from '@faker-js/faker'
​
// 设置种子,这样每次生成的数据和上一次是相同的
faker.seed(1)
​
export function createRandomUser() {
  return {
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    city: faker.location.city()
  };
}
​
export const users = faker.helpers.multiple(createRandomUser, {
  count: 1000,
});
​
// src/mock/database.ts
import { factory, primaryKey } from '@mswjs/data';
import { users } from './faker';
​
export const db = factory({
  user: {
    id: primaryKey(String),
    name: String,
    age: Number,
    city: String,
  },
});
​
// 初始化我们的数据
users.map(item => {
  db.user.create(item)
})
​
export default db

我们修改一下接口mock,添加分页参数

tsx 复制代码
// src/mock/handlers/user.ts
export const userHandlers = [
  // 获取用户列表
  http.post<never, { pageNo: number; pageSize: number }>(
    getApiUrl("/user/getList"),
    async ({ request }) => {
      const params = await request.json();
      const totalData = db.user.getAll();
      const pageNo = params.pageNo || 1,
        pageSize = params.pageSize || 10;
      const total = totalData.length,
        totalPage = Math.floor(total / pageSize);
      const list = totalData.slice((pageNo - 1) * pageSize, pageNo * pageSize);
      const data = {
        total,
        totalPage,
        list,
        pageNo,
        pageSize,
      };
      return sendJson(RESPONSE_CODE_DICT.SUCCESS, data);
    }
  ),
  //...
];
​
export default userHandlers;

修改我们的 UserList 组件,添加分页的逻辑,这里我们不用 Table 组件自带的 Pagination 功能,引入 Pagination 组件(其实是一样的,单独用把分页功能从列表的属性里摘出来)。我们使用一个副作用来处理在 paginate 变化的时候获取数据。

tsx 复制代码
// src/pages/user/UserList.tsx
function UserList() {
  const [paginate, setPaginate] = useState({
    pageNo: 1,
    pageSize: 10
  })
  
  const [pageInfo, setPageInfo] = useState({
    total: 0
  })
​
  const handlePageChange = (pageNo: number, pageSize: number) => {
    if (pageSize !== paginate.pageSize) {
      setPaginate({
        pageNo: 1,
        pageSize
      })
    } else {
      setPaginate({
        ...paginate,
        pageNo
      })
    }
  }
​
  useEffect(() => {
    loadData()
  }, [paginate])
​
  const fetchData = async () => {
    const res = await request.post("user/getList", { ...condition, ...paginate });
    return {
      data: res.data.data.list || [],
      total: res.data.data.total || 0
    }
  }
​
  const loadData = async () => {
    const {data, total} = await fetchData();
    setData(data);
    setPageInfo({
      ...pageInfo,
      total
    })
  };
​
  return (
    <div>
      {/*...*/}
      <Pagination total={pageInfo.total} current={paginate.pageNo} pageSize={paginate.pageSize} onChange={(pageNo, pageSize) => {
        handlePageChange(pageNo, pageSize)
      }} />
    </div>
  );
}
export default UserList;
​
2.筛选项

我们之前定义了一个keyword,现在我们用它来筛选列表项的名字

tsx 复制代码
// src/pages/user/UserList.tsx
const defaultCondition = {
  keyword: "",
};
​
function UserList() {
  const [condition, setCondition] = useState({ ...defaultCondition });
​
  const updateCondition = (
    key: keyof typeof condition,
    value: (typeof condition)[keyof typeof condition]
  ) => {
    setCondition({
      ...condition,
      [key]: value,
    });
  };
​
  return (
    <div>
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: "12px",
          marginBottom: "12px"
        }}
      >
        <Input
          style={{
            width: "200px",
          }}
          placeholder="请输入用户姓名"
          value={condition.keyword}
          onChange={(e) => {
            updateCondition("keyword", e.target.value);
          }}
        />
        <Button type="primary" onClick={loadData}>
          搜索
        </Button>
        <Button onClick={() => navigate("../form")}>创建</Button>
      </div>
    </div>
  );
}
​
export default UserList;

接着我们修改一下 mock 接口的逻辑来筛选目标数据

tsx 复制代码
// src/mock/handlers/user.ts
import { http } from 'msw'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../database'
import { getApiUrl, sendJson } from '../utils'
import type { User } from '@/types/user'
import { RESPONSE_CODE_DICT } from '@/types/http'
​
export const userHandlers = [
  // 获取用户列表
   http.post<never, { pageNo: number; pageSize: number; keyword: string }>(
    getApiUrl("/user/getList"),
    async ({ request }) => {
      const params = await request.json();
      const keyword = params.keyword || "";
      // 过滤符合条件的项
      const totalData = db.user.getAll().filter((i) => {
        return i.name.includes(keyword);
      });
      const pageNo = params.pageNo || 1,
        pageSize = params.pageSize || 10;
      const total = totalData.length,
        totalPage = Math.floor(total / pageSize);
      const list = totalData.slice((pageNo - 1) * pageSize, pageNo * pageSize);
      const data = {
        total,
        totalPage,
        list,
        pageNo,
        pageSize,
      };
      return sendJson(RESPONSE_CODE_DICT.SUCCESS, data);
    }
  ),
]
​
export default userHandlers

在页面上看看效果,发现能够正常筛选

看起来好像一切都很顺利,但是真的是这样吗?来到我们的思考环节

3.阶段性思考

想象一个很常见的场景,在翻页查看数据之后,我们可能会修改筛选参数然后再次点击搜索来获取数据,这意味着我们要将分页置为第一页,按照前面的代码,我们会很自然的想到调用 setPaginate 来修改分页,于是问题就来了。在这个场景下,我们会预期会经历点击搜索 => 判断修改了条件 => setPaginate => 获取数据 这样一个流程。那么试验一下是否会按照我们的预期进行

tsx 复制代码
// src/pages/user/UserList.tsx
const updateCondition = (
  key: keyof typeof condition,
  value: (typeof condition)[keyof typeof condition]
) => {
  setCondition({
    ...condition,
    [key]: value,
  });
};
​
const fetchData = async () => {
  const nextPaginate = {
    ...paginate,
    pageNo: 1,
  }
  const res = await request.post("user/getList", {
    ...condition,
    ...nextPaginate,
  });
  
  // 数据回来之后修改分页状态
  setPaginate(nextPaginate);
​
  return {
    data: res.data.data.list || [],
    total: res.data.data.total || 0,
  };
};

打开控制台,在 keyword 中输入文字之后,点击搜索发现有两次请求出现

回想一下我们之前的代码,我们注册了一个副作用依赖于 paginate,paginate 改变时会执行副作用去请求数据。那我们在输入keyword之后点击搜索,实际上经历的是 [点击搜索 => 判断修改了条件 => 获取数据 => setPaginate => setData => 第一次渲染] => [effect => 获取数据 => setData => 第二次渲染] , 我们分析一下为什么会出现这个问题,我们想在分页参数修改后获取数据,所以选择了用 effect 去执行,但是分页参数的修改除了点击分页器,还有可能在其他地方改动,而我们想要实现的其实是分页器改动的时候执行这段逻辑,这是一个具体的行为,抽象来说就是一个"事件",而不是一种副作用。对此我们想到去修改handlePageChange 函数来执行获取数据的逻辑。

tsx 复制代码
// src/pages/user/UserList.tsx
const handlePageChange = (pageNo: number, pageSize: number) => {
  if (pageSize !== paginate.pageSize) {
    setPaginate({
      pageNo: 1,
      pageSize,
    });
  } else {
    setPaginate({
      ...paginate,
      pageNo,
    });
  }
  loadData();
};

修改之后测试一下,我们发现了另一个问题,翻页的时候传的参数都是上一次修改的参数,于是我们碰到了 react 经典的闭包陷阱,由于 setState 是一个异步操作,所以在 loadData 我们每次读取到参数都是上一次的参数。分析一下如何解决这个问题,我们想到可以修改函数签名,让获取数据的函数直接接收参数。重构一下我们的 fetchData 函数,使其接收条件参数,而它的职责只负责获取数据 & 更新数据。接着我们调整获取数据的地方,由于搜索是一个具体行为,而且它需要在数据回来之后修改分页状态(为什么是在数据回来之后而不是之前?因为如果搜索失败,分页的状态已经被重置了,但是数据还是旧的)

tsx 复制代码
// src/pages/user/UserList.tsx
const [loading, setLoading] = useState(false);
​
const fetchData = async (
  customCondition = condition,
  customPaginate = paginate
) => {
  try {
    setLoading(true);
    const {
      data: {
        data: {
          list,
          total
        }
      }
    } = await request.post("user/getList", {
      ...customCondition,
      ...customPaginate,
    });
​
    setData(list);
    setPageInfo({
      ...pageInfo,
      total,
    });
  } finally {
    setLoading(false);
  }
};
​
const handlePageChange = (pageNo: number, pageSize: number) => {
  const nextPaginate = {
    pageNo,
    pageSize,
  };
  if (pageSize !== paginate.pageSize) {
    nextPaginate.pageNo = 1;
  }
  setPaginate(nextPaginate);
  fetchData(undefined, nextPaginate);
};
​
const handleSearch = async () => {
  await fetchData();
  setPaginate((pre) => ({
    ...pre,
    pageNo: 1,
  }));
};

至此,我们完善了了我们的数据获取的细节,同时解决了之前的 bug

4.优化

上述的 fetchData 并未解决竞态问题,而我们可能翻页很快速。查询 caniuse 和 axios 文档后发现,AbortController api 被大多数浏览器支持,而我们的后台管理系统一般都会指定浏览器使用(不需要考虑万恶的 ie 兼容),所以这里我们使用 AbortController 来处理竞态问题

tsx 复制代码
// src/pages/user/UserList.tsx
const abortRef = useRef<AbortController | null>(null)
const fetchData = async (
  customCondition = condition,
  customPaginate = paginate
) => {
  if (abortRef.current) {
    abortRef.current.abort()
  }
​
  const abortController = new AbortController()
  abortRef.current = abortController
​
  try {
    setLoading(true);
    const {
      data: {
        data: {
          list,
          total
        }
      }
    } = await request.post("user/getList", {
      ...customCondition,
      ...customPaginate,
    }, {
      signal: abortController.signal
    });
​
    if (!abortController.signal.aborted) {
      setData(list);
      setPageInfo({
        ...pageInfo,
        total,
      });
    }
  } finally {
    setLoading(false);
  }
};

小结

至此我们完整的走完了增删改查的业务逻辑,中间出现了一些 bug,比如副作用意外执行,闭包陷阱。我们不能依赖副作用来驱动我们的业务逻辑,例如之前的 paginate 问题,这也和 React 官方文档中的将事件从 Effect 中分开这一章节对应。

接下来我们研究一下 React 比较流行的状态管理库 Zustand 的使用。

相关推荐
WebInfra几秒前
Rsdoctor 1.2 发布:打包产物体积一目了然
前端·javascript·github
用户527096487449033 分钟前
SCSS模块系统详解:@import、@use、@forward 深度解析
前端
兮漫天33 分钟前
bun + vite7 的结合,孕育的 Robot Admin 【靓仔出道】(十一)
前端·vue.js
xianxin_34 分钟前
CSS Text(文本)
前端
秋天的一阵风35 分钟前
😈 藏在对象里的 “无限套娃”?教你一眼识破循环引用诡计!
前端·javascript·面试
电商API大数据接口开发Cris39 分钟前
API 接口接入与开发演示:教你搭建淘宝商品实时数据监控
前端·数据挖掘·api
用户14095081128040 分钟前
原型链、闭包、事件循环等概念,通过手写代码题验证理解深度
前端·javascript
汪子熙40 分钟前
错误消息 Could not find Nx modules in this workspace 的解决办法
前端·javascript
skeletron20111 小时前
🚀AI评测这么玩(2)——使用开源评测引擎eval-engine实现问答相似度评估
前端·后端
前端开发爱好者1 小时前
Vite 7.1.1 疑似遭受大规模 "攻击"!
前端·vue.js·vite