乐趣区

动手做个聊天室,前端工程师百无聊赖的人生

本项目服务端基于 node.js 技术,使用了 koa 框架,所有数据存储在 mongodb 中。客户端使用 react 框架,使用 redux 和 immutable.js 管理状态,APP 端基于 react-native 和 expo 开发。本文需要对 JavaScript 较为熟悉,讲解核心功能点的设计思路。

服务端架构
服务端负责两件事:
1. 提供基于 WebSocket 的接口 2. 提供 index.html 响应
服务端使用了 koa-socket 这个包,它集成了 socket.io 并实现了 socket 中间件机制,服务端基于该中间件机制,自己实现了一套接口路由
每个接口都是一个 async 函数,函数名即接口名,同时也是 socket 事件名
async login(ctx) {
return ‘login success’
}
然后写了个 route 中间件,用来完成路由匹配,当判断路由匹配时,以 ctx 对象作为参数执行路由方法,并将方法返回值作为接口返回值
function noop() {}

/**
* 路由处理
* @param {IO} io koa socket io 实例
* @param {Object} routes 路由
*/
module.exports = function (io, _io, routes) {
Object.keys(routes).forEach((route) => {
io.on(route, noop); // 注册事件
});

return async (ctx) => {
// 判断路由是否存在
if (routes[ctx.event]) {
const {event, data, socket} = ctx;
// 执行路由并获取返回数据
ctx.res = await routes[ctx.event]({
event, // 事件名
data, // 请求数据
socket, // 用户 socket 实例
io, // koa-socket 实例
_io, // socket.io 实例
});
}
};
};
还有一个重要 catchError 中间件是,它负责捕获全局异常,业务流程中大量使用 assert 判断业务逻辑,不满足条件时会中断流程并返回错误消息,catchError 将捕获业务逻辑异常,并取出错误消息返回给客户端
const assert = require(‘assert’);

/**
* 全局异常捕获
*/
module.exports = function () {
return async (ctx, next) => {
try {
await next();
} catch (err) {
if (err instanceof assert.AssertionError) {
ctx.res = err.message;
return;
}
ctx.res = `Server Error: ${err.message}`;
console.error(‘Unhandled Error\n’, err);
}
};
};
这些就是服务端的核心逻辑,基于该架构下定义接口组成业务逻辑
另外,服务端还负责提供 index.html 响应,即客户端首页。客户端的其他资源是放在 CDN 上的,这样可以缓解服务端带宽压力,但是 index.html 不能使用强缓存,因为会使得客户端更新不可控,因此 index.html 放在服务端
客户端架构
客户端使用 socket.io-client 连接服务端,连接成功后请求接口尝试登录,如果 localStorage 没有令牌或者接口返回令牌过期,将会以游客身份登录,登录成功会返回用户信息以及群组,好友列表,接着去请求各群组,好友的历史消息
客户端需要监听 connect / disconnect / message 三个消息
1.connect:socket 连接成功 2.disconnect socket 连接断开 3.message 接收到新消息
客户端使用 redux 管理数据,需要被组件共享的数据放在 redux 中,只有自身使用的数据还是放在组件的 state 中,客户端存储的 redux 数据结构如下:

用户用户信息_id 用户 id 用户名用户名 linkmans 联系人列表,包括群组,好友以及临时会话 isAdmin 是否是管理员焦点当前聚焦的联系人 id,既对话中的目标连接连接状态
ui 客户端 UI 相关和功能开关
客户端的数据流,主要有两条线路
1. 用户操作 => 请求接口 => 返回数据 => 更新 redux => 视图重新渲染 2. 监听新消息 => 处理数据 => 更新 redux => 视图重新渲染
用户系统
用户架构定义:
const UserSchema = new Schema({
createTime: {type: Date, default: Date.now},
lastLoginTime: {type: Date, default: Date.now},

username: {
type: String,
trim: true,
unique: true,
match: /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]){1,8}$/,
index: true,
},
salt: String,
password: String,
avatar: {
type: String,
},
});

