diff --git a/README.md b/README.md index 29cead6..bc5f85e 100755 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Building a user friendly interface with React.js, Redux/Mobx and LESS/SCSS. - Step1: clone this repository to your account. - Step2: finish the following tasks. -- Step3: Send Pull Request to repository *bridge5/react-examination*. +- Step3: Send Pull Request to repository _bridge5/react-examination_. ## Tasks -- Please add pagination support to the list when there are more than *5* entries. +- Please add pagination support to the list when there are more than _5_ entries. - Please add option to select position of a player SF/PG and display it. - Please add tests using your preferred testing tool (jest, enzyme, mocha, ...). - Please add some features that could help you show your personal abilities. @@ -24,4 +24,41 @@ Building a user friendly interface with React.js, Redux/Mobx and LESS/SCSS. - 第一步:克隆这个仓库到你自己的账号里。 - 第二步:完成下列任务。 -- 第三步:发送 * Pull Request* 到仓库 *bridge5/react-examination*。 \ No newline at end of file +- 第三步:发送 _ Pull Request_ 到仓库 _bridge5/react-examination_。 + +## 修改 + +1. 启动项目报错,经排查是 webpack 内部 createHash 函数有问题,更新 react-scripts 包可解决 +2. 整体思路 + - 由于该功能类似于留言板功能,因此增删改查、分页等为必要功能。这里将 PlayerListApp 组件作为容器组件,负责逻辑处理 + - 其他 添加、搜索、查看等 UI 组件负责渲染即可,不参与逻辑处理 + - 将 store 当做数据层,数据层需要返回两个数据:需要显示的列表数据 showPlayersById 和 总数据量 total,分页、过滤等逻辑都应在数据层做处理 + - 添加逻辑:当输入名称、定位、球队信息提交时,将数据传给 store,成功后清空输入框,并重新请求最新的列表数据 + - 删除逻辑:当点击删除按钮时,需要将 id 传给 数据处理层,删除成功后请求最新的列表数据。(这里需要注意:如果此时列表数据为空 且 page > 1,则使用 page-1 重新请求列表数据) + - 查找逻辑:封装 useMount 处理第一次渲染的逻辑请求,封装 useUpdateEffect 和 useUpdateDebounceEffect,当重新渲染时处理其中的逻辑 + - 分页器:按照 antd 用法封装的简易分页器 + - 加入 form 必填检验,自动生成 id 等,筛选框支持过滤收藏的等 +3. 样式修复 +4. 这里面遇到的卡点:select 中的 onChange 触发时,获取到的 event.target.value 为空,经研究,跟 react 的版本有关系,16.12.0 版本应该用 e.nativeEvent.target.value 来取值 +5. 用户体验优化:封装 useStorageState,该 hook 可用来缓存用户的查询参数,防止用户刷新或再次进入时查询数据丢失 + +## 自测用例 + +1. 添加球员 + - 当输入球员姓名、位置、团队时,列表有无正常显示 + - 当球员姓名为空时,应添加失败 +2. 删除球员 + - 当点击删除按钮,列表是否正常显示 + - 先选择第二页,将第二页删完,此时是否重置到了第一页且正常显示 +3. 查询列表 + - 输入球员姓名、位置、团队时,列表有无正常过滤 + - 当输入大小写时,列表有无正常过滤 + - 当改变搜索条件时,分页是否重置并正常显示数据 + - 当输入搜索条件后,刷新列表,搜索条件是否依然保留 +4. 收藏球员 + - 点击收藏按钮时,列表有无正常显示 + - 点击取消收藏按钮时,列表有无正常显示 + +## 单元测试 + +由于单元测试只是了解,而且在写的过程中库报错,简单判断下是因为 react-dom 包变更所致,因此只写了一个 useFirstMount.test.js diff --git a/package.json b/package.json index f6c7705..7167ffa 100755 --- a/package.json +++ b/package.json @@ -18,7 +18,12 @@ "redux": "^4.0.5" }, "devDependencies": { - "react-scripts": "3.4.0" + "react-scripts": "^5.0.1" }, - "browserslist": [">0.2%", "not dead", "not ie <= 11", "not op_mini all"] + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] } diff --git a/src/App.test.js b/src/App.test.js index b84af98..c9ac3ef 100755 --- a/src/App.test.js +++ b/src/App.test.js @@ -1,8 +1,8 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./App"; -it('renders without crashing', () => { - const div = document.createElement('div'); +it("renders without crashing", () => { + const div = document.createElement("div"); ReactDOM.render(, div); }); diff --git a/src/actions/PlayersActions.js b/src/actions/PlayersActions.js index 8f427c2..471938e 100755 --- a/src/actions/PlayersActions.js +++ b/src/actions/PlayersActions.js @@ -1,9 +1,9 @@ -import * as types from '../constants/ActionTypes'; +import * as types from "../constants/ActionTypes"; -export function addPlayer(name) { +export function addPlayer(playerInfo) { return { type: types.ADD_PLAYER, - name, + playerInfo, }; } @@ -20,3 +20,11 @@ export function starPlayer(id) { id, }; } + +// 获取最新球员的信息 +export const getLatestPlayerData = (params) => { + return { + type: types.GET_LATEST_PLAYER_LIST, + ...params, + }; +}; diff --git a/src/components/AddPlayerInput.css b/src/components/AddPlayerInput.css deleted file mode 100755 index 1507c6e..0000000 --- a/src/components/AddPlayerInput.css +++ /dev/null @@ -1,6 +0,0 @@ -:local(.addPlayerInput) { - border-radius: 0; - border-color: #abaaaa; - border-left: 0; - border-right: 0; -} diff --git a/src/components/AddPlayerInput.js b/src/components/AddPlayerInput.js index 5d914d8..8c4f743 100755 --- a/src/components/AddPlayerInput.js +++ b/src/components/AddPlayerInput.js @@ -1,45 +1,94 @@ -import React, { Component } from 'react'; -import classnames from 'classnames'; -import PropTypes from 'prop-types'; -import styles from './AddPlayerInput.css'; +import React, { useState } from "react"; +import classnames from "classnames"; +import PropTypes from "prop-types"; -class AddPlayerInput extends Component { - render() { - return ( - - ); - } +import { PLAYER_POSITION, PLAYER_TEAM } from "../constants"; - constructor(props, context) { - super(props, context); - this.state = { - name: this.props.name || '', - }; - } +import styles from "./AddPlayerInput.module.css"; - handleChange(e) { - this.setState({ name: e.target.value }); - } +const AddPlayerInput = ({ handleAddPlayer, addPlayerSuccessCallback }) => { + // 球员基本信息 + const [playerInfo, setPlayerInfo] = useState({ + name: "", + position: PLAYER_POSITION[0].value, + team: PLAYER_TEAM[0].value, + }); - handleSubmit(e) { - const name = e.target.value.trim(); - if (e.which === 13) { - this.props.addPlayer(name); - this.setState({ name: '' }); + const handleSubmit = (e) => { + if (e.which === 13 && playerInfo?.name !== "") { + handleAddPlayer(playerInfo); + // 添加成功后初始化数据、并重新请求数据 + setPlayerInfo({ + name: "", + position: PLAYER_POSITION[0].value, + team: PLAYER_TEAM[0].value, + }); + addPlayerSuccessCallback(); } - } -} + }; + + return ( + <> +

