Redxu(RTK) 基础 使用数据 第4节 显示和编辑单个文章 添加用户 存储文章日期 增加排序和交互功能
啊,标题好长捏。
经过上一节,我们实践了最基本的rtk维护状态的流程
在 第三节:基本数据流 中,我们看到了如何从一个空的 Redux+React 项目设置开始,添加一个新的状态 slice ,并创建 React 组件 可以从 Redux store 中读取数据并 dispatch action 来更新该数据。我们还研究了数据如何在应用程序中流动,组件 dispatch action,reducer 处理 action 并返回新状态,以及组件读取新状态并重新渲染 UI。
现在您已经了解了编写 Redux 逻辑的核心步骤,我们将使用这些相同的步骤向我们的社交媒体提要添加一些很实用的新功能:查看单个文章、编辑现有文章、详细信息显示文章作者、发布时间戳和交互按钮。
快马加鞭,继续深入捏!
显示单个文章
上一篇中,文章太长,会让页面拉的很长很长,那么就应该让PostList只显示个文章摘要,点击单个文章,就换到单独显示全文的页面
创建单个文章显示页面
加个组件捏,SinglePostPage ,这个要配合路由使用吖!(/posts/123这种形式就是显示123相关的文章呢!这里123我们一般选取postId捏!)
//"/features/posts/SinglePostPage"
import React from "react";
import { useParams } from "react-router";
import { useAppSelector } from "../../app/hooks";
const SinglePostPage = ({}) => {
// The useParams hook returns an object of key/value pairs of the dynamic params from
// the current URL that were matched by the <Route path>.
// Child routes inherit all params from their parent routes.
const { postId } = useParams();
const post = useAppSelector((state) =>
state.posts.find((post) => post.id === postId)
);
if (!post) {
return (
<section>
<h2>页面未找到!</h2>
</section>
);
}
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
</section>
);
};
export default SinglePostPage;
要知道吖,React-Router V6提供了一个钩子 useParams,这这个钩子挺神的,比如你的路由设置是
<Route path="/posts/:postId" element={
<SinglePostPage></SinglePostPage>
}></Route>
他就能在对应渲染的那个东西里直接拿到 动态的postId,省老事了!
要注意的是!useSelector返回值一旦是新的引用,组件一定重渲染,所以为了性能考虑,组件应始终尝试从 store 中选择它们需要的尽可能少的数据,这将有助于确保它仅在实际需要时才渲染。
上面也是文档原话,我的理解就是,你尽量少从redux里拿数据,这个少至少有两面
1.如果有一堆同种数据,只拿必要的那一个或几个
2.没必要,就不从仓库里拿数据
注意文档上这一段!
您可能会注意到,这看起来与我们在
组件主体中的逻辑非常相似,其中我们遍历整个 posts 数组以显示主要提要的文章摘录。我们可以尝试提取一个可以在两个地方使用的“文章”组件,但是我们在显示文章摘录和整个文章方面已经存在一些差异。即使有一些重复,通常最好还是暂时分开写,然后我们可以稍后决定不同的代码部分是否足够相似,我们可以真正提取出可重用的组件。
这段特有意思!我们在开发时候,肯定会遇到这种情况,很多时候两个组件基本功能一致,就是有点小差别,比如要进行逻辑判断,是xxx就显示x,是yyy就显示y,我以前都会把两个组件直接合并(其实就是只写一个),结果逻辑块儿肯定会越来越多越来越复杂,如果按照文档上的说法,初期不要怕重复,后期再合适地选择要抽取的部分,这个做法真的是很好,因为那个时候,就是顺理成章地在做,会浑然天成吖!
有了 SinglePostPage 只要考虑在合适时机显示它就行了,当然了,因为要配合路由使用,所以把显示这个页面的(是动态的)路由配置加上
添加单个文章的路由
嗯,SinglePostPage有了,加路由!之前也说过了,要配合路由使用捏!
(注意不同版本ReactRouter的API不同捏!)
// "/src/App.tsx"
import React from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { Navbar } from "./components/Navbar";
import { AddPostForm } from "./features/posts/AddPostForm";
import PostList from "./features/posts/PostList";
import SinglePostPage from "./features/posts/SinglePostPage";
function App() {
return (
<Router>
<div className="App">
<Navbar></Navbar>
<Routes>
<Route
path="/"
element={
<div>
<AddPostForm></AddPostForm>
<PostList />
</div>
}
/>
<Route path="/posts/:postId" element={
<SinglePostPage></SinglePostPage>
}></Route>
<Route path="*" element={<div>This is nowhere</div>} />
</Routes>
</div>
</Router>
);
}
export default App;
然后更新PostList就好辣,只要加一个Link,然后点击某个文章就能跳转到某个特定文章的全文页面了呢!
(注意和文档不完全一样,核心在领会使用rtk具体怎么操作数据,操作数据的过程怎么和UI组件连接在一起,再加上UI组件怎么和router配合)
//
// "/features/posts/PostList"
import React from "react";
import { Link } from "react-router-dom";
import { useAppSelector } from "../../app/hooks";
const PostsList = () => {
const posts = useAppSelector((state) => state.posts);
const renderedPosts = posts.map((post) => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
));
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
);
};
//好嘞
export default PostsList;
现在可以加个Navbar组件了,其实很简单,不过是用于在单个文章和所有文章列表之间切换罢了!
当然要注意,Navbar也得加入到合适的Router位置,让他在合适的时机显示!
// "/src/components/Navbar"
import React from "react";
import { Link } from "react-router-dom";
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux 基础教程示例</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">文章列表</Link>
</div>
</div>
</section>
</nav>
);
};
好,经过以上操作,配合router,现在新增了一个显示特定文章全文的页面,并且,可以在全文页和列表页之间切换捏。
现在你点击一个文章下面的"View Post"应该就能出现这种东西呢!
编辑文章
让我们添加一个新的
组件,该组件能够获取现有文章 ID,从 store 读取该文章,让用户编辑标题和文章内容,然后保存更改以更新 store 中的文章。
很明显,这个EditPostForm组件可以用于编辑文章呢! 他跟rtk的联系在于(我们做他的原因)他需要dipatch一个修改数据的action!
更新文章条目
既然要编辑文章,也就是更改数据,那就slice里写个新的reducer吖(虽然很啰嗦,但是action 现在rtk自动提供了呢!)
因为要更新文章,需要知道文章id和具体要更新的标题和内容
上面需要更新的东西,id titile和 content 这些东西统一放在action.payload里边。
注意力+++! ! ! 这里注意一点吖,之前一直没有特殊说过,rtk提供的 action
(或者说action creator)的参数就是payload,就是action.payload,
换句话说明白点,aciton就是一个纯对象,{type:xxx,payload:yyy},而rtk提供的actionCreator(就是导出的那些和reducer中的成员函数名字重名的东西,是通过xxxSlice.actions形式由RTK提供的)不用我们输入type,如果有需要,直接传入payload!!!
那我想postUpdate(将要新添加的用于编辑文章的函数)这个 reducer的逻辑就很明白。
import { createSlice } from "@reduxjs/toolkit";
const initialState = [
{ id: "1", title: "First Post!", content: "Hello!" },
{ id: "2", title: "Second Post", content: "More text" },
];
// 这里直接复制文档内容捏
const postsSlice = createSlice({
name: "posts",
initialState: initialState,
//记住吖,处理数据就是reducer的责任捏!
//同步的数据处理就全在这里呢!
reducers: {
postAdd: (state, action) => {
state.push(action.payload);
},
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.find((post) => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
} else {
// 好歹报个错吧
console.log("花Q,你在干啥?");
}
},
},
});
//好辣,现在 slice完毕了
//我们要去store里引入reducer呢
//不要忘记把createSlice自动生成的action导出呢!
export const { postAdd, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;
编辑文章的页面
这是处理数据的slice,下面写具体处理数据的页面!其实主要内容跟那个AddPostForm差不多
// features/posts/EditPostForm.tsx
import React, { useState } from "react";
import { useParams } from "react-router";
import { useNavigate } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { postUpdated } from "./postsSlice";
//这个就是个无奈的绕过,理论上useAppSelector有可能返回undefined,
//下面两行对undefined还挺敏感的。
//const [title, setTitle] = useState(post.title);
//const [content, setContent] = useState(post.content);
function initializeUndefinedPostWhenErrorHappensHelper(
post:
| {
id: string;
content: string;
title: string;
}
| undefined
) {
let undefinedPost = {
id: "not able to fetch post",
content: "not able to fetch post",
title: "not able to fetch post",
};
if (post === undefined) {
post = undefinedPost;
return post;
} else {
return post;
}
}
export const EditPostForm = () => {
const { postId } = useParams();
let post = initializeUndefinedPostWhenErrorHappensHelper(
useAppSelector((state) => state.posts.find((post) => post.id === postId))
);
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const dispatch = useAppDispatch();
const GOTO = useNavigate();
// 这两个any无所谓捏,因为人家一般都会写内联函数的捏。
const onTitleChanged = (e: any) => setTitle(e.target.value);
const onContentChanged = (e: any) => setContent(e.target.value);
const onSavePostClicked = () => {
if (title && content) {
dispatch(postUpdated({ id: postId, title, content }));
GOTO(`/posts/${postId}`);
}
};
return (
<section>
<h2>编辑文章</h2>
<form>
<label htmlFor="postTitle">文章标题:</label>
<input
type="text"
id="postTitle"
name="postTitle"
placeholder="What's on your mind?"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postContent">内容:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
</form>
<button type="button" onClick={onSavePostClicked}>
保存文章
</button>
</section>
);
};
emmmmm,现在处理数据的slice和具体处理数据的页面都有了,还差啥?
之前有单独显示文章的链接吧,那公平点,也得有编辑文章的链接
// "/features/posts/PostList"
import React from "react";
import { Link } from "react-router-dom";
import { useAppSelector } from "../../app/hooks";
const PostsList = () => {
const posts = useAppSelector((state) => state.posts);
const renderedPosts = posts.map((post) => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
{/* 这个div就是一个简单的分隔符捏! */}
<div> ____</div>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
</article>
));
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
);
};
export default PostsList;
额,前面一段已经讲了 路由相关的东西了吧,很明显,这个EditPostForm也得配置路由。
import React from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { Navbar } from "./components/Navbar";
import { AddPostForm } from "./features/posts/AddPostForm";
import { EditPostForm } from "./features/posts/EdigPostForm";
import PostList from "./features/posts/PostList";
import SinglePostPage from "./features/posts/SinglePostPage";
function App() {
return (
<Router>
<div className="App">
<Navbar></Navbar>
<Routes>
<Route
path="/"
element={
<div>
<AddPostForm></AddPostForm>
<PostList />
</div>
}
/>
<Route
path="/posts/:postId"
element={<SinglePostPage></SinglePostPage>}
></Route>
<Route
path="/editPost/:postId"
element={<EditPostForm></EditPostForm>}
></Route>
<Route path="*" element={<div>This is nowhere</div>} />
</Routes>
</div>
</Router>
);
}
export default App;
现在你的网页应该有 点击去到某个编辑文章的页面的功能了
准备 Action Payloads (抽离一些东西,让action的具体构建过程和组件的耦合度降低)
上面标题括号里的内容是按我的理解总结的,
虽然我们一直讲dispatch action,但是实际上action不是手动创建的(老版本都是要手动构建的),而是rtk直接给的actionCreator函数,调用函数,还要传入action具体内容,这个过程我叫他action具体构建过程。
举个栗子:我们在 addPostForm里边要添加文章的时候,是这样构建action的 ,
dispatch(
postAdd({
id: nanoid(),
title,
content,
})
);
文档上提出了这样一个情况,很有意思,如果我们有20个组件都需要触发同一个action,那么这二十个组件里是不是都要重复上面一段代码?当前看,是这样,虽然我们可以自己做点操作,但是RTK贴心的给我们铺垫了另外一条更宽敞的道路!
就是slice中 创建reducer的第二种模式,很简单下面说,但是我要提前说点别的,无论如何,reducer是纯函数,请不要在reducer里边拉取数据(那个需要thunk做),也不要在reducer里边计算随机值,(会导致不可知的错误,而且nanoid就是计算随机值,所以正好告诉我们应该怎么做捏!)。
做法就是!类似中间件的感觉,加一个prepare成员,实际上就是让prepare在reducer之前准备好数据,然后reducer自己取用。
如果 action 需要包含唯一 ID 或其他一些随机值,请始终先生成该随机值并将其放入 action 对象中。 Reducer 中永远不应该计算随机值,因为这会使结果不可预测。
幸运的是,createSlice 允许我们在编写 reducer 时定义一个 prepare 函数。 prepare 函数可以接受多个参数,生成诸如唯一 ID 之类的随机值,并运行需要的任何其他同步逻辑来决定哪些值进入 action 对象。然后它应该返回一个包含 payload 字段的对象。(返回对象还可能包含一个 meta 字段,可用于向 action 添加额外的描述性值,以及一个 error 字段,该字段应该是一个布尔值,指示此 action 是否表示某种错误。)
在 createSlice 的 reducers 字段内,我们可以将其中一个字段定义为一个类似于 {reducer, prepare} 的对象:
我的理解是,就这样,prepare先把脏活干了(这里是调用nanoid捏),所谓岁月静好就有reducer享受呢!
//features/posts/postSlice
import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
const initialState = [
{ id: "1", title: "First Post!", content: "Hello!" },
{ id: "2", title: "Second Post", content: "More text" },
];
type postAction = {
id: string;
content: string;
title: string;
};
const postsSlice = createSlice({
name: "posts",
initialState: initialState,
//记住吖,处理数据就是reducer的责任捏!
//同步的数据处理就全在这里呢!
reducers: {
postAdd: {
reducer: (state, action: PayloadAction<postAction>) => {
state.push(action.payload);
},
prepare: (title, content) => {
return {
payload: {
id: nanoid(),
title,
content,
},
};
},
},
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.find((post) => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
} else {
// 好歹报个错吧
console.log("花Q,你在干啥?");
}
},
//end
},
});
//好辣,现在 slice完毕了
//我们要去store里引入reducer呢
//不要忘记把createSlice自动生成的action导出呢!
export const { postAdd, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;
上面内容改完,不要忘记对应更新UI组件捏,现在UI中的action构建,已经不用输入id了呢,因为过去传入一个action对象需要的所有内容,现在的参数则跟prepare一样,只要传入title和content就可以了捏!
以上为bonus track,说到底,提供了一个思想和具体解决方案,把action构建过程中可以抽离的东西抽到prepare里,让操作便捷一些了捏!
用户与文章
到目前为止,我们只有一个状态 slice 。 逻辑在 postsSlice.js 中定义,数据存储在 state.posts 中,我们所有的组件都与 posts 功能相关。真实的应用程序可能会有许多不同的状态 slice ,以及用于 Redux 逻辑和 React 组件的几个不同的“功能文件夹”。
如果没有任何其他人参与,您就无法做出社交媒体。让我们添加在我们的应用程序中跟踪用户列表的功能,并更新与发布相关的功能。
所以,下一步可以添加另外一个slice,跟 用户(人)相关的slice呢!
添加用户 slice
由于“用户”的概念不同于“文章”的概念,我们希望将用户的代码和数据与文章的代码和数据分开。我们将添加一个新的 features/users 文件夹,并在其中放置一个 usersSlice 文件。 与文章 slice 一样,现在我们将添加一些初始条目,以便我们可以使用数据。
//这是注释,显示文件路径捏:/src/features/users/usersSlice.ts
import { createSlice } from '@reduxjs/toolkit'
const initialState = [
{ id: '0', name: 'Tianna Jenkins' },
{ id: '1', name: 'Kevin Grant' },
{ id: '2', name: 'Madison Price' }
]
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {}
})
export default usersSlice.reducer
之前已说过了, 要添加功能 ,就肯定有对应的slice,这里 reducer留空,后边会再回来实现。
然后下一步,把store和(特定slice的)reducer联系一下
//这是注释,显示文件路径捏:/src/app/store.ts
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit";
import postsReducer from "../features/posts/postsSlice";
import usersReducer from "../features/users/usersSlice";
export const store = configureStore({
reducer: {
posts: postsReducer,
users: usersReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
为文章添加作者
我们应用中的每篇文章都是由我们的一个用户撰写的,每次我们添加新文章时,我们都应该跟踪哪个用户写了该文章。 在一个真正的应用程序中,我们会有某种 state.currentUser 字段来跟踪当前登录的用户,并在他们添加文章时使用该信息。
为了让这个例子更简单,我们将更新我们的
首先,我们需要更新我们的 postAdded action creator 以接受用户 ID 作为参数,并将其包含在 action 中。(我们还将更新 initialState 中的现有文章条目,使其具有包含示例用户 ID 之一的 post.user 字段。)
上面一段的意思就是,啊,现在想给每篇文章添加一个对应的作者,具体教教我们怎么做捏!
首先得更新postsSlice,为啥呢,因为之前post和user没关系,现在每篇文章都有个user,那么就得更新post存储时候要存到store中的数据,已经存储数据时候对应的reducer对吧。
//这是注释,显示文件路径捏:/src/features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
const initialState = [
{ id: "1", title: "First Post!", content: "Hello!", user: "0" },
{ id: "2", title: "Second Post", content: "More text", user: "1" },
];
type postAction = {
id: string;
content: string;
title: string;
//类型见usersSlice里边的内容捏
user: string;
};
// 这里直接复制文档内容捏
//天了噜,我没复制捏,稍后我会补充的,哭哭
const postsSlice = createSlice({
name: "posts",
initialState: initialState,
//记住吖,处理数据就是reducer的责任捏!
//同步的数据处理就全在这里呢!
reducers: {
postAdd: {
reducer: (state, action: PayloadAction<postAction>) => {
state.push(action.payload);
},
prepare: (title, content, userId) => {
return {
payload: {
id: nanoid(),
title,
content,
user: userId,
},
};
},
},
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.find((post) => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
} else {
// 好歹报个错吧
console.log("花Q,你在干啥?");
}
},
//end
},
});
//好辣,现在 slice完毕了
//我们要去store里引入reducer呢
//不要忘记把createSlice自动生成的action导出呢!
export const { postAdd, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;
更新后的AddPostForm
//这是注释,显示文件路径捏:/src/features/posts/AddPostForm.tsx
import React, { useState } from "react";
import { nanoid } from "@reduxjs/toolkit";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { postAdd } from "./postsSlice";
export const AddPostForm = () => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
//不要忘记用类型修饰过的useAppDispatch呢!
const dispatch = useAppDispatch();
//新增的用于存储 select中选择的的userId (是从state.users里边选的)
const [userId, setUserId] = useState("");
// 具体userId, 对吧,是从state.users里边拿的,(用<select>显示
const users = useAppSelector((state) => state.users);
const onTitleChanged = (e: React.ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value);
const onContentChanged = (e: React.ChangeEvent<HTMLTextAreaElement>) =>
setContent(e.target.value);
const onAuthorChanged = (e: React.ChangeEvent<HTMLSelectElement>) =>
setUserId(e.target.value);
//强制要求,必须填写文章标题、内容和用户的id才能 存储这条post
const canSave = Boolean(title) && Boolean(content) && Boolean(userId);
//这是个 option 组件啊,造出来一个下拉菜单的效果
const usersOptions = users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
));
return (
<section>
<h2>添加新文章</h2>
<form>
<label htmlFor="postTitle">文章标题:</label>
<input
type="text"
id="postTitle"
name="postTitle"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">内容:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
<button
type="button"
onClick={() => {
dispatch(postAdd(title, content, userId));
}}
disabled={!canSave}
>
保存文章
</button>
</form>
</section>
);
};
现在呢,AddPostForm 就已经能够在添加post的时候,把user给一并添加上了,那么还有个问题。而且一定的,store里边post数据这块儿也有userId,咱们打开devTools看一下。
图1,没添加之前。(注意选择的这个user,他的userId就是0)
图二,添加之后(注意选择的这个user,他的userId就是0)
userId 其实是固定写好在内存里的捏,这里就是为了演示捏,正常情况,是从数据库里拉取出来的呢(也一般都是数据库自动生成的捏)
下一步,是要再写一个组件,能够用于获取文章作者的userId和显示他的名字,这个组件可以多处复用,比如在PostList里或者是SinglePostPage里边显示用户信息吖。
//这是注释,显示文件路径捏:/src/features/posts/PostAuthor.tsx
import React from "react";
import { useSelector } from "react-redux";
import { useAppSelector } from "../../app/hooks";
interface Props {
userId: string;
}
export const PostAuthor = ({ userId }: Props) => {
const author = useAppSelector((state) =>
state.users.find((user) => user.id === userId)
);
return <span>by {author ? author.name : "Unknown author"}</span>;
};
请注意,我们在每个组件中都遵循相同的模式。任何需要从 Redux store 读取数据的组件都可以使用 useSelector 钩子,并提取它需要的特定数据片段。此外,许多组件可以同时访问 Redux store 中的相同数据。
我们现在可以将 PostAuthor 组件导入到 PostsList.tsx 和 SinglePostPage.tsx 中,并将其渲染为
如下,对PostList的改变
//这是注释,显示文件路径捏:/src/features/posts/PostList.tsx
import React from "react";
import { Link } from "react-router-dom";
import { useAppSelector } from "../../app/hooks";
import { PostAuthor } from "./PostAuthor";
const PostsList = () => {
const posts = useAppSelector((state) => state.posts);
const renderedPosts = posts.map((post) => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<PostAuthor userId={post.user} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
{/* 这个div就是一个简单的分隔符捏! */}
<div> ____</div>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
</article>
));
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
);
};
export default PostsList;
如下,对SinglePostPage的改变捏:
//这是注释,显示文件路径捏:/src/features/posts/SinglePostPage.tsx
//"/features/posts/SinglePostPage"
import React from "react";
import { useParams } from "react-router";
import { useAppSelector } from "../../app/hooks";
import { PostAuthor } from "./PostAuthor";
const SinglePostPage = ({}) => {
// The useParams hook returns an object of key/value pairs of the dynamic params from
// the current URL that were matched by the <Route path>.
// Child routes inherit all params from their parent routes.
const { postId } = useParams();
const post = useAppSelector((state) =>
state.posts.find((post) => post.id === postId)
);
if (!post) {
return (
<section>
<h2>页面未找到!</h2>
</section>
);
}
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
<PostAuthor userId={post.user} />
</section>
);
};
export default SinglePostPage;
额。下一步,要实现更多功能
更多文章功能
emmmm,到现在为止,我们已经能够创建、编辑文章,而且每篇文章都有作者,下面添加一些额外的逻辑,使我们的文章提要更有用。
额,这里杂乱的介绍了一些功能,比如为文章加入创建日期,为文章列表排序,增加文章交互按钮(实际上就是点赞、点踩的计数功能)。
存储文章的日期
社交媒体提要通常按文章创建时间排序,并向我们显示文章创建时间作为相对描述,例如“5 小时前”。为此,我们需要开始跟踪文章条目的“日期”字段。
与 post.user 字段一样,我们将更新我们的 postAdded prepare 回调,以确保在 dispatch action 时始终包含 post.date。然而,它不是将被传入的另一个参数。我们希望始终使用 dispatch action 时的时间戳,因此我们将让 prepare 回调自己处理它。
上面一段就是说,现在我们的文章post上得有个date数据,也就是要更新postSlice 中的reducer和原始数据,确保state.posts上的数据有date这个字段。
类似之前的 nanoid(还记得那个生成随机id的功能吧),这里的这个date也是个“随机值”,那它肯定不能放在reducer这个纯函数里,并且,我们也不想在dispatch action的时候以构建action的形式把date传入(这样太麻烦,也不安全,比如另外一个人不知道要传入文章构建时候的时间戳,就传了其他的日期,那就GG了辣),我们为了简单,也为了确保post.date一定是文章创建时候的时间,那就用prepare预处理它。
注意
Redux action 和 state 应该只能包含普通的 JS 值,如对象、数组和基本类型。不要将类实例、函数或其他不可序列化的值放入 Redux!。
上面内容提醒核心在于,只能放入store 数组、对象(只能是JavaScript里狭义的花括号的对象,不是引用类型的实例吖)和基本类型。
类实例、函数或者其他不可序列化的值,放进去就完犊子辣(GG well play)。
因为,Date类实例不能放入到redux 的store里,那我们把实例化Date 后的实例给序列化再放入post.date ,完美。new Date().toISOString()
注意一下, date: new Date().toISOString(),
就这一行,简简单单,就能在创建post的时候把date字段给嵌入到state里边的,给你康康图片,
但是呢,我们的初始state,initialState里边其实没有这个date这个字段捏,再但是,Typescript也没报错,这个要注意,具体怎么处理这个字段,你根据实际情况判断,反正我是加了。。。。
//这是注释,显示文件路径捏:/src/features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
import { sub } from "date-fns";
const initialState = [
{
id: "1",
title: "First Post!",
content: "Hello!",
user: "0",
//意思就是这篇文章的创建时间是渲染时间10分钟之前(强制写的,为了示范)
date: sub(new Date(), { minutes: 10 }).toISOString(),
},
{
id: "2",
title: "Second Post",
content: "More text",
user: "1",
date: sub(new Date(), { minutes: 5 }).toISOString(),
},
//注意这里新增了date
];
export type IPost = {
id: string;
content: string;
title: string;
//类型见usersSlice里边的内容捏
user: string;
//注意这里也新增了date,我试验了一下,他对ui中dispatch action没任何影响,
//但它可以直接约束reducer的prepare中payload的构建
//我想了一下,prepare的形参就是dispatch action时候,actionCreator的形参
// 但是prepare内部预构建了id/date,所以这样看逻辑也很通,但是具体Typescript怎么做的,我实在不知道。
date: string;
};
const postsSlice = createSlice({
name: "posts",
initialState: initialState,
//记住吖,处理数据就是reducer的责任捏!
//同步的数据处理就全在这里呢!
reducers: {
postAdd: {
reducer: (state, action: PayloadAction<IPost>) => {
state.push(action.payload);
},
prepare: (title, content, userId) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
},
};
},
},
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.find((post) => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
} else {
// 好歹报个错吧
console.log("花Q,你在干啥?");
}
},
//end
//如果你想给UI加上交互式的 排序。。。就得这么做,但是文档没介绍,我们先注释掉。。
// postSort: (state) => {
// state.sort((a, b) => b.date.localeCompare(a.date));
// },
},
});
export const { postAdd, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;
额,现在因为prepare新增了date字段,所以手动创建的post已经有了date,也就是创建时间这个数据,下一步把他用起来。
与文章作者一样,我们需要在
和 组件中显示相对时间戳描述。我们将添加一个 组件来处理格式化时间戳字符串作为相对描述。像 date-fns 这样的库有一些有用的工具函数来解析和格式化日期,可以在这里使用:
这里安装了一下挺酷的库,叫做 date-fns, 一般我们都爱自己写这种东西,费时费力而且总会出错,倒不如用已经很成熟的库来的好。
npm i date-fns -S
额,注意啊,date-fns自己有类型定义,不用再安装社区版(社区版已经deprecated了,6年没更新了[到2023年])。。。
下面创建一个TimeAgo 组件,专门用于显示文章创建于什么时间。
主要是接受一个 Date实例化(new Date)后又序列化的东东(就是我们在prepare里边做的),然后通过date-fns提供的api计算出文章创建的时间和当前时间(指渲染时间点)的差了多久。。。
//这是注释,显示文件路径捏:/src/features/posts/TimeAgo.tsx
import React from "react";
import { parseISO, formatDistanceToNow } from "date-fns";
interface Prop {
timestamp: string;
}
export const TimeAgo = ({ timestamp }: Prop) => {
let timeAgo = "";
if (timestamp) {
const date = parseISO(timestamp);
const timePeriod = formatDistanceToNow(date);
timeAgo = `${timePeriod} ago`;
}
return (
<span title={timestamp}>
<i>{timeAgo}</i>
</span>
);
};
export default TimeAgo;
额,不要忘记更新使用TimeAgo的页面,我就随便粘一个过来示范。
//这是注释,显示文件路径捏:/src/features/posts/PostList.tsx
import React from "react";
import { Link } from "react-router-dom";
import { useAppSelector } from "../../app/hooks";
import { PostAuthor } from "./PostAuthor";
import TimeAgo from "./TimeAgo";
const PostsList = () => {
const posts = useAppSelector((state) => state.posts);
// 根据日期时间对文章进行倒序排序
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date));
const renderedPosts = orderedPosts.map((post) => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<PostAuthor userId={post.user} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
{/* 这个div就是一个简单的分隔符捏! */}
<div> ____</div>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
<TimeAgo timestamp={post.date}></TimeAgo>
</article>
));
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
);
};
export default PostsList;
为文章列表排序
我们的
当前以文章在 Redux store 中保存的相同顺序显示所有文章。我们的示例首先包含最旧的文章,每当我们添加新文章时,它都会添加到文章数组的末尾。这意味着最新的文章总是在页面底部。
通常,社交媒体提要首先显示最新文章,然后向下滚动以查看旧文章。即使数据在 store 中是旧的在前,仍然可以在组件中重新排序数据,以便最新的文章在最前面。理论上,由于我们知道 state.posts 数组已经排序,我们可以只反转列表。但是,为了确定起见,最好还是自己进行排序。
由于 array.sort() 改变了现有数组,我们需要制作 state.posts 的副本并对该副本进行排序。我们知道我们的 post.date 字段被保存为日期时间戳字符串,我们可以直接比较它们以按正确的顺序对文章进行排序:
这里不是给文章列表这个UI组件加上“按时间排序”这个功能,就是在组件内部直接给数据按照时间排了序。
如果你想加交互,起码得加个按钮,然后在slice里加至少一个(一般两个)reducer,比如下面的slice中注释掉的 postSort 这个reducer:
还有点注意,我这个slice已经更新了,具体看initialState的date字段。
//这是注释,显示文件路径捏:/src/features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
import { sub } from "date-fns";
const initialState = [
{
id: "1",
title: "First Post!",
content: "Hello!",
user: "0",
//意思就是这篇文章的创建时间是渲染时间10分钟之前(强制写的,为了示范)
date: sub(new Date(), { minutes: 10 }).toISOString(),
},
{
id: "2",
title: "Second Post",
content: "More text",
user: "1",
date: sub(new Date(), { minutes: 5 }).toISOString(),
},
//注意这里新增了date
];
type postAction = {
id: string;
content: string;
title: string;
//类型见usersSlice里边的内容捏
user: string;
//注意这里也新增了date,我试验了一下,他对ui中dispatch action没任何影响,
//但它可以直接约束reducer的prepare中payload的构建
//我想了一下,prepare的形参就是dispatch action时候,actionCreator的形参
// 但是prepare内部预构建了id/date,所以这样看逻辑也很通,但是具体Typescript怎么做的,我实在不知道。
date: string;
};
const postsSlice = createSlice({
name: "posts",
initialState: initialState,
//记住吖,处理数据就是reducer的责任捏!
//同步的数据处理就全在这里呢!
reducers: {
postAdd: {
reducer: (state, action: PayloadAction<postAction>) => {
state.push(action.payload);
},
prepare: (title, content, userId) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
},
};
},
},
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.find((post) => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
} else {
// 好歹报个错吧
console.log("花Q,你在干啥?");
}
},
//end
//如果你想给UI加上交互式的 排序。。。就得这么做,但是文档没介绍,我们先注释掉。。
// postSort: (state) => {
// state.sort((a, b) => b.date.localeCompare(a.date));
// },
},
});
export const { postAdd, postUpdated } = postsSlice.actions;
export default postsSlice.reducer;
当然,上面那个功能没具体实现,我们还是关心文档上的内容,文档上现在直接在PostList里把posts 按照创建时间进行了原地排列,注意,这个sort会改变数组,所以调用了slice进行数组复制。
//这是注释,显示文件路径捏:/src/features/posts/PostList.tsx
import React from "react";
import { Link } from "react-router-dom";
import { useAppSelector } from "../../app/hooks";
import { PostAuthor } from "./PostAuthor";
const PostsList = () => {
const posts = useAppSelector((state) => state.posts);
// 根据日期时间对文章进行倒序排序
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date));
const renderedPosts = orderedPosts.map((post) => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<PostAuthor userId={post.user} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
{/* 这个div就是一个简单的分隔符捏! */}
<div> ____</div>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
</article>
));
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
);
};
export default PostsList;
文章交互按钮
额,最后是文章交互按钮,这个把我吓了一跳,其实就是视频网站上视频下方的那个点赞按钮,当然也可以延伸到观看数、评论数等功能,因为具体原理都一样,就是在store里存放这个按钮的点击次数(也可以延伸到视频播放次数等内容对吧)。
现在添加一个新功能,我们的文章有点无聊。我们需要让他们更令人兴奋,还有什么比让我们的朋友在我们的文章中添加表情交互按钮是更好的方法呢?
我们将在和 的每个文章底部添加一行表情符号交互按钮。每次用户单击一个交互按钮时,我们都需要更新 Redux store 中该文章的匹配计数器字段。由于交互计数器数据位于 Redux store 中,因此在应用程序的不同部分之间切换应该在使用该数据的任何组件中始终显示相同的值。
与文章作者和时间戳一样,我们希望在显示文章的任何地方使用它,因此我们将创建一个以 post 作为 props 的组件。我们将首先显示里面的按钮,以及每个按钮的当前交互计数:
上面一段话就两个意思:
- 在postSlice里,给每个post加个reaction字段,这里reaction是个对象,那么对象里的每个成员键值对形式是
name:number
比如:
reactions: {
thumbsUp: 0,
hooray: 0,
heart: 0,
rocket: 0,
eyes: 0,
},
然后增加对应的修改reaction 的reducer,
- 创立一个组件,渲染五个按钮,点击按钮,dispatch 修改reaction的 action 。
好,开始照搬:
先更新slice ,注意,这里我把postAction更新成为了IPost,因为这个类型会导出在其他组件使用捏。
然后更新后的reactions也有对应的类型Treactions,这个类型也会导出在其他组件使用捏。
注意,这里已经更新了 reducer reactionAdded 并导出对应的action了。
//这是注释,显示文件路径捏:/src/features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
import { sub } from "date-fns";
const initialState = [
{
id: "1",
title: "First Post!",
content: "Hello!",
user: "0",
//意思就是这篇文章的创建时间是渲染时间10分钟之前(强制写的,为了示范)
date: sub(new Date(), { minutes: 10 }).toISOString(),
reactions: {
thumbsUp: 0,
hooray: 0,
heart: 0,
rocket: 0,
eyes: 0,
},
},
{
id: "2",
title: "Second Post",
content: "More text",
user: "1",
date: sub(new Date(), { minutes: 5 }).toISOString(),
reactions: {
thumbsUp: 0,
hooray: 0,
heart: 0,
rocket: 0,
eyes: 0,
},
},
//注意这里新增了date
];
export type Treactions = typeof initialState[number]["reactions"];
export type IPost = {
id: string;
content: string;
title: string;
//类型见usersSlice里边的内容捏
user: string;
//注意这里也新增了date,我试验了一下,他对ui中dispatch action没任何影响,
//但它可以直接约束reducer的prepare中payload的构建
//我想了一下,prepare的形参就是dispatch action时候,actionCreator的形参
// 但是prepare内部预构建了id/date,所以这样看逻辑也很通,但是具体Typescript怎么做的,我实在不知道。
date: string;
reactions: Treactions;
};
const postsSlice = createSlice({
name: "posts",
initialState: initialState,
//记住吖,处理数据就是reducer的责任捏!
//同步的数据处理就全在这里呢!
reducers: {
postAdd: {
reducer: (state, action: PayloadAction<IPost>) => {
state.push(action.payload);
},
prepare: (title, content, userId) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
reactions: {
thumbsUp: 0,
hooray: 0,
heart: 0,
rocket: 0,
eyes: 0,
},
},
};
},
},
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.find((post) => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
} else {
// 好歹报个错吧
console.log("花Q,你在干啥?");
}
},
reactionAdded(state, action) {
const { postId, reaction } = action.payload;
const existingPost = state.find((post) => post.id === postId);
if (existingPost) {
// https://stackoverflow.com/questions/57086672/element-implicitly-has-an-any-type-because-expression-of-type-string-cant-b
existingPost.reactions[reaction as keyof Treactions]++;
}
},
//end
//如果你想给UI加上交互式的 排序。。。就得这么做,但是文档没介绍,我们先注释掉。。
// postSort: (state) => {
// state.sort((a, b) => b.date.localeCompare(a.date));
// },
},
});
export const { postAdd, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer;
然后创建渲染按钮的组件,并绑定dispatch action的动作,注意下面用到了 Treactions捏!
//这是注释,显示文件路径捏:/src/features/posts/ReactionButtons.tsx
import React from "react";
import { useAppDispatch } from "../../app/hooks";
import { IPost, reactionAdded, Treactions } from "./postsSlice";
const reactionEmoji = {
thumbsUp: "👍",
hooray: "🎉",
heart: "❤️",
rocket: "🚀",
eyes: "👀",
};
interface Props {
post: IPost;
}
export const ReactionButtons = ({ post }: Props) => {
const dispatch = useAppDispatch();
const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
return (
<button
key={name}
type="button"
className="muted-button reaction-button"
onClick={() => {
dispatch(reactionAdded({ postId: post.id, reaction: name }));
}}
>
{emoji} {post.reactions[name as keyof Treactions]}
</button>
);
});
return <div>{reactionButtons}</div>;
};
最后一步,把ReactionButtons 在合适的地方渲染出来捏!
到这儿,页面大概是这样的(跟文档不太一样,因为没有复制样式捏!)