基于Node搭建一个聊天室

前言

这篇文章适合已有前端基础,但是还没接触过服务端的小伙伴,可以作为node入门教程看一看。由于作者也只是自学前端一年左右,刚接触node,难免会有不对的地方,欢迎指正,拒绝人身攻击,谢谢~

项目截图:


在线地址:http://www.xi-g.com:4000/toChat.html

首先我们看看要用到哪些东西
前端:vue+vue-router
后端:node+express+socket.io+mongodb

vue是一个基于数据驱动的轻量级前端框架,拥有虚拟DOM,聊天室里的聊天记录会有很多,用vue挺合适的,当然要用jquery拼接字符串来操作DOM也是没问题的啦。

node是一个用c++编写的可以运行javascript的平台,和java一样属于应用层的平台。node是单线程异步非阻塞IO,非常善于处理高迸发IO,但是不擅长处理计算任务,由于是单线程,编写代码需要一定鲁棒性,一个用户报错就会让整个程序终止。

express可以看作是node的拓展包,自带类似阿帕奇的静态服务,且可以更加方便的组织路由和中间件。

socket.io是一个同步通讯的解决方案,基于websocket协议,内部封装了很多种解决方案,可以兼容所有浏览器,区别于ajax,ajax是客户端向服务端请求一次就返回一次数据,没有请求服务端就没法向客户端发送数据,而socket.io是双向的,可以看成是管道。

mongodb是一个非关系型的数据库,没有表的概念,都是一个个的集合,可以往集合里直接存入一条条的json数据,没有字段的约束,有时非常方便。

服务端的搭建

首先得安装node这个东西,去官网http://nodejs.cn/download/下一个就好了,32位和64位的要看清楚,下载.msi的版本,直接安装完,连环境变量也帮我们配好了,打开命令行输入node -v,再输入npm -v,我们发现node已经安装成功了,并且还顺带把npm也安装了,npm是个包管理器。


现在我们来创建服务端,先建一个文件夹,在这个文件夹里打开命令行输入npm init

之后命令行会问你很多东西,第一个是项目名字,其它可以先全部默认,不明白的可以自行百度,一路按回车下去,然后这个文件夹里就多了一个package.json的配置文件。现在我们继续敲命令行:

npm install express --save
npm install socket.io --save
npm install mongodb@2.2.33 --save

这个时候,服务端需要用到的依赖已经全部安装完了,接下来我们在根目录创建一个server.js文件,先引入必须的模块:

// 引入express
var express = require('express');
// 获得express的实例
var app = express();
// 引入http模块
var http = require('http');
// 用http模块创建一个服务并把express的实例挂载上去
var server = http.Server(app);
// 引入socket.io并立即实例化,把server挂载上去
var io = require('socket.io')(server);

这个时候,我们的服务已经创建好了,什么,你不信?我们来监听一下:

var server = server.listen(4001, function () {
    console.log('服务端启动成功!端口4001');
});

保存一下代码,在当前文件夹打开命令行,输入node server.js(.js可以省略),不出意外,你会看到:


前端代码在这里就不做过多介绍了,下一步开始默认已经把静态页面写好了。
我们继续来写服务端,先用socket.io的实例来监听客户端的连接:

var onlieCount=0;
// 新用户连接进来时
io.on('connection', function (socket) {
    onlieCount++;
    console.log('有一个人进来了,'+'现在有'+onlieCount+'人在线!');
});

现在我们来写前端,前端需要引入一个socket.io.js的文件,这个文件在后端文件夹下的node_modules\socket.io-client\dist文件夹下,我们拷贝出来丢到前端文件夹里,并引用到html里去,同时打开socket的连接通道,连到本机的4001端口:

<script src="static/js/socket.io.js"></script>
<script type="text/javascript">
    var socket=io.connect('http://localhost:4001');
</script>

这时,我们来重启一下服务端,并把前端页面打开两个,不出意外,你会看到:



继续转到服务端,我们来监听一下用户断开连接:

// 新用户连接进来时
io.on('connection', function (socket) {
    onlieCount++;
    console.log('有一个人进来了,'+'现在有'+onlieCount+'人在线!');
    // 当有用户断开时
    socket.on('disconnect', function () {
        onlieCount--;
        console.log(socket.id+'离开了,'+'现在有'+onlieCount+'人在线!');
    });
});

注意了,断开连接的监听需要写到新用户连接的回调里,不然你怎么知道是谁断开了,新用户连接的回调有一个参数,那是这个连接的实例对象,它有个id的属性并且是唯一的,之后做单人聊天要用到,我们试着打印一下:



接下来服务端的代码默认都是写在新用户连接的回调里,现在给客户端广播发消息:

// 给所有客户端发送消息
io.emit('notice','大家新年快乐!');

前端里写一下监听接收,如果是vue就写在mounted钩子函数里:

socket.on('notice', function (info) {
    console.log('这是来自服务端的消息:'+info);
});

我们重启一下服务器和前端页面:



