最近练手开发了一个项目,是一个聊天室应用。项目虽不大,但是使用到了react, react-router, redux, socket.io,后端开发使用了koa,算是一个比较综合性的案例,很多概念和技巧在开发的过程中都有所涉及,非常有必要再来巩固一下。
项目目前部署在heroku平台上,在线演示地址: online demo, 因为是国外的平台速度可能有点慢,点进去耐心等一会儿就能加载好了。
加载好之后,首先出现的页面是让用户起一个昵称:
输入昵称之后,就会进入聊天页面,左边是进入聊天室的在线用户,右边则是聊天区域,下图是三个在线用户聊天的情形:
项目源码的github地址: 源码地址, 有兴趣的同学欢迎关注学习~
下面就来分析一下项目的整体架构,以及一下值得注意的技巧和知识点。
项目的目录如下:
├── README.md
├── node_modules
├── dist
│ ├── bundle.css
│ ├── bundle.js
│ └── resource
│ ├── background.jpeg
│ └── preview.png
├── package.json
├── server.js
├── src
│ ├── action
│ │ └── index.js
│ ├── components
│ │ ├── chatall
│ │ │ ├── index.js
│ │ │ └── index.less
│ │ ├── login
│ │ │ ├── index.js
│ │ │ └── index.less
│ │ ├── msgshow
│ │ │ ├── index.js
│ │ │ └── index.less
│ │ ├── namelist
│ │ │ ├── index.js
│ │ │ └── index.less
│ │ ├── nav
│ │ │ ├── index.js
│ │ │ └── index.less
│ │ └── typein
│ │ ├── index.js
│ │ └── index.less
│ ├── container
│ │ ├── chatAll.js
│ │ └── login.js
│ ├── index.ejs
│ ├── index.js
│ ├── index.less
│ ├── index2.js
│ ├── reducer
│ │ └── index.js
│ ├── redux_middleware
│ │ └── index.js
│ └── resource
│ ├── background.jpeg
│ └── preview.png
└── webpack.config.js
其中src当中是前端部分的源代码。项目使用webpack进行打包,打包后的代码在dist目录当中。由于我们的项目是一个单页面应用,因此只需要统一打包出一个bundle.js和一个bundle.css。而后端使用了koa框架,由于代码相对比较少,都集中在了server.js这一个文件当中。
开发过程中,由于要webpack打包,一般我们会配合webpack-dev-server来使用。webpack-dev-server运行的时候自身就会开启一个server,而在我们的项目当中,后端koa也是一个server,因此为了简单起见,我们可以使用koa-webpack-dev-middleware来在koa当中开启webpack-dev-server。
var webpackDev = require('koa-webpack-dev-middleware');
var webpackConf = require('./webpack.config.js');
var compiler = webpack(webpackConf);
app.use(webpackDev(compiler, {
contentBase: webpackConf.output.path,
publicPath: webpackConf.output.publicPath,
hot: true
}));
在这个项目中我们有意识的使用了flex布局,作为面向未来的一种新的布局方式,实践一下还是很有必要的!没有学习郭flexbox的同学可以参考这篇来学一下:http://www.ruanyifeng.com/blog/2015/07/flex-grammar.html
以聊天界面为例进行分析,使用flex布局的话,可以非常方便,下图就是对界面的一个简单的切分:
整个聊天框最外层红框框起来的部分display设置为flex,并且flex-direction设置为column,这样它里面的两个元素(即粉框和蓝框部分)就会竖直方向排列,同时粉框的flex设置为0 0 90px,代表该框不可伸缩,固定高度90px,而对于蓝框,则设置flex为1,代表伸展系数为1,这样,蓝框的高度就会占满除了粉框以外的全部空间。
而于此同时,粉框和蓝框本身又分别设置display为flex。对粉框而言,内部一共有欢迎标签和退出button两个元素,分列两侧,因此只需要设置justify-content为space-between即可做到这一点。而对蓝框而言,内部有在线用户列表以及聊天区域两个元素。这里在线用户列表(即黄色框)需要设置固定宽度,因此类似于刚才粉框的设置,flex: 0 0 240px,而聊天区域(即绿色框)则设置flex为1,这样会自适应占满剩余宽度。
最后,聊天区域内部又分为信息展示区以及打字区,因此聊天区域自身又是一个flexbox,设置方式类似,就不再具体分析了。
可以看出,使用flexbox,相比使用float以及position等等而言,更加的规整,使用这种思路,整个页面就像庖丁解牛一般,布局格外清晰。
项目中使用了redux作为数据流管理工具,配合react,能够让页面组件同页面数据形成规律的映射。
分析我们的聊天页面,可以看出,主要的数据就是目前在线的用户昵称列表,以及消息记录,此外我们还需要记录自己的用户昵称,方便消息发送时候取用。因此,整个应用的数据结构如下, 也就是redux中的store的数据结构如下:
{
"nickName": "your nickname",
"nameList": ["user A","user B","user C","...."],
"msgList": [
{
"nickName": "some user",
"msg": "some string"
},{
"nickName": "another user",
"msg": "another string"
},
]
}
有了这个总体的数据结构,我们就可以根据该结构设计具体的action,reducer等等部分了。这里整个程序的模块拆分遵循了redux官方实例当中的拆分方法,action文件夹当中定义action creators,reducer文件夹中定义reducer函数,component文件夹中定义一些通用的组件,container文件夹当中则是将通用组件取出,定义store中的数据同组件如何映射,以及组件中的事件如何dispatch action,从而引起store数据的改变。
以component/namelist中的组件为例,该组件用于显示在线用户昵称列表,因此它接受一个数组,也就是store中的nameList作为参数,因此其通用组件的写法也很简单:
class NameList extends React.Component {
constructor(props) {
super(props);
}
render() {
var {nameList} = this.props;
return (
<ul className='name-list'>
<li className='name-list-title'>在线用户:</li>
{nameList.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
)
}
}
export default NameList
而在container当中,只需要将store中的nameList赋值到该组件的props上面即可。其他组件也是类似的写法。
可以看出,在redux的思想下,我们可以对整个应用抽象出一个总体的数据结构,数据结构的改变,会引发各个组件的改变,而组件当中的各种事件,又会反过来修改数据结构,从而再次引起页面的改变,这是一种单向的数据流,总体的数据都在store这个对象中进行维护,从而让整个应用开发变得更加有规律。redux的这种程序架构是对react提出的flux架构的一种消化和改良,下图是flux架构的示意图:
由于是一个即时聊天应用,websocket协议自然是首选。而socket.io就是基于websocket实现的一套基于事件订阅与发布的js通信库。
在socket.io中,主要有server端和client端。创建一个server和client都非常容易,对于server端,配合koa,只需要如下代码:
var app=require('koa')();
var server = require('http').Server(app.callback());
var io = require('socket.io')(server);
client端更加简单:
var io=require('socket.io-client');
var socket = io();
一旦连接建立,client和server即可通过时间订阅与发布来彼此通信,socket.io提供的api非常类似于nodejs中的event对象的使用,对于server端:
io.on('connection',function(socket){
socket.on('some event',function(data){
//do something here....
socket.emit('another event',{some data here});
});
});
对于client端,同样通过socket.on以及socket.emit来订阅和发布事件。比如说,某一个client端口emit了event A,而如果server端口订阅了event A,那么在server端,对应的回调函数就会被执行。通过这种方式,可以方便的编写即时通信程序。
下面对程序中涉及的一些我认为值得注意的细节和技巧进行一下简要分析。
在程序编写过程当中,我遇到一个难题,就是如何将socket.io的client实例结合到redux当中。
socket.io的client类似于一个全局的对象,它不属于任何一个react组件,它订阅到的任何消息都可能更改整个应用的数据结构,而这种更改在redux当中又只能通过dispatch来实现。思考之后,我觉得编写一个redux中间件来处理socket.io相关的事件是一个很好的选择。
关于redux中间件,简单来说,就是在redux真正出发dispatch之前,中间件可以首先捕获到react组件出发的action,并针对不同action做一些处理,然后再调用dispatch。中间件的写法,在redux的官方文档当中写的非常详细,有兴趣的可以参考一下: http://redux.js.org/docs/advanced/Middleware.html , 后续我也会出一些系列文章,深入分析redux包括react-redux的原理,其中就会提到中间件的原理,尽请期待~
知道了redux中间件是怎么一回事之后,我们就可以发现,socket.io相关的事件非常适合通过写一个中间件来处理。我们程序当中中间件如下所示:
import { message_update, guest_update } from '../action'
function createSocketMiddleware(socket) {
var eventFlag = false;
return store => next => action => {
//如果中间件第一次被调用,则首先绑定一些socket订阅事件
if (!eventFlag) {
eventFlag = true;
socket.on('guest update', function(data) {
next(guest_update(data));
});
socket.on('msg from server', function(data) {
next(message_update(data));
});
socket.on('self logout', function() {
window.location.reload();
});
setInterval(function() {
socket.emit('heart beat');
}, 10000);
}
//捕获action,如果是和发送相关的事件,则调用socket对应的发布函数
if (action.type == 'MSG_UPDATE') {
socket.emit('msg from client', action.msg);
} else if (action.type == 'NICKNAME_GET') {
socket.emit('guest come', action.nickName);
} else if (action.type == 'NICKNAME_FORGET') {
socket.emit('guest leave', store.getState().nickName);
}
return next(action);
}
}
export default createSocketMiddleware
这段代码是一个socket middleware的创建函数,从中我们可以看出,这个中间件如果第一次调用的话(eventFlag),会首先绑定一些订阅主题和对应的回调函数,主要是订阅了消息到达、新用户来到、用户离开等等事件。同时,中间件会在真正dispatch函数调用之前,首先捕获action,然后分析action的type。如果是和发送事件相关的,就会调用socket.emit来发布对应的事件和数据。比如说,在我们的应用中,点击“发送”按钮会触发一个type为”MSG_UPDATE”的事件,这个事件首先被中间件捕获,那么这时候就会出发socket.emit(‘msg from client’)来将消息发送给server。
整个应用使用react-router,做成了一个单页面应用,其中前端路由的层级非常简单:
render(
<Provider store={store}>
<Router history={hashHistory}>
<Route path='/' component={ChatAllContainer}/>
<Route path='/login' component={LoginContainer}/>
</Router>
</Provider>
,
document.getElementById('test'));
可以看出,主要是两条路径: ‘/’和’/login’,其中’/’是我们的聊天界面,而’/login’则是起昵称界面。
由于应用的逻辑是,只有用户起了昵称才可以进入聊天界面,因此我们需要做一些权限验证,对于没有起昵称就进入’/’路径的用户,需要跳转到’/login’。在传统多页面web应用中,我们对于跳转非常熟悉,无非是服务器发送一个重定向请求,浏览器就会重定向到新的页面。然而在单页面中,由于始终只有一页,服务器又能够让浏览器跳转到哪里去呢?也就是说,服务器重定向的方法是行不通的。
因此,我们换一种思路,页面跳转的逻辑需要在浏览器端执行,在react-router的框架下,执行跳转也非常简单,只需要使用其中的hashHistory对象,通过hashHistory.push(‘path’),即可让应用跳转到指定路径对应的界面。有了这个认知,那么我们下面要解决的,就是何时控制单页面的跳转?
我的思路是,将用户的昵称通过一定的加密和编码,保存在cookie当中。当用户访问’/’的时候,在对于界面的组件挂载之前,首先会向服务器发送一个认证请求,服务器会从请求中读取cookie,如果cookie当中没有用户名存在,那么服务器返回的参数当中有一个’permit’字段,设置为false,当应用解析到该字段后,就会调用hashHistory.push(‘/login’)来让页面跳转到起昵称界面下。这部分对应的逻辑主要在container/chatAll.js文件当中实现。
在我们的聊天应用中,如果不对用户的输入进行一些处理,就有可能导致xss漏洞。举个例子,比如说有一个用户输入了’‘,如果不进行一些防范,输入到消息显示界面,这段文字就直接被解析成为了一段js代码。为了防范这类攻击,这里我们需要做一些简单的预防:
var regLeft = /</g;
var regRight = />/g;
value = value.replace(regLeft, '<');
value = value.replace(regRight, '>');
这段代码在components/typein组件当中。
此外,为了方便用户快速发送消息,在消息输入框中,我们设置了’enter’按键为之间发送按键。那么,为了让用户能够打出换行,我们模仿微信,约定用户输入ctrl+enter组合键的时候是换行,这样,在消息输入框中,就需要监听组合键。在js的键盘事件中,event对象有一个ctrlKey属性,用于判断ctrl按键是否按下:
someDom.onkeydown=function(e){
if(e.keyCode==13&&e.ctrlKey){
//组合键被按下
}
}
这就是组合键监听的原理。
以上就是对于这个项目的概述以及一些细节的讲解。最后安利一下我的博客 http://mly-zju.github.io/,会不定期更新我的原创技术文章和学习感悟,欢迎大家关注~