接连着昨日的年轻莽撞,今天继续研究如何去制作一个类Slack、Discord的网页聊天室App。

其实这篇文在1月23日开始起草的,然后写代码写着写着就忘了写文。

再加上近期加入了一个新的项目,自己的项目不得不搁置一下。

前端

页面设计的工作我交给了reactstrap包,其实用react-bootstrap包、或者干脆直接引入Bootstrap的CSS文件都是可以的。

1
npm install reactstrap

仿制Discord的登录&注册页面还是相当容易的:

登录页面

注册页面

这里就不说写页面的具体细节,只挑几个我花了时间去搞的地方说。

1. 卡片居中

居中,是前端界最老生常谈的话题之一。浏览器上搜索“居中”一词,会发现十年前大家在聊怎么居中,几年后在聊怎么居中,现在还有GitHub网页上出现没有好好居中的标签。

Discord的登录/注册页面的设计方案很简单,正中间一个卡片,上面嘎嘎放表单即可。

我的实现:

1
2
3
<Container className='d-flex vh-100'>
<Row className='m-auto align-self-center'> ... </Row>
</Container>

这里推荐一下微软近期出的强力工具:PowerToys,用快捷键 Windows + Shift + C 就可以在屏幕上吸色了,吸的RGB值正好用来给我们的标签添加颜色样式。

2. 生日日期选择

这个其实就是:

1
2
3
4
5
6
7
<Container>
<Row xs='3'>
<Col className='ps-0 pe-1'></Col>
<Col className='px-1'></Col>
<Col className='ps-1 pe-0'></Col>
</Row>
</Container>

不过重点不在这里,而在JSX中用.map()方法生成下拉选项。

先看一眼年份的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const currentYear = new Date().getFullYear();
const years = [];
for (let i = 0; i < 100; i++) {
years.push(currentYear - i);
}

// ...
<Input>
{years.map(
year => (
<option key={year}>{year}</option>
)
)}
</Input>

其实可以再简化一些,不过能看就行!

月份的逻辑相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'November', 'December'
];

// ...

<Input>
{months.map(
month => (
<option key={month}>{month}</option>
)
)}
</Input>

日期就更简单了:

1
2
3
4
5
6
7
<Input>
{Array.from({ length: 31 }, (_, i) => i + 1).map(
day => (
<option key={day} style={{ color: 'rgba(117,122,129)' }}>{day}</option>
)
)}
</Input>

看着没做每个月内有多少天的逻辑对吧?其实Discord也是这样设计的。这种逻辑交给后端就好啦~

不过我研究了会儿都没实现出来Discord的效果:用户还未选择时,年月份三个下拉框都默认显示“年”/“月”/“日”。

3. 数据处理

今日的重头戏。用户在注册时这些数据总要传到服务端去的吧?今天就是来解决这个的。

先装包:

1
npm install react-redux @reduxjs/toolkit

Redux的知识点可以去看我之前的一篇关于React的文章。Redux的概念可以用三个东西来概括:Action、Store和Reducer。每当用户与某个组件交互时就会触发Action(比方说点击按钮),接着Action会携带着数据去往Store进行存储,途中遇到Reducer、状态被按照我们要求的进行了更改,最终回到Store这个大仓库手里。

为什么我们要使用Redux呢?如果我们需要在客户端向服务端发送数据,Redux可以更好地帮我们管理这些数据,并且Store还是全局的,在任一组件中我们都可以访问Store中的数据。

我们可以用@reduxjs/toolkit包配置Redux Store。新建一个文件store.js

1
2
3
4
5
import { configureStore } from "@reduxjs/toolkit";

const store = configureStore({ reducers: {} });

export default store;

不过在完成这段代码之前,我们需要初始化状态。我们的状态需要什么样的数据存储其中?作为一个可以登陆注册的网页App,我们需要存储用户的登录状态、用户的信息、错误信息等等。这些存储的动作都需要一个Reducer来完成。

