react课程10-ReactQuiz(useReducer学习)

Last updated on August 29, 2024 pm

本节课做出了一个小小的测验应用,主要目的是为了了解useReducer这个hook的强大功能!

Auto Rename Tag插件,我为什么没有早点下载你!!!!!!😭😭😭

一、useReducer讲解

如何使用useReducer?(三步走)

  1. initialState
  2. reducer函数
  3. useReducer初定义状态(使用initialState)

image-20240827161620491

image-20240827161715601

img

img

img

image-20240827162143695

二、json-server

JSON Server 是一个简单易用的工具,用于快速创建一个基于 JSON 文件的 RESTful API。它非常适合在开发和测试过程中使用,特别是在没有后端或后端未完成时,你可以使用 JSON Server 来模拟 API 进行前端开发。

JSON Server 的主要功能包括:

  1. 模拟 RESTful API: JSON Server 可以根据一个简单的 JSON 文件自动生成 RESTful API,包括 GET、POST、PUT、PATCH 和 DELETE 等常见的 HTTP 方法。
  2. 快速原型设计: 在开发过程中,你可以使用 JSON Server 快速创建一个 API,允许你专注于前端开发或 API 调用逻辑,而无需编写真实的后端代码。
  3. 处理查询参数: JSON Server 支持处理 URL 中的查询参数,允许你基于查询条件返回不同的数据集。
  4. 支持分页和排序: 它可以根据 URL 中的参数轻松实现数据的分页和排序。
  5. 支持完整的 CRUD 操作: 通过简单的配置,JSON Server 可以让你对 JSON 数据进行创建、读取、更新和删除操作。

