Redux 要点,第 4 部分:使用 Redux 数据
在多个 React 组件中使用 Redux 数据
¥Using Redux data in multiple React components
组织调度动作的逻辑
¥Organizing logic that dispatches actions
使用选择器查找状态值
¥Using selectors to look up state values
在 reducer 中编写更复杂的更新逻辑
¥Writing more complex update logic in reducers
如何考虑 Redux 操作
¥How to think about Redux actions
介绍
¥Introduction
在 第 3 部分:基本 Redux 数据流 中,我们了解了如何从空的 Redux+React 项目设置开始,添加新的状态片,并创建可以从 Redux 存储读取数据并分派操作来更新该数据的 React 组件。我们还研究了数据如何流经应用,其中组件调度操作、Reducer 处理操作并返回新状态,以及组件读取新状态并重新渲染 UI。我们还了解了如何创建 useSelector
和 useDispatch
钩子的 "pre-typed" 版本,这些钩子会自动应用正确的存储类型。
¥In Part 3: Basic Redux Data Flow, we saw how to start from an empty Redux+React project setup, add a new slice of state, and create React components that can read data from the Redux store and dispatch actions to update that data. We also looked at how data flows through the application, with components dispatching actions, reducers processing actions and returning new state, and components reading the new state and rerendering the UI. We also saw how to create "pre-typed" versions of the useSelector
and useDispatch
hooks that have the correct store types applied automatically.
现在你已经了解了编写 Redux 逻辑的核心步骤,我们将使用这些相同的步骤向我们的社交媒体源添加一些新功能,使其更加有用:查看单个帖子,编辑现有帖子,显示帖子作者详细信息、帖子时间戳、反应按钮和身份验证。
¥Now that you know the core steps to write Redux logic, we're going to use those same steps to add some new features to our social media feed that will make it more useful: viewing a single post, editing existing posts, showing post author details, post timestamps, reaction buttons, and auth.
提醒一下,代码示例重点关注每个部分的关键概念和更改。请参阅 CodeSandbox 项目和 项目仓库中的 tutorial-steps
分支 以了解应用中的完整更改。
¥As a reminder, the code examples focus on the key concepts and changes for each section. See the CodeSandbox projects and the tutorial-steps
branch in the project repo for the complete changes in the application.
显示单个帖子
¥Showing Single Posts
由于我们能够向 Redux 存储添加新帖子,因此我们可以添加更多以不同方式使用帖子数据的功能。
¥Since we have the ability to add new posts to the Redux store, we can add some more features that use the post data in different ways.
目前,我们的帖子条目显示在主提要页面中,但如果文本太长,我们只会显示内容的摘录。如果能够在自己的页面上查看单个帖子条目,将会很有帮助。
¥Currently, our post entries are being shown in the main feed page, but if the text is too long, we only show an excerpt of the content. It would be helpful to have the ability to view a single post entry on its own page.
创建单个帖子页面
¥Creating a Single Post Page
首先,我们需要将新的 SinglePostPage
组件添加到 posts
功能文件夹中。当页面 URL 类似于 /posts/123
时,我们将使用 React Router 来显示此组件,其中 123
部分应该是我们要显示的帖子的 ID。
¥First, we need to add a new SinglePostPage
component to our posts
feature folder. We'll use React Router to show this component when the page URL looks like /posts/123
, where the 123
part should be the ID of the post we want to show.
import { useParams } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
</section>
)
}
当我们设置路由以渲染此组件时,我们将告诉它将 URL 的第二部分解析为名为 postId
的变量,我们可以从 useParams
钩子中读取该值。
¥When we set up the route to render this component, we're going to tell it to parse the second part of the URL as a variable named postId
, and we can read that value from the useParams
hook.
一旦我们有了 postId
值,我们就可以在选择器函数中使用它来从 Redux 存储中找到正确的 post 对象。我们知道 state.posts
应该是所有帖子对象的数组,因此我们可以使用 Array.find()
函数循环遍历该数组并返回具有我们要查找的 ID 的帖子条目。
¥Once we have that postId
value, we can use it inside a selector function to find the right post object from the Redux store. We know that state.posts
should be an array of all post objects, so we can use the Array.find()
function to loop through the array and return the post entry with the ID we're looking for.
需要注意的是,每当从 useAppSelector
返回的值更改为新引用时,组件都会重新渲染。组件应始终尝试从存储中选择所需的尽可能少的数据,这将有助于确保它仅在实际需要时渲染。
¥It's important to note that the component will re-render any time the value returned from useAppSelector
changes to a new reference. Components should always try to select the smallest possible amount of data they need from the store, which will help ensure that it only renders when it actually needs to.
我们的存储中可能没有匹配的帖子条目 - 也许用户尝试直接输入 URL,或者我们没有加载正确的数据。如果发生这种情况,find()
函数将返回 undefined
而不是实际的 post 对象。我们的组件需要检查这一点并通过在页面中显示 "帖子未找到!" 消息来处理它。
¥It's possible that we might not have a matching post entry in the store - maybe the user tried to type in the URL directly, or we don't have the right data loaded. If that happens, the find()
function will return undefined
instead of an actual post object. Our component needs to check for that and handle it by showing a "Post not found!" message in the page.
假设我们在存储中有正确的帖子对象,useAppSelector
将返回该对象,我们可以使用它在页面中渲染帖子的标题和内容。
¥Assuming we do have the right post object in the store, useAppSelector
will return that, and we can use it to render the title and content of the post in the page.
你可能会注意到,这看起来与 <PostsList>
组件主体中的逻辑非常相似,我们在整个 posts
数组上循环以显示主提要上的帖子摘录。我们可以尝试提取可在两个地方使用的 Post
组件,但我们显示帖子摘录和整个帖子的方式已经存在一些差异。即使存在一些重复,通常最好还是继续单独编写一段时间,然后我们可以稍后决定不同的代码部分是否足够相似,以至于我们可以真正提取可重用的组件。
¥You might notice that this looks fairly similar to the logic we have in the body of our <PostsList>
component, where we loop over the whole posts
array to show post excerpts on the main feed. We could try to extract a Post
component that could be used in both places, but there are already some differences in how we're showing a post excerpt and the whole post. It's usually better to keep writing things separately for a while even if there's some duplication, and then we can decide later if the different sections of code are similar enough that we can really extract a reusable component.
添加单个帖子路由
¥Adding the Single Post Route
现在我们有了 <SinglePostPage>
组件,我们可以定义一个显示它的路由,并在首页提要中添加指向每个帖子的链接。
¥Now that we have a <SinglePostPage>
component, we can define a route to show it, and add links to each post in the front page feed.
在我们这样做的同时,也值得将 "主页" 内容提取到单独的 <PostsMainPage>
组件中,只是为了可读性。
¥While we're at it, it's also worth extracting the "main page" content into a separate <PostsMainPage>
component as well, just for readability.
我们将在 App.tsx
中导入 PostsMainPage
和 SinglePostPage
,并添加路由:
¥We'll import PostsMainPage
and SinglePostPage
in App.tsx
, and add the route:
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
</Routes>
</div>
</Router>
)
}
export default App
然后,在 <PostsList>
中,我们将更新列表渲染逻辑以包含路由到该特定帖子的 <Link>
:
¥Then, in <PostsList>
, we'll update the list rendering logic to include a <Link>
that routes to that specific post:
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
export const PostsList = () => {
const posts = useAppSelector(state => state.posts)
const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
</article>
))
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
)
}
由于我们现在可以点击进入不同的页面,因此在 <Navbar>
组件中提供返回主帖子页面的链接也会很有帮助:
¥And since we can now click through to a different page, it would also be helpful to have a link back to the main posts page in the <Navbar>
component as well:
import { Link } from 'react-router-dom'
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
</div>
</div>
</section>
</nav>
)
}
编辑帖子
¥Editing Posts
作为一个用户,写完一篇文章,保存它,然后意识到自己在某个地方犯了错误,这真的很烦人。创建帖子后能够对其进行编辑会很有用。
¥As a user, it's really annoying to finish writing a post, save it, and realize you made a mistake somewhere. Having the ability to edit a post after we created it would be useful.
让我们添加一个新的 <EditPostForm>
组件,该组件能够获取现有帖子 ID、从存储读取该帖子、让用户编辑标题和帖子内容,然后保存更改以更新存储中的帖子。
¥Let's add a new <EditPostForm>
component that has the ability to take an existing post ID, read that post from the store, lets the user edit the title and post content, and then save the changes to update the post in the store.
更新帖子条目
¥Updating Post Entries
首先,我们需要更新 postsSlice
以创建新的 reducer 函数和操作,以便存储知道如何实际更新帖子。
¥First, we need to update our postsSlice
to create a new reducer function and an action so that the store knows how to actually update posts.
在 createSlice()
调用内部,我们应该向 reducers
对象添加一个新函数。请记住,该 reducer 的名称应该能够很好地描述所发生的情况,因为每当调度此操作时,我们都会看到 reducer 名称作为操作类型字符串的一部分显示在 Redux DevTools 中。我们的第一个 reducer 称为 postAdded
,所以我们将其称为 postUpdated
。
¥Inside of the createSlice()
call, we should add a new function into the reducers
object. Remember that the name of this reducer should be a good description of what's happening, because we're going to see the reducer name show up as part of the action type string in the Redux DevTools whenever this action is dispatched. Our first reducer was called postAdded
, so let's call this one postUpdated
.
Redux 本身并不关心你对这些 Reducer 函数使用什么名称 - 如果它被命名为 postAdded
、addPost
、POST_ADDED
或 someRandomName
,它将以相同的方式运行。
¥Redux itself doesn't care what name you use for these reducer functions - it'll run the same if it's named postAdded
, addPost
, POST_ADDED
, or someRandomName
.
也就是说,我们鼓励将 Reducer 命名为过去时 "发生这种情况" 名称,如 postAdded
,因为我们正在描述 "应用中发生的事件"。
¥That said, we encourage naming reducers as past-tense "this happened" names like postAdded
, because we're describing "an event that occurred in the application".
为了更新 post 对象,我们需要知道:
¥In order to update a post object, we need to know:
正在更新的帖子的 ID,以便我们可以在状态中找到正确的帖子对象
¥The ID of the post being updated, so that we can find the right post object in the state
用户输入的新
title
和content
字段¥The new
title
andcontent
fields that the user typed in
Redux 操作对象需要有一个 type
字段,该字段通常是一个描述性字符串,并且还可能包含其他字段,其中包含有关所发生事件的更多信息。按照惯例,我们通常将附加信息放在名为 action.payload
的字段中,但由我们决定 payload
字段包含什么内容 - 它可以是字符串、数字、对象、数组或其他东西。在本例中,由于我们需要三条信息,因此我们计划将 payload
字段设置为一个内部包含三个字段的对象。这意味着操作对象将类似于 {type: 'posts/postUpdated', payload: {id, title, content}}
。
¥Redux action objects are required to have a type
field, which is normally a descriptive string, and may also contain other fields with more information about what happened. By convention, we normally put the additional info in a field called action.payload
, but it's up to us to decide what the payload
field contains - it could be a string, a number, an object, an array, or something else. In this case, since we have three pieces of information we need, let's plan on having the payload
field be an object with the three fields inside of it. That means the action object will look like {type: 'posts/postUpdated', payload: {id, title, content}}
.
默认情况下,createSlice
生成的操作创建者希望你传入一个参数,并且该值将作为 action.payload
放入操作对象中。因此,我们可以将包含这些字段的对象作为参数传递给 postUpdated
操作创建者。与 postAdded
一样,这是一个完整的 Post
对象,因此我们声明 Reducer 参数是 action: PayloadAction<Post>
。
¥By default, the action creators generated by createSlice
expect you to pass in one argument, and that value will be put into the action object as action.payload
. So, we can pass an object containing those fields as the argument to the postUpdated
action creator. As with postAdded
, this is an entire Post
object, so we declare that the reducer argument is action: PayloadAction<Post>
.
我们还知道,reducer 负责确定在分派操作时实际应如何更新状态。鉴于此,我们应该让 reducer 根据 ID 找到正确的 post 对象,并专门更新该 post 中的 title
和 content
字段。
¥We also know that the reducer is responsible for determining how the state should actually be updated when an action is dispatched. Given that, we should have the reducer find the right post object based on the ID, and specifically update the title
and content
fields in that post.
最后,我们需要导出 createSlice
为我们生成的操作创建器函数,以便 UI 可以在用户保存帖子时调度新的 postUpdated
操作。
¥Finally, we'll need to export the action creator function that createSlice
generated for us, so that the UI can dispatch the new postUpdated
action when the user saves the post.
考虑到所有这些要求,完成后我们的 postsSlice
定义应如下所示:
¥Given all those requirements, here's how our postsSlice
definition should look after we're done:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
// omit state types
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
postUpdated(state, action: PayloadAction<Post>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
export const { postAdded, postUpdated } = postsSlice.actions
export default postsSlice.reducer
创建编辑帖子表单
¥Creating an Edit Post Form
我们的新 <EditPostForm>
组件看起来与 <AddPostForm>
和 <SinglePostPage>
相似,但逻辑需要略有不同。我们需要根据 URL 中的 postId
从存储中检索正确的 post
对象,然后使用它来初始化组件中的输入字段,以便用户可以进行更改。当用户提交表单时,我们会将更改后的标题和内容值保存回存储。我们还将使用 React Router 的 useNavigate
钩子切换到单个帖子页面,并在他们保存更改后显示该帖子。
¥Our new <EditPostForm>
component will look similar to both the the <AddPostForm>
and <SinglePostPage>
, but the logic needs to be a bit different. We need to retrieve the right post
object from the store based on the postId
in the URL, then use that to initialize the input fields in the component so the user can make changes. We'll save the changed title and content values back to the store when the user submits the form. We'll also use React Router's useNavigate
hook to switch over to the single post page and show that post after they save the changes.
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { postUpdated } from './postsSlice'
// omit form element types
export const EditPostForm = () => {
const { postId } = useParams()
const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)
const dispatch = useAppDispatch()
const navigate = useNavigate()
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const onSavePostClicked = (e: React.FormEvent<EditPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
if (title && content) {
dispatch(postUpdated({ id: post.id, title, content }))
navigate(`/posts/${postId}`)
}
}
return (
<section>
<h2>Edit Post</h2>
<form onSubmit={onSavePostClicked}>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
defaultValue={post.title}
required
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue={post.content}
required
/>
<button>Save Post</button>
</form>
</section>
)
}
请注意,此处特定于 Redux 的代码相对较少。再次,我们通过 useAppSelector
从 Redux 存储中读取一个值,然后在用户与 UI 交互时通过 useAppDispatch
调度一个操作。
¥Note that the Redux-specific code here is relatively minimal. Once again, we read a value from the Redux store via useAppSelector
, and then dispatch an action via useAppDispatch
when the user interacts with the UI.
与 SinglePostPage
一样,我们需要将其导入到 App.tsx
中并添加一条路由,该路由将使用 postId
作为路由参数来渲染此组件。
¥Like with SinglePostPage
, we'll need to import it into App.tsx
and add a route that will render this component with the postId
as a route parameter.
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</div>
</Router>
)
}
export default App
我们还应该向 SinglePostPage
添加一个新链接,该链接将路由到 EditPostForm
,例如:
¥We should also add a new link to our SinglePostPage
that will route to EditPostForm
, like:
import { Link, useParams } from 'react-router-dom'
export const SinglePostPage = () => {
// omit other contents
<p className="post-content">{post.content}</p>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
准备操作有效负载
¥Preparing Action Payloads
我们刚刚看到,createSlice
中的动作创建者通常期望一个参数,该参数将成为 action.payload
。这简化了最常见的使用模式,但有时我们需要做更多的工作来准备操作对象的内容。在我们的 postAdded
操作中,我们需要为新帖子生成一个唯一的 ID,并且我们还需要确保有效负载是一个看起来像 {id, title, content}
的对象。
¥We just saw that the action creators from createSlice
normally expect one argument, which becomes action.payload
. This simplifies the most common usage pattern, but sometimes we need to do more work to prepare the contents of an action object. In the case of our postAdded
action, we need to generate a unique ID for the new post, and we also need to make sure that the payload is an object that looks like {id, title, content}
.
现在,我们正在 React 组件中生成 ID 并创建有效负载对象,并将有效负载对象传递到 postAdded
。但是,如果我们需要从不同的组件分派相同的操作,或者准备有效负载的逻辑很复杂怎么办?We'每次我们想要分派该操作时,都必须重复该逻辑,并且我们' 强制组件确切地知道此操作的有效负载应该是什么样子。
¥Right now, we're generating the ID and creating the payload object in our React component, and passing the payload object into postAdded
. But, what if we needed to dispatch the same action from different components, or the logic for preparing the payload is complicated? We'd have to duplicate that logic every time we wanted to dispatch the action, and we're forcing the component to know exactly what the payload for this action should look like.
如果操作需要包含唯一 ID 或其他随机值,请始终先生成该值并将其放入操作对象中。reducer 永远不应该计算随机值,因为这会使结果不可预测。
¥If an action needs to contain a unique ID or some other random value, always generate that first and put it in the action object. Reducers should never calculate random values, because that makes the results unpredictable.
如果我们手工编写 postAdded
动作创建器,我们可以自己将设置逻辑放入其中:
¥If we were writing the postAdded
action creator by hand, we could have put the setup logic inside of it ourselves:
// hand-written action creator
function postAdded(title: string, content: string) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}
但是,Redux Toolkit 的 createSlice
正在为我们生成这些动作创建器。这使得代码更短,因为我们不必自己编写它们,但我们仍然需要一种方法来自定义 action.payload
的内容。
¥But, Redux Toolkit's createSlice
is generating these action creators for us. That makes the code shorter because we don't have to write them ourselves, but we still need a way to customize the contents of action.payload
.
幸运的是,createSlice
让我们在编写 reducer 时定义了 "准备回调" 函数。"准备回调" 函数可以采用多个参数,生成随机值(例如唯一 ID),并运行所需的任何其他同步逻辑来决定将哪些值放入操作对象中。然后它应该返回一个内部包含 payload
字段的对象。(返回对象还可能包含一个 meta
字段,该字段可用于向操作添加额外的描述性值,以及一个 error
字段,该字段应该是一个布尔值,指示该操作是否表示某种错误。)
¥Fortunately, createSlice
lets us define a "prepare callback" function when we write a reducer. The "prepare callback" function can take multiple arguments, generate random values like unique IDs, and run whatever other synchronous logic is needed to decide what values go into the action object. It should then return an object with the payload
field inside. (The return object may also contain a meta
field, which can be used to add extra descriptive values to the action, and an error
field, which should be a boolean indicating whether this action represents some kind of an error.)
在 createSlice
的 reducers
字段内部,我们可以将其中一个字段定义为看起来像 {reducer, prepare}
的对象:
¥Inside of the reducers
field in createSlice
, we can define one of the fields as an object that looks like {reducer, prepare}
:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string) {
return {
payload: { id: nanoid(), title, content }
}
}
}
// other reducers here
}
})
现在我们的组件不必担心有效负载对象是什么样子 - 动作创建者将负责以正确的方式将其组合在一起。因此,我们可以更新组件,以便在分派 postAdded
时传入 title
和 content
作为参数:
¥Now our component doesn't have to worry about what the payload object looks like - the action creator will take care of putting it together the right way. So, we can update the component so that it passes in title
and content
as arguments when it dispatches postAdded
:
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Now we can pass these in as separate arguments,
// and the ID will be generated automatically
dispatch(postAdded(title, content))
e.currentTarget.reset()
}
使用选择器读取数据
¥Reading Data With Selectors
我们现在有几个不同的组件通过 ID 查找帖子,并重复 state.posts.find()
调用。这是重复的代码,我们总是值得考虑是否应该删除重复的内容。它也很脆弱 - 正如我们将在后面的章节中看到的,我们最终将开始改变帖子切片状态结构。当我们这样做时,我们必须找到引用 state.posts
的每个地方并相应地更新逻辑。TypeScript 将通过在编译时抛出错误来帮助捕获不再与预期状态类型匹配的损坏代码,但如果我们不必在每次更改 Reducer 中的数据格式时都重写组件,也不必在组件中重复逻辑,那就太好了。
¥We now have a couple different components that are looking up a post by ID, and repeating the state.posts.find()
call. This is duplicate code, and it's always worth considering if we should de-duplicate things. It's also fragile - as we'll see in later sections, we are eventually going to start changing the posts slice state structure. When we do that, we'll have to find each place that we reference state.posts
and update the logic accordingly. TypeScript will help catch broken code that no longer matches the expected state type by throwing errors at compile time, but it would be nice if we didn't have to keep rewriting our components every time we made a change to the data format in our reducers, and didn't have to repeat logic in the components.
避免这种情况的一种方法是在切片文件中定义可重用的选择器函数,并让组件使用这些选择器来提取所需的数据,而不是在每个组件中重复选择器逻辑。这样,如果我们再次更改状态结构,我们只需要更新切片文件中的代码。
¥One way to avoid this is to define reusable selector functions in the slice files, and have the components use those selectors to extract the data they need instead of repeating the selector logic in each component. That way, if we do change our state structure again, we only need to update the code in the slice file.
定义选择器函数
¥Defining Selector Functions
每次我们调用 useAppSelector
时,你都已经编写了选择器函数,例如 useAppSelector( state => state.posts )
。在这种情况下,选择器是内联定义的。由于它只是一个函数,我们也可以将其写成:
¥You've already been writing selector functions every time we called useAppSelector
, such as useAppSelector( state => state.posts )
. In that case, the selector is being defined inline. Since it's just a function, we could also write it as:
const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)
选择器通常作为切片文件中的独立函数编写。它们通常接受整个 Redux RootState
作为第一个参数,也可能接受其他参数。
¥Selectors are typically written as standalone individual functions in a slice file. They normally accept the entire Redux RootState
as the first argument, and may also accept other arguments as well.
提取帖子选择器
¥Extracting Posts Selectors
<PostsList>
组件需要读取所有帖子的列表,<SinglePostPage>
和 <EditPostForm>
组件需要通过 ID 查找单个帖子。让我们从 postsSlice.ts
导出两个小的选择器函数来覆盖这些情况:
¥The <PostsList>
component needs to read a list of all the posts, and the <SinglePostPage>
and <EditPostForm>
components need to look up a single post by its ID. Let's export two small selector functions from postsSlice.ts
to cover those cases:
import type { RootState } from '@/app/store'
const postsSlice = createSlice(/* omit slice code*/)
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = (state: RootState) => state.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.find(post => post.id === postId)
请注意,这些选择器函数的 state
参数是根 Redux 状态对象,因为它是我们直接在 useAppSelector
内部编写的内联匿名选择器的参数。
¥Note that the state
parameter for these selector functions is the root Redux state object, as it was for the inlined anonymous selectors we wrote directly inside of useAppSelector
.
然后我们可以在组件中使用它们:
¥We can then use them in the components:
// omit imports
import { selectAllPosts } from './postsSlice'
export const PostsList = () => {
const posts = useAppSelector(selectAllPosts)
// omit component contents
}
// omit imports
import { selectPostById } from './postsSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
// omit imports
import { postUpdated, selectPostById } from './postsSlice'
export const EditPostForm = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
请注意,我们从 useParams()
获得的 postId
被类型化为 string | undefined
,但 selectPostById
需要有效的 string
作为参数。我们可以使用 TS !
运算符来告诉 TS 编译器此值在代码中的此时不会是 undefined
。(这可能很危险,但我们可以做出假设,因为我们知道路由设置仅在 URL 中有帖子 ID 时才显示 <EditPostForm>
。)
¥Note that the postId
we get from useParams()
is typed as string | undefined
, but selectPostById
expects a valid string
as the argument. We can use the TS !
operator to tell the TS compiler this value will not be undefined
at this point in the code. (This can be dangerous, but we can make the assumption because we know the routing setup only shows <EditPostForm>
if there's a post ID in the URL.)
我们将继续这种在切片中编写选择器的模式,而不是在组件中的 useAppSelector
内内联编写它们。请记住,这不是必需的,但这是一个值得遵循的好模式!
¥We'll continue this pattern of writing selectors in slices as we go forward, rather than writing them inline inside of useAppSelector
in components. Remember, this isn't required, but it's a good pattern to follow!
有效使用选择器
¥Using Selectors Effectively
通过编写可重用选择器来封装数据查找通常是一个好主意。理想情况下,组件甚至不必知道 Redux state
中值所在的位置 - 它们只是使用切片中的选择器来访问数据。
¥It's often a good idea to encapsulate data lookups by writing reusable selectors. Ideally, components don't even have to know where in the Redux state
a value lives - they just use a selector from the slice to access the data.
你还可以创建 "memoized" 选择器,通过优化重新渲染和跳过不必要的重新计算来帮助提高性能,我们将在本教程的后面部分介绍。
¥You can also create "memoized" selectors that can help improve performance by optimizing rerenders and skipping unnecessary recalculations, which we'll look at in a later part of this tutorial.
但是,就像任何抽象一样,你不应该随时随地都这样做。编写选择器意味着需要理解和维护更多的代码。不要觉得你需要为你状态的每个字段编写选择器。尝试从不使用任何选择器开始,然后当你发现自己在应用代码的许多部分中查找相同的值时添加一些选择器。
¥But, like any abstraction, it's not something you should do all the time, everywhere. Writing selectors means more code to understand and maintain. Don't feel like you need to write selectors for every single field of your state. Try starting without any selectors, and add some later when you find yourself looking up the same values in many parts of your application code.
可选:在 createSlice
中定义选择器
¥Optional: Defining Selectors Inside of createSlice
我们已经看到,我们可以将选择器编写为切片文件中的独立函数。在某些情况下,你可以通过直接在 createSlice
内部定义选择器来缩短这一过程。
¥We've seen that we can write selectors as standalone functions in slice files. In some cases, you can shorten this a bit by defining selectors directly inside createSlice
itself.
Defining Selectors inside createSlice
我们已经看到 createSlice
需要 name
、initialState
和 reducers
字段,并且还接受可选的 extraReducers
字段。
¥We've already seen that createSlice
requires the name
, initialState
, and reducers
fields, and also accepts an optional extraReducers
field.
如果你想直接在 createSlice
内部定义选择器,则可以传入一个额外的 selectors
字段。selectors
字段应该是一个类似于 reducers
的对象,其中键将是选择器函数名称,值是要生成的选择器函数。
¥If you want to define selectors directly inside of createSlice
, you can pass in an additional selectors
field. The selectors
field should be an object similar to reducers
, where the keys will be the selector function names, and the values are the selector functions to be generated.
请注意,与编写独立的选择器函数不同,这些选择器的 state
参数将只是切片状态,而不是整个 RootState
!。
¥Note that unlike writing a standalone selector function, the state
argument to these selectors will be just the slice state, and not the entire RootState
!.
将帖子切片选择器转换为在 createSlice
内定义后可能如下所示:
¥Here's what it might look like to convert the posts slice selectors to be defined inside of createSlice
:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
/* omit reducer logic */
},
selectors: {
// Note that these selectors are given just the `PostsState`
// as an argument, not the entire `RootState`
selectAllPosts: postsState => postsState,
selectPostById: (postsState, postId: string) => {
return postsState.find(post => post.id === postId)
}
}
})
export const { selectAllPosts, selectPostById } = postsSlice.selectors
export default postsSlice.reducer
// We've replaced these standalone selectors:
// export const selectAllPosts = (state: RootState) => state.posts
// export const selectPostById = (state: RootState, postId: string) =>
// state.posts.find(post => post.id === postId)
有时你仍然需要将选择器编写为 createSlice
之外的独立函数。如果你调用需要整个 RootState
作为参数的其他选择器,则尤其如此,以确保类型正确匹配。
¥There are still times you'll need to write selectors as standalone functions outside of createSlice
. This is especially true if you're calling other selectors that need the entire RootState
as their argument, in order to make sure the types match up correctly.
用户和帖子
¥Users and Posts
到目前为止,我们只有一个状态切片。逻辑在 postsSlice.ts
中定义,数据存储在 state.posts
中,我们所有的组件都已与帖子功能相关。真实的应用可能会有许多不同的状态切片,以及用于 Redux 逻辑和 React 组件的几个不同的 "功能文件夹"。
¥So far, we only have one slice of state. The logic is defined in postsSlice.ts
, the data is stored in state.posts
, and all of our components have been related to the posts feature. Real applications will probably have many different slices of state, and several different "feature folders" for the Redux logic and React components.
如果没有其他人参与,你就无法拥有 "社交媒体" 应用!让我们添加跟踪应用中的用户列表的功能,并更新帖子相关功能以利用该数据。
¥You can't have a "social media" app if there aren't any other people involved! Let's add the ability to keep track of a list of users in our app, and update the post-related functionality to make use of that data.
添加用户切片
¥Adding a Users Slice
由于 "users" 的概念与 "posts" 的概念不同,我们希望将用户的代码和数据与帖子的代码和数据分开。我们将添加一个新的 features/users
文件夹,并在其中放入 usersSlice
文件。与 posts 切片一样,现在我们将添加一些初始条目,以便我们有数据可以使用。
¥Since the concept of "users" is different than the concept of "posts", we want to keep the code and data for the users separated from the code and data for posts. We'll add a new features/users
folder, and put a usersSlice
file in there. Like with the posts slice, for now we'll add some initial entries so that we have data to work with.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '@/app/store'
interface User {
id: string
name: string
}
const initialState: User[] = [
{ 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
export const selectAllUsers = (state: RootState) => state.users
export const selectUserById = (state: RootState, userId: string | null) =>
state.users.find(user => user.id === userId)
目前,我们不需要实际更新数据,因此我们将 reducers
字段保留为空对象。(我们将在后面的部分中讨论这一点。)
¥For now, we don't need to actually update the data, so we'll leave the reducers
field as an empty object. (We'll come back to this in a later section.)
和以前一样,我们将 usersReducer
导入到我们的存储文件中并将其添加到存储设置中:
¥As before, we'll import the usersReducer
into our store file and add it to the store setup:
import { configureStore } from '@reduxjs/toolkit'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})
现在,根状态看起来像 {posts, users}
,与我们作为 reducer
参数传入的对象匹配。
¥Now, the root state looks like {posts, users}
, matching the object we passed in as the reducer
argument.
添加帖子作者
¥Adding Authors for Posts
我们应用中的每一篇帖子都是由我们的一位用户撰写的,每次添加新帖子时,我们都应该跟踪哪个用户撰写了该帖子。这将需要对 Redux 状态和 <AddPostForm>
组件进行更改。
¥Every post in our app was written by one of our users, and every time we add a new post, we should keep track of which user wrote that post. This will need changes for both the Redux state and the <AddPostForm>
component.
首先,我们需要更新现有的 Post
数据类型以包含一个 user: string
字段,该字段包含创建帖子的用户 ID。我们还将更新 initialState
中的现有帖子条目,使其具有带有示例用户 ID 之一的 post.user
字段。
¥First, we need to update the existing Post
data type to include a user: string
field that contains the user ID that created the post. We'll also update the existing post entries in initialState
to have a post.user
field with one of the example user IDs.
然后,我们需要相应地更新我们现有的 Reducer。postAdded
准备回调需要接受用户 ID 作为参数,并将其包含在操作中。此外,我们不想在更新帖子时包含 user
字段 - 我们唯一需要的是更改的帖子的 id
,以及更新文本的新 title
和 content
字段。我们将定义一个仅包含 Post
中的这三个字段的 PostUpdate
类型,并将其用作 postUpdated
的有效负载。
¥Then, we need to update our existing reducers accordingly. The postAdded
prepare callback needs to accept a user ID as an argument, and include that in the action. Also, we don't want to include the user
field when we update a post - the only things we need are the id
of the post that changed, and the new title
and content
fields for the updated text. We'll define a PostUpdate
type that contains just those three fields from Post
, and use that as the payload for postUpdated
instead.
export interface Post {
id: string
title: string
content: string
user: string
}
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
const initialState: Post[] = [
{ id: '1', title: 'First Post!', content: 'Hello!', user: '0' },
{ id: '2', title: 'Second Post', content: 'More text', user: '2' }
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
现在,在 <AddPostForm>
中,我们可以从 useSelector
的存储中读取用户列表,并将其显示为下拉列表。然后,我们将获取所选用户的 ID 并将其传递给 postAdded
操作创建者。当我们这样做时,我们可以向表单添加一些验证逻辑,以便用户只能在标题和内容输入中包含一些实际文本时单击 "保存帖子" 按钮:
¥Now, in our <AddPostForm>
, we can read the list of users from the store with useSelector
and show them as a dropdown. We'll then take the ID of the selected user and pass that to the postAdded
action creator. While we're at it, we can add a bit of validation logic to our form so that the user can only click the "Save Post" button if the title and content inputs have some actual text in them:
import { selectAllUsers } from '@/features/users/usersSlice'
// omit other imports and form types
const AddPostForm = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const userId = elements.postAuthor.value
dispatch(postAdded(title, content, userId))
e.currentTarget.reset()
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" name="postAuthor" required>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}
现在,我们需要一种方法来在帖子列表项和 <SinglePostPage>
中显示帖子作者的名称。由于我们希望在多个位置显示相同类型的信息,因此我们可以创建一个 PostAuthor
组件,该组件将用户 ID 作为 prop,查找正确的用户对象,并格式化用户名:
¥Now, we need a way to show the name of the post's author inside of our post list items and <SinglePostPage>
. Since we want to show this same kind of info in more than one place, we can make a PostAuthor
component that takes a user ID as a prop, looks up the right user object, and formats the user's name:
import { useAppSelector } from '@/app/hooks'
import { selectUserById } from '@/features/users/usersSlice'
interface PostAuthorProps {
userId: string
}
export const PostAuthor = ({ userId }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))
return <span>by {author?.name ?? 'Unknown author'}</span>
}
请注意,我们在每个组件中都遵循相同的模式。任何需要从 Redux 存储读取数据的组件都可以使用 useAppSelector
钩子,并提取它需要的特定数据片段。此外,许多组件可以同时访问 Redux 存储中的相同数据。
¥Notice that we're following the same pattern in each of our components as we go. Any component that needs to read data from the Redux store can use the useAppSelector
hook, and extract the specific pieces of data that it needs. Also, many components can access the same data in the Redux store at the same time.
我们现在可以将 PostAuthor
组件导入到 PostsList.tsx
和 SinglePostPage.tsx
中,并将其渲染为 <PostAuthor userId={post.user} />
。每次我们添加帖子条目时,所选用户的名称都应显示在渲染的帖子内。
¥We can now import the PostAuthor
component into both PostsList.tsx
and SinglePostPage.tsx
, and render it as <PostAuthor userId={post.user} />
. Every time we add a post entry, the selected user's name should show up inside of the rendered post.
更多帖子功能
¥More Post Features
此时,我们可以创建和编辑帖子。让我们添加一些额外的逻辑以使我们的帖子提要更有用。
¥At this point, we can create and edit posts. Let's add some additional logic to make our posts feed more useful.
存储帖子日期
¥Storing Dates for Posts
社交媒体源通常按帖子创建时间排序,并向我们显示帖子创建时间作为相对描述,例如 "5 小时前"。为此,我们需要开始跟踪帖子条目的 date
字段。
¥Social media feeds are typically sorted by when the post was created, and show us the post creation time as a relative description like "5 hours ago". In order to do that, we need to start tracking a date
field for our post entries.
与 post.user
字段一样,我们将更新 postAdded
准备回调,以确保在分派操作时始终包含 post.date
。但是,它不是要传入的另一个参数。我们希望始终使用分派操作时的确切时间戳,因此我们将让准备回调自行处理该时间戳。
¥Like with the post.user
field, we'll update our postAdded
prepare callback to make sure that post.date
is always included when the action is dispatched. However, it's not another parameter that will be passed in. We want to always use the exact timestamp from when the action is dispatched, so we'll let the prepare callback handle that itself.
Redux 操作和状态应该只包含普通的 JS 值,例如对象、数组和基元。不要将类实例、函数、Date/Map/Set
实例或其他不可序列化的值放入 Redux!。
¥Redux actions and state should only contain plain JS values like objects, arrays, and primitives. Don't put class instances, functions, Date/Map/Set
instances, or other non-serializable values into Redux!.
由于我们不能只将 Date
类实例放入 Redux 存储中,我们将跟踪 post.date
值作为时间戳字符串。我们将把它添加到初始状态值中(使用 date-fns
从当前日期和时间中减去几分钟),并将其添加到准备回调中的每个新帖子中
¥Since we can't just put a Date
class instance into the Redux store, we'll track the post.date
value as a timestamp string. We'll add it to the initial state values (using date-fns
to subtract a few minutes from the current date and time), and also add it to each new post in the prepare callback
import { createSlice, nanoid } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
const initialState: Post[] = [
{
// omitted fields
content: 'Hello!',
date: sub(new Date(), { minutes: 10 }).toISOString()
},
{
// omitted fields
content: 'More text',
date: sub(new Date(), { minutes: 5 }).toISOString()
}
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId
}
}
}
}
// omit `postUpdated
}
})
与帖子作者一样,我们需要在 <PostsList>
和 <SinglePostPage>
组件中显示相对时间戳描述。我们将添加一个 <TimeAgo>
组件来处理将时间戳字符串格式化为相对描述。像 date-fns
这样的库有一些有用的实用函数来解析和格式化日期,我们可以在这里使用:
¥Like with post authors, we need to show the relative timestamp description in both our <PostsList>
and <SinglePostPage>
components. We'll add a <TimeAgo>
component to handle formatting a timestamp string as a relative description. Libraries like date-fns
have some useful utility functions for parsing and formatting dates, which we can use here:
import { parseISO, formatDistanceToNow } from 'date-fns'
interface TimeAgoProps {
timestamp: string
}
export const TimeAgo = ({ timestamp }: TimeAgoProps) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}
return (
<time dateTime={timestamp} title={timestamp}>
<i>{timeAgo}</i>
</time>
)
}
对帖子列表进行排序
¥Sorting the Posts List
我们的 <PostsList>
目前显示所有帖子的顺序与帖子在 Redux 存储中保存的顺序相同。我们的示例首先包含最旧的帖子,每当我们添加新帖子时,它都会添加到 posts 数组的末尾。这意味着最新的帖子始终位于页面底部。
¥Our <PostsList>
is currently showing all the posts in the same order the posts are kept in the Redux store. Our example has the oldest post first, and any time we add a new post, it gets added to the end of the posts array. That means the newest post is always at the bottom of the page.
通常,社交媒体源首先显示最新的帖子,然后向下滚动以查看较旧的帖子。即使数据在存储中按照最旧的顺序保存,我们也可以对 <PostsList>
组件中的数据重新排序,以便最新的帖子位于最前面。理论上,由于我们知道 state.posts
数组已经排序,因此我们可以反转列表。但是,为了确定起见,最好还是自己进行排序。
¥Typically, social media feeds show the newest posts first, and you scroll down to see older posts. Even though the data is being kept oldest-first in the store, we can reorder the data in our <PostsList>
component so that the newest post is first. In theory, since we know that the state.posts
array is already sorted, we could just reverse the list. But, it's better to go ahead and sort it ourselves just to be sure.
由于 array.sort()
改变了现有数组,我们需要复制 state.posts
并对该副本进行排序。我们知道我们的 post.date
字段被保存为日期时间戳字符串,我们可以直接比较这些字段以正确的顺序对帖子进行排序:
¥Since array.sort()
mutates the existing array, we need to make a copy of state.posts
and sort that copy. We know that our post.date
fields are being kept as date timestamp strings, and we can directly compare those to sort the posts in the right order:
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
const renderedPosts = orderedPosts.map(post => {
return (
// omit rendering logic
)
})
后反应按钮
¥Post Reaction Buttons
现在,我们的帖子有点无聊。我们需要让它们更加令人兴奋,还有什么比让我们的朋友在我们的帖子中添加反应表情符号更好的方法呢?🎉
¥Right now, our posts are kind of boring. We need to make them more exciting, and what better way to do that than letting our friends add reaction emoji to our posts? 🎉
我们将在 <PostsList>
和 <SinglePostPage>
中每个帖子的底部添加一行表情符号反应按钮。每次用户单击其中一个反应按钮时,我们都需要更新 Redux 存储中该帖子的匹配计数器字段。由于反应计数器数据位于 Redux 存储中,因此在应用的不同部分之间切换应该在使用该数据的任何组件中一致地显示相同的值。
¥We'll add a row of emoji reaction buttons at the bottom of each post in <PostsList>
and <SinglePostPage>
. Every time a user clicks one of the reaction buttons, we'll need to update a matching counter field for that post in the Redux store. Since the reaction counter data is in the Redux store, switching between different parts of the app should consistently show the same values in any component that uses that data.
跟踪帖子中的反应数据
¥Tracking Reactions Data in Posts
我们的数据中还没有 post.reactions
字段,因此我们需要更新 initialState
帖子对象和 postAdded
准备回调函数,以确保每个帖子都包含该数据,例如 reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0}
。
¥We don't yet have a post.reactions
field in our data, so we'll need to update the initialState
post objects and our postAdded
prepare callback function to make sure that every post has that data inside, like reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0}
.
然后,我们可以定义一个新的 Reducer,当用户单击反应按钮时,它将处理更新帖子的反应计数。
¥Then, we can define a new reducer that will handle updating the reaction count for a post when a user clicks the reaction button.
与编辑帖子一样,我们需要知道帖子的 ID,以及用户点击了哪个反应按钮。我们将让 action.payload
成为一个看起来像 {id, reaction}
的对象。然后,reducer 可以找到正确的 post 对象,并更新正确的 Reactions 字段。
¥Like with editing posts, we need to know the ID of the post, and which reaction button the user clicked on. We'll have our action.payload
be an object that looks like {id, reaction}
. The reducer can then find the right post object, and update the correct reactions field.
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
export interface Reactions {
thumbsUp: number
tada: number
heart: number
rocket: number
eyes: number
}
export type ReactionName = keyof Reactions
export interface Post {
id: string
title: string
content: string
user: string
date: string
reactions: Reactions
}
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
const initialReactions: Reactions = {
thumbsUp: 0,
tada: 0,
heart: 0,
rocket: 0,
eyes: 0
}
const initialState: Post[] = [
// omit initial state
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit other reducers
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
正如我们已经看到的,createSlice
让我们在 reducer 中编写 "mutating" 逻辑。如果我们不使用 createSlice
和 Immer 库,existingPost.reactions[reaction]++
行确实会改变现有的 post.reactions
对象,这可能会导致我们应用中其他地方出现错误,因为我们没有遵循 reducer 的规则。但是,由于我们使用的是 createSlice
,所以我们可以用更简单的方式编写这个更复杂的更新逻辑,并让 Immer 完成将这段代码转换为安全的不可变更新的工作。
¥As we've seen already, createSlice
lets us write "mutating" logic in our reducers. If we weren't using createSlice
and the Immer library, the line existingPost.reactions[reaction]++
would indeed mutate the existing post.reactions
object, and this would probably cause bugs elsewhere in our app because we didn't follow the rules of reducers. But, since we are using createSlice
, we can write this more complex update logic in a simpler way, and let Immer do the work of turning this code into a safe immutable update.
请注意,我们的操作对象仅包含描述所发生事件所需的最少量信息。我们知道需要更新哪个帖子,以及点击了哪个反应名称。我们可以计算新的反应计数器值并将其放入操作中,但最好保持操作对象尽可能小,并在 reducer 中进行状态更新计算。这也意味着 reducer 可以包含计算新状态所需的尽可能多的逻辑。事实上,状态更新逻辑应该放在 Reducer 中!。这有助于避免在不同组件中重复逻辑的问题,或者 UI 层可能没有最新数据的情况。
¥Notice that our action object just contains the minimum amount of information needed to describe what happened. We know which post we need to update, and which reaction name was clicked on. We could have calculated the new reaction counter value and put that in the action, but it's always better to keep the action objects as small as possible, and do the state update calculations in the reducer. This also means that reducers can contain as much logic as necessary to calculate the new state. In fact, state update logic should go in a reducer!. This helps avoid issues with duplicating logic in different components, or cases where the UI layer might not have the latest data to work with.
使用 Immer 时,你可以对现有状态对象进行 "mutate",或者自己返回一个新的状态值,但不能同时执行这两种操作。有关更多详细信息,请参阅有关 陷阱 和 返回新数据 的 Immer 文档指南。
¥When using Immer, you can either "mutate" an existing state object, or return a new state value yourself, but not both at the same time. See the Immer docs guides on Pitfalls and Returning New Data for more details.
显示反应按钮
¥Showing Reaction Buttons
与帖子作者和时间戳一样,我们希望在显示帖子的任何地方都使用它,因此我们将创建一个 <ReactionButtons>
组件,该组件将 post
作为属性。当用户单击按钮时,我们将分派带有该反应表情符号名称的 reactionAdded
操作。
¥Like with post authors and timestamps, we want to use this everywhere we show posts, so we'll create a <ReactionButtons>
component that takes a post
as a prop. When the user clicks a button, we'll dispatch the reactionAdded
action with the name of that reaction emoji.
import { useAppDispatch } from '@/app/hooks'
import type { Post, ReactionName } from './postsSlice'
import { reactionAdded } from './postsSlice'
const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
interface ReactionButtonsProps {
post: Post
}
export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const dispatch = useAppDispatch()
const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => dispatch(reactionAdded({ postId: post.id, reaction }))}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)
return <div>{reactionButtons}</div>
}
现在,每次我们点击一个反应按钮时,该反应的计数器都应该增加。如果我们浏览应用的不同部分,则每次查看此帖子时,我们都应该看到显示正确的计数器值,即使我们单击 <PostsList>
中的反应按钮,然后在 <SinglePostPage>
上查看该帖子本身。这是因为每个组件都从 Redux 存储中读取相同的帖子数据。
¥Now, every time we click a reaction button, the counter for that reaction should increment. If we browse around to different parts of the app, we should see the correct counter values displayed any time we look at this post, even if we click a reaction button in the <PostsList>
and then look at the post by itself on the <SinglePostPage>
. This is because each component is reading the same post data from the Redux store.
添加用户登录
¥Adding User Login
本节中我们还需要添加一项功能。
¥We've got one more feature to add in this section.
现在,我们只需在 <AddPostForm>
中选择哪个用户正在撰写每个帖子。为了增加一点真实感,我们应该让用户登录到应用,这样我们就知道谁在写帖子(以后对其他功能有用)。
¥Right now, we just select which user is writing each post in the <AddPostForm>
. To add a bit more realism, we ought to have the user log in to the application, so that we already know who is writing the posts (and be useful for other features later).
由于这是一个小型示例应用,我们不会实现任何真正的身份验证检查(这里的重点是学习如何使用 Redux 功能,而不是如何实际实现真正的身份验证)。相反,我们只显示用户名列表,让实际用户选择其中一个。
¥Since this is a small example app, we aren't going to implement any real authentication checks (and the point here is to learn how to use Redux features, not how to actually implement real auth). Instead, we'll just show a list of user names and let the actual user select one of them.
对于此示例,我们只需添加一个跟踪 state.auth.username
的 auth
切片,这样我们就知道用户是谁。然后,每当他们添加帖子时,我们都可以使用该信息来自动将正确的用户 ID 添加到帖子中。
¥For this example, we'll just add an auth
slice that tracks state.auth.username
so we know who the user is. Then, we can use that information whenever they add a post to automatically add the right user ID to the post.
添加身份验证切片
¥Adding an Auth Slice
第一步是创建 authSlice
并将其添加到存储中。这是我们已经看到的相同模式 - 定义初始状态,使用几个 Reducer 编写切片以处理登录和注销的更新,并将切片 Reducer 添加到存储中。
¥The first step is to create the authSlice
and add it to the store. This is the same pattern we've seen already - define the initial state, write the slice with a couple of reducers to handle updates for login and logout, and add the slice reducer to the store.
在这种情况下,我们的身份验证状态实际上只是当前登录的用户名,如果他们注销,我们会将其重置为 null
。
¥In this case, our auth state is really just the current logged-in username, and we'll reset it to null
if they log out.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
username: string | null
}
const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
userLoggedIn(state, action: PayloadAction<string>) {
state.username = action.payload
},
userLoggedOut(state) {
state.username = null
}
}
})
export const { userLoggedIn, userLoggedOut } = authSlice.actions
export const selectCurrentUsername = (state: RootState) => state.auth.username
export default authSlice.reducer
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer
}
})
添加登录页面
¥Adding the Login Page
目前,应用的主屏幕是带有帖子列表和添加帖子表单的 <Posts>
组件。我们将改变这种行为。相反,我们希望用户首先看到登录屏幕,并且只有在登录后才能看到帖子页面。
¥Currently, the app's main screen is the <Posts>
component with the posts list and add post form. We're going to change that behavior. Instead, we want the user to first see a login screen, and only be able to see the posts page after they've logged in.
首先,我们将创建一个 <LoginPage>
组件。这将从存储读取用户列表,在下拉列表中显示它们,并在提交表单时分派 userLoggedIn
操作。我们还将导航到 /posts
路由,以便在登录后看到 <PostsMainPage>
:
¥First, we'll create a <LoginPage>
component. This will read the list of users from the store, show them in a dropdown, and dispatch the userLoggedIn
action when the form is submitted. We'll also navigate to the /posts
route so that we can see the <PostsMainPage>
after login:
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'
import { userLoggedIn } from './authSlice'
interface LoginPageFormFields extends HTMLFormControlsCollection {
username: HTMLSelectElement
}
interface LoginPageFormElements extends HTMLFormElement {
readonly elements: LoginPageFormFields
}
export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()
const handleSubmit = (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()
const username = e.currentTarget.elements.username.value
dispatch(userLoggedIn(username))
navigate('/posts')
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Welcome to Tweeter!</h2>
<h3>Please log in:</h3>
<form onSubmit={handleSubmit}>
<label htmlFor="username">User:</label>
<select id="username" name="username" required>
<option value=""></option>
{usersOptions}
</select>
<button>Log In</button>
</form>
</section>
)
}
接下来,我们需要更新 <App>
组件中的路由。它需要显示根 /
路由的 <LoginPage>
,并将任何未经授权的访问重定向到其他页面,以便用户返回登录屏幕。
¥Next, we need to update the routing in the <App>
component. It needs to show <LoginPage>
for the root /
route, and also redirect any unauthorized access to other pages so that the user goes back to the login screen instead.
一种常见的方法是添加一个 "受保护的路线" 组件,该组件接受一些 React 组件作为 children
,进行授权检查,并且仅在用户获得授权时才显示子组件。我们可以添加一个 <ProtectedRoute>
组件,该组件读取我们的 state.auth.username
值并将其用于身份验证检查,然后将路由设置的整个帖子相关部分封装在该 <ProtectedRoute>
中:
¥One common way to do this is to add a "protected route" component that accepts some React components as children
, does an authorization check, and only shows the child components if the user is authorized. We can add a <ProtectedRoute>
component that reads our state.auth.username
value and uses that for the auth check, then wrap the entire posts-related section of the routing setup in that <ProtectedRoute>
:
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'
import { useAppSelector } from './app/hooks'
import { Navbar } from './components/Navbar'
import { LoginPage } from './features/auth/LoginPage'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
import { selectCurrentUsername } from './features/auth/authSlice'
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const username = useAppSelector(selectCurrentUsername)
if (!username) {
return <Navigate to="/" replace />
}
return children
}
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
)
}
export default App
我们现在应该看到身份验证行为的两侧都在工作:
¥We should now see both sides of the auth behavior working:
如果用户尝试在未登录的情况下访问
/posts
,<ProtectedRoute>
组件将重定向回/
并显示<LoginPage>
¥If the user tries to access
/posts
without having logged in, the<ProtectedRoute>
component will redirect back to/
and show the<LoginPage>
当用户登录时,我们分派
userLoggedIn()
来更新 Redux 状态,然后强制导航到/posts
,这次<ProtectedRoute>
将显示帖子页面。¥When the user logs in, we dispatch
userLoggedIn()
to update the Redux state, and then force a navigation to/posts
, and this time<ProtectedRoute>
will display the posts page.
使用当前用户更新 UI
¥Updating the UI with the Current User
由于我们现在知道谁在使用应用时登录,我们可以在导航栏中显示用户的实际名称。我们还应该通过添加 "注销" 按钮为他们提供一种注销方式。
¥Since we now know who is logged in while using the app, we can show the user's actual name in the navbar. We should also give them a way to log out as well, by adding a "Log Out" button.
我们需要从存储中获取当前用户对象,以便我们可以读取 user.name
进行显示。我们可以首先从身份验证切片中获取当前用户名,然后使用它来查找正确的用户对象。这似乎是我们可能想在一些地方做的事情,所以这是将其编写为可重用的 selectCurrentUser
选择器的好时机。我们可以将其放在 usersSlice.ts
中,但让它导入并依赖来自 authSlice.ts
的 selectCurrentUsername
:
¥We need to get the current user object from the store so we can read user.name
for display. We can do that by first getting the current username from the auth slice, then using that to look up the right user object. This seems like a thing we might want to do in a few places, so this is a good time to write it as a reusable selectCurrentUser
selector. We can put that in usersSlice.ts
, but have it import and rely on the selectCurrentUsername
from authSlice.ts
:
import { selectCurrentUsername } from '@/features/auth/authSlice'
// omit the rest of the slice and selectors
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
return selectUserById(state, currentUsername)
}
将选择器组合在一起并在另一个选择器中使用一个选择器通常很有用。在这种情况下,我们可以同时使用 selectCurrentUsername
和 selectUserById
。
¥It's often useful to compose selectors together and use one selector inside of another. In this case, we can use both selectCurrentUsername
and selectUserById
together.
与我们构建的其他功能一样,我们将从存储中选择相关状态(当前用户对象),显示值,并在用户单击 "注销" 按钮时分派 userLoggedOut()
操作:
¥As with the other features we've built, we'll select the relevant state (the current user object) from the store, display the values, and dispatch the userLoggedOut()
action when they click the "Log Out" button:
import { Link } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { userLoggedOut } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
import { UserIcon } from './UserIcon'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(userLoggedOut())
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
}
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
{navContent}
</section>
</nav>
)
}
在我们这样做的同时,我们还应该切换 <AddPostForm>
以使用来自状态的登录用户名,而不是显示用户选择下拉列表。可以通过删除对 postAuthor
输入字段的所有引用并添加 useAppSelector
以从 authSlice
读取用户 ID 来完成此操作:
¥While we're at it, we should also switch the <AddPostForm>
to use the logged-in username from state, instead of showing a user selection dropdown. This can be done by removing all references to the postAuthor
input field, and adding a useAppSelector
to read the user ID from the authSlice
:
export const AddPostForm = () => {
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Removed the `postAuthor` field everywhere in the component
dispatch(postAdded(title, content, userId))
e.currentTarget.reset()
}
最后,允许当前用户编辑其他用户定义的帖子也没有意义。我们可以更新 <SinglePostPage>
,仅在帖子作者 ID 与当前用户 ID 匹配时才显示 "编辑帖子" 按钮:
¥Finally, it also doesn't make sense to allow the current user to edit posts defined by other users. We can update the <SinglePostPage>
to only show an "Edit Post" button if the post author ID matches the current user ID:
import { selectCurrentUsername } from '@/features/auth/authSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
const currentUsername = useAppSelector(selectCurrentUsername)!
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const canEdit = currentUsername === post.user
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
{canEdit && (
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
)}
</article>
</section>
)
}
注销时清除其他状态
¥Clearing Other State on Logout
我们需要查看身份验证处理的另一个部分。现在,如果我们以用户 A 身份登录,创建新帖子,注销,然后以用户 B 身份重新登录,我们将看到初始示例帖子和新帖子。
¥There's one more piece of the auth handling that we need to look at. Right now, if we log in as user A, create a new post, log out, and then log back in as user B, we'll see both the initial example posts and the new post.
这是 "correct",因为 Redux 按照我们迄今为止编写的代码的预期工作。我们更新了 Redux 存储中的帖子列表状态,并且我们尚未刷新页面,因此相同的 JS 数据仍在内存中。但就应用行为而言,这有点令人困惑,甚至可能侵犯隐私。如果用户 B 和用户 A 彼此没有连接会怎样?如果多个人共用同一台计算机会怎样?它们登录时不应该能够看到彼此的数据。
¥This is "correct", in that Redux is working as intended for the code we've written so far. We updated the posts lists state in the Redux store, and we haven't refreshed the page, so the same JS data is still in memory. But in terms of app behavior, it's kind of confusing, and probably even a breach of privacy. What if user B and user A aren't connected to each other? What if multiple people are sharing the same computer? They shouldn't be able to see each other's data when they log in.
鉴于此,如果我们可以在当前用户注销时清除现有的帖子状态,那就太好了。
¥Given that, it would be good if we can clear out the existing posts state when the current user logs out.
处理多个切片中的操作
¥Handling Actions in Multiple Slices
到目前为止,每次我们想要进行另一次状态更新时,我们都会定义一个新的 Redux 案例化简器,导出生成的动作创建器,并从组件中分派该动作。我们可以在这里做。但是,我们最终会连续分派两个单独的 Redux 操作,例如:
¥So far, every time we've wanted to make another state update, we've defined a new Redux case reducer, exported the generated action creator, and dispatched that action from a component. We could do that here. But, we'd end up dispatching two separate Redux actions back-to-back, like:
dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())
每次我们分派操作时,整个 Redux 存储更新过程都必须发生 - 运行 Reducer,通知订阅的 UI 组件,并重新渲染更新的组件。没关系,这就是 Redux 和 React 的工作方式,但连续分派两个操作通常表明我们需要重新考虑如何定义我们的逻辑。
¥Every time we dispatch an action, the whole Redux store update process has to happen - running the reducer, notifying subscribed UI components, and re-rendering updated components. That's fine, that's how Redux and React work, but dispatching two actions in a row is usually a sign that we need to rethink how we're defining our logic.
我们已经分派了 userLoggedOut()
操作,但这是从 auth
切片导出的操作。如果我们也可以在 posts
切片中监听这一点,那就太好了。
¥We've already got the userLoggedOut()
action being dispatched, but that's an action that was exported from the auth
slice. It would be nice if we could just listen for that in the posts
slice too.
我们之前提到过,如果我们将操作视为 "应用中发生的事件" 而不是 "设置值的命令",它会有所帮助。这是实践中的一个很好的例子。我们不需要对 clearUserData
进行单独的操作,因为只发生了一个事件 - "用户已注销"。我们只需要一种方法来在多个地方处理一个 userLoggedOut
操作,以便我们可以同时应用所有相关的状态更新。
¥We mentioned earlier that it helps if we think about the action as "an event that occurred in the app", rather than "a command to set a value". This is a good example of that in practice. We don't need a separate action for clearUserData
, because there's only one event that occurred - "the user logged out". We just need a way to handle the one userLoggedOut
action in multiple places, so that we can apply all the relevant state updates at the same time.
使用 extraReducers
处理其他操作
¥Using extraReducers
to Handle Other Actions
很高兴,我们可以!createSlice
接受一个名为 extraReducers
的选项,可用于让切片监听在应用其他地方定义的操作。任何时候调度其他操作时,此切片也可以更新其自身状态。这意味着许多不同的切片reducer都可以响应相同的调度操作,并且每个切片都可以根据需要更新自己的状态!
¥Happily, we can! createSlice
accepts an option called extraReducers
, which can be used to have the slice listen for actions that were defined elsewhere in the app. Any time those other actions are dispatched, this slice can update its own state as well. That means many different slice reducers can all respond to the same dispatched action, and each slice can update its own state if needed!
extraReducers
字段是一个接收名为 builder
的参数的函数。builder
对象附加了三种方法,每种方法都允许切片监听其他操作并执行自己的状态更新:
¥The extraReducers
field is a function that receives a parameter named builder
. The builder
object has three methods attached, each of which lets the slice listen for other actions and do its own state updates:
builder.addCase(actionCreator, caseReducer)
:监听一种特定的操作类型¥
builder.addCase(actionCreator, caseReducer)
: listens for one specific action typebuilder.addMatcher(matcherFunction, caseReducer)
:监听多种操作类型中的任意一种,使用 Redux Toolkit "matcher" 函数 比较操作对象¥
builder.addMatcher(matcherFunction, caseReducer)
: listens for any one of multiple action types, using a Redux Toolkit "matcher" function for comparing action objectsbuilder.addDefaultCase(caseReducer)
:添加一个案例缩减器,如果此切片中没有其他内容与操作匹配,则运行该案例缩减器(相当于switch
内的default
案例)。¥
builder.addDefaultCase(caseReducer)
: adds a case reducer that runs if nothing else in this slice matched the action (equivalent to adefault
case inside of aswitch
).
你可以将它们链接在一起,例如 builder.addCase().addCase().addMatcher().addDefaultCase()
。如果多个匹配器与该操作匹配,它们将按照定义的顺序运行。
¥You can chain these together, like builder.addCase().addCase().addMatcher().addDefaultCase()
. If multiple matchers match the action, they will run in the order they were defined.
鉴于此,我们可以将 authSlice.ts
中的 userLoggedOut
动作导入 postsSlice.ts
,在 postsSlice.extraReducers
中监听该动作,并返回一个空的帖子数组以在注销时重置帖子列表:
¥Given that, we can import the userLoggedOut
action from authSlice.ts
into postsSlice.ts
, listen for that action inside of postsSlice.extraReducers
, and return an empty posts array to reset the posts list on logout:
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
import { userLoggedOut } from '@/features/auth/authSlice'
// omit initial state and types
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
// omit postAdded and other case reducers
},
extraReducers: (builder) => {
// Pass the action creator to `builder.addCase()`
builder.addCase(userLoggedOut, (state) => {
// Clear out the list of posts whenever the user logs out
return []
})
},
})
我们调用 builder.addCase(userLoggedOut, caseReducer)
。在该 Reducer 内部,我们可以编写 "mutating" 状态更新,与 createSlice
调用中的任何其他 case Reducer 相同。但是,由于我们想完全替换现有状态,最简单的方法是只返回一个空数组作为新帖子状态。
¥We call builder.addCase(userLoggedOut, caseReducer)
. Inside of that reducer, we could write a "mutating" state update, same as any of the other case reducers inside of a createSlice
call. But, since we want to replace the existing state entirely, the simplest thing is to just return an empty array for the new posts state.
现在,如果我们点击 "注销" 按钮,然后以另一个用户身份登录,"帖子" 页面应该是空的。太好了!我们已经成功清除了注销时的帖子状态。
¥Now, if we click the "Log Out" button, then log in as another user, the "Posts" page should be empty. That's great! We've successfully cleared out the posts state on logout.
reducers
and extraReducers
?两者之间有什么区别createSlice
中的 reducers
和 extraReducers
字段有不同的用途:
¥The reducers
and extraReducers
fields inside of createSlice
serve different purposes:
reducers
字段通常是一个对象。对于reducers
对象中定义的每个案例 Reducer,createSlice
将自动生成一个具有相同名称的动作创建器,以及一个要在 Redux DevTools 中显示的动作类型字符串。使用reducers
定义切片中的新操作。¥The
reducers
field is normally an object. For every case reducer defined in thereducers
object,createSlice
will automatically generate an action creator with the same name, as well as an action type string to show in the Redux DevTools. Usereducers
to define new actions as part of the slice.extraReducers
接受带有builder
参数的函数,builder.addCase()
和builder.addMatcher()
方法用于处理其他操作类型,而无需定义新操作。使用extraReducers
处理切片之外定义的操作。¥
extraReducers
accepts a function with abuilder
parameter, and thebuilder.addCase()
andbuilder.addMatcher()
methods are used to handle other action types, without defining new actions. UseextraReducers
to handle actions that were defined outside of the slice.
你学到了什么
¥What You've Learned
这就是本节的全部内容!我们已经做了很多工作。我们现在可以查看和编辑单个帖子,查看每个帖子的作者,添加表情符号反应,并在当前用户登录和注销时跟踪他们。
¥And that's it for this section! We've done a lot of work. We can now view and edit individual posts, see authors for each post, add emoji reactions, and track the current user as they log in and log out.
经过所有这些更改后,我们的应用如下所示:
¥Here's what our app looks like after all these changes:
它实际上开始看起来更加有用和有趣!
¥It's actually starting to look more useful and interesting!
我们在本节中介绍了很多信息和概念。让我们回顾一下需要记住的重要事项:
¥We've covered a lot of information and concepts in this section. Let's recap the important things to remember:
任何 React 组件都可以根据需要使用 Redux 存储中的数据
¥Any React component can use data from the Redux store as needed
任何组件都可以读取 Redux 存储中的任何数据
¥Any component can read any data that is in the Redux store
多个组件甚至可以同时读取相同的数据
¥Multiple components can read the same data, even at the same time
组件应该提取渲染自身所需的最少量的数据
¥Components should extract the smallest amount of data they need to render themselves
组件可以组合来自 props、state 和 Redux 存储的值来确定它们需要渲染什么 UI。他们可以从存储中读取多条数据,并根据显示需要重塑数据。
¥Components can combine values from props, state, and the Redux store to determine what UI they need to render. They can read multiple pieces of data from the store, and reshape the data as needed for display.
任何组件都可以调度操作来导致状态更新
¥Any component can dispatch actions to cause state updates
Redux 动作创建者可以准备具有正确内容的动作对象
¥Redux action creators can prepare action objects with the right contents
createSlice
和createAction
可以接受返回操作有效负载的 "准备回调"¥
createSlice
andcreateAction
can accept a "prepare callback" that returns the action payload唯一 ID 和其他随机值应该放在 action 中,而不是在 reducer 中计算
¥Unique IDs and other random values should be put in the action, not calculated in the reducer
reducer 应该包含实际的状态更新逻辑
¥Reducers should contain the actual state update logic
reducer 可以包含计算下一个状态所需的任何逻辑
¥Reducers can contain whatever logic is needed to calculate the next state
动作对象应该包含足够的信息来描述发生的事情
¥Action objects should contain just enough info to describe what happened
你可以编写可重用的 "selector" 函数来封装从 Redux 状态读取值
¥You can write reusable "selector" functions to encapsulate reading values from the Redux state
选择器是获取 Redux
state
作为参数并返回一些数据的函数¥Selectors are functions that get the Redux
state
as an argument, and return some data
应该将操作视为对 "发生的事件" 的描述,并且许多 Reducer 可以响应相同的调度操作
¥Actions should be thought of as describing "events that happened", and many reducers can respond to the same dispatched action
应用通常一次只应调度一个操作
¥Apps should normally only dispatch one action at a time
大小写化简器名称(和操作)通常应以过去时命名,如
postAdded
¥Case reducer names (and actions) should typically be named past-tense, like
postAdded
许多切片reducer可以各自执行自己的状态更新以响应相同的操作
¥Many slice reducers can each do their own state updates in response to the same action
createSlice.extraReducers
让切片监听在切片之外定义的操作¥
createSlice.extraReducers
lets slices listen for actions that were defined outside of the slice可以通过从 case Reducer 返回新值作为替换来重置状态值,而不是修改现有状态
¥State values can be reset by returning a new value from the case reducer as a replacement, instead of mutating the existing state
下一步是什么?
¥What's Next?
到目前为止,你应该能够轻松地处理 Redux 存储和 React 组件中的数据。到目前为止,我们仅使用了初始状态或用户添加的数据。在 第 5 部分:异步逻辑和数据获取 中,我们将了解如何使用来自服务器 API 的数据。
¥By now you should be comfortable working with data in the Redux store and React components. So far we've just used data that was in the initial state or added by the user. In Part 5: Async Logic and Data Fetching, we'll see how to work with data that comes from a server API.