服务端已经把消息发给客户端并且打印出来了,这里要注意这个notice可以自己命名,但是两边都要一样,相当于一个标记;还有客户端向服务端发送消息也是一样的代码,只是需要反过来,这里就不演示了,小伙伴们自己尝试一下。
现在已经可以把聊天室的公共聊天基础功能给写出来了,客户端点发送的时候,把textarea里的文本传到后台,后台接收之后发一次广播给所有人:

客户端点击发送 → emit给服务端 → 服务端emit给所有在线客户端

多人聊天的思路

这里主要讲一下思路,不会写太多的代码。
我们在前端定义一个数组变量,然后把收到的广播消息一条条push追加到这个数组里,根据这个数组是不是就能渲染出聊天消息列表啦:



小伙伴们可能会问,你这列表里怎么除了消息,还有头像、昵称和时间呢,没错,socket.io支持发送{}对象,在对象里你想装多少属性都OK的啦,看需要自己添加。
我们现在来看看在线列表的基础功能怎么实现,首先需要显示所有的在线用户,每个用户需要显示头像、用户名,这个可以在登录页面用vue的动态路由传递到聊天页面,然后立即传给服务端,服务端建一个数组push进去,再把这个列表emit广播给所有人,这样就同步了客户端的在线列表:

this.$router.push({ name: 'chat', params: userinfo});

在聊天页面的mounted钩子里拿到userinfo存起来:

this.userinfo = this.$route.params;
socket.emit('userinfo', this.userinfo);

服务端这样写,建一个全局变量存放用户信息:

var onlineList = {list: []};
// 监听还是要写在连接用户的回调里
socket.on('userinfo', function (userinfo) {
    // 给用户信息添加socketid属性,值为此管道的唯一id
    userinfo.socketid=socket.id;
    onlineList.list.push(userinfo);
    // 广播在线列表
    io.emit('onlineList', onlineList);
});

这样,只要在客户端监听一下‘onlineList’,只要有人上线,都能收到一个列表的数据,然后根据数据再渲染页面。下线更新所有客户端列表也是一个原理,当用户下线,服务端能拿到这个用户的socket.id,根据这个id去遍历一下onlineList.list这个数组,有相等的就把这个数组项删除,之后再把这个onlineList广播给所有客户端,这就实现了用户上下线保持列表一致的功能。

至此,我们已经可以实现简单的在线列表和聊天窗口的对话框显示了,但是如何在聊天窗口区分自己和别人的聊天消息了,我想让别人的都显示白色,自己的显示绿色,这个其实很简单,首先我们定义一个变量来装自己的名字,然后再给对话框写一个绿色的class样式,聊天窗口的数据是一个对象数组,通过v-for渲染出来的,我们可以这么写:

<div v-for="(item, index) in msgList" :class="['whiteBox', {'greenBox':myName == msgList[index].username}]">

这样只要是匹配到msgList里面的对象的username属性和我的名字一致,那就会加上greenBox这个样式了。

单人聊天的思路

我们先来分析一下,多人聊天的流程是这样:

用户A → 服务端 → 所有用户

那么单人聊天就是A发给B,只有AB两个人能看到,流程是这样:

用户A → 服务端 → 用户B

那么服务端怎么才能指定某个用户单独发送消息呢,很简单,每一个客户端与服务端连接的这根管道都是独立的,且是一个对象,我们只要找到这个对象,然后调用他的emit方法,就能单独给此用户发送数据了。
之前提到了,在新用户连接的时候,回调里会有一个参数,这个参数正是此用户的管道对象,只要有新用户连接,我们就把这个管道对象存起来,有用户下线我们就把这个管道对象删除,我在服务端是这么做的:

var allsocket = {};
// 新用户连接进来时
io.on('connection', function (socket) {
    var the_id = socket.id;
    // 把每一个用户的管道对象存到一个总对象里,key就是socket.id
    allsocket[the_id] = socket;
});

之后,我们可以通过allsocket[key].emit()来向指定客户端发送消息。
前端只需要在向客户端发送消息的时候带上目标用户的socketid属性就可以了,后端拿到数据后直接在回调里把消息allsocket[key].emit()转发。
现在来说说前端怎么显示个人聊天的信息吧,这个时候vue数据驱动的好处就凸显出来了,和公共聊天一样,也是存一个数组,但是会有很多不同的人,我们再建一个对象allMsgList,把这些个人聊天记录的数组存进去,key设为此人的名字,value就是消息的数组。
我们再写一个vue的computed计算属性取名curChatMsg,用这个计算属性去v-for渲染聊天消息列表,计算属性怎么写呢,我们先给在线用户列表里的列表项添加一个点击事件,data里再设一个变量curChat,点哪个用户就把此用户的名字赋值给curChat,然后这个计算属性里判断一下,根据不同的key用户名字返回不同的value消息数组:

curChatMsg: function(){
    // 如果存在就返回
    if(allMsgList[this.curChat]){
        return allMsgList[this.curChat];
    }
},    

现在只要点击用户列表里的不同用户,就会显示与不同用户单独聊天的消息啦。现在一个简单的支持多人聊天和单人聊天的聊天室就实现了,其它功能根据需要再慢慢添砖加瓦,例如新消息提示、新消息置顶、列表用户搜索、表情发送与接收等等,在vue里基于数据驱动实现起来都不难。