createTime:创建时间 lastLoginTime:最后一次登录时间,用来清理僵尸号用 username:用户昵称,同时也是账号 salt:加密盐 password:用户密码 avatar:用户头像 URL 地址
用户注册
注册接口需要 username/ password 两个参数,首先做判空处理
const {
username, password
} = ctx.data;
assert(username, ‘ 用户名不能为空 ’);
assert(password, ‘ 密码不能为空 ’);
然后判断用户名是否已存在,同时获取默认群组,新注册用户要加入到默认群组
const user = await User.findOne({username});
assert(!user, ‘ 该用户名已存在 ’);
const defaultGroup = await Group.findOne({isDefault: true});
assert(defaultGroup, ‘ 默认群组不存在 ’);

存密码明文肯定是不行的,生成随机盐,并使用盐加密密码
const salt = await bcrypt.genSalt$(saltRounds);
const hash = await bcrypt.hash$(password, salt);

给用户一个随机默认头像,保存用户信息到数据库
let newUser = null;
try {
newUser = await User.create({
username,
salt,
password: hash,
avatar: getRandomAvatar(),
});
} catch (err) {
if (err.name === ‘ValidationError’) {
return ‘ 用户名包含不支持的字符或者长度超过限制 ’;
}
throw err;
}
将用户添加到默认群组,然后生成用户令牌令是用来免费码登录的凭证,存储在客户端 localStorage,令牌里带带用户 id,过期时间,客户端信息三个数据,用户 id 和过期时间容易理解,客户端信息是为了防止令牌盗用,之前也试过验证客户端 ip 一致性,但是 ip 可能会有经常改变的情况,搞得用户每次自动登录都被判定为盗用了 ……
defaultGroup.members.push(newUser);
await defaultGroup.save();

const token = generateToken(newUser._id, environment);
将用户 id 与当前 socket 连接关联, 服务端是以 ctx.socket.user 是否为 undefined 来判断登录态的 更新 Socket 表中当前 socket 连接信息, 后面获取在线用户会取 Socket 表数据
ctx.socket.user = newUser._id;
await Socket.update({id: ctx.socket.id}, {
user: newUser._id,
os, // 客户端系统
browser, // 客户端浏览器
environment, // 客户端环境信息
});
最后将数据返回客户端
return {
_id: newUser._id,
avatar: newUser.avatar,
username: newUser.username,
groups: [{
_id: defaultGroup._id,
name: defaultGroup.name,
avatar: defaultGroup.avatar,
creator: defaultGroup.creator,
createTime: defaultGroup.createTime,
messages: [],
}],
friends: [],
token,
}
用户登录
fiora 是不限制多登陆的,每个用户都可以在无限个终端登录
登录有三种情况:
游客登录令牌登录用户名 / 密码登录
游客登录仅能查看默认群组消息,并且不能发消息,主要是为了降低第一次来的用户的体验成本
令牌登录是最常用的,客户端首先从 localStorage 取令牌,令牌存在就会使用令牌登录首先对令牌解码取出负载数据,判断令牌是否过期以及客户端信息是否匹配
let payload = null;
try {
payload = jwt.decode(token, config.jwtSecret);
} catch (err) {
return ‘ 非法 token’;
}

assert(Date.now() < payload.expires, ‘token 已过期 ’);
assert.equal(environment, payload.environment, ‘ 非法登录 ’);
从数据库查找用户信息,更新最后登录时间,查找用户所在的群组,并将 socket 添加到该群组,然后查找用户的好友
const user = await User.findOne({_id: payload.user}, {_id: 1, avatar: 1, username: 1});
assert(user, ‘ 用户不存在 ’);

user.lastLoginTime = Date.now();
await user.save();