add player:

+
+ 球员姓名: + + setPlayerInfo({ ...playerInfo, name: e.target.value.trim() }) + } + onKeyDown={handleSubmit} + /> + 球员位置: + + 所属团队: + +
+ + ); +}; AddPlayerInput.propTypes = { - addPlayer: PropTypes.func.isRequired, + handleAddPlayer: PropTypes.func.isRequired, + addPlayerSuccessCallback: PropTypes.func.isRequired, }; export default AddPlayerInput; diff --git a/src/components/AddPlayerInput.module.css b/src/components/AddPlayerInput.module.css new file mode 100755 index 0000000..39c35de --- /dev/null +++ b/src/components/AddPlayerInput.module.css @@ -0,0 +1,32 @@ +h3 { + color: #fff; + font-size: 16px; +} + +.add-player-layout { + display: flex; + align-items: center; +} + +.label { + margin-left: 20px; + color: #e9eada; +} + +.add-player-layout :first-child { + margin-left: 0; +} + +.addPlayerInput { + width: 200px; + border-radius: 0; + border-color: #abaaaa; + border-left: 0; + border-right: 0; +} + +.select { + width: 200px; + height: 34px; + margin: "0 20px"; +} diff --git a/src/components/Pagination.css b/src/components/Pagination.css new file mode 100644 index 0000000..c6174a8 --- /dev/null +++ b/src/components/Pagination.css @@ -0,0 +1,28 @@ +.pagination-container { + display: flex; + justify-content: center; + margin-top: 20px; + align-items: center; +} + +button { + padding: 8px; + margin: 0 5px; + border: 1px solid #ddd; + background-color: #f9f9f9; + cursor: pointer; +} + +button:hover { + background-color: #ddd; +} + +button:disabled { + color: #999; + cursor: not-allowed; +} + +button.active { + background-color: #007bff; + color: #fff; +} diff --git a/src/components/Pagination.js b/src/components/Pagination.js new file mode 100644 index 0000000..c63d57e --- /dev/null +++ b/src/components/Pagination.js @@ -0,0 +1,98 @@ +/** + * 分页器,按照 antd 的用法来写的 + */ + +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import "./Pagination.css"; // 根据需要调整样式导入 + +/** + * current:当前页数 + * pageSize:每页条数 + * total:总数 + * onChange:改变页码的回调 + * hideSinglePage:一页时是否隐藏 + */ +const Pagination = ({ + current = 1, + pageSize = 5, + total, + onChange, + hideSinglePage = false, +}) => { + // 使用状态钩子来追踪当前页码 + const [currentPage, setCurrentPage] = useState(current); + + // 计算总页数 + const totalPages = Math.ceil(total / pageSize); + + // 处理页码变化的函数 + const handlePageChange = (newPage) => { + // 确保新页码在有效范围内,并且与当前页码不同 + if (newPage >= 1 && newPage <= totalPages && newPage !== currentPage) { + setCurrentPage(newPage); + // 调用传入的 onChange 回调函数处理页码变化 + onChange(newPage); + } + }; + + // 渲染页码按钮 + const renderPageNumbers = () => { + const pageNumbers = []; + for (let i = 1; i <= totalPages; i++) { + pageNumbers.push( + + ); + } + return pageNumbers; + }; + + // 当传入的 current 发生变化时更新当前页码 + useEffect(() => { + setCurrentPage(current); + }, [current]); + + // 只有一页时是否隐藏分页器 + if (hideSinglePage && totalPages === 1) { + return <>; + } + + return ( +
+ {pageSize}条/页 + + + {renderPageNumbers()} + + + 共: {total}条 +
+ ); +}; + +// 定义组件的属性类型 +Pagination.propTypes = { + current: PropTypes.number, + pageSize: PropTypes.number, + total: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default Pagination; diff --git a/src/components/PlayerList.css b/src/components/PlayerList.css deleted file mode 100755 index b8d6f00..0000000 --- a/src/components/PlayerList.css +++ /dev/null @@ -1,4 +0,0 @@ -:local(.playerList) { - padding-left: 0; - margin-bottom: 0; -} diff --git a/src/components/PlayerList.js b/src/components/PlayerList.js index 7b40246..86269b9 100755 --- a/src/components/PlayerList.js +++ b/src/components/PlayerList.js @@ -1,33 +1,44 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import styles from './PlayerList.css'; -import PlayerListItem from './PlayerListItem'; +import React from "react"; +import PropTypes from "prop-types"; +import styles from "./PlayerList.module.css"; +import PlayerListItem from "./PlayerListItem"; -class PlayerList extends Component { - render() { - return ( - - ); - } -} +const PlayerList = ({ players, ...rest }) => { + return ( + + ); +}; PlayerList.propTypes = { players: PropTypes.array.isRequired, - actions: PropTypes.object.isRequired, + handleDeletePlayer: PropTypes.func.isRequired, + handleStarPlayer: PropTypes.func.isRequired, + successCallback: PropTypes.func.isRequired, }; export default PlayerList; diff --git a/src/components/PlayerList.module.css b/src/components/PlayerList.module.css new file mode 100755 index 0000000..bfd9ea5 --- /dev/null +++ b/src/components/PlayerList.module.css @@ -0,0 +1,6 @@ +.player-list { + padding-left: 0; + margin-bottom: 0; + height: 400px; + overflow-y: auto; +} diff --git a/src/components/PlayerListItem.js b/src/components/PlayerListItem.js index ec9758c..ee6b106 100755 --- a/src/components/PlayerListItem.js +++ b/src/components/PlayerListItem.js @@ -1,45 +1,59 @@ -import React, { Component } from 'react'; -import classnames from 'classnames'; -import PropTypes from 'prop-types'; -import styles from './PlayerListItem.css'; +import React from "react"; +import classnames from "classnames"; +import PropTypes from "prop-types"; +import styles from "./PlayerListItem.module.css"; -class PlayerListItem extends Component { - render() { - return ( -
  • -
    -
    - {this.props.name} -
    -
    - - {this.props.team} · {this.props.position} - -
    +const PlayerListItem = ({ + id, + name, + team, + position, + starred, + handleDeletePlayer, + handleStarPlayer, + successCallback, +}) => { + return ( +
  • +
    +
    + {name}
    -
    - - +
    + + {team} · {position} +
    -
  • - ); - } -} + +
    + + +
    + + ); +}; PlayerListItem.propTypes = { id: PropTypes.number.isRequired, @@ -47,7 +61,9 @@ PlayerListItem.propTypes = { team: PropTypes.string.isRequired, position: PropTypes.string.isRequired, starred: PropTypes.bool, - starPlayer: PropTypes.func.isRequired, + handleDeletePlayer: PropTypes.func.isRequired, + handleStarPlayer: PropTypes.func.isRequired, + successCallback: PropTypes.func.isRequired, }; export default PlayerListItem; diff --git a/src/components/PlayerListItem.css b/src/components/PlayerListItem.module.css similarity index 96% rename from src/components/PlayerListItem.css rename to src/components/PlayerListItem.module.css index 4ed1654..726bf67 100755 --- a/src/components/PlayerListItem.css +++ b/src/components/PlayerListItem.module.css @@ -16,6 +16,7 @@ :local(.playerActions) { flex: 0 0 90px; + display: flex; } :local(.btnAction), diff --git a/src/components/SearchContent.js b/src/components/SearchContent.js new file mode 100644 index 0000000..4cf279b --- /dev/null +++ b/src/components/SearchContent.js @@ -0,0 +1,75 @@ +/** + * 搜索区域 + */ + +import React from "react"; +import classnames from "classnames"; + +import { PLAYER_POSITION, PLAYER_TEAM } from "../constants"; + +import styles from "./AddPlayerInput.module.css"; + +const SearchContent = ({ filterParams, setFilterParams }) => { + return ( +
    +

    search player:

    +
    + 球员姓名: + + setFilterParams({ + ...filterParams, + keyword: e.target.value.trim(), + }) + } + /> + 球员位置: + + 所属团队: + +
    +
    + ); +}; + +export default SearchContent; diff --git a/src/components/index.js b/src/components/index.js index 12ce117..8b15b6e 100755 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,3 +1,5 @@ -export { default as AddPlayerInput } from './AddPlayerInput'; -export { default as PlayerList } from './PlayerList'; -export { default as PlayerListItem } from './PlayerListItem'; +export { default as AddPlayerInput } from "./AddPlayerInput"; +export { default as PlayerList } from "./PlayerList"; +export { default as PlayerListItem } from "./PlayerListItem"; +export { default as Pagination } from "./Pagination"; +export { default as SearchContent } from "./SearchContent"; diff --git a/src/constants/ActionTypes.js b/src/constants/ActionTypes.js index b796fae..ed0e851 100755 --- a/src/constants/ActionTypes.js +++ b/src/constants/ActionTypes.js @@ -1,3 +1,4 @@ -export const ADD_PLAYER = 'ADD_PLAYER'; -export const STAR_PLAYER = 'STAR_PLAYER'; -export const DELETE_PLAYER = 'DELETE_PLAYER'; +export const ADD_PLAYER = "ADD_PLAYER"; +export const STAR_PLAYER = "STAR_PLAYER"; +export const DELETE_PLAYER = "DELETE_PLAYER"; +export const GET_LATEST_PLAYER_LIST = "GET_LATEST_PLAYER_LIST"; diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 0000000..da37c15 --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1,45 @@ +// 定义球员位置 + +export const PLAYER_POSITION = [ + { + label: "SF", + value: "SF", + }, + { + label: "PF", + value: "PF", + }, + { + label: "PG", + value: "PG", + }, + { + label: "SG", + value: "SG", + }, +]; +// 定义球队 +export const PLAYER_TEAM = [ + { + label: "LOS ANGELES LAKERS", + value: "LOS ANGELES LAKERS", + }, + { + label: "GOLDEN STATE WARRIORS", + value: "GOLDEN STATE WARRIORS", + }, + { + label: "NEW ORLEANS PELICANS", + value: "NEW ORLEANS PELICANS", + }, + { + label: "HOUSTON ROCKETS", + value: "HOUSTON ROCKETS", + }, + { + label: "TORONTO RAPTORS", + value: "TORONTO RAPTORS", + }, +]; +// 每页显示的条目数 +export const PAGE_SIZE = 5; diff --git a/src/containers/PlayerListApp.js b/src/containers/PlayerListApp.js index 0e4bfa5..2e04738 100755 --- a/src/containers/PlayerListApp.js +++ b/src/containers/PlayerListApp.js @@ -1,41 +1,151 @@ -import React, { Component } from 'react'; -import styles from './PlayerListApp.css'; -import { connect } from 'react-redux'; - -import { addPlayer, deletePlayer, starPlayer } from '../actions/PlayersActions'; -import { PlayerList, AddPlayerInput } from '../components'; - -class PlayerListApp extends Component { - render() { - const { - playerlist: { playersById }, - } = this.props; - - const actions = { - addPlayer: this.props.addPlayer, - deletePlayer: this.props.deletePlayer, - starPlayer: this.props.starPlayer, - }; - - return ( -
    -

    NBA Players

    - - -
    - ); - } -} - -function mapStateToProps(state) { - return state; -} - -export default connect( - mapStateToProps, - { - addPlayer, - deletePlayer, - starPlayer, - }, -)(PlayerListApp); +import React from "react"; +import { useSelector, useDispatch } from "react-redux"; + +import { + useMount, + useUpdateDebounceEffect, + useUpdateEffect, + useStorageState, +} from "../hooks"; + +import { + PlayerList, + AddPlayerInput, + Pagination, + SearchContent, +} from "../components"; + +import { + addPlayer, + deletePlayer, + starPlayer, + getLatestPlayerData, +} from "../actions/PlayersActions"; + +import { PAGE_SIZE } from "../constants"; + +import styles from "./PlayerListApp.module.css"; + +const PlayerListApp = () => { + const dispatch = useDispatch(); + + const { showPlayersById, total } = useSelector((state) => state?.playerlist); + + // 当前的筛选参数,这里可以防止刷新搜索条件丢失,后期甚至可以做到通过 url 中的参数来定位搜索条件 + const [filterParams, setFilterParams] = useStorageState("filterParams", { + page: 1, + pageSize: PAGE_SIZE, + keyword: "", + position: "", + team: "", + }); + + // 获取最新的球员列表 + const getPlayerDataList = (params = filterParams) => { + dispatch(getLatestPlayerData(params)); + }; + + // 添加球员 + const handleAddPlayer = (playerInfo) => { + dispatch(addPlayer(playerInfo)); + }; + + // 删除球员 + const handleDeletePlayer = (playerId) => { + dispatch(deletePlayer(playerId)); + }; + + // 收藏球员 + const handleStarPlayer = (playerId) => { + dispatch(starPlayer(playerId)); + }; + + // 初始渲染时,请求列表数据 + useMount(() => { + getPlayerDataList(filterParams); + }); + + // 这里关注点分离,当改变条件时,各自做好各自的 setFilterParams就行, + // 忽略首次渲染,当改变这些条件时,需要重置分页 + useUpdateEffect(() => { + setFilterParams({ ...filterParams, page: 1 }); + }, [ + filterParams?.pageSize, + filterParams?.keyword, + filterParams?.position, + filterParams?.team, + ]); + + // 忽略首次渲染,当这些参数改变时,重新请求数据(该 hook 有防抖的功能) + useUpdateDebounceEffect( + () => { + getPlayerDataList(filterParams); + }, + [ + filterParams?.page, + filterParams?.pageSize, + filterParams?.keyword, + filterParams?.position, + filterParams?.team, + ], + 300 + ); + + // TODO:这里由于没有安装redux-thunk,导致所有的action都是同步的,无法模拟异步的情况 + // 因此在这里做了特殊处理,对删除场景时,如果点击删除了该页的最后一项数据,那么需要重置到前一页 + // useEffect(() => { + // if (showPlayersById?.length === 0 && filterParams?.page > 1) { + // setFilterParams({ ...filterParams, page: filterParams?.page - 1 }); + // } + // }, [showPlayersById]); + + return ( +
    +

    NBA Players

    + + getPlayerDataList(filterParams)} + /> +
    + +
    + getPlayerDataList(filterParams)} + /> + + setFilterParams({ ...filterParams, page: newPage }) + } + /> +
    + ); +}; + +export default PlayerListApp; diff --git a/src/containers/PlayerListApp.css b/src/containers/PlayerListApp.module.css similarity index 64% rename from src/containers/PlayerListApp.css rename to src/containers/PlayerListApp.module.css index f438bf1..64ea1e7 100755 --- a/src/containers/PlayerListApp.css +++ b/src/containers/PlayerListApp.module.css @@ -5,20 +5,18 @@ body { justify-content: center; } -:local(.playerListApp) { - width: 540px; - margin-top: 10px; - padding-top: 18px; +.playerListApp { + width: 1000px; + padding: 20px; background-color: #5c75b0; border: 1px solid #e3e3e3; } -:local(.playerListApp h1) { +.playerListApp h1 { color: white; - font-size: 16px; + font-size: 20px; line-height: 20px; margin-bottom: 10px; margin-top: 0; - padding-left: 10px; font-family: Helvetica; } diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..73aa38f --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,5 @@ +export { useMount } from "./useMount"; +export { useUpdateDebounceEffect } from "./useUpdateDebounceEffect"; +export { useStorageState } from "./useStorageState"; +export { useFirstMount } from "./useFirstMount"; +export { useUpdateEffect } from "./useUpdateEffect"; diff --git a/src/hooks/useFirstMount.js b/src/hooks/useFirstMount.js new file mode 100644 index 0000000..113cbf5 --- /dev/null +++ b/src/hooks/useFirstMount.js @@ -0,0 +1,18 @@ +/** + * 判断是否是第一次渲染 + */ + +import { useRef } from "react"; + +export function useFirstMount() { + const isFirst = useRef(true); + + //如果是初次渲染 + if (isFirst.current) { + isFirst.current = false; + + return true; + } + + return isFirst.current; +} diff --git a/src/hooks/useFirstMount.test.js b/src/hooks/useFirstMount.test.js new file mode 100644 index 0000000..8b3db2e --- /dev/null +++ b/src/hooks/useFirstMount.test.js @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { useFirstMount } from "./useFirstMount"; + +// 在这里定义一个简单的组件,用于测试 useFirstMount +function ComponentToTest() { + const isFirstMount = useFirstMount(); + + // 渲染时,将 isFirstMount 值显示在页面上 + return
    {isFirstMount.toString()}
    ; +} + +test("useFirstMount returns true on first render", () => { + // 渲染组件 + render(); + + // 在初次渲染时,useFirstMount 应该返回 true + expect(useFirstMount()).toBe(true); +}); + +test("useFirstMount returns false on subsequent renders", () => { + // 渲染组件 + render(); + + // 初次渲染后,useFirstMount 应该返回 false + expect(useFirstMount()).toBe(false); +}); diff --git a/src/hooks/useMount.js b/src/hooks/useMount.js new file mode 100644 index 0000000..875e704 --- /dev/null +++ b/src/hooks/useMount.js @@ -0,0 +1,5 @@ +import { useEffect } from "react"; + +export const useMount = (callback) => { + useEffect(callback, []); +}; diff --git a/src/hooks/useStorageState.js b/src/hooks/useStorageState.js new file mode 100644 index 0000000..48cafac --- /dev/null +++ b/src/hooks/useStorageState.js @@ -0,0 +1,86 @@ +/** + * 用为缓存的hook,可以像setState一样使用 + */ +import { useState } from "react"; + +// 保存值到本地存储或会话存储 +const valueToStorageFun = ({ type, key, value }) => { + if (typeof window !== "undefined") { + switch (type) { + case "localStorage": + window.localStorage.setItem(key, JSON.stringify(value)); + break; + case "sessionStorage": + window.sessionStorage.setItem(key, JSON.stringify(value)); + break; + default: + window.localStorage.setItem(key, JSON.stringify(value)); + } + } +}; + +// 自定义 Hook,用于在组件中处理具有本地存储/会话存储支持的状态 +export function useStorageState(key, initialValue, options) { + const { priority = "local", type = "localStorage" } = { + priority: "local", + type: "localStorage", + ...options, + }; + + // 使用 useState 初始化状态值 + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === "undefined") { + return initialValue; + } + try { + // 根据类型判断从何处读取数据 + let item; + switch (type) { + case "localStorage": + item = window.localStorage.getItem(key); + break; + case "sessionStorage": + item = window.sessionStorage.getItem(key); + break; + default: + item = window.localStorage.getItem(key); + } + + // 解析 state 并按优先级进行处理 + if (item) { + switch (priority) { + case "local": + return JSON.parse(item); + case "initialValue": + valueToStorageFun({ key, type, value: initialValue }); + return initialValue; + default: + return JSON.parse(item); + } + } else { + return initialValue; + } + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // 定义 setValue 函数,用于更新状态值并同步到存储中 + const setValue = (value) => { + try { + // 和 useState 保持相同用法,支持函数和默认值 + const valueToStore = + value instanceof Function ? value(storedValue) : value; + // 保存 state + setStoredValue(valueToStore); + // 同步到 storage 中 + valueToStorageFun({ key, type, value: valueToStore }); + } catch (error) { + console.log(error); + } + }; + + // 返回状态值和更新函数 + return [storedValue, setValue]; +} diff --git a/src/hooks/useUpdateDebounceEffect.js b/src/hooks/useUpdateDebounceEffect.js new file mode 100644 index 0000000..f19357d --- /dev/null +++ b/src/hooks/useUpdateDebounceEffect.js @@ -0,0 +1,31 @@ +/** + * 忽略首次渲染、防抖 + */ + +import { useEffect, useRef, useCallback } from "react"; + +export const useUpdateDebounceEffect = (callback, dependencies, delay) => { + const isFirstRender = useRef(true); + + // 使用 useCallback 确保在依赖项变化时返回新的回调 + const debouncedCallback = useCallback(callback, dependencies); + + useEffect(() => { + // 首次渲染时不执行回调 + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + // 设置一个定时器 + const timer = setTimeout(() => { + // 当定时器触发时执行回调 + debouncedCallback(); + }, delay); + + // 在每次 effect 运行之前清除上一个定时器 + return () => clearTimeout(timer); + + // 在依赖项或延迟改变时重新运行 effect + }, [debouncedCallback, delay]); +}; diff --git a/src/hooks/useUpdateEffect.js b/src/hooks/useUpdateEffect.js new file mode 100644 index 0000000..aef1494 --- /dev/null +++ b/src/hooks/useUpdateEffect.js @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import { useFirstMount } from "./useFirstMount"; + +export const useUpdateEffect = (effect, deps) => { + const isFirstMount = useFirstMount(); //判断是否是初次渲染 + + useEffect(() => { + if (!isFirstMount) { + return effect(); //二次渲染才执行 + } + }, deps); +}; diff --git a/src/reducers/playerlist.js b/src/reducers/playerlist.js index 1bc7457..a6b8283 100755 --- a/src/reducers/playerlist.js +++ b/src/reducers/playerlist.js @@ -1,74 +1,127 @@ -import * as types from '../constants/ActionTypes'; +import * as types from "../constants/ActionTypes"; const initialState = { + // 这里相当于是数据库 playersById: [ { - name: 'LeBron James', - team: 'LOS ANGELES LAKERS', - position: 'SF', + id: 1, + name: "LeBron James", + team: "LOS ANGELES LAKERS", + position: "SF", starred: true, }, { - name: 'Kevin Duran', - team: 'GOLDEN STATE WARRIORS', - position: 'SF', + id: 2, + name: "Kevin Duran", + team: "GOLDEN STATE WARRIORS", + position: "SF", starred: false, }, { - name: 'Anthony Davis', - team: 'NEW ORLEANS PELICANS', - position: 'PF', + id: 3, + name: "Anthony Davis", + team: "NEW ORLEANS PELICANS", + position: "PF", starred: false, }, { - name: 'Stephen Curry', - team: 'GOLDEN STATE WARRIORS', - position: 'PG', + id: 4, + name: "Stephen Curry", + team: "GOLDEN STATE WARRIORS", + position: "PG", starred: false, }, { - name: 'James Harden', - team: 'HOUSTON ROCKETS', - position: 'SG', + id: 5, + name: "James Harden", + team: "HOUSTON ROCKETS", + position: "SG", starred: false, }, { - name: 'Kawhi Leonard', - team: 'TORONTO RAPTORS', - position: 'SF', + id: 6, + name: "Kawhi Leonard", + team: "TORONTO RAPTORS", + position: "SF", starred: false, }, ], + total: 6, + // 这里是给前端显示的数据 + showPlayersById: [], }; export default function players(state = initialState, action) { switch (action.type) { + // 添加 case types.ADD_PLAYER: + const { playerInfo } = action; + + // 将时间戳作为默认id + var id = new Date().getTime(); + return { ...state, playersById: [ - ...state.playersById, { - name: action.name, - team: 'LOS ANGELES LAKERS', - position: 'SF', + id, + name: playerInfo.name, + team: playerInfo.team, + position: playerInfo.position, + starred: false, }, + ...state.playersById, ], }; + + // 删除 case types.DELETE_PLAYER: return { ...state, playersById: state.playersById.filter( - (item, index) => index !== action.id, + (item) => item?.id !== action?.id ), }; + + // 收藏 case types.STAR_PLAYER: - let players = [...state.playersById]; - let player = players.find((item, index) => index === action.id); - player.starred = !player.starred; + try { + const { id } = action; + let clonePlayers = JSON.parse(JSON.stringify(state.playersById)); + let player = clonePlayers.find((item) => item?.id === id); + player.starred = !player.starred; + return { + ...state, + playersById: clonePlayers, + }; + } catch (error) { + console.error(error); + } + + // 获取数据列表 + case types.GET_LATEST_PLAYER_LIST: + const { page, pageSize, keyword, position, team } = action; + // 先根据查询条件去过滤数据 + const data = + state.playersById.filter( + (player) => + player?.name?.toLowerCase()?.includes(keyword?.toLowerCase()) && + player?.position?.includes(position) && + player?.team?.includes(team) + ) || []; + + // 计算切片的起始和结束索引 + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + + // 获取对应页码的数据切片 + const updatedPlayers = data.slice(startIndex, endIndex); + + // 这里是给前端返回的数据 return { ...state, - playersById: players, + showPlayersById: updatedPlayers, + total: data?.length || 0, }; default: