本节将引导大家完成创建一个更高级的应用。尽管我们已经讲解了不少Redux的基础知识,但这个例子会让你更深入地了解所学的一些概念如何在更广泛的范围内工作。
我们将讨论规划应用程序、设计和规范状态对象等等。真正的应用程序需要的不仅仅是Redux,我们还依然还需要一些CSS和React的技术。
系好安全带,因为这将是一个漫长的旅程!
规划应用程序
规划应用程序是一个很大的问题。当开始一个新的React应用程序时,你通常首先做什么?
是的,我们都有我们的偏好。
你是否将整个应用程序分解为组件,并按自己的方式堆积?
你是否首先从应用程序的整体布局开始?
你的应用的状态对象是什么样的?你有没有花时间也考虑一下?
确实需要考虑很多事情。我会让你用你喜欢的做事方式。
在构建Skypey时,我会采取自顶向下的方法。我们将首先讨论应用程序的整体布局,然后讨论应用程序状态对象的设计,最后构建较小的组件。
再次重申,要做到这一点,没有完美的套路。对于一个较复杂的项目来说,自下而上的方法可能更合适一些。
如下是我们要实现的最终结果:
解决初始应用程序布局
首先,我们命令行控制台(CLI),执行如下命令,用create-react-app
脚手架创建一个新react应用程序Skypey
:
create-react-app Skypey
Skypey的布局是一个2列布局:左边是一个定宽侧边栏,右边是一个占据剩余视口宽度的主内容区。
然后,在根目录下创建两个文件Sidebar.js
和Main.js
。创建完Sidebar
和Main
组件后,我们会在App
组件中渲染像这样的内容:
App.js
const App = () => {
return (
<div className="App">
<Sidebar />
<Main />
</div>
);
};
我们知道,由create-react-app
脚手架生成的项目结构,其应用程序的入口点是index.js
。这个文件主要用来渲染一个App
组件。
在开始创建侧边栏和主内容区之前,首先需要进行一些CSS管理工作。确保渲染应用程序的DOM节点#root
占用视口的整个高度。
index.css
#root {
height: 100vh;
}
此外,我们还应该从body
中删除所有不想要的空白:
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
应用程序的布局将会用Flexbox创建。为让Flexbox起作用,我们让.App
成为一个flex-container
,并且确保它占据100%的可用高度。
App.css
.App {
height: 100%;
display: flex;
color: rgba(189, 189, 192, 1);
}
现在,我们可以舒舒服服地开始创建Sidebar
和Main
组件了。
在开发应用程序时,我们最好采用渐进式的方式创建。也就是说,一点一点创建,并确保该应用程序能工作。所以我们开始尽量保持简单。
对于侧边栏,我们现在只是在该组件中渲染<aside>
元素中的文本Sidebar
。
Sidebar.js
import React from "react";
import "./Sidebar.css";
const Sidebar = () => {
return <aside className="Sidebar">Sidebar</aside>;
};
export default Sidebar;
此外,我们也导入了相应的样式表Sidebar.css
。在Sidebar.css
中,我们需要限制侧边栏的宽度,加上其它一些简单的样式:
Sidebar.css
.Sidebar {
width: 80px;
background-color: rgba(32, 32, 35, 1);
height: 100%;
border-right: 1px solid rgba(189, 189, 192, 0.1);
transition: width 0.3s;
}
/* 较大的设备 */
@media (min-width: 576px) {
.Sidebar {
width: 320px;
}
}
采用移动优先的方式,侧边栏的width
会是80px
,而在较大设备上是320px
。
好了,现在该转到<Main>
组件了。像之前一样,我们依然让它保持简单,只是在<main
>元素内渲染一段简单文本。如下是<Main>
组件:
import React from "react";
import "./Main.css";
const Main = () => {
return <main className="Main">Main Stuff</main>;
};
export default Main;
这里相关的样式表Main.css
也被导入了。
<Main />
和<Sidebar />
的被渲染的元素都有一个CSS类名,分别是.Main
和.Sidebar
。
既然这两个组件都是在<App />
内被渲染,那么.Sidebar
和.Main
类都是父类.App
的子类。
还记得.App
是一个flex-container
吧。因此,.Main
可以按如下方式,填充视口中剩余的空间:
.Main { flex: 1 1 0;}
如下是完整的代码:
.Main {
flex: 1 1 0;
background-color: rgba(25, 25, 27, 1);
height: 100%;
}
很简单,对吧?
如下是目前我们所写的所有代码的结果。
看起来也什么值得兴奋的。不过请忍耐,我们会完成我们的目标的。
现在,应用程序的基本布局已设置好了。干的不错!
设计state对象
React应用程序的创建方式是,整个应用程序很大程度上是state
对象的一个函数。
不管你是在创建复杂的应用程序,还是简单的应用程序,都应该把大量心思投入在考虑如何构造应用程序的state对象上。特别是在使用Redux时,正确设计state对象可以减少很多复杂性。
那么,该如何设计才好呢?
首先,考虑Skypey应用程序,应用程序的用户有多个联系人。
每个联系人又都有多条消息,从而组成与主应用程序用户的对话。当我们点击任何联系人时,此视图就被激活。
通过联想,在你的脑海里有这样一幅画面是没有错的。
然后你可能继续像这样描述应用程序的状态。
用普通JavaScript对象来表示的话,可能就是这样子:
const state = {
user: [
{
contact1: 'Alex',
messages: [
'msg1',
'msg2',
'msg3'
]
},
{
contact2: 'john',
messages: [
'msg1',
'msg2',
'msg3'
]
}
]
在上面的state
对象内,是一个被一个巨大的数组表示的user
字段。由于用户具有多个联系人,所以用数组中的对象表示这些联系人。因为可能有很多不同的消息,所以消息也存储在一个数组messages
中。
乍一看,这可能看起来像一个不错的解决方案。但是它是吗?
如果您要从后端接收数据,则结构可能与此类似!不错,对吧?但是并非如此。这是一个相当不错的数据表示,它似乎展示了每个实体之间的关系。但就我们的前端应用程序的状态而言,这是一个糟糕的主意。说糟糕,措辞是有点强烈。我们只能说,有更好的方法来做到这一点。
我的看法如下:
如果你管理一支足球队,那么一个好的计划就是选出队中最好的得分手,并把他们放在前锋的位置。你可能会争辩说,优秀的球员可以从任何地方得分。是的,不过,我敢打赌,如果他们被放在对手的门柱位置附近,他们会更有效。
state对象也是如此。我们得选出state对象内的前锋,并将它们放在前方。请注意:当我说前锋
的时候,是指state对象中,我们会在上面执行更多CRUD动作的那些字段。state中的这部分是应用程序的核心,我们会对这一部分比其它部分更经常创建、读取、更新和删除。
这并非一个铁律,但它是一个很好的指标。
查看当前的state对象以及应用程序的需求,我们可以一起挑选出前锋
。
举例来说,对于每个用户的联系人,我们会经常阅读Messages
字段,还需要编辑和删除用户的消息。那么这就是个前锋。Contacts
也是如此。
现在,我们把它们放在前方。注意,不要将Messages
和Contacts
字段嵌套,而是将其挑出来,并将其作为state对象中的主键。像这样:
const state = {
user: [],
messages: [
'msg1',
'msg2'
],
contacts: ['Contact1', 'Contact2']
}
这仍然是一个不完整的表示,但我们已经大大改善了应用程序state对象的表示。
现在我们继续。
请记住,用户可以发送任何联系人的消息。不过,现在,state对象中的messages
和contact
字段是独立的。在state对象中让这些字段成为主键之后,没有任何内容显示某个消息与相关联系人之间的关系。它们是独立的,这不好,因为我们需要知道哪些消息属于谁。在不知道的情况下,点击联系人时我们如何呈现正确的消息?很显然,这样是没法的。
以下是处理此问题的一种方法:
const state = {
user: [],
messages: [
{
messageTo: 'contact1',
text: "Hello"
},
{
messageTo: 'contact2',
text: "Hey!"
}
],
contacts: ['Contact1', 'Contact2']
}
这里,我所做的就是让messages
字段成为一个对象数组,这个对象有一个messageTo
键,该键显示特定消息属于哪个联系人。
而用户用一个user
对象而不是数组来描述可能会更好一些:
user: {
name,
email,
profile_pic,
status:,
user_id
}
用户会有姓名、电子邮件、个人资料图片、花哨的文本状态以及唯一的用户ID。用户ID非常重要,并且必须对每个用户都是唯一的。
想想看,一个人的联系人也可以由一个类似的user对象来表示。因此,state对象内的contacts
字段可以用用户对象列表表示。
contacts: [
{
name,
email,
profile_pic,
status,
user_id
},
{
name,
email,
profile_pic,
status,
user_id_2
}
]
好了,到目前为止貌似还挺不错。
contacts
字段现在是由巨大的user
对象数组来表示。不过,我们可以用一个对象来表示contacts
,而不是使用数组。与其将所有用户的联系人包装在一个巨大的数组中,还不如放在一个对象中。比如:
contacts: {
user_id: {
name,
email,
profile_pic,
status,
user_id
},
user_id_2: {
name,
email,
profile_pic,
status,
user_id_2
}
}
因此对象必须有一个键/值对,所以联系人的唯一ID将用作他们各自用户对象的键。
明白了吧?
使用对象而不是数组有一些优点,也有缺点。在这个应用程序中,我会主要使用对象来描述state对象内的字段。如果你不习惯这种方法,这个可爱的视频解释了它的一些优点。就像我之前说的那样,这种方法有一些缺点,但我会告诉你如何克服它们。
我们已经解决了如何在应用程序的state对象内设计contacts
字段。现在,我们转到messages
字段。我们现在将messages
作为一个包含消息对象的数组:
messages: [
{
messageTo: 'contact1',
text: "Hello"
},
{
messageTo: 'contact2',
text: "Hey!"
}
]
现在我们会为消息对象定义一个更合适的形态。消息对象将由以下message对象表示:
{
text,
is_user_msg
};
text
是聊天泡泡中显示的文本。不过,is_user_msg
是一个布尔值true或false。消息是来自联系人还是默认的应用程序用户,这一点很重要。
看上面的图形,我们会注意到用户的消息和联系人的消息在聊天窗口中的样式不同。用户的消息保持在右侧,而联系人位于左侧。一个是蓝色背景,另一个是暗色背景。所以,现在明白为什么布尔值is_user_msg
很重要吧,因为我们需要它来适当地渲染消息。
例如,消息对象可能如下所示:
{
text: "Hello there. U good?",
is_user_msg: false
}
现在,要用一个对象表示state内的messages
字段,我们应该有类似如下的东西:
messages: {
user_id: {
text,
is_user_msg
},
user_id_2: {
text,
is_user_msg
}
}
请注意,这里我又用了对象而不是数组。另外,我们将把每条消息映射到唯一的键,即联系人的user_id
。这是因为一个用户可以与不同的联系人进行不同的对话,而且,在state对象内显示这种关系很重要。比如,点击联系人时,我们需要知道点击了哪个联系人!那么,我们如何做到这一点?是的,用他们的user_id
。
上面的表示还不完整,不过我们已经取得了很大进展!我们在这里表示的messages
字段假定每个联系人(由他们唯一的用户标识表示)只有一条消息。但是,情况并非总是如此,用户可以在一次对话中来回发送许多条消息。
那么我们如何做到这一点?最简单的方法是有一个messages数组,但相反,我会用对象来表示这个:
messages: {
user_id: {
0: {
text,
is_user_msg
},
1: {
text,
is_user_msg
}
},
user_id_2: {
0: {
text,
is_user_msg
}
}
}
现在,我们正在考虑在对话内发送的任何数量的消息。一条消息、两条消息或更多,它们现在在上面的messages
对象表示中表示。
你可能想知道为什么我用数字0
、1
等等来创建每个联系人消息的映射。接下来我会解释一下。
从state对象中移除嵌套实体,并像我们在这里所做的那样设计它的过程,称为规范化state对象。
使用对象而不是数组的主要问题
大多数情况下,我喜欢使用对象而不是数组这种理念。不过,有一些注意事项。
第 1 个注意事项:在视图逻辑中遍历数组更容易一些
一个常见的情况是,我们需要渲染一个组件列表。比如,为了渲染一个用users
prop给出的用户列表,程序逻辑会看起来像这样:
const users = this.props.users;
users.map(user => {
return <User />
})
不过,如果users
是被存为state中的一个对象,当它作为props
被获取和传递时,users
会依然是一个对象。由于我们不能在对象上使用数组对象的map()
,所以遍历它就困难得多。
那么,我们如何解决这个问题呢?
解决方案 #1a:
使用Lodash
遍历对象。
Lodash
是一个强大的JavaScript工具库。甚至对于遍历数组,很多人会劝你还是用Lodash
,因为它有助于处理假值。
使用Lodash
遍历对象的语法不难掌握。它看起来是这样的:
//导入lodash库
import _ from "lodash"
//使用
it_.map(users, (user) => {
return <User />
})
你通过_.map()
调用Lodash
对象上的map
方法。你传入要遍历的对象,然后像用默认的JavaScript map
函数一样,传入一个回调函数。
解决方案 #1b:
考虑一下你将数组映射为创建一个渲染的用户列表的常用方法:
const users = this.props.users;
users.map(user => {
return <User />
})
现在,假设users
是一个对象。也就是说我们不能map
它。如果我们能轻易将users
转换为数组,会怎么样?
这时候Lodash
又可以派上用场了。如下是用Lodash
的样子:
const users = this.props.users; //这是个对象
_.values(users).map(user => {
return <User />
})
看到了吧?_.values()
会将对象转换为数组。这就让map()
成为可能!如下是其工作原理。
假如你有一个类似如下的users
对象:
{
user_id_1: {user_1_object},
user_id_2 {user_2_object},
user_id_3: {user_3_object},
user_id_4: {user_4_object},
}
_.values(users)
会将其转换为:
[
{user_1_object},
{user_2_object},
{user_3_object},
{user_4_object},
]
是的!一个带有对象值的数组。刚好就是我们遍历所需要的。问题解决了。
还有一个注意事项,也许是一个更大的注意事项。
第 2 个注意事项:顺序的保存
数组可以保存其值的顺序,这可能是人们使用数组的最大的理由。我们来看一个示例来理解这一点。
const numbers = [0,3,1,6,89,5,7,9]
不管你怎么做,获取numbers
的值会总是返回相同的数组,而输入的顺序不会改变。那么对象会如何呢?
const numbers = {
0: "Zero",
3: "Three",
1: "One",
6: "Six",
89: "Eighty-nine",
5: "Five",
7: "Seven",
9: "Nine"
}
numbers的顺序与前面的数组是一样的。
现在,看看我在浏览器控制台复制和粘贴这,然后试着获取值。
你很可能会看漏掉。那么看看下面:
看上图的强调部分。对象值的顺序变了!
现在,取决于你在创建的应用程序的类型,这可能会导致很严重的问题。特别是在顺序是至高无上的那些应用程序中。
你知道这类应用的例子么?是的,我知道。比如说,聊天应用!如果你将用户谈话表示为一个对象,那么你肯定关心消息显示的顺序!你肯定不想昨天发的消息被显示为像今天发的一样。顺序很重要。
那么,该如何解决这个问题呢?
解决方案 #2:
用一个单独的ID数组来表示顺序。你之前一定看过这个,但你可能没注意。例如,如果有以下对象:
const numbers = {
0: "Zero",
3: "Three",
1: "One",
6: "Six",
89: "Eighty-nine",
5: "Five",
7: "Seven",
9: "Nine"
}
你可以用另一个数组描述值的顺序:
numbersOrderIDs: [0, 3, 1, 6, 89, 5, 7, 9]
通过这种方式,你可以一直跟踪值的顺序,而不管对象的行为。如果需要给对象添加值,只需要也给numbersOrderIDs
存入一个关联的ID即可。
了解这些事情非常重要,因为总会有一些事情是你无法控制的。你可以用这种方式来模拟应用程序的状态。即使你不喜欢这个想法,你绝对应该知道。
为了简单起见,Skypey应用程序的消息ID将始终按顺序排列,因为它们的编号是从零向上增加。
真正的应用程序中可能就不是这样的。你可能会有自动生成的奇怪的ID,看起来像乱码一样,比如y68fnd0a9wyb
。
在这种情况下,你就得专门用一个数组来跟踪值的顺序。
这就对了!
值得指出的是,规范化state对象的整个过程可以总结如下:
- 每种类型的数据都应该在状态对象中有自己的键。
- 每个键应将各个条目存储在一个对象中,其中条目的ID作为键,条目本身作为值。
- 任何对单个条目的引用都应该通过存储该条目的ID来完成。
- 理想情况下,保留一个ID数组来指示排序。
回顾State对象的设计
现在我知道这已经成了关于state对象结构的长篇大论。它现在对你来说可能看起来并不重要,但是当你创建项目时,你会发现在设计state时有一些想法是非常宝贵的,它可以帮助您更轻松地执行CRUD操作,还可以减少reducer中很多过于复杂的逻辑,并且还可以帮助您使用Reducer组合(这是本书后面将要介绍的术语)。
我希望您了解我的决定背后的原因,并在您创建自己的应用程序时能够做出明智的决定。我相信你现在拥有正确的信息。
综上所述,下面是Skypey状态对象的可视化表示:
这个图中只有两个用户联系人。请看一下。
创建用户列表
接下来,该写点代码了。首先,本节的目标是创建如下所示的用户列表:
创建这玩意需要什么呢?
从较高层面来看,应该很清楚的是,在Sidebar
组件中,需要渲染用户的联系人列表。
据推测,在Sidebar
中,你可能有这样的一些东西:
contacts.map(contact => <User />)
明白了吧?
您可以映射来自状态的一些contacts
数据,并为每个contact
渲染一个User
组件。
但是这些数据来自哪里呢?
理想情况下,在真实世界的场景中,你会用Ajax调用从服务器获取此数据。就我们的学习目的而言,这会带来一层我们可以避免的复杂性。
因此,与从服务器远程获取数据相反,我已经创建了几个函数来处理应用程序数据的创建。我们将使用这个静态数据来创建应用程序。
例如,static-data.js中已经创建了一个contacts
变量,它总是会返回一个随机生成的联系人列表。你所要做的就是将它导入到应用程序中。没有Ajax调用。
因此,在项目的根目录中创建一个新文件并将其命名为static-data.js
。
将这里gist的内容复制到该文件中。我们很快就会要用它。
设置store
让我们快速复习一下设置应用程序store的过程,这样我们就可以获取在侧边栏中创建用户列表所需的数据。
创建Redux应用程序的第一步是设置Store。由于这是读取数据的地方,因此解决这个问题势在必行。
所以,请从cli用如下命令安装redux
:
yarn add redux
安装完了后,创建一个文件夹store
,在该目录中,创建一个新文件index.js
。
不要忘记那个比喻:让主要的Redux角色有自己的目录。
像我们已经知道的,store会被通过来自redux的createStore()
工厂函数创建,像这样:
store/index.js
import { createStore } from "redux";
const store = createStore(someReducer, initialState);
export default store;
Redux的createStore()
需要知道reducer(记住我之前讲过的store和reducer的关系)。
现在,把第二行修改为:
const store = createStore(reducer, {contacts});
现在导入reducer
,并且从静态数据中导入contacts
:
import reducer from "../reducers";
import { contacts } from "../static-data";
因为我们实际上还没有创建reducers
目录,请现在创建好。同时,在reducers
目录中创建一个index.js
文件。下面,我们开始创建reducer。
reducers/index.js
export default (state, action) => {
return state;
};
reducer只是一个带有state
和action
两个参数,并且返回一个新state
的函数。
如果在创建store时,const store = createStore(reducer, {contacts});
,你搞糊涂了,请记住:createStore()
中的第二个参数是应用程序的初始状态。
我已经把初始状态设置为对象{contacts}
。这是个ES6语法,跟{contacts: contacts}
是一样的,有一个contacts
键,和一个来自于static-data
的contacts
的值。
现在还没有办法知道我们已经完成的是否正确。下面我们试图解决这个问题。
在Index.js
中, 如下是你现在应该有的代码:
Index.js
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import registerServiceWorker from "./registerServiceWorker";
ReactDOM.render(<App />, document.getElementById("root"));
registerServiceWorker();
就像我们在第一个例子中所做的那样,将ReactDOM.render
调用重构,将它放在一个render
函数中。
const render = () => {
return ReactDOM.render(<App />, document.getElementById("root"));
};
然后调用这个render()
函数,让应用程序能正确渲染。
render()
现在,导入前面创建的store
:
import store from "./store";
确保无论何时,只要store被更新了,就调用render()
函数:
store.subscribe(render);
不错!
现在,我们来用用这个设置。
就是这样:
const render = () => {
fancyLog();
return ReactDOM.render(<App />, document.getElementById("root"));
};
只调用一个新函数fancyLog()
,我们马上写出来。如下是fancyLog()
函数:
function fancyLog() {
console.log("%c Rendered with 👉 👉👇", "background: purple; color: #FFF");
console.log(store.getState());
}
这里我做了什么?
console.log(store.getState())
是你熟悉的,会输出从store获取的状态。
第一行console.log("%c Rendered with 👉 👉👇", "background: purple; color: #fff");
会输出文本Rendered with …
和一些表情符号,并带有一些CSS样式让它辨认得出来。写在文本Rendered with …
之前的%c
让使用CSS样式成为可能。
如下是完整的代码:
index.js
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import registerServiceWorker from "./registerServiceWorker";
import store from "./store";
const render = () => {
fancyLog();
return ReactDOM.render(<App />, document.getElementById("root"));
};
render();
store.subscribe(render);
registerServiceWorker();
function fancyLog() {
console.log("%c Rendered with 👉 👉👇", "background: purple; color: #fff");
console.log(store.getState());
}
如下是被输出的state对象。
正如你所看到的,在state对象中有一个contacts
字段,用于保存特定用户可用的联系人。数据的结构就像我们之前讨论的那样。每个联系人都使用他们的user_id
进行映射。
我们已经取得了不错的进展。
通过props传递侧边栏数据
如果你现在看看完整的代码,你会同意应用程序的入口点依然是index.js
。然后index.js
渲染App
组件。然后App
组件负责渲染Main
和Sidebar
组件。
为了让Sidebar
能访问所需的联系人数据,我们会通过props传入数据。
在App.js
中,像下面这样,从store中获取contacts
,并将其传给Sidebar
:
App.js
const App = () => {
const { contacts } = store.getState();
return (
<div className="App">
<Sidebar contacts={contacts} />
<Main />
</div>
);
};
像我在上面的截图中所做的那样,检查Sidebar组件,你会发现contacts
被传递为一个prop。contacts是将ID映射到user对象的一个对象。
现在,我们可以继续渲染联系人。
首先,从命令行控制台安装Lodash
:
yarn add lodash
在App.js
中导入lodash
:
import _ from lodash
我知道,下划线看起来很滑稽,不过这是个好的习惯。你会爱上它的 :)
现在,要用lodash
提供给我们的工具方法的话,只需要在的导入的下划线上调用该方法即可,比如_.fakeMethod()
。
现在,开始好好用用Lodash
。用Lodash
工具函数之一,当contacts
对象作为props被传入时,就可以轻松被转换为一个数组。就是这样:
<Sidebar contacts={_.values(contacts)} />
如果你愿意的话,可以详细看一下Lodash
的.values()方法。简而言之,它会用传入的对象的所有键/值创建一个数组。
现在,我们开始在侧边栏中实际渲染一点东西。
Sidebar.js
import React from "react";
import User from "./User";
import "./Sidebar.css";
const Sidebar = ({ contacts }) => {
return (
<aside className="Sidebar">
{contacts.map(contact => <User user={contact} key={contact.user_id} />)}
</aside>
);
};
export default Sidebar;
在上述代码中,我们映射contacts prop,并为每个contact
渲染一个User
组件。
为防止React的警告key,联系人的user_id
被用作为一个key。此外,每个联系人被当作一个user
prop传给User
组件。
创建User组件
上面我们是在Sidebar
内渲染一个User
组件,不过这个组件目前还不存在。所以下面我们把它创建出来。
请在根目录内创建一个User.js
和User.css
文件。
User.js
文件的内容如下:
User.js
import React from "react";
import "./User.css";
const User = ({ user }) => {
const { name, profile_pic, status } = user;
return (
<div className="User">
<img src={profile_pic} alt={name} className="User__pic" />
<div className="User__details">
<p className="User__details-name">{name}</p>
<p className="User__details-status">{status}</p>
</div>
</div>
);
};
export default User;
不要让大段的代码骗了你。它实际上很容易阅读和理解。再看一下。
name
、profile_pic
URL以及用户的status
都是通过解构从props中获取的:const { name, profile_pic, status } = user;
。
然后这些值被用在return语句中,从而可以正确渲染。如下是渲染的结果:
虽然上面的结果超级丑,但它表明这是有效的!
下面,我们来给它加上样式。
首先,防止用户列表移除侧边栏容器:
Sidebar.css
.Sidebar {
...
overflow-y: scroll;
}
此外,字体也很丑。我们改一下:
Index.css
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700");
body {
...
font-weight: 400;
font-family: "Nunito Sans", sans-serif;
}
最后,处理User
组件的整体显示:
User.css
.User {
display: flex;
align-items: flex-start;
padding: 1rem;
}
.User:hover {
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.User__pic {
width: 50px;
border-radius: 50%;
}
.User__details {
display: none;
}
/* not small devices */
@media (min-width: 576px) {
.User__details {
display: block;
padding: 0 0 0 1rem;
}
.User__details-name {
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 1rem;
}
}
如下是我们现在得到的漂亮的显示:
我们已经从什么都没有,到在屏幕上渲染出一个漂亮的用户列表了。
如果你正在编码,请调整浏览器的大小以便在移动设备上查看漂亮的视图。
你不必向下传递props
看看如下的Skypey UI高层解构:
在传统React应用中(没有用context api),你需要将props从<App />
向下传递到<Sidebar />
和<Main />
。
不过,如果用Redux的话,我们就不用被这条规则绑死。如果某个组件需要访问state对象的一个值,你可以直达store,获取当前状态。比如,<Sidebar />
和<Main />
可以访问Redux store,而不需要依赖于<App />
。
这里我没有这么做的唯一原因是因为<App />
是一个直接父组件,<Sidebar />
和<Main />
在组件层次结构中的深度不超过一层。
正如您将在后面的章节中看到的那样,对于在组件层次结构中嵌套更深的组件,我们将直接访问Redux store来获取当前状态,而不需要向下传递props。
容器和组件文件夹结构
在我们开始编写Skypey应用程序之前,需要做点重构。
在Redux应用程序中,将组件分为两个不同的目录是一种常见模式。直接与Redux对话的每个组件,无论是从store中获取状态,还是发送一个action,都应该移到containers
目录。其他组件,那些不能与Redux交谈的组件,应该移到components
目录。
那么,为什么要搞得这么麻烦呢?
首先,这样做会让我们的代码库变得更简洁。只要你知道它们是否与Redux交谈,那么找到某些组件就变得更简单。所以,请继续。
查看应用程序当前状态下的组件,并相应地进行重新组合。
注意,请别忘记,要记得把组件的相关CSS文件也移过来。
这是我的解决方案:
- 创建两个文件夹:
containers
和components
。 App.js
试图从store获取contacts
。所以,将App.js
和App.css
移到containers
文件夹。- 把
Sidebar.js
、Sidebar.css
、Main.js
和Main.css
移到components
文件夹。它们不直接与Redux对话。 - 请不要动
Index.js
和Index.css
。它们是应用程序的入口点。就把它们放在项目的根目录好了。 - 请把
User.js
和User.css
移到containers
目录。User
组件现在还没有与Redux对话,不过它以后会。还记得吧,当应用程序完成时,在侧边栏点击一个用户,他们的消息就会显示出来。这就暗示要分发一个action。在接下来的小节中,我们会把它弄出来。 - 到了现在,很多你导入的URL会被破坏了,必须要修改import的URL。我把这留给你。这很容易修复 :)
如下是上面第6条的一个示例解决方案:在App.js
中,将Sidebar
和Main
import修改为:
import Sidebar from "../components/Sidebar";
import Main from "../components/Main";
对比之前的:
import Sidebar from "./Sidebar";
import Main from "./Main";
明白了么?
以下是一些可以自己解决挑战的技巧:
- 检查
Sidebar.js
中User
组件的import语句。 - 检查
Index.js
中App
组件的import语句。 - 检查
App.js
中store
组件的import语句。
一旦完成,你会让Skypey按预期工作!
重构为从Reducer设置初始状态
首先,看看store/index.js
文件中store
的创建。特别是,考虑这行代码:
const store = createStore(reducer, { contacts });
初始state对象是直接传入createStore()
。还记得吧,store是用签名createStore(reducer, initialState)
创建的。在本例中,初始状态已经被设置为对象{contacts: contacts}
。
即使这种方法有效,这通常用于服务器端渲染(如果您不知道这是什么意思,请不要烦恼)。现在,请理解这种在createStore()
中设置初始状态的方法在现实世界中更多地用于服务器端渲染。
现在,删除掉createStore()
方法中的初始状态。我们会让应用程序的初始状态只通过reducer设置。相信我,你会明白这个的窍门。
如下是从createStore()
中删除掉初始状态后,store/index.js
文件看起来的样子:
import { createStore } from "redux";
import reducer from "../reducers";
const store = createStore(reducer);
export default store;
而如下是reducer/index.js
文件的当前内容:
export default (state, action) => {
return state;
};
请把它变为:
import { contacts } from "../static-data";
export default (state = { contacts }, action) => {
return state;
};
那么,这里发生了什么?
使用ES6默认参数,我们已经将state参数设置为默认值{contacts}
。这本质上与{contacts: contacts}
是一样的。因此,reducer中的return state
语句会返回这个值,{contacts: contacts}
,作为应用程序的初始状态。
到这里,应用程序现在跟以前一样起作用。这里唯一的不同之处在于,应用程序的初始状态现在是由Reducer管理的。
下面我们继续重构。
Reducer组合
在迄今为止我们创建的所有应用程序中,我们只使用一个Reducer来管理应用程序的整个状态。
这暗示了什么?这就像在整个银行大厅只有一个银行柜员。如何扩展?即使银行柜员能够高效完成所有工作,但在银行大厅中有多个银行柜员可能更容易管理 - 也许更好的客户体验。此外,如果一个银行柜员要关注每个客户,这工作量也太大了!
Redux应用程序也是如此。
对比一个reducer处理所有状态的操作,在您的应用程序中有多个reducer是常见的。然后,我们可以这些reducer合并为一个。比如,银行大厅里可能有5到10个收银员,但所有这些收银员组合起来为一个目的服务。多个reducer也是这样工作。
考虑我们之前创建的Hello World应用程序的state对象:
{
tech: "React"
}
相当简单。
我们所做的是用一个reducer管理整个状态更新。
不过,考虑更复杂的Skypey应用程序的state对象:
让一个reducer管理整个state对象是可行的,不过不是最佳方式。
如果我们让一个reducer管理state对象中的一个字段,而不是让一个reducer管理整个对象,该怎么样呢?
像一对一映射?
你看到我们这里正在做什么吗?引入更多的银行柜员!
Reducer组合需要一个reducer处理state对象中一个字段的状态更新。
比如,对于messages
字段,你有一个messagesReducer
。对于contacts
字段,你也有一个contactsReducer
,依此类推。
还有一点需要指出的是,每个reducer的返回值仅为他们所代表的字段。
所以,如果我有一个像下面这样写的messagesReducer
:
export const function messagesReducer (state={}, action) {
return state
}
那么,这里返回的state
就不是整个应用程序的状态了,它只是messages
字段的值。
其它reducer也是一样的。
明白了吧?
下面我们在实践中看一看,看看如何将这些reducer组合为一个用途。
重构Skypey来使用多个reducer
还记得我是如何谈到多个reducer处理state对象中的每个字段吗?
现在,我们将会有如下图所示的多个reducer:
对于state对象中的每个字段,我们将创建一个相应的reducer。目前的阶段是contacts
和user
。
我们先来看看它如何影响我们的代码。 然后我会回过头来解释它是如何工作的。
看看reducer/index.js
:
import { contacts } from "../static-data";
export default (state = contacts, action) => {
return state;
};
将这个文件重命名为contacts.js
。它会成为contacts的reducer。
在reducers
目录中创建一个user.js
文件。它会成为user的reducer,其内容如下:
import { generateUser } from "../static-data";
export default function user(state = generateUser(), action) {
return state;
}
这里我又创建了一个generateUser()
函数来生成一些静态的用户信息。
用ES6的默认参数,初始状态被设置为调用这个函数的结果。因此,return state
现在会返回一个user对象。
现在,我们有了两个不同的reducer。下面为了更大的好,我们将二者组合起来 :)
在reducer目录中创建一个index.js
文件。首先,导入这两个reducer,user
和contacts
:
import user from "./user";
import contacts from "./contacts";
为了将这个两个reducer组合,我们需要来自redux的辅助函数combineReducers
。像这样导入它:
import { combineReducers } from "redux";
现在,index.js
会像这样,导入两个reducer的组合:
export default combineReducers({
user,
contacts,
});
请注意,combineReducers
函数带有一个对象作为参数。这个对象的形状与应用程序的state对象正好一样。这段代码块与如下代码是一样的:
export default combineReducers({
user: user,
contacts: contacts
})
这个对象有键user
和contacts
,就像我们已经记住的state对象一样。
这些键的值是什么?值是来自于reducer!
理解这个很重要。
我搞糊涂了!这又是如何工作的?
让我退后一步,再解释reducer组合是如何工作。这一次,是从另一个角度来看。
考虑下面的JavaScript对象:
const state = {
user: "me",
messages: "hello",
contacts: ["no one", "khalid"],
activeUserId: 1234
}
现在,假设我们不想让键的值被硬编码,而是希望它通过函数调用来表示。可能是这样的:
const state = {
user: getUser(),
messages: getMsg(),
contacts: getContacts(),
activeUserId: getID()
}
这假定getUser()
也会返回前一个值,"me"
。其他被替换的函数也是如此。
现在,我们重命名这些函数。
const state = {
user: user(),
messages: messages(),
contacts: contacts(),
activeUserId: activeUserId()
}
现在,这些函数的名称与其对应的对象键相同。我们现在不用getUser()
,而是使用user()
。
让我们变得富有想象力。
想象一下,从某个库导入了某个工具函数。我们把这个函数称为killerFunction
。现在,killerFunction
让做如下的事情变成可能:
const state = killerFunction({
user: user,
messages: messages,
contacts: contacts,
activeUserId: activeUserId
})
什么改变了?
不用调用每个函数,你只需写函数名称即可。killerFunction
将负责调用函数。
现在使用ES6,我们可以进一步简化代码:
const state = killerFunction({
user,
messages,
contacts,
activeUserId
})
这与前面的代码块相同。假设这些函数在作用域内,并且与对象的key具有相同的名称(标识符)。
明白了吗?
这就是Redux
的combineReducer
的工作原理。
state对象中每个键的值都将从reducer
中获得。不要忘记,reducer就是一个函数。就像killerFunction
一样,combineReducers
能够确保值是通过调用传递的函数获得的。所有的键和值放在一起,就会得出应用程序的state对象。
这就对了!
总之,要记住的一个重点是,当使用combineReducers
时,从每个reducer返回的值不是应用程序的状态。它只是它们在state对象中表示的特定键的值
!
例如,user
reducer返回的是state对象中user
键的值。同样,messages
reducer返回的是state对象中messages
键的值。
如下是reducer/index.js
的完整内容:
import { combineReducers } from "redux";
import user from "./user";
import contacts from "./contacts";
export default combineReducers({
user,
contacts
});
现在如果我们检查输出,会发现user
和contacts
都在state对象中。
创建空白屏
现在,Main
组件只是显示文本main stuff
。这不是我们想要的。最终目标是显示一个空白屏,但是点击联系人时,要展示用户消息。
下面我们就来创建空白屏。
为此,我们需要一个新组件Empty.js
,还有一个对应的CSS文件Empty.css
。请将它们创建在components
目录中。
<Empty />
会渲染空白屏的标记。为此,它会需要某个user
prop。
很显然,user
是要从应用程序的state传进来。不要忘记我们之前解决的state对象的整体结构:
所以,下面是<Main />
组件的当前内容:
import React from "react";
import "./Main.css";
const Main = () => {
return <main className="Main">Main Stuff</main>;
};
export default Main;
它仅返回文本Main Stuff
。
<Main />
组件负责在没有用户活动时显示<Empty />
组件。只要一个用户被点击了,<Main />
就会渲染被点击的用户的对话。这可以用一个组件<ChatWindow />
来表示。
为了让这个渲染切换起作用,并且让<Main />
要么渲染<Empty />
,要么渲染<ChatWindow />
,我们需要跟踪某个activeUserId
。
比如,默认情况下activeUserId
会是null,然后会显示<Empty />
。
不过,只要用户被点击,那么activeUserId
就变成被点击的联系人的user_id
,此时,<Main />
会渲染<ChatWindow />
组件。
很酷吧!
为了让这起作用,我们会在state对象中添加一个新字段activeUserId
。
到现在为止,你应该已经知道诀窍了。要向state对象添加一个新字段,我们将在reducer中进行设置。
在reducers
文件夹中创建一个新文件activeUserId.js
,文件的内容如下:
reducers/activeUserId.js
export default function activeUserId(state = null, action) {
return state;
}
默认情况下,它返回null
。
现在,将这个新创建的reducer像下面这样,挂到combineReducer
方法调用中:
...
import activeUserId from "./activeUserId";
export default combineReducers({
user, contacts, activeUserId
});
现在如果你查看输出,会发现activeUserId
就在state对象中。
下面我们继续。
在App.js
中,像下面这样,从store中获取user
和activeUserId
:
const { contacts, user, activeUserId } = store.getState();
我们之前的是:
const { contacts } = store.getState();
现在,将这些值作为props传递给<Main />
组件:
<Main user={user} activeUserId={activeUserId} />
之前的是:
<Main />
显然,我们让<Main />
中的渲染逻辑更具体一些。
之前:
import React from "react";
import "./Main.css";
const Main = () => {
return <main className="Main">Main Stuff</main>;
};
export default Main;
现在:
import React from "react";
import "./Main.css";
import Empty from "../components/Empty";
import ChatWindow from "../components/ChatWindow";
const Main = ({ user, activeUserId }) => {
const renderMainContent = () => {
if (!activeUserId) {
return <Empty user={user} activeUserId={activeUserId} />;
} else {
return <ChatWindow activeUserId={activeUserId} />;
}
};
return <main className="Main">{renderMainContent()}</main>;
};
export default Main;
改变了什么不难理解。user
和activeUserId
被接收为props。组件内的return语句有函数renderMainContent()
被调用。
renderMainContent()
所做的就是检测activeUserId
是否存在。如果不存在,它就渲染一个空白屏;如果存在,就渲染ChatWindow
。
很好!
我们还没有把Emptyp
和ChatWindow
组件创建出来。原谅我,我打算一次贴一大堆代码出来。
编辑Empty.js
文件为如下:
import React from "react";
import "./Empty.css";
const Empty = ({ user }) => {
const { name, profile_pic, status } = user;
const first_name = name.split(" ")[0];
return (
<div className="Empty">
<h1 className="Empty__name">Welcome, {first_name} </h1>
<img src={profile_pic} alt={name} className="Empty__img" />
<p className="Empty__status">
<b>Status:</b> {status}
</p>
<button className="Empty__btn">Start a conversation</button>
<p className="Empty__info">
Search for someone to start chatting with or go to Contacts to see who is available
</p>
</div>
);
};
export default Empty;
哎呀,那是什么代码?
退一步,它并不像看起来那么复杂。
<Empty />
组件带有一个user
属性。这个user属性是一个如下形式的对象:
{
name,
email,
profile_pic,
status,
user_id:
}
使用ES6的解构语法,从user
对象中获取name
、profile_pic
和status
。
const { name, profile_pic, status } = user;
对于大多数用户来说,name
包含两个单词,比如Ohans Emmanuel
。像下面这样,获取第一个单词,然后将其赋值给变量first_name
:
const first_name = name.split(" ")[0];
return语句只是吐出一堆标记。
你很快会看到结果。
在我们继续前,不要忘记在containers
目录中创建一个ChatWindow
组件。ChatWindow
会负责为每个活动的用户联系人显示对话,并且它打算做很多与Redux的直接对话!
在ChatWIndow.js
中写入如下代码:
import React from "react";
const ChatWindow = ({ activeUserId }) => {
return (
<div className="ChatWindow">Conversation for user id: {activeUserId}</div>
);
};
export default ChatWindow;
我们会回来充实这段代码。不过现在已经够了。
保存目前为止我们所做的所有修改,然后如下是我们得到的结果!
空白屏起作用了,但是很丑,没人会喜欢丑的应用。
我已经为<Empty />
组件写好了CSS:
Empty.css
.Empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.Empty__name {
color: #fff;
}
.Empty__status,
.Empty__info {
padding: 1rem;
}
.Empty__status {
color: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.7);
}
.Empty__img {
border-radius: 50%;
margin: 2rem 0;
}
.Empty__btn {
padding: 1rem;
margin: 1rem 0;
font-weight: bold;
font-size: 1.2rem;
border-radius: 30px;
outline: 0;
}
.Empty__btn:hover {
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
现在,结果变成如下这样:
如下是关掉Devtools后的结果:
现在看起来显然就好看多了。
创建聊天窗口
看看<Main />
组件内的逻辑。<ChatWindow />
只会在activeUserId
不为null的时候显示。而现在,activeUserId
是被设置为null
。
我们需要确保只要点击一个联系人,就设置了activeUserId
。
你在想什么?我们需要分发一个action,对吧?是的!
下面我们就来定义action的形状。
记住,action只是一个带有一个type
字段和一个payload
字段的对象。type
字段是强制的,而你可以把payload
叫做你喜欢的任何东西。不过,payload
是个好名字,也很常见。
因此,如下是action的表示:
{
type: "SET_ACTION_ID",
payload: user_id
}
action的类型或者名称将被称为SET_ACTION_ID
。action的类型使用大写字母很常见,比如SET_ACTION_ID
,而不是setactionid
或set-action-id
。
此外,action payload会是被设置为活动的用户的user_id
。
现在我们基于用户交互来发送action。
由于这是我们第一次在这个应用程序中发送action,所以创建一个新的actions
目录。同时还也创建一个constants
文件夹。
在constants
文件夹中,创建一个新文件action-types.js
。这个文件的唯一职责是保存action类型常量。我会尽快解释为什么这很重要。
在action-types.js
中写下如下代码:
constants/action-types.js
export const SET_ACTIVE_USER_ID = "SET_ACTIVE_USER_ID";
那么,为什么这很重要呢?
为了理解这一点,我们需要调查在Redux应用程序中在哪里使用action类型。
在大多数Redux应用程序中,它们将显示在两个地方。
- Reducer
当你在reducer中切换action类型时:
switch(action.type) {
case "WITHDRAW_MONEY":
doSomething();
break;
}
- Action creator
在action creator中,你也像这样写代码:
export const seWithdrawAmount = amount => ({
type: "WITHDRAW_MONEY,
payload: amount
})
现在,看看上面的reducer和action creatord逻辑。二者有什么共同点?"WITHDRAW_MONEY"
字符串!
随着应用程序的增长,并且你有很多这些字符串到处都是,你(或其他人)可能有一天会写错WITDDRAW_MONEY
或WITHDRAW_MONY
,而不是WITHDRAW_MONEY
。
我要说的是,使用这样的原始字符串会更容易产生拼写错误。根据经验,来自错别字的错误是非常烦人的。你可能会要寻找很长时间,只是为了看到问题是由于你的一个非常小的错别字而引起的。
为了不让你遇到这种麻烦,一个好的方法是将字符串作为常量存储在一个单独的文件中,这样就不用在多个位置写入原始字符串,只需从声明的常量中导入字符串即可。
你把常量声明在一个地方,但是能在尽可能多的地方用它们,而且不会打错字。
这正是我创建constants/action-types.js
文件的原因。
现在,我们来创建action creator。
action/index.js
import { SET_ACTIVE_USER_ID} from "../constants/action-types";
export const setActiveUserId = id => ({
type: SET_ACTIVE_USER_ID,
payload: id
});
如你所见,我已经从constants
文件夹中导入action type字符串。就像我之前解释的那样。
再提醒一次,action creator只是一个函数。我已经把这个函数叫做setActiveUserId
了。它带有一个参数为一个用户的id
,返回正确设置好type和payload的action(即该对象)。
有了这个,剩下的就是在用户点击用户时,分发这个action,并且在我们的reducer中对被分发的action做一些事情。
让我们继续前进。
看看User.js
组件。
return
语句的第一行是一个类名为User
的div
:
<div className="User">
这里就是设置点击事件处理器的地方。只要这个div
被点击,我们就会分发一个我们刚创建的action。所以,如下是修改后的代码:
<div className="User" onClick={handleUserClick.bind(null, user)}>
而handleUserClick()
函数是这样的:
function handleUserClick({ user_id }) {
store.dispatch(setActiveUserId(user_id));
}
这里setActiveUserId
从哪里导入进来?是从action creator中!
import { setActiveUserId } from "../actions";
现在,下面是此时你应该有的全部User.js
代码:
containers/User.js
import React from "react";
import "./User.css";
import store from "../store";
import { setActiveUserId } from "../actions";
const User = ({ user }) => {
const { name, profile_pic, status } = user;
return (
<div className="User" onClick={handleUserClick.bind(null, user)}>
<img src={profile_pic} alt={name} className="User__pic" />
<div className="User__details">
<p className="User__details-name">{name}</p>
<p className="User__details-status">{status}</p>
</div>
</div>
);
};
function handleUserClick({ user_id }) {
store.dispatch(setActiveUserId(user_id));
}
export default User;
要发送action,我还得导入store
,并调用方法store.dispatch()
。
还要注意,我已经用了ES6解构语法在handleUserClick()
中从use
参数获取user_id
。
如果你在跟着写代码,如我所推荐的,点击任一用户联系人,检查一下输出。你可以像下面这样,添加一个控制台输出给handleUserClick()
:
function handleUserClick({ user_id }) {
console.log(user_id);
store.dispatch(setActiveUserId(user_id));
}
你会发现输出的用户联系人的user id。
正如你可能已经注意了,action正被发送,不过屏幕上什么都没有改变。activeUserId
并没有在state对象中设置。这是因为现在,reducer对发送的action还一无所知。
下面我们修复这个问题,不过不要忘记在检查了输出后,删掉console.log(user_id)
。
看一下activeUserId
这个reducer:
export default function activeUserId(state = null, action) {
return state;
}
reducer/activeUserId.js
import { SET_ACTIVE_USER_ID } from "../constants/action-types";
export default function activeUserId(state = null, action) {
switch (action.type) {
case SET_ACTIVE_USER_ID:
return action.payload;
default:
return state;
}
}
你应该理解这里发生了什么。
第一行导入字符串SET_ACTIVE_USER_ID
。
然后我们检查传进的action的type是否是SET_ACTIVE_USER_ID
。如果是,那么activeUserId
的新值就被设置为action.payload
。
不要忘记,action的payload包含了用户联系人的user_id
。
下面我们来看看这能否起作用。它会按预想的起作用吗?是的!
现在,ChatWindow
组件被用正确的activeUserId
渲染了。
提醒一下,重要的是要记住,用reducer组合时,每个reducer的返回值是它们所代表的state字段的值,而不是整个state对象。
将ChatWindow分成更小的组件
看一看完整的聊天窗口看起来是什么样子:
为更理智的开发方式,我已经将这个分成三个子组件,Header
、Chats
和MessageInput
:
那么,为了完成chatWindow
组件,我们将创建这三个子组件。然后我们会将它们组合,形成chatWindow
组件。
准备好了么?
下面我们从Header
组件开始。
chatWindow
组件的当前内容是这样的:
import React from "react";
const ChatWindow = ({ activeUserId }) => {
return (
<div className="ChatWindow">Conversation for user id: {activeUserId}</div>
);
};
export default ChatWindow;
没啥帮助。我们将代码更新为:
import React from "react";
import store from "../store";
import Header from "../components/Header";
const ChatWindow = ({ activeUserId }) => {
const state = store.getState();
const activeUser = state.contacts[activeUserId];
return (
<div className="ChatWindow">
<Header user={activeUser} />
</div>
);
};
export default ChatWindow;
什么被改变了?
记住activeUserId
被作为props传入ChatWindow
组件。
现在,我们不是渲染文本Conversation for user id: …
,而是渲染Header
组件。
如果不知道被点击的用户,header
组件就不能被正确渲染。为什么?因为Header
中被渲染的name
和status
是属于被点击的用户的。
要跟踪激活的用户,就得创建一个新变量activeUser
,并且从state对象获取的值应该像这样:const activeUser = state.contacts[activeUserId]
。
这个是怎么用的?
首先,我们从Redux store中获取状态:const state = store.getState()
。
现在,记得应用程序用户的每个联系人都存在contacts
字段中。并且,每个用户是用他们的user_id
映射的。
因此,活动的用户可以用从contacts
对象中相关的id
字段获取用户来获得:state.contacts[activeUserId]
。
此时,我们需要创建出被渲染的Header
组件。
在components
目录中,创建文件Header.js
和Header.css
。
Header.js
的内容很简单:
import React from "react";
import "./Header.css";
function Header({ user }) {
const { name, status } = user;
return (
<header className="Header">
<h1 className="Header__name">{name}</h1>
<p className="Header__status">{status}</p>
</header>
);
}
export default Header;
它是一个无状态的函数式组件,渲染一个header
元素和h1
、p
标记,来保存活动用户的name
和status
。
还记得活动用户是从侧边栏被点击的用户吧。
<Header />
组件的样式很简单:
.Header {
padding: 1rem 2rem;
border-bottom: 1px solid rgba(189, 189, 192, 0.2);
}
.Header__name {
color: #fff;
}
现在我们就得到如下界面:
下面,我们继续创建<Chats />
组件。
<Chats />
组件基本上是渲染一个用户的对话列表。
那么,我们从哪里得到这些对话呢?是的,从应用程序的state对象中。
就像我之前解释的那样,真实的应用程序会从服务器获取用户对话。不过,我学习Redux的方式是在学习基础知识时,尽可能地消除复杂性。因此,这里就没有获取资源的服务器。我们将使用我为随机用户数据生成创建的一些辅助函数来连接数据。
下面我们开始连接所需的数据到应用程序的state。
过程与我们已经做过多次的一样。
- 创建一个Reducer
- 使用ES6,添加一个默认参数值给reducer
combineReducers
函数调用中包含reducer。
在reducers
目录中,创建一个新文件messages.js
。它将是messages reducer,其内容如下:
reducers/messages.js
import { getMessages } from "../static-data";
export default function messages(state = getMessages(10), action) {
return state;
}
为生成随机消息,我从static-data
导入了getMessages
函数。这个函数的参数是一个数字。然后getMessages
函数会为每个用户联系人生成该数字数量的消息。比如,getMessages(10)
会为每个联系人生成10条消息。
现在,在reducers/index.js
中将该reducer包含到combineReducers
函数调用中:
reducers/index.js
import messages from "./messages";
export default combineReducers({
user,
messages,
contacts,
activeUserId
});
这样做会将一个messages
字段包含到state对象中。
下面是输出。现在你会找到像如下那样的messages
:
然后,我们可以安全地恢复创建Chats
组件。
如果你还没有的话,在components
目录中创建文件Chats.js
和Chats.css
。
现在,在ChatWindow
中,导入Chats
,并将它渲染在<Header />
组件下面。
containers/ChatWindow.js
...
import Chats from "../components/Chats";
...
return (
<div className="ChatWindow">
<Header user={activeUser} />
<Chats />
</div>
);
<Chats/>
组件会采用来自state对象的消息列表,对消息列表进行映射,然后渲染它们。
要记住传递给Chats
的消息是活动用户的消息。
同时,state.messages
保存每个用户联系人的所有消息,state.messages[activeUserId]
会获取活动用户的消息。
这就是为什么每次对话都被映射给用户的user id的原因:是为了方便获取。
获取活动用户的消息,然后将其传为Chats
的props。
containers/ChatWindow.js
...
import Chats from "../components/Chats";
...
const activeMsgs = state.messages[activeUserId];
return (
<div className="ChatWindow">
<Header user={activeUser} />
<Chats messages={activeMsgs} />
</div>
);
现在,请记住每个用户的消息是一个巨大的对象,每条消息有一个number
字段:
为更容易遍历和渲染,我们会把消息对象转换为数组。就像我们在侧边栏对用户列表所做的那样。
为此,我们将需要Lodash
。
containers/ChatWindow.js
...
import _ from "lodash";
import Chats from "../components/Chats";
...
const activeMsgs = state.messages[activeUserId];
return (
<div className="ChatWindow">
<Header user={activeUser} />
<Chats messages={_.values(activeMsgs)} />
</div>
);
现在我们不是传递activeMsgs
,而是传入_.values(activeMsgs)
。
在我们查看结果之前,还有一个重要的步骤。
组件Chats
还没有创建。
在Chats.js
中,写下如下代码。之后我会解释。
**containers/Chat.js**
import React, { Component } from "react";
import "./Chats.css";
const Chat = ({ message }) => {
const { text, is_user_msg } = message;
return (
<span className={`Chat ${is_user_msg ? "is-user-msg" : ""}`}>{text}</span>
);
};
class Chats extends Component {
render() {
return (
<div className="Chats">
{this.props.messages.map(message => (
<Chat message={message} key={message.number} />
))}
</div>
);
}
}
export default Chats;
这并不难理解,不过我会解释发生了什么。
首先,看看Chats
组件。你会注意到,这里我用了一个基于类的组件。稍后你会看到为什么我这样做。
在render()
函数中,我们映射messages
props,并且对于每个message
,我们返回一个Chat
组件。
这个Chat
组件超级简单:
const Chat = ({ message }) => {
const { text, is_user_msg } = message;
return (
<span className={`Chat ${is_user_msg ? "is-user-msg" : ""}`}>{text}</span>
);
};
对于传入的每条消息,消息的text
内容以及is_user_msg
标志都是使用ES6解构语法获取的,即const { text, is_user_msg } = message;
。
return语句更有趣。这里渲染了一个简单的span
标记。剔除掉一些JSX
,如下就是被渲染的东西:
<span> {text} </span>
消息的文本内容被包在一个span
元素中。很简单。
不过,我们需要区分应用程序用户的消息以及联系人的消息。
不要忘记,至少有两个人来回发送消息时才会发生对话。
如果被渲染的消息是用户的消息,我们想被渲染的标记是这样的:
<span className="Chat is-user-msg"> {text} </span>
如果不是,我们想它是这样的:
<span className="Chat"> {text} </span>
请注意,被改变的是否带有is-user-msg
类。
通过这种方式,我们可以特别对用户的消息使用如下所示的CSS选择器设置样式:
.Chat.is-user-msg {
}
所以,这就是为什么我们有一些花哨的JSX
根据is_user_msg
标志存在与否渲染类名。
<span className={`Chat ${is_user_msg ? "is-user-msg" : ""}`}>{text}</span>
现在你可以明白containers/Chats.js
内的所有代码了。
如下是到目前为止的结果。
消息被渲染了,不过它看起来不咋的。这是因为所有消息都是在span
标记中被渲染。
因为span
标记是一个行内元素,所有的信息只是渲染在一条连续的线上,看起来很压抑。这就是CSS派上用场的地方。
下面我们加上一些CSS,让它好看起来。
我们先从聊天室窗口开始,在containers
目录下,创建一个新文件ChatWindow.css
。然后别忘了在ChatWindow.js
中像这样导入它:
import "./ChatWindow.css"`
在ChatWindow.css
中写入:
.ChatWindow {
display: flex;
flex-direction: column;
height: 100vh;
}
这将确保ChatWindow
占据所有可用的高度100vh
。我还让它变成了一个flex容器,这样在我对条目Header
、Chats
和Message
对齐时,我就可以利用弹性盒的一些好处。
你可以看到如下红色边框内的ChatWindow
。
现在我们开始给Chat
消息设置样式。
components/Chats.css
.Chats {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 85%;
margin: 0 auto;
overflow-y: scroll;
}
.Chat {
margin: 1rem 0;
color: #fff;
padding: 1rem;
background: linear-gradient(90deg, #1986d8, #7b9cc2);
max-width: 90%;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.Chat.is-user-msg {
margin-left: auto;
background: #2b2c33;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
@media (min-width: 576px) {
.Chat {
max-width: 60%;
}
}
天哪! 这已经很好看了!
我来解释一下一些重要的样式声明。
通过设置flex: 1 1 0
,.Chats
就被设置为在ChatWindow
内相应地伸展(占据可用空间)和收缩。
.Chats
也由一个带有display:flex
的flex-container组成。通过设置flex-direction:column
,所有聊天消息都垂直对齐。他们不再是行内元素,而是弹性项目!
非用户的消息会被用background: linear-gradient(90deg, #1986d8, #7b9cc2);
设置为蓝色背景渐变。
如果是用户的消息,这个背景就被覆盖:
.Chat.is-user-msg {
background: #2b2c33;
}
下面我们创建消息输入组件。
我们已经创建了更复杂的组件。这个组件没那么难创建。不过,这里有一点要考虑。
输入组件将是一个受控组件。因此我们会把输入值存储在应用程序状态对象中。为此,我们需要在state对象中有一个新字段typing
。
下面我们搞定它。
出于我们的考虑,只要用户键入,我们会分发一个SET_TYPING_VALUE
action type。请确保把这个常量添加到constants/action-types.js
文件中:
export const SET_TYPING_VALUE = "SET_TYPING_VALUE";
同时,分发的action的代码应该是这样的:
{
type: SET_TYPING_VALUE,
payload: "input value"
}
这里action的playload
是在输入框中键入的值。
下面我们创建一个action creator来处理这个action的创建:
actions/index.js
import {
SET_ACTIVE_USER_ID,
SET_TYPING_VALUE
} from "../constants/action-types";
…
export const setTypingValue = value => ({
type: SET_TYPING_VALUE,
payload: value
})
现在,我们来创建一个新的typing
reducer,这个reducer会处理这个创建的action。
reducers/typing.js
import { SET_TYPING_VALUE } from "../constants/action-types";
export default function typing(state = "", action) {
switch (action.type) {
case SET_TYPING_VALUE:
return action.payload;
default:
return state;
}
}
typing
字段的默认值就被设置为一个空字符串。
不过,当SET_TYPING_VALUE
类型的action被分发时,playload
的值会被返回。
否则,就返回默认状态""
。
然后不要忘记,确保在combineReducers
函数调用中包含这个新创建的reducer:
reducers/index.js
...
import typing from "./typing";
export default combineReducers({
user,
messages,
typing,
contacts,
activeUserId
});
检查一下数据,确认typing
字段出现在state对象中。
OK。下面我们来创建实际的MessageInput
组件。既然这个组件会直接与Redux store对话,以设置它的type
值,所以它应该创建在container
目录中。同时,还要创建一个MessageInput.css
文件。
containers/MessageInput
import React from "react";
import store from "../store";
import { setTypingValue } from "../actions";
import "./MessageInput.css";
const MessageInput = ({ value }) => {
const handleChange = e => {
store.dispatch(setTypingValue(e.target.value));
};
return (
<form className="Message">
<input
className="Message__input"
onChange={handleChange}
value={value}
placeholder="write a message"
/>
</form>
);
};
export default MessageInput;
这里没有什么神奇的事情发生。
只要用户在输入框中键入,就会触发onChange
事件,然后会调用handlechange()
函数,然后handleChange()
会分发我们前面创建的setTypingValue
action。此时,传递所需的payload: e.target.value
。
我们已经创建好了这个组件,但是要在聊天窗口中显示出来的话,我们还需要将它包含在ChatWindow.js
的return语句中:
...
import MessageInput from "./MessageInput";
const { typing } = state;
return (
<div className="ChatWindow">
<Header user={activeUser} />
<Chats messages={_.values(activeMsgs)} />
<MessageInput value={typing} />
</div>
);
现在,我们已经让它可以工作了!不过它确实很难看。下面,我们让它变得好看点。
containers/MessageInput.css
.Message {
width: 80%;
margin: 1rem auto;
}
.Message__input {
width: 100%;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border: 0;
border-radius: 10px;
font-size: 1rem;
outline: 0;
}
这样子就差不多了!
好看多了吧!
提交表单
现在,当你键入消息并回车时,它并没有显示在对话列表中,而且页面重新加载了。太糟糕了!
下面我们处理表单提交。
在MessageInput.js
中,按如下这样,添加一个handleSubmit
事件处理器:
...
<form className="Message" onSubmit={handleSubmit}>
...
</form>
...
思考一会儿。要更新对话中的消息列表,我们需要分发一个action!
这个action需要采用输入框中的value
,然后将它添加到活动用户的消息中。OK,如下是这个action的结构:
{
type: "SEND_MESSAGE",
payload: {
message,
userId
}
}
现在,我们来写handleSubmit()
函数:
// 先获取当前state对象
const state = store.getState();
const handleSubmit = e => {
e.preventDefault();
const { typing, activeUserId } = state;
store.dispatch(sendMessage(typing, activeUserId));
};
在handleSubmit()
函数内,用e.preventDefault()
,我认为你已经知道这是干什么。typing
值和activeUserId
是从state
获取,因为二者都要用于创建分发的action。
最后,action用store.dispatch(sendMessage(typing, activeUserId))
分发。
哦,还用了一个action creator:sendMessage
。
在actions/index.js
中,创建sendMessage
action creator:
import {
...
SEND_MESSAGE
} from "../constants/action-types";
export const sendMessage = (message, userId) => ({
type: SEND_MESSAGE,
payload: {
message,
userId
}
})
这还意味着SEND_MESSAGE
action type 常量需要在constants/action-types.js
中创建:
export const SEND_MESSAGE = "SEND_MESSAGE";
在测试代码之前,别忘记更新MessageInput.js
中的import,让它包含sendMessage
:
import { setTypingValue, sendMessage } from "../actions";
OK,试一下,看看代码能不能工作。哦豁,还是不行!
表单被提交时,页面没有重新加载,是因为表单提交,action被分发,但是依然没有更新。
我们没有做错任何事情,除了这类action还没有符合任何reducer。就是说,reducer对新创建的action类型SEND_MESSAGE
还一无所知。
下面我们来修复这个问题。
更新消息状态
如下是此时我们所有的所有reducer的一个列表:
activeUserId.js
contacts.js
messages.js
typing.js
user.js
你认为其中哪一个应该关心更新用户对话中的消息呢?
是的,是messages
reducer。
如下是当前messages
reducer的内容:
import { getMessages } from "../static-data";
export default function messages(state = getMessages(10), action) {
return state;
}
这里没有那么多东西。
下面我们导入这个SEND_MESSAGE
action类型,开始在messages
reducer中处理它:
import { getMessages } from "../static-data";
import { SEND_MESSAGE } from "../constants/action-types";
export default function messages(state = getMessages(10), action) {
switch (action.type) {
case SEND_MESSAGE:
return "";
default:
return state;
}
}
现在我们是在处理这个action类型SEND_MESSAGE
,不过返回的是一个空字符串。
这不是我们想要的,但我们会从这里开始构建。 与此同时,你认为在这里返回一个空字符串的结果是什么?下面我展示给你看看。
所有消息都消失了!这是为什么呢?是因为只要我们回车,就发送了一个SEND_MESSAGE
action。只要这个action达到reducer,reducer就返回一个空字符串""
。
因此,state对象就没有消息了。一切都消息了!这显然是不可接受的。
我们想要的是所有消息都保存在state
中。不过,我们只想把新消息添加给活动用户的消息。
但是该怎么做呢?
别忘了,每个用户都有映射给他们ID的消息。所有我们要做的是对准这个ID,只更新这里的消息。
下面我们把要做的事情用图形化的方式表示出来:
看看上图中的控制台。这个图假设用户提交了三次表单输入文本Hi
。那么,文本Hi
就应该在不同联系人的聊天对话中显示三次。
现在,看看控制台。它会让你了解我们在代码解决方案中的目标。
在这个应用程序中,每个用户有10条消息。每条消息有一个范围从0
到9
的数字。因此,只要用户提交了一条新消息,我们就要添加一个带有递增数字的新message
对象。在上图的控制台中,你会注意到递增的数字10
、11
和12
。同时,消息的结构保持相同,有number
、text
和is_user_msg
字段。
{
number: 10,
text: "the text typed",
is_user_msg: true
}
is_user_msg
对于这些消息总是为true,因为它们是来自用户!
现在,我们用一些代码来表示出来。我会很好解释一下,因为代码开始会看起来有点复杂。
总之,如下是message
reducer的switch
块中的表示:
switch (action.type) {
case SEND_MESSAGE:
const { message, userId } = action.payload;
const allUserMsgs = state[userId];
const number = +_.keys(allUserMsgs).pop();
return {
...state,
[userId]: {
...allUserMsgs,
[number]: {
number,
text: message,
is_user_msg: true
}
}
};
default:
return state;
}
我们一行一行解释。
在case SEND_MESSAGE:
之后,我们保存了一个对从action传入的message
和userId
的引用:
const {message, userId } = action.payload
继续,获取活动用户的消息也很重要。这在下一行实现:
const allUserMsgs = state[userId];
我们已经知道,这里的state
并非整个应用程序的state对象。它只是该reducer所管理的messages
字段的那一部分state。
由于每个联系人的消息都用其用户ID进行映射,因此上面的代码将获取从该action传入的特定用户ID的消息。
现在,每条消息都有一个number
,这个数字就充当了唯一ID。为让到来的消息有一个唯一的ID,_.keys(allUserMsgs)
会返回该用户的消息的所有键的一个数组。
好吧,我解释一下。_.keys
就如同Object.keys()
。唯一的区别是我在用Lodash
。如果你想的话,也可以用Object.keys()
。
此外,allUserMsgs
是一个包含所有用户消息的对象。它看起来是这样的:
{
0: {
number: 0,
text: "first message"
is_user_msg: false
},
1: {
number: 0,
text: "first message"
is_user_msg: false
}
}
这会继续,直到第10条消息!
当我们做_.keys(allUserMsgs)
或者Object.keys(allUserMsgs)
时,这会返回所有键的一个数组。像这样的:
[ 0, 1, 2, 3, 4, 5]
Array.pop()
函数被用户获取数组中最后一个元素。这是联系人的消息中已经存在最大数字。有点像联系人的最后一个消息ID。一旦获取了这个数字,我们就对它加1
,确保新消息比可用消息的最大数字加1
。如下是负责这个操作的代码:
const number = +_.keys(allUserMsgs).pop();
如果你想知道为什么在_.keys(allUserMsgs).pop()
前面有一个+
,这是为了确保结果被转换为一个数字而不是字符串。
就是这样!
转到return代码块:
return {
...state,
[userId]: {
...allUserMsgs,
[number]: {
number,
text: message,
is_user_msg: true
}
}
};
仔细看看,我要确保你能搞懂它的意思。
...state
将确保我们不会混淆应用程序中以前的消息。
因为我们使用的是对象符号,所以我们可以使用[userID]
轻松获取具有特定用户ID的消息。
在对象内部,我们确保所有用户的消息都是未触及的:... allUserMsgs
。
最后,我们用之前算出的数字添加新的消息对象!
[number]: {
number,
text: message,
is_user_msg: true
}
它可能看起来很复杂,但事实并非如此。希望你有从React开发中获得这种非变异状态计算的经验。
还是糊涂的?
再看看return语句。这次使用一些彩色代码。这可能有助于为代码注入活力:
而这是输入时更新对话的结束!
我们只做了一些调整。
调整一下,让聊天体验更自然
如下是我输入Hello!
,并提交三次时,事情的当前状态:
你会很快注意到两个问题:
- 即使提交了输入内容,并且将这些消息正确添加到对话中,我也必须向下滚动以查看消息。这不是聊天应用程序的工作方式。聊天窗口应该自动向下滚动。
- 提交时清楚输入值是很好的。这样用户可以立即获得他们的输入已提交的反馈。
第二个问题很容易修复。我们就从这里开始。
我们已经发送了一个SEND_MESSAGE
action。我们可以监听这个action,并在typing.js
reducer中清除输入值。下面我们就把它搞定。
在typing.js
reducer的switch块中添加如下内容:
case SEND_MESSAGE:
return "";
整个代码就变成:
reducer/typing.js
import { SET_TYPING_VALUE, SEND_MESSAGE } from "../constants/action-types";
export default function typing(state = "", action) {
switch (action.type) {
case SET_TYPING_VALUE:
return action.payload;
case SEND_MESSAGE:
return "";
default:
return state;
}
}
现在,只要action到了这里,typing
值就会被清除,并返回一个空字符串。
有用!正如所料,输入值现在被清除。
好的,下面我们要确保聊天窗口在更新时滚动。
为此,我们需要一些DOM操作。这就是我坚持让<Chats />
变成类组件的原因。
OK,下面我们谈谈代码。
首先,我们需要创建一个Ref
保存Chats的DOM节点。
constructor(props) {
super(props);
this.chatsRef = React.createRef();
}
如果你对React.createRef()
不熟悉的话,这很正常。这是因为React 16引入一种创建Refs的新方法。
我用this.chatsRef
保存对这个Ref
的一个引用。
在DOM渲染中,然后我们像下面这样更新这个ref:
<div className="Chats" ref={this.chatsRef}>
现在我们有了一个对保存所有聊天对话的div
的引用。
下面我们来确保在更新时,这个会总是滚动到底部。这就要用到生命周期方法!
componentDidMount() {
this.scrollToBottom();
}
componentDidUpdate() {
this.scrollToBottom();
}
所以,一旦该组件挂载,我们就调用一个scrollToBottom()
函数。每当应用程序更新时,我们也会这样做!
scrollToBottom()
函数的代码如下:
scrollToBottom = () => {
this.chatsRef.current.scrollTop = this.chatsRef.current.scrollHeight;
};
我们在所的是更新scrollTop
属性以匹配scrollHeight
。
没那么难。this.chatsRef.current
引用正在谈论的DOM节点。
如下是此时Chats.js
中的全部代码:
...
class Chats extends Component {
constructor(props) {
super(props);
this.chatsRef = React.createRef();
}
componentDidMount() {
this.scrollToBottom();
}
componentDidUpdate() {
this.scrollToBottom();
}
scrollToBottom = () => {
this.chatsRef.current.scrollTop = this.chatsRef.current.scrollHeight;
};
render() {
return (
<div className="Chats" ref={this.chatsRef}>
{this.props.messages.map(message => (
<Chat message={message} key={message.number} />
))}
</div>
);
}
}
export default Chats;
嘿! 我们让Skypey按预期工作了!
如下是一个演示。请注意滚动位置在组件装入后如何更新,并且键入消息时,组件也会更新。
棒极了!
总结
以下是我们迄今为止学到的一些内容的总结:
- 在写代码之前,始终规划应用程序开发过程是一种很好的做法。
- 在你的state对象中,要不惜任何代价避免嵌套实体。保持state对象规范化。
- 将state的字段存储为对象确实有一些优点。同样,要意识到使用对象的问题,主要是缺乏顺序。
- 如果您选择在state对象内使用对象而不是数组,那么用
lodash
工具库会非常方便。 - 无论多少,总是需要一些时间来设计应用程序的state对象。
- 使用Redux时,你不必总是向下传递props。可以直接从store访问状态值。
- 在你的Redux应用程序中始终保持一个整洁的文件夹结构,比如让所有主要的Redux角色都放在自己的文件夹中。除了优雅的整体代码结构之外,还可以让其他人可以更容易地协作您的项目,因为他们可能熟悉相同的文件夹结构。
- Reducer组合非常棒,特别是随着您的应用程序的增长。这增加了可测试性,并减少了难以追踪错误的趋势。
- 对于reducer组合,请使用来自
redux
库的combineReducers
。 - 传入
combineReducers
函数的对象被设计为类似于应用程序的状态,每个值都来自相关的reducer。 - 总是将更大的组件分解成更小的可管理组件。
练习
这里创建完成的Skypey应用程序并非全部。你还有两个任务。
- 扩展我们构建的Skypey应用程序,以处理编辑用户的消息,如下所示。
- 扩展我们构建的Skypey应用程序,以处理删除用户的消息。 如下所示。