Redux官网中在文档里使用了createSlice方法来创建State Slice。

新建一个文件authSlice.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createSlice } from "@reduxjs/toolkit";

export const authSlice = createSlice({
name: "auth",
initialState: {
isAuthenticated: false,
user: {},
error: null
},
reducers: {
setCurrentUser: (state, action) => {
state.isAuthenticated = true;
state.user = action.payload;
},
setError: (state, action) => {
state.error = action.payload;
}
}
});

export const { setCurrentUser, setError } = authSlice.actions;
export default authSlice.reducer;

这下我们可以完成store.js了:

1
2
3
4
5
6
7
8
9
import { configureStore } from "@reduxjs/toolkit";

import authSlice from "./reducers/authSlice";

export default configureStore({
reducer: {
auth: authSlice
}
});

Store和Reducer都有了,自然少不了Action。

Socket.io的连接逻辑我会在下一篇文章中讲解,这里我们只需要知道,当用户点击注册按钮时,我们需要将用户的信息发送到服务端。这个过程就是一个Action。

目前我们只需要验证用户信息是否合法,所以只写了一个Action:authActions.js

验证用户信息需要使用到Socket.io来和服务端进行通信:

1
2
3
import socketIO from "socket.io-client";

const socket = socketIO.connect("http://localhost:4000");

此处假设服务端的端口是4000。

4. 注册和登录

这两个页面的逻辑是一样的,都是用户输入信息后点击按钮,触发Action,将用户信息发送到服务端。

在Socket.io的连接逻辑中,用户点击按钮后、信息会在客户端中被发送到服务端,服务端会对用户信息进行验证,如果验证通过,服务端会返回一个Token给客户端,客户端将Token存储到Store中;如果验证不通过,服务端则会返回一个错误信息给客户端。

这里只讲一下注册页面的逻辑。

1
2
3
4
5
6
import { useState } from "react";

import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";

import { registerUser } from "./utils/actions/authActions";

useState 是React的一个Hook,用于在函数组件中使用状态。

useNavigate 是React Router的一个Hook,用于在函数组件中进行页面跳转。

registerUser 是等会儿我们会定义的Action,先不写。

1
2
3
4
5
6
7
8
9
10
11
12
const Register = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const [userData, setUserData] = useState({
username: "",
emailAddress: "",
password: "",
birthYear: "",
birthMonth: "",
birthDay: ""
});
}

useState 的用法是这样的:接受一个参数作为状态的初始值,比方说我们这里是一个字典,包含着注册页面中所有输入框的值。它会返回一个数组,第一个元素代表着状态的当前值,第二个元素代表着一个函数,用于更新状态。

每当用户输入信息时,我们都应该更新状态。React提供了一个 onChange 事件供我们使用:

1
2
3
4
5
6
7
8
9
10
const handleChange = e => {
setUserData({
...userData,
[e.target.name]: e.target.value
});
};

// ...

<Input onChange={ handleChange } />

每次用户输入信息时,handleChange 函数都会被触发,而 handleChange 函数会调用 setUserData 函数,更新状态。

表单被用户提交后,我们也需要触发一个Action来发送用户信息到服务端:

1
2
3
4
5
6
7
8
const handleSubmit = e => {
e.preventDefault();
dispatch(registerUser(userData, navigate));
};

// ...

<Form onSubmit={ handleSubmit } />

useDispatch 是React Redux的一个Hook,用于在函数组件中传递Action。

那么 registerUser 方法是怎么写的呢?

1
2
3
4
5
6
7
8
9
const registerUser = (userData, navigate) => {
return dispatch => {
socket.emit("register", userData);

socket.on("newRegisteredUser", data => {
data.status === "00000" ? navigate("/login") : console.log(data.message);
});
}
};

socket.emit 用于发送数据到服务端;socket.on 用于接收服务端返回的数据。