在本次创建的应用中,我们有一个question.json,包含了一个questions数组,但是我们想要假装是通过一个(虚假的)API来获取到问题的内容,因此拆分了一下终端,npm i json-server命令下载json-server,然后找到配置文件把命令加入,就可以运行npm run server将question.json运行在指定的端口,访问(http://localhost:8001/questions)就可以获取questions数组。

"server": "json-server --watch data/questions.json --port 8001"

image-20240826154217162

三、应用界面和代码

(1)主要界面

屏幕截图 2024-08-27 162942

屏幕截图 2024-08-27 162955

image-20240827163054783

(2)要点

1、使用useReducer来批量集中地更新状态:由于reducer函数将状态的更新集中在一起,因此可以通过这个函数来得知所有状态的更新情况。除此之外,action.type的划分也可以很容易看到整个应用的多种状态。

2、状态列举:questions(问题数组)、status(应用状态:loading、error、ready、active、finished,对应界面的切换)、index(正在显示的问题坐标)、answer(用户的答案)、points(总分数)、highscore(最高分)、secondsRemaing(倒计时剩余时间)

3、如何使代码更具有可读性:将常量定义在函数体外而不是直接出现在某个js语句中;在构建APP的HTML时,尽量全部使用组件构建,不要出现冗余的的代码块;拆分组件的工作一定要做到位,例如本次应用中,拆分出了近十个小的组件,这样会使修改组件内容变得容易。

4、每个按钮的onClick函数!!在回答完最后一个问题之后,要进入finish界面了,如果不停止更新index就会报错。

5、当组件已然很多,可以把所有组件全部都放入components文件夹中了,但是此时要记得更新在index.js中导入App.js的时候路径问题。

6、不太会用reducer积累器(需要复习js基础知识)

(三)代码

1、index.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
:root {
--color-darkest: #343a40;
--color-dark: #495057;
--color-medium: #ced4da;
--color-light: #f1f3f5;

--color-theme: #1098ad;
--color-accent: #ffa94d;
}

@import url("https://fonts.googleapis.com/css2?family=Codystar&display=swap");

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

html {
font-size: 62.5%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}

body {
min-height: 100vh;
color: var(--color-light);
background-color: var(--color-darkest);
padding: 3.2rem;
}

.app {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.main {
width: 50rem;
}

.app-header {
width: 66rem;
margin-bottom: 4rem;
display: flex;
align-items: center;
justify-content: space-between;
}

.error {
text-align: center;
font-size: 1.6rem;
font-weight: 500;
padding: 2rem;
background-color: #495057;
border-radius: 100px;
}

img {
width: 14rem;
}

h1 {
font-family: "Codystar";
font-size: 5.6rem;
}

h2 {
font-size: 3.6rem;
margin-bottom: 2rem;
}

h3 {
font-size: 2.4rem;
font-weight: 600;
margin-bottom: 4rem;
}

h4 {
font-size: 2.2rem;
font-weight: 600;
margin-bottom: 2.4rem;
}

.start {
display: flex;
flex-direction: column;
align-items: center;
}

.progress {
margin-bottom: 4rem;
display: grid;
justify-content: space-between;
gap: 1.2rem;
grid-template-columns: auto auto;
font-size: 1.8rem;
color: var(--color-medium);
}

progress {
-webkit-appearance: none;
width: 100%;
height: 12px;
grid-column: 1 / -1;
}

::-webkit-progress-bar {
background-color: var(--color-medium);
border-radius: 100px;
}
::-webkit-progress-value {
background-color: var(--color-theme);
border-radius: 100px;
}

.btn {
display: block;
font-family: inherit;
color: inherit;
font-size: 2rem;
border: 2px solid var(--color-dark);
background-color: var(--color-dark);
padding: 1.2rem 2.4rem;
cursor: pointer;
border-radius: 100px;
transition: 0.3s;
}

.btn:not([disabled]):hover {
background-color: var(--color-darkest);
}

.btn-option:not([disabled]):hover {
transform: translateX(1.2rem);
}

.btn[disabled]:hover {
cursor: not-allowed;
}

.btn-ui {
float: right;
}

.options {
display: flex;
flex-direction: column;
gap: 1.2rem;
margin-bottom: 3.2rem;
}

.btn-option {
width: 100%;
text-align: left;
}

.btn-option.correct {
background-color: var(--color-theme);
border: 2px solid var(--color-theme);
color: var(--color-light);
}
.btn-option.wrong {
background-color: var(--color-accent);
border: 2px solid var(--color-accent);
color: var(--color-darkest);
}

.answer {
transform: translateX(2rem);
}

.result {
background-color: var(--color-theme);
color: var(--color-light);
border-radius: 100px;
text-align: center;
padding: 2rem 0;
font-size: 2rem;
font-weight: 500;
margin-bottom: 1.6rem;
}

.result span {
font-size: 2.2rem;
margin-right: 4px;
}

.highscore {
font-size: 1.8rem;
text-align: center;
margin-bottom: 4.8rem;
}

.loader-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 4rem;
gap: 1.6rem;

color: var(--color-medium);
font-size: 1.4rem;
}

.timer {
float: left;
font-size: 1.8rem;
color: var(--color-medium);
border: 2px solid var(--color-dark);
padding: 1.35rem 2.8rem;
border-radius: 100px;
}

/* CREDIT: https://dev.to/afif/i-made-100-css-loaders-for-your-next-project-4eje */
.loader {
width: 50px;
height: 24px;
background: radial-gradient(circle closest-side, currentColor 90%, #0000) 0%
50%,
radial-gradient(circle closest-side, currentColor 90%, #0000) 50% 50%,
radial-gradient(circle closest-side, currentColor 90%, #0000) 100% 50%;
background-size: calc(100% / 3) 12px;
background-repeat: no-repeat;
animation: loader 1s infinite linear;
}

@keyframes loader {
20% {
background-position: 0% 0%, 50% 50%, 100% 50%;
}
40% {
background-position: 0% 100%, 50% 0%, 100% 50%;
}
60% {
background-position: 0% 50%, 50% 100%, 100% 0%;
}
80% {
background-position: 0% 50%, 50% 50%, 100% 100%;
}
}
2、App.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
import { useEffect, useReducer } from "react";
import Header from "./Header";
import Main from "./Main";
import Loader from "./Loader";
import Error from "./Error";
import StartScreen from "./StartScreen";
import Question from "./Question";
import NextButton from "./NextButton";
import Progress from "./Progress";
import FinishScreen from "./FinishScreen";
import Footer from "./Footer";
import Timer from "./Timer";

const SECS_PER_QUESTION = 30;

const initialState = {
questions: [],
status: "loading", //'loading','error','ready','active','finished'
index: 0,
answer: null,
points: 0,
highscore: 0,
secondsRemaining: 0,
};

function reducer(state, action) {
switch (action.type) {
case "dataReceived": //接收到数据之前是显示Loading界面
return {
...state,
questions: action.payload,
status: "ready",
};
case "dataFailed": //error情况处理
return {
...state,
status: "error",
};
case "start": //显示开始界面(最初显示第一道问题)
return {
...state,
status: "active",
secondsRemaining: state.questions.length * SECS_PER_QUESTION,//开始回答问题时启动计时
};
case "newAnswer":
const question = state.questions.at(state.index); //定位问题
return {
...state,
answer: action.payload, //通过payload传递答案的选项
points:
action.payload === question.correctOption
? state.points + question.points
: state.points,
};
case "nextQuestion":
return { ...state, index: state.index + 1, answer: null };
case "finish":
return {
...state,
status: "finished",
highscore:
state.points > state.highscore ? state.points : state.highscore,
};
case "restart":
return {
...initialState,
status: "ready",
questions: state.questions,
};
case "tick":
return {
...state,
secondsRemaining: state.secondsRemaining - 1,
status: state.secondsRemaining === 0 ? "finished" : state.status,
};
default:
throw new Error("Action unknown");
}
}

function App() {
const [
{ questions, status, index, answer, points, highscore, secondsRemaining },
dispatch,
] = useReducer(reducer, initialState);

const numQuestions = questions.length;
const maxPossiblePoints = questions.reduce(
(prev, cur) => prev + cur.points,
0
); //reducer积累器

useEffect(function () {
fetch("http://localhost:8001/questions")
.then((res) => res.json())
.then((data) => dispatch({ type: "dataReceived", payload: data }))
.catch((err) => dispatch({ type: "dataFailed" }));
}, []);//使用useEffect来通过API获取问题数组

return (
<div className="app">
<Header />
<Main>
{status === "loading" && <Loader />}
{status === "error" && <Error />}
{status === "ready" && (
<StartScreen numQuestions={numQuestions} dispatch={dispatch} />
)}
{status === "active" && (
<>
<Progress
index={index}
numQuestions={numQuestions}
points={points}
maxPossiblePoints={maxPossiblePoints}
answer={answer}
/>
<Question
question={questions[index]}
dispatch={dispatch}
answer={answer}
/>
<Footer>
<Timer dispatch={dispatch} secondsRemaining={secondsRemaining} />
<NextButton
dispatch={dispatch}
answer={answer}
index={index}
numQuestions={numQuestions}
/>
</Footer>
</>
)}
{status === "finished" && (
<FinishScreen
points={points}
maxPossiblePoints={maxPossiblePoints}
highscore={highscore}
dispatch={dispatch}
/>
)}
</Main>
</div>
);
}

export default App;

3、Error.js
1
2
3
4
5
6
7
8
9
function Error() {
return (
<p className="error">
<span>💥</span> There was an error fecthing questions.
</p>
);
}

export default Error;
4、Loader.js
1
2
3
4
5
6
7
8
9
export default function Loader() {
return (
<div className="loader-container">
<div className="loader"></div>
<p>Loading questions...</p>
</div>
);
}

5、StartScreen.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function StartScreen({ numQuestions, dispatch }) {
return (
<div className="start">
<h2>Welcome to The React Quiz!</h2>
<h3>{numQuestions} questions to test your React mastery</h3>
<button
className="btn btn-ui"
onClick={() => dispatch({ type: "start" })}
>
Let's start
</button>
</div>
);
}

export default StartScreen;

6、Header.js
1
2
3
4
5
6
7
8
9
10
11
function Header() {
return (
<header className='app-header'>
<img src='logo512.png' alt='React logo' />
<h1>The React Quiz</h1>
</header>
);
}

export default Header;

7、Main.js
1
2
3
4
5
6
function Main({ children }) {
return <main className="main">{children}</main>;
}

export default Main;

8、Progress.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Progress({ index, numQuestions, points, maxPossiblePoints, answer }) {
return (
<header className="progress">
<progress max={numQuestions} value={index + Number(answer !== null)} />{*很巧妙地设置了progress的值,有回答时不移动,回答后移动*}
<p>
Question <strong>{index + 1}</strong>/{numQuestions}
</p>
<p>
<strong>{points}</strong> /{maxPossiblePoints}
</p>
</header>
);
}

export default Progress;

9、Questions.js
1
2
3
4
5
6
7
8
9
10
11
12
13
import Options from "./Options";

function Question({ question, dispatch, answer }) {
return (
<div>
<h4>{question.question}</h4>
<Options question={question} dispatch={dispatch} answer={answer} />
</div>
);
}

export default Question;

10、Options.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function Options({ question, dispatch, answer }) {
const hasAnswered = answer !== null;

return (
<div className="options">
{question.options.map((option, index) => (
<button
className={`btn btn-option ${index === answer ? "answer" : ""} ${
hasAnswered
? index === question.correctOption
? "correct"
: "wrong"
: ""
}`}
disabled={hasAnswered}
key={option}
onClick={() => dispatch({ type: "newAnswer", payload: index })}
>
{option}
</button>
))}
</div>
);
}

export default Options;

11、Footer.js
1
2
3
4
5
6
function Footer({ children }) {
return <footer>{children}</footer>;
}

export default Footer;

12、Timer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

import { useEffect } from "react";

function Timer({ dispatch, secondsRemaining }) {
const mins = Math.floor(secondsRemaining / 60);
const seconds = secondsRemaining % 60;
useEffect(
function () {
const id = setInterval(function () {
dispatch({ type: "tick" });
}, 1000);

return () => clearInterval(id);
},
[dispatch]
);

return (
<div className="timer">
{mins < 10 && "0"}
{mins} : {seconds < 10 && "0"}
{seconds}
</div>
);
}

export default Timer;

setInterval 每隔 1000 毫秒(1秒)触发一次,向 dispatch 发送一个 { type: "tick" } 的动作,通常这个动作会在 reducer 中被处理为减少 secondsRemaining 的值,从而实现倒计时。clearInterval(id) 是清除定时器的操作,返回的函数会在组件卸载时或在 dispatch 发生变化时被调用,从而清除定时器,避免内存泄漏或不必要的计时操作。

13、NextButton.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function NextButton({ dispatch, answer, index, numQuestions }) {
if (answer === null) return null;
if (index < numQuestions - 1)
return (
<button
className="btn btn-ui"
onClick={() => dispatch({ type: "nextQuestion" })}
>
Next
</button>
);
if (index === numQuestions - 1)
return (
<button
className="btn btn-ui"
onClick={() => dispatch({ type: "finish" })}
>
Finish
</button>
);
}

export default NextButton;

14、FinishScreen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function FinishScreen({ points, maxPossiblePoints, highscore, dispatch }) {
const percentage = (points / maxPossiblePoints) * 100;

let emoji;
if (percentage === 100) emoji = "👑";
if (percentage >= 80 && percentage < 100) emoji = "🤩";
if (percentage >= 60 && percentage < 80) emoji = "🥳";
if (percentage >= 0 && percentage < 60) emoji = "🙍‍♀️";
if (percentage === 0) emoji = "🤡";

return (
<>
<p className="result">
<span>{emoji}</span>You scored <strong>{points}</strong> out of{" "}
{maxPossiblePoints}({Math.ceil(percentage)}%)
</p>
<p className="highscore">(Highscore: {highscore} points)</p>
<button
className="btn btn-ui"
onClick={() => dispatch({ type: "restart" })}
>
Restart quiz
</button>
</>
);
}

export default FinishScreen;


react课程10-ReactQuiz(useReducer学习)
http://example.com/2024/08/26/react课程10-ReactQuiz/
Author
Yaodeer
Posted on
August 26, 2024
Licensed under