使用 React 创建一个聊天应用
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
聊天是大多数交互式应用程序的关键组成部分。从一对一约会应用、群聊到聊天机器人,实时通信是任何多用户应用的基本需求。如果从一开始就选择合适的框架和基础架构,集成此功能将更加顺畅。在本教程中,我们将向您展示如何做到这一点——使用React、Material-UI和PubNub创建一个聊天窗口。
我们的应用将允许任何人通过任何他们想用的频道进行实时连接和聊天。我们将使用 React 框架和 Material-UI 组件从零开始构建这个聊天系统。PubNub API 用于处理消息的发送和接收。这三者将帮助我们打造一个现代化且快速的聊天平台。
本教程还将用到Hooks,这是一种编写 React 组件的新方法,可以减少冗余代码并组织相关部分。稍后我会详细解释为什么要使用这些新特性以及如何使用它们。完成本教程后,我们将拥有一个聊天室,任何拥有频道名称的用户都可以互相交流。频道名称会显示在 URL 和页面上,因此分享频道非常方便!
发布/订阅和检索历史记录
PubNub 提供了一个简单且速度极快的消息发送基础架构。它能够连接全球几乎无限数量的用户或系统,传输时间不到四分之一秒。PubNub提供丰富的 SDK,甚至还有一个专注于聊天功能的资源中心,可以满足您的各种使用场景。在本应用中,我们将使用发布/订阅机制进行实时消息传递,并使用存储和回放机制来保存消息。
发布功能让我们能够向特定渠道上的听众发送消息。了解如何在 React 中发布内容。
订阅是我们告诉 PubNub 我们想要接收发送到特定频道的消息的方式。了解如何在 React 中实现订阅。
存储与回放功能意味着用户无需订阅即可接收频道消息。当用户连接时,我们可以检索他们最近的消息供其查看!了解如何在 React 中存储和回放消息。
入门
在这个聊天示例中,我们只需要使用一个 API 即可实现所有聊天功能。您需要创建一个 PubNub 帐户,如果您已有帐户,则需要登录。
首先,在管理后台获取您的专属发布/订阅密钥,然后在密钥选项页面左下角启用“存储和播放”功能。我将消息保留时间设置为一天,但您可以根据自身需求选择合适的保留时间。请务必保存更改。
现在一切就绪,我们可以开始设置我们的 React 项目了。
如何安装 React.js 和 PubNub
要安装 React.js 和 PubNub,首先需要确保已安装 Node.js 和 npm。您可以从Node.js 官方网站进行安装。如果您已经安装了它们,请在终端中输入命令,确保 npm 版本高于 5.2 npm -v。现在我们已经有了创建 React 应用和安装 PubNub SDK 所需的包管理器。
安装好 Node.js 后,运行以下命令创建项目并安装必要的模块。请耐心等待 React 构建您的网站!完成后,第二行命令将安装 PubNub。第三行命令将安装我们的样式框架 Material-UI。
npx create-react-app <your-app-name>
npm install --save pubnub
npm install @material-ui/core
现在我们已经准备好开始编写代码了!npm start在终端中输入命令,运行完成后点击它提供的链接,你应该会看到一个空白的 React 页面!让我们开始编写代码吧!
为什么要使用 React Hooks?
在 2018 年 10 月之前,你必须使用类组件来存储局部变量。Hooks 的出现使我们能够在函数组件内部保存状态,并且 Hooks 还消除了类组件带来的许多冗余代码。
Hooks 让开发大型应用程序变得更加容易,它的函数可以帮助我们将类似的代码分组在一起。我们根据组件的功能和执行时间来组织组件中的逻辑。我们放弃了像 componentDidMount 和 componentDidUpdate 这样常用的生命周期函数,转而使用 useEffect。
useEffect 是我们主要使用的两个 Hook 之一,另一个是 useState。useState 是 setState 的新版本,但工作方式略有不同。React Hooks 文档详细介绍了更多 Hook,但 Hooks 的另一个优点是我们可以创建自定义 Hook!这样可以利用已有的代码,节省时间和代码量。
接下来几节我将向您展示如何创建自己的钩子函数,以及如何使用 useEffect 和 useState!
创建自定义 React Hook
我们先来创建一个自定义钩子,这样以后就能简化一些代码了。与其为每个输入单独创建 onChange 函数,不如现在就把所有相关的功能都整合到一个钩子里!
如果你查看我们创建的项目文件夹,你会看到里面有几个不同的文件夹。进入“src”文件夹,并在其中创建一个名为“useInput.js”的新文件。Hooks 的规则规定所有 Hook 都必须以“use”开头。规则还规定 Hook 只能在顶层使用,因此我们不能在函数、条件语句或循环中使用它们。我们也不能从普通的 JS 函数中调用它们,只能在 React 函数组件和自定义 Hook 中使用!现在我们了解了 Hooks 的基本规则,让我们来创建一个示例吧!
我们将通过这个钩子使用 useState 钩子。在react文件顶部导入 useState,然后创建一个名为 useState 的函数useInput。
import { useState } from 'react';
function useInput()
{
//Define our Hook
}
这里我们可以稍微灵活运用一下语法。我们可以使用解构赋值,仅用一行代码就能接收 useState 返回的两个对象。但是 useState 返回的是什么呢?它实际上返回了一个 getter 和一个 setter,一个包含值的变量,以及一个用于设置该值的函数!这样,this.state.xxxxx我们就无需再通过 `state` 来访问状态,只需使用其名称即可。
let [value, setValue] = useState('');
创建一个函数表达式,并将其赋值给我们之前创建的新变量 onChange。我们将“event”作为参数传递给该函数,并在函数内部将状态值设置为事件目标的值。之后,我们返回这三个变量/函数:value、setValue 和 onChange。
let onChange = function(event){
setValue(event.target.value);
};
return {
value,
setValue,
onChange
};
最后export default useInput;,我们完成了文件的编写,使其可供我们的主应用程序使用!
设计我们的 React 组件
现在我们的 Hook 已经完成了。接下来让我们设置 App.js 文件!我们需要在文件顶部导入几个关键文件:React 和我们需要的两个默认 Hook、我们刚刚创建的 useInput Hook、App.css 文件、PubNub 以及 Material-UI 组件。
请将 App.css 文件中的内容替换为以下内容。
* {
margin: 0;
padding: 0;
}
body {
width: 500px;
margin: 30px auto;
background-color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
.top {
display: flex;
flex-direction: row;
justify-content: space-between;
}
让我们利用功能组件的标题来勾勒出聊天界面的轮廓。这将帮助我们确定聊天界面的设计和流程。我选择了三个不同的组件:应用、日志和消息。
应用程序包含日志、输入框和提交按钮。日志保存消息列表,消息框显示消息内容和发送者。请确保在文件开头导入所需的模块!
//These are the two hooks we use the most of through this Chat
import React, { useState, useEffect} from 'react';
//has a few css stylings that we need.
import './App.css';
//This is a hook we created to reduce some of the bloat we get with watching inputs for changes.
import useInput from './useInput.js';
//Lets us import PubNub for our chat infrastructure capabailites.
import PubNub from 'pubnub';
//Material UI Components
import {Card, CardActions, CardContent,List, ListItem,Button,Typography,Input} from '@material-ui/core';
// Our main Component, the parent to all the others, the one to rule them all.
function App(){
//Bunch of functions!
//return()
}
//Log functional component that contains the list of messages
function Log(props){
//return()
}
//Our message functional component that formats each message.
function Message(props){
//return()
}
每个组件都包含一个返回函数,使我们能够设计每个组件的具体实现方式。我们可以决定从父组件向子组件传递哪些信息。通过这种设计,我们只向下传递信息,确保每个组件都能获得正常运行所需的信息。
设置应用组件:使用 React Hooks 实现状态
我们的 App 是我们主要的 React 聊天组件。对于这个组件,我们需要设置一些东西,例如检查 URL 是否有频道更改、设置状态,然后我们可以编写一些 useEffect 函数来决定 App 要执行的操作以及这些操作何时发生。
应用内的第一个操作是创建默认频道。“全局”就是一个不错的选择。然后检查 URL 中是否存在频道。如果没有,我们可以保留默认设置;如果存在,则将默认频道设置为该频道。
let defaultChannel = "Global";
//Access the parameters provided in the URL
let query = window.location.search.substring(1);
let params = query.split("&");
for(let i = 0; i < params.length;i++){
var pair = params[i].split("=");
//If the user input a channel then the default channel is now set
//If not, we still navigate to the default channel.
if(pair[0] === "channel" && pair[1] !== ""){
defaultChannel = pair[1];
}
}
让我们先定义状态及其初始值。使用 `useState` 获取通道的 getter 和 setter 方法,并确保将默认通道设置为其初始值。对消息数组执行相同的操作,但将其初始化为空数组。
我还根据当前时间设置了一个通用用户名。接下来,为我们创建的新钩子设置了一个临时频道和消息变量。好了,我们的应用状态就设置好了。
const [channel,setChannel] = useState(defaultChannel);
const [messages,setMessages] = useState([]);
const [username,] = useState(['user', new Date().getTime()].join('-'));
const tempChannel = useInput();
const tempMessage = useInput();
React 中的 useEffect
接下来,我们要用到大家都在讨论的全新 useEffect。它基本上替换并重组了之前未使用 hooks 时的所有旧生命周期方法。除非我们指定一个变量数组作为第二个参数,否则每个函数都会在每次重新渲染时运行。每次这些变量发生变化时,useEffect 都会重新运行。
请注意:这只是一个浅层的相等性检查。数字和字符串每次被设置为其他类型时都会被视为不同的值,但 useEffect 只检查对象的指针,而不检查它们的属性。
我们可以创建多个这样的函数,只需确保每个函数的第二个参数各不相同即可。本质上,每个 useEffect 都根据其依赖项进行分组,因此具有相似依赖关系的操作会一起运行。
useEffect(()=>{
//Put code we want to run every time these next variables/states change
},[channel, username]);
在 React 中设置 PubNub
现在我们已经了解了这个新 Hook 的工作原理,下一步就是创建一个新的 PubNub 对象!打开 PubNub,获取我们之前生成的发布键和订阅键,并将它们添加到你的新对象中。你还可以为这个连接设置一个 UUID,可以是 IP 地址、用户名、生成的 UUID,或者任何你用例定义的唯一标识符。为了简单起见,我将其设置为用户名。
const pubnub = new PubNub({
publishKey: "<ENTER-PUB-KEY-HERE>",
subscribeKey: "<ENTER-SUB-KEY-HERE>",
uuid: username
});
在对象填充了连接信息之后,让我们添加一个PubNub 事件监听器!这有助于检测新消息、新连接或状态,以及处理在线状态事件。我们的应用不使用在线状态,也不需要创建状态监听器,但我至少想实现状态功能并记录一些结果。我们的应用真正需要的是接收和处理传入消息的能力,所以让我们来定义这个功能!
检查消息文本是否为空,如果不是,则创建一个新的消息对象。将消息数组设置为其当前状态,并连接新收到的消息。箭头函数确保我们使用的是消息的当前状态,而不是初始渲染时的状态。
pubnub.addListener({
status: function(statusEvent) {
if (statusEvent.category === "PNConnectedCategory") {
console.log("Connected to PubNub!")
}
},
message: function(msg) {
if(msg.message.text){
let newMessages = [];
newMessages.push({
uuid:msg.message.uuid,
text: msg.message.text
});
setMessages(messages=>messages.concat(newMessages))
}
}
});
订阅您所在州的频道将是我们首次连接到 PubNub 服务器!如果 Presence 对您的使用场景很重要,您可以在这里启用它。使用 PubNub React SDK 的 Presence 功能,即可了解频道中的用户。
pubnub.subscribe({
channels: [channel]
});
聊天记录是任何聊天系统的关键特性,所以我们来提取一些消息来构建聊天日志。首次连接到频道时,使用 history 函数检索已存储的消息。利用响应访问旧消息,并将它们存储在一个临时数组中。由于我们的数组应该为空,我们可以将这些旧消息添加到状态的空消息数组中。
pubnub.history({
channel: channel,
count: 10, // 100 is the default
stringifiedTimeToken: true // false is the default
}, function (status, response){
let newMessages = [];
for (let i = 0; i < response.messages.length;i++){
newMessages.push({
uuid:response.messages[i].entry.uuid ,
text: response.messages[i].entry.text
});
}
setMessages(messages=>messages.concat(newMessages))
});
useEffect 的另一个强大之处在于,我们可以定义一个行为,在它再次运行时关闭所有服务!让我们返回一个名为“cleanup”的函数,并在其中取消订阅所有频道,并将消息设置为另一个空数组。
return function cleanup(){
pubnub.unsubscribeAll();
setMessages([]);
}
出版/订阅:出版
我们已经订阅了一个频道,但还没有发布内容。与之前 useEffect 中的 PubNub 功能不同,我们希望在用户发送消息时发布内容。让我们创建一个名为 publishMessage 的函数,用于将消息发布到我们的频道。
创建函数并检查临时消息中是否有内容。如果有,则创建消息对象!我同时包含了消息内容和用户名,这样无论从哪个设备访问消息,我们都能知道是谁发送的。首先,创建一个与上一个完全相同的 PubNub 对象。调用该对象的 publish 方法,并将新消息和频道作为参数传入。
发送消息后,清除临时消息状态。这样用户就可以根据需要发送另一条消息。目前我们还没有任何代码调用这个函数,所以它不会触发,但我们定义的下一个函数会触发它!
function publishMessage(){
if (tempMessage.value) {
let messageObject = {
text: tempMessage.value,
uuid: username
};
const pubnub = new PubNub({
publishKey: "<ENTER-PUB-KEY-HERE>",
subscribeKey: "<ENTER-SUB-KEY-HERE>",
uuid: username
});
pubnub.publish({
message: messageObject,
channel: channel
});
tempMessage.setValue('');
}
}
创建 React 事件处理程序
创建流畅的用户交互体验至关重要。让我们创建一个处理程序,让用户可以通过“回车”键提交消息或切换频道。我们将创建一个名为 handleKeyDown 的函数,该函数接收一个事件对象。
function handleKeyDown(event){
//Handling key down event
}
进入这个函数后,我们的目标是找出触发此事件的原因。稍后创建输入框时,我们会为它们设置 ID。首先检查事件目标的 ID。如果是“messageInput”,则进一步检查按下的键是否为“Enter”。如果是,则调用 publishMessage 函数。
if(event.target.id === "messageInput"){
if (event.key === 'Enter') {
publishMessage();
}
}
像上一个 else if 语句一样,先进行相同的检查,但这次使用channelInputID 作为参数。创建一个常量值来保存临时通道,但务必去除开头和结尾的空格。如果这里只调用 setChannel,则无需检查新旧通道是否相同。
由于我们还要将当前 URL 更改为我们创建的 URL,因此需要进行检查,以免出现不必要的重复。创建一个包含新频道名称的新 URL 字符串,也方便用户分享页面链接。最后,将临时频道的状态设置为空字符串。
else if(event.target.id === "channelInput"){
if (event.key === 'Enter') {
//Navigates to new channels
const newChannel = tempChannel.value.trim()
if(newChannel){
if(channel !== newChannel){
//If the user isnt trying to navigate to the same channel theyre on
setChannel(newChannel);
let newURL = window.location.origin + "?channel=" + newChannel;
window.history.pushState(null, '',newURL);
tempChannel.setValue('');
}
}
//What if there was nothing in newChannel?
}
如果用户在输入框中输入了频道,这当然很好,但如果他们没有输入呢?我们可以提醒他们输入错误,让他们停留在当前频道,或者将他们引导至我们指定的默认频道。我选择了最后一个选项,将他们引导至“全局”。执行与之前相同的检查,但这次使用“全局”,然后将频道设置为该频道。
我们创建一个新的 URL 并将其添加到页面历史记录中,就像之前一样,但这次不带任何参数。我们在应用开头添加的代码会识别到这个 URL 并使用默认通道。同样,将临时通道设置为空字符串,并确保将此代码片段放在最后一个花括号之前。
else{
//If the user didnt put anything into the channel Input
if(channel !== "Global"){
//If the user isnt trying to navigate to the same channel theyre on
setChannel("Global");
let newURL = window.location.origin;
window.history.pushState(null, '',newURL);
tempChannel.setValue('');
}
}
我们将当前 URL 添加到浏览器的后退按钮历史记录中,以便用户可以通过该按钮返回之前的频道。为了让聊天功能真正支持使用后退按钮在之前的频道之间来回切换,我们还需要做一些其他设置。
在之前的频道之间切换
现在我们已经为 React 聊天室设置好了所有功能,接下来让我们添加一个页面重新渲染的功能。当用户在页面之间点击后退或前进按钮时,我们将改变页面状态,而不是重新加载页面。
创建一个名为 goBack 的函数,该函数检查 URL 中是否存在频道,并将频道状态设置为“Global”或找到的频道。除非我们在页面上添加事件监听器,否则此函数不会运行!
function goBack() {
//Access the parameters provided in the URL
let query = window.location.search.substring(1);
if(!query){
setChannel("Global")
}else{
let params = query.split("&");
for(let i = 0; i < params.length;i++){
var pair = params[i].split("=");
//If the user input a channel then the default channel is now set
//If not, we still navigate to the default channel.
if(pair[0] === "channel" && pair[1] !== ""){
setChannel(pair[1])
}
}
}
}
我们只想在页面加载时添加监听器,并在页面离开时移除它。这听起来像是 useEffect hook 的另一个用途!再创建一个 useEffect hook,并将一个空数组作为第二个参数传入。现在,它只会在聊天页面首次加载时运行一次,而不会在每次页面重新渲染时都运行。
在我们的“window”元素上创建一个事件监听器,并返回一个清理函数来移除该监听器。该事件监听器将等待“popstate”事件,即用户点击浏览器中的后退/前进按钮的事件。将我们之前创建的函数“goBack”放在事件名称之后。现在,我们的页面不会重新加载,而是在需要时重新渲染所需的内容!
useEffect(() => {
window.addEventListener("popstate",goBack);
return function cleanup(){
window.removeEventListener("popstate",goBack);
}
},[]);
使用 JSX 创建 React UI
现在后端所需的逻辑已经全部完成,接下来让我们构建一个简洁而现代的前端!为此,我们将使用JSX,一种 JavaScript UI 描述语言。它允许我们在称为组件的组中使用自定义变量和对象。它的语法看起来有点像带有模板引擎的 HTML,但它本质上是 JSX!
当变量/状态发生变化时,任何使用它的组件都会使用新值重新渲染。这使得我们的应用响应速度更快,一旦发生变化,它就能立即更新。因此,将 PubNub 和 React 结合使用是一个绝佳的方案。PubNub 能够快速传递消息,而 React 则通过更新其组件来保持响应速度!
应用设计
现在我们来设计 App 组件。Material-UI 为我们提供了精美的组件,我们可以直接使用并填充我们自己的信息。请参考以下设计,我们将逐一讲解各个区域调用的函数。
return(
<Card >
<CardContent>
<div className="top">
<Typography variant="h4" inline >
PubNub React Chat
</Typography>
<Input
style={{width:'100px'}}
className="channel"
id="channelInput"
onKeyDown={handleKeyDown}
placeholder ={channel}
onChange = {tempChannel.onChange}
value={tempChannel.value}
/>
</div>
<div >
<Log messages={messages}/>
</div>
</CardContent>
<CardActions>
<Input
placeholder="Enter a message"
fullWidth={true}
id="messageInput"
value={tempMessage.value}
onChange={tempMessage.onChange}
onKeyDown={handleKeyDown}
inputProps={{'aria-label': 'Message Field',}}
autoFocus={true}
/>
<Button
size="small"
color="primary"
onClick={publishMessage}
>
Submit
</Button>
</CardActions>
</Card>
);
这里看起来设计元素很多,但实际上是由几个不同的元素组合而成的。
首先,我们将标题放在一个 Typography 组件中。之后,在同一个 div 元素中放置频道输入框。输入框包含许多属性,用于定义它可以执行的操作。这些属性包括 ID、onKeyDown 事件处理函数、占位符、onChange 事件处理函数以及值。
它还包含用于引用其样式的区域。在该 div 之后,是我们的日志组件,这是另一个我们尚未创建的函数式组件。日志组件接收消息数组,并在数组每次更改时重新渲染。在日志组件之后,我们可以添加另一个输入框和一个按钮。用户可以通过输入框创建消息。我们会用相应的状态和变量填充它的属性。
我们还将其设置为自动聚焦。将按钮的 onClick 事件设置为我们的发布消息函数,以便用户可以通过另一种方式发送消息。至此,我们的 App 组件就完成了,后端也已编写完毕。接下来,我们需要创建两个小型组件来显示消息。
日志和消息设计
我们的应用程序定义了聊天功能的大部分运作方式,但还需要两个组件才能完善它。这两个组件都返回 JSX 代码,并负责组织消息的显示方式。第一个组件 Log 会显示一个填充了 Typography 的 ListItem 列表。这些 ListItem 会遍历消息映射表并输出一个 Message 对象。Message 对象的键是数组中的索引,键是消息的 UUID,键是消息的文本。
function Log(props) {
return(
<List component="nav">
<ListItem>
<Typography component="div">
{ props.messages.map((item, index)=>(
<Message key={index} uuid={item.uuid} text={item.text}/>
)) }
</Typography>
</ListItem>
</List>
)
};
Message 组件代表一条消息,它是一个 div 元素,其中包含 UUID 和文本,两者之间用冒号分隔。我们的 App 组件的子组件通过 props 访问这些消息。它们不能编辑或更改消息,只能读取和显示传递给它们的 props。
现在我们已经完成了组件的定义,接下来在文件末尾导出应用程序。index.js 中的代码会将我们的应用程序渲染到网页上!npm start在项目文件夹中运行程序,然后在浏览器中访问localhost:3000,我们就可以看到应用程序正在运行了!
function Message(props){
return (
<div >
{ props.uuid }: { props.text }
</div>
);
}
export default App;
我们成功开发了一款应用,用户可以选择在自己喜欢的频道进行聊天。快来体验一下吧!完整的代码库也在这里。
接下来是什么?
现在基本消息功能已经实现,是时候添加更多功能了!前往我们的聊天资源中心,探索新的教程、最佳实践和设计模式,将您的聊天应用提升到一个新的水平。
文章来源:https://dev.to/pubnub/create-a-chat-app-in-react-5bb3