registerUser 方法会先发送用户填写的信息到服务端,接着监听服务端返回的数据。如果服务端返回的数据中 status00000,则说明注册成功,我们就跳转到登录页面;如果不是,我们就在控制台打印出服务端返回的错误信息。

而在服务端中,我们需要接受名为 register 的事件、验证用户填写的信息,然后发送 newRegisteredUser 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
socket.on("register", async userData => {
const existingUser = await User.findOne({
emailAddress: userData.emailAddress,
username: userData.username
});
if (existingUser) {
console.log(`[U0102] User already exists: ${userData.username}`);
socket.emit("newRegisteredUser", {
status: "U0102",
message: "User already exists."
});
return;
}

await User.create({
emailAddress: userData.emailAddress,
username: userData.username,
password: userData.password,
DOBYear: userData.birthYear,
DOBMonth: MonthToNumber[userData.birthMonth],
DOBDay: userData.birthDay
})
console.log(`[00000] User registered: ${userData.username}`);

socketIO.emit("newRegisteredUser", {
status: "00000",
token: generateJWT(userData.username)
});
});

User 是一个Mongoose模型,用于操作MongoDB数据库。

1
2
3
4
5
6
7
const mongoose = require("mongoose");

mongoose.connect("mongodb://localhost:27017/hotaru")
.then(() => console.log("Connected to MongoDB"))
.catch(err => console.error("Could not connect to MongoDB", err));

module.exports = mongoose;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const bcrypt = require( "bcrypt");
const mongoose = require("./mongodb");

const UserSchema = new mongoose.Schema({
emailAddress: {
type: String,
unique: true
},
username: {
type: String,
unique: true
},
password: {
type: String,
set(val) { return bcrypt.hashSync(val, 10) },
select: false
},
DOBYear: {
type: Number
},
DOBMonth: {
type: Number
},
DOBDay: {
type: Number
},
createTime: {
type: Date,
default: Date.now
}
})

const User = mongoose.model("User", UserSchema);
module.exports = { User };

bcrypt 是一个用于加密密码的包,不只是加密密码,我们验证用户登录时也会用到它。

最根本的原因是我们不会在数据库中存储用户的明文密码,要验证用户登陆的话,只能用加密后的用户输入的密码和数据库中的密码进行比对。

我这里也根据网上的文章自己定义了一套错误码,未来可能会展开说说。

这样,我们就完成了注册页面的逻辑。

登录页面的逻辑和注册页面的逻辑是一样的,只是在 registerUser 方法中,我们需要发送 login 事件,而在服务端中,我们需要接受名为 login 的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const loginUser = (userData, navigate) => {
return dispatch => {
socket.emit("login", userData);

socket.on("loggedInUser", data => {
if (data.status === "00000") {
const { token } = data;
localStorage.setItem("jwtToken", token);
userData["token"] = token;
dispatch(setCurrentUser(userData));
navigate("/channels/@me");
} else {
console.log(data.message);
}
});
}
};

localStorage 是浏览器提供的一个API,用于在浏览器中存储数据。这里存储了JWT Token,以后会提到这是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
socket.on("login", async userData => {
const existingUser = await User.findOne({
username: userData.username
});
if (!existingUser) {
console.log(`[U0201] User does not exist: ${userData.username}`);
socket.emit("loggedInUser", {
status: "U0201",
message: "User does not exist."
});
return;
}

bcrypt.compare(userData.password, existingUser.password, (err, confirmPassword) => {
if (err) {
console.error(err);
return;
}
if (!confirmPassword) {
console.log(`[U0202] Password is incorrect: ${userData.password}`);
socket.emit("loggedInUser", {
status: "U0202",
message: "Password is incorrect."
});
return;
}

console.log(`[00000] User logged in: ${userData.username}`);
socket.emit("loggedInUser", {
status: "00000",
token: generateJWT(userData.username)
});
});
});

一套组合拳下来,一旦用户的信息被验证成功,就会跳转到频道页面。