const groups = await Group.find({members: user}, {_id: 1, name: 1, avatar: 1, creator: 1, createTime: 1});
groups.forEach((group) => {
ctx.socket.socket.join(group._id);
return group;
});

const friends = await Friend
.find({from: user._id})
.populate(‘to’, { avatar: 1, username: 1});
更新 socket 信息,与注册相同
ctx.socket.user = user._id;
await Socket.update({id: ctx.socket.id}, {
user: user._id,
os,
browser,
environment,
});
最后返回数据
用户名 / 密码与令牌登录仅一开始的逻辑不同,没有解码令牌验证数据这步先验证用户名是否存在,然后验证密码是否匹配
const user = await User.findOne({username});
assert(user, ‘ 该用户不存在 ’);

const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, ‘ 密码错误 ’);
接下来逻辑就与令牌登录一致了
消息系统
发送消息
sendMessage 接口有三个参数:
to:发送的对象,群组或者用户 type:消息类型 content:消息内容
因为群聊和私聊共用这一个接口,所以首先需要判断是群聊还是私聊,获取群组 id 或者用户户 ID,群聊 / 私聊通过参数区分群聊时到是相应的群组 id,然后获取群组信息私聊时到是发送者和接收者二人 id 拼接的结果,去掉发送者 id 就得到了接收者 id,然后获取接收者信息
let groupId = ”;
let userId = ”;
if (isValid(to)) {
const group = await Group.findOne({_id: to});
assert(group, ‘ 群组不存在 ’);
} else {
userId = to.replace(ctx.socket.user, ”);
assert(isValid(userId), ‘ 无效的用户 ID’);
const user = await User.findOne({_id: userId});
assert(user, ‘ 用户不存在 ’);
}

部分消息类型需要做些处理,文本消息判断长度并做 xss 处理,邀请消息判断邀请的群组是否存在,然后将邀请人,群组 id,群组名等信息存储到消息体中
let messageContent = content;
if (type === ‘text’) {
assert(messageContent.length <= 2048, ‘ 消息长度过长 ’);
messageContent = xss(content);
} else if (type === ‘invite’) {
const group = await Group.findOne({name: content});
assert(group, ‘ 目标群组不存在 ’);

const user = await User.findOne({_id: ctx.socket.user});
messageContent = JSON.stringify({
inviter: user.username,
groupId: group._id,
groupName: group.name,
});
}
将新消息存入数据库
let message;
try {
message = await Message.create({
from: ctx.socket.user,
to,
type,
content: messageContent,
});
} catch (err) {
throw err;
}
接下来构造一个不包含敏感信息的消息数据, 数据中包含发送者的 id、用户名、头像, 其中用户名和头像是比较冗余的数据, 以后考虑会优化成只传一个 id, 客户端维护用户信息, 通过 id 匹配出用户名和头像, 能节约很多流量 如果是群聊消息, 直接把消息推送到对应群组即可 私聊消息更复杂一些, 因为 fiora 是允许多登录的, 首先需要推送给接收者的所有在线 socket, 然后还要推送给自身的其余在线 socket
const user = await User.findOne({_id: ctx.socket.user}, {username: 1, avatar: 1});
const messageData = {
_id: message._id,
createTime: message.createTime,
from: user.toObject(),
to,
type,
content: messageContent,
};