查看聊天历史记录

实现这个功能,我们需要用到数据库,这里选择用MongoDB,首先我们得去官网下载安装文件https://www.mongodb.com/download-center?jmp=nav#community


一直往下点,安装完成后,我们需要配置一下,在某个地方新建一个文件夹,里面再建三个子文件夹,用来存放数据库数据和日志等:

在oth文件夹里,再建一个conf文件,txt改后缀即可:

#数据库路径
dbpath=d:\mongo\data\
#日志输出文件路径
logpath=d:\mongo\logs\mongodb.log
#错误日志采用追加模式
logappend=true
#启用日志文件,默认启用
journal=true
#端口号 默认为27017
port=27017
#http 访问配置 端口为28017
httpinterface=true

现在我们来启动一下数据库试试,找到MongoDB的安装路径,进入到里面的bin文件夹,在此处打开命令行,执行:

mongod --config d:\mongo\oth\mongo.conf

命令行没有任何反应,但是数据库已经启动了,我们打开浏览器进到localhost:28017,可以看到数据库已经在运行了。
我们需要把聊天室多人聊天的每一句对话都存在数据库里,先在服务端引入之前安装的mongodb驱动,拿到MongoClient的实例:

var MongoClient = require('mongodb').MongoClient;

接着我们存一下数据库地址:

// mychat是数据库的名字,连接的时候如果找不到会自动建立
var DB_CONN_STR = 'mongodb://localhost:27017/mychat';

我们在接收多人聊天的回调里,把客户端传过来的消息存进数据库:

MongoClient.connect(DB_CONN_STR, function(err, db) {
    if (err) throw err;
    // data是客户端传过来的数据
    var data = {};
    // chat_msg是要存入的集合的名字,找不到会自动建立
    db.collection('chat_msg').insert(data , function(err, res) {
        if (err) throw err;
        console.log("插入成功");
        db.close();
    });
});

现在,每一条多人聊天的消息都已经入库了,我们需要在前端显示,可以在聊天消息框顶部加一个按钮,如下:


只要点这个按钮,服务端就需要去数据库里查询并把相应的列表数据发送到前端,这里的查询条件需要好好的琢磨一下,首先存入数据库的消息肯定要设计一个时间戳的属性,我们按时间戳倒序查询,时间靠后的先查,且不能直接分页,最开始我写了一个分页查询,点一下往后查10条,但是如果在你翻看聊天记录的时候,刚好有其他人发了消息,是不是分页就对不上了,会出现重复对话显示。这里需要用到skip(num)来跳过查询,参数是一个数字,表示跳过多少条往后查询,前端要把这个参数传过来,就是msgList的长度,比如msgList当前显示的聊天消息有8条,那么就跳过8条往后查,假设查10条,我们把这10条消息拿到后在前端reverse().concat(msgList)连接到旧msgList的前面,这时msgList一共18条,我们正准备再点一次“查看更多消息”之前刚好另一个人发了1条消息,这时msgList就是19条,我们点“查看更多消息”的时候顺带就把19这个数字传到服务端,服务端skip(19).limit(10),这样就能对上了:

MongoClient.connect(DB_CONN_STR, function(err, db) {
    if (err) throw err;
    // find({})是查询所有,.sort({'time':-1})是按time字段倒序排列,skip(num).limit(10)是跳过num条往后查10条
    db.collection('chat_msg').find({}).sort({'time':-1}).skip(num).limit(10).toArray(function(err, result) {
        if (err) throw err;
        console.log("查询成功");
        // 查到数据后直接根据socketid单发给客户端
        allsocket[socketid].emit('chat_record', result);
        db.close();
    });
});

客户端拿到后reverse()颠倒一下,再concat(msgList),拼接到旧msgList前面,v-for根据数据自动就渲染了页面。

到这里,聊天室的基础核心功能就已经完成了,支持多人聊天,支持单人聊天,支持查看多人聊天的历史记录,其它零碎的功能大部分需要在前端去实现了,我就先写到这里,之后有时间再补充其它的零碎功能。

表情的实现

表情本质上就是图片,可以是一整张精灵图,也可以每个表情单独一张图片,实现略有不同。我们可以创建一个json对象保存图片的路径和名字(做精灵图的话是背景位置的坐标),点击某个表情的时候,给消息发送框加上一个特定的词语,例如〖微笑〗,然后渲染消息的时候匹配一次(可用计算属性),如果检测到〖微笑〗的字符串,则删除掉,并操作DOM插入相对应的图片。

未完待续

完整版的在线列表实现起来还是有点困难的,我自己写的时候大概30%以上的时间花在这上面了,按照数据驱动,写出健壮的逻辑不容易,既要考虑到用户上下线,又要精准的显示聊天消息缩略和消息提示红点,新消息还需要置顶,环环相扣,最后虽然把BUG都解决了,但是逻辑还是一片混乱,这里就不误导大家了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容