if (groupId) {
ctx.socket.socket.to(groupId).emit(‘message’, messageData);
} else {
const sockets = await Socket.find({user: userId});
sockets.forEach((socket) => {
ctx._io.to(socket.id).emit(‘message’, messageData);
});
const selfSockets = await Socket.find({user: ctx.socket.user});
selfSockets.forEach((socket) => {
if (socket.id !== ctx.socket.id) {
ctx._io.to(socket.id).emit(‘message’, messageData);
}
});
}
最后把消息数据返回给客户端,表示消息发送成功。客户端为了优化用户体验,发送消息时会立即在页面上显示新信息,同时请求接口发送消息。如果消息发送失败,就删掉该条消息
获取历史消息
getLinkmanHistoryMessages 接口有两个参数:
linkmanId:联系人 id,群组或者俩用户 id 拼接 existCount:已有的消息个数
详细逻辑比较简单,按创建时间倒序查找已有个数 + 每次获取个数数量的消息,然后去掉已有个数的消息再反转一下,就是按时间排序的新消息
const messages = await Message
.find(
{to: linkmanId},
{type: 1, content: 1, from: 1, createTime: 1},
{sort: { createTime: -1}, limit: EachFetchMessagesCount + existCount },
)
.populate(‘from’, { username: 1, avatar: 1});
const result = messages.slice(existCount).reverse();
返回给客户端
接收推送消息
客户端订阅消息事件接收新消息 socket.on(‘message’)
接收到新消息时,先判断状态中是否存在该联系人,如果存在则将消息存到对应的联系人下,如果不存在则是一条临时会话的消息,构造一个临时联系人并获取历史消息,然后将临时联系人添加到州中。如果是来自自己其它终端的消息,则不需要创建联系人
const state = store.getState();
const isSelfMessage = message.from._id === state.getIn([‘user’, ‘_id’]);
const linkman = state.getIn([‘user’, ‘linkmans’]).find(l => l.get(‘_id’) === message.to);
let title = ”;
if (linkman) {
action.addLinkmanMessage(message.to, message);
if (linkman.get(‘type’) === ‘group’) {
title = `${message.from.username} 在 ${linkman.get(‘name’)} 对大家说:`;
} else {
title = `${message.from.username} 对你说:`;
}
} else {
// 联系人不存在并且是自己发的消息, 不创建新联系人
if (isSelfMessage) {
return;
}
const newLinkman = {
_id: getFriendId(
state.getIn([‘user’, ‘_id’]),
message.from._id,
),
type: ‘temporary’,
createTime: Date.now(),
avatar: message.from.avatar,
name: message.from.username,
messages: [],
unread: 1,
};
action.addLinkman(newLinkman);
title = `${message.from.username} 对你说:`;

fetch(‘getLinkmanHistoryMessages’, { linkmanId: newLinkman._id}).then(([err, res]) => {
if (!err) {
action.addLinkmanMessages(newLinkman._id, res);
}
});
}
如果当前聊天页是在后台的,并且打开了消息通知开关,则会弹出桌面提醒
if (windowStatus === ‘blur’ && state.getIn([‘ui’, ‘notificationSwitch’])) {
notification(
title,
message.from.avatar,
message.type === ‘text’ ? message.content : `[${message.type}]`,
Math.random(),
);
}
如果打开了声音开关,则响一声新消息提示音
if (state.getIn([‘ui’, ‘soundSwitch’])) {
const soundType = state.getIn([‘ui’, ‘sound’]);
sound(soundType);
}
如果打开了语言播报开关并且是文本消息,将消息内的 url 和#过滤掉,排除长度大于 200 的消息,然后推送到消息朗读队列中
if (message.type === ‘text’ && state.getIn([‘ui’, ‘voiceSwitch’])) {
const text = message.content
.replace(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, ”)
.replace(/#/g, ”);
// The maximum number of words is 200
if (text.length > 200) {
return;
}

const from = linkman && linkman.get(‘type’) === ‘group’ ?
`${message.from.username} 在 ${linkman.get(‘name’)} 说 `
:
`${message.from.username} 对你说 `;
if (text) {
voice.push(from !== prevFrom ? from + text : text, message.from.username);
}
prevFrom = from;
}
更多中间件
限制未登录请求
大多数接口是只允许已登录用户访问的,如果接口需要登录且 socket 连接没有用户信息,则返回“未登录”错误
/**
* 拦截未登录请求
*/
module.exports = function () {
const noUseLoginEvent = {
register: true,
login: true,
loginByToken: true,
guest: true,
getDefalutGroupHistoryMessages: true,
getDefaultGroupOnlineMembers: true,
};
return async (ctx, next) => {
if (!noUseLoginEvent[ctx.event] && !ctx.socket.user) {
ctx.res = ‘ 请登录后再试 ’;
return;
}
await next();
};
};
限制调用频率
为了防止刷接口的情况,减轻服务器压力,限制同一插座连接每分钟内最多请求 30 次接口
const MaxCallPerMinutes = 30;
/**
* Limiting the frequency of interface calls
*/
module.exports = function () {
let callTimes = {};
setInterval(() => callTimes = {}, 60000); // Emptying every 60 seconds

return async (ctx, next) => {
const socketId = ctx.socket.id;
const count = callTimes[socketId] || 0;
if (count >= MaxCallPerMinutes) {
return ctx.res = ‘ 接口调用频繁 ’;
}
callTimes[socketId] = count + 1;
await next();
};
};
小黑屋
管理员账号可以将用户添加到小黑屋,被添加到小黑屋的用户无法请求任何接口,10 分钟后自动解禁
/ **
/**
* Refusing to seal user requests
*/
module.exports = function () {
return async (ctx, next) => {
const sealList = global.mdb.get(‘sealList’);
if (ctx.socket.user && sealList.has(ctx.socket.user.toString())) {
return ctx.res = ‘ 你已经被关进小黑屋中, 请反思后再试 ’;
}

await next();
};
};
其它
表情
表情是一张雪碧图,点击表情会向输入框插入格式为 #(xx) 的文本,例如#(滑稽)。在渲染消息时,通过正则匹配将这些文本替换为 <img>,并计算出该表情在雪碧图中的位置,然后渲染到页面上不设置 src 会显示一个边框,需要将 src 设置为一张透明图
function convertExpression(txt) {
return txt.replace(
/#\(([\u4e00-\u9fa5a-z]+)\)/g,
(r, e) => {
const index = expressions.default.indexOf(e);
if (index !== -1) {
return `class=”expression-baidu” src=”${transparentImage}” style=”background-position: left ${-30 * index}px;” onerror=”this.style.display=’none'” alt=”${r}”>`;
}
return r;
},
);
}
表情包搜索
的爬 https://www.doutula.com 上的搜 …
const res = await axios.get(`https://www.doutula.com/search?keyword=${encodeURIComponent(keywords)}`);
assert(res.status === 200, ‘ 搜索表情包失败, 请重试 ’);

const images = res.data.match(/data-original=”[^ “]+”/g) || [];
return images.map(i => i.substring(15, i.length – 1));
桌面消息通知

效果如上图,不同系统 / 浏览器在样式上会有区别经常有人问到这个是怎么实现的,其实是 HTML5 增加的功能 Notification
粘贴发图
监听 paste 事件,获取粘贴内容,如果包含 Files 类型内容,则读取内容并生成 Image 对象。注意:通过该方式拿到的图片,会比原图片体积大很多,因此最好压缩一下再使用
@autobind
handlePaste(e) {
const {items, types} = (e.clipboardData || e.originalEvent.clipboardData);

// 如果包含文件内容
if (types.indexOf(‘Files’) > -1) {
for (let index = 0; index < items.length; index++) {
const item = items[index];
if (item.kind === ‘file’) {
const file = item.getAsFile();
if (file) {
const that = this;
const reader = new FileReader();
reader.onloadend = function () {
const image = new Image();
image.onload = () => {
// 获取到 image 图片对象
};
image.src = this.result;
};
reader.readAsDataURL(file);
}
}
}
e.preventDefault();
}
}
后话
想把前端学好,js 真的真的很重要!!!我的 web 前端学习 q.u.n【731771211】,学习资源免费分享,五年资深前端攻城狮在线课堂讲解实战技术。欢迎新手,进阶
点击:加入

退出移动版