前言
随着跨端技术的发展,前端开发职能不再局限于浏览器,而是具备了很多客户端开发的能力,比如桌面应用框架Electorn
,移动App
框架React native
.
一般而言,前端同学对http
协议非常熟悉,在平时的工作中使用http
与后端通信居多.但在原生客户端领域,比如Java
语言开发的安卓应用,与后端通信的方式大多采用socket
.
众所周知,http
连接是一种短连接,即客户端向服务器端发送一次请求,服务器端响应后连接即会断掉.而socket
连接是一种长连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉.
前端领域存在一个和socket
连接功能相似的通信协议,即WebSocket
.WebSocket
创建了一种持久性的连接,后端不仅能正常处理客户端发送的消息,还能主动向客户端推送消息.
后端主动推送消息的能力在一些特殊的场景中太重要了,比如App
接受到的信息通知,即时通讯接受的好友信息,另外面板上实时展现波动的金融数据.
不管是桌面应用框架Electron
,还是App
开发框架React native
,它们都拥有基于原生平台封装的WebSocket
.比起浏览器端开放的WebSocket
,原生平台提供的协议要稳定很多.
因此在使用前端技术开发客户端应用时,完全可以使用WebSocket
协议作为前后端通信的主要方式,不再需要往项目中引入http
,因为http
拥有的能力WebSocket
同样也能找到替代方案.
本文接下来将详细介绍用react hook
开发一款客户端应用时,如何在项目中搭建有效的通信机制,让WebSocket
和redux
有机结合,在不影响前端习惯的编程风格下,建立起客户端与服务器的全双工通信.
实现
数据格式
前后端约定连接建立后,使用WebSocket
协议通信的数据格式(参考如下).
{
request_id,
command,
data,
}
复制代码
request_id
是一段随机生成的字符串,用来标识本次客户端向服务器请求的id
值.command
是命令关键词,类似于接口中的url
,后端根据此标识来决定返回接口数据.data
是发送的参数.
前端向后端发起请求时,以上3
个参数都得携带,其他参数可根据业务需要增添.
后端主动向前端推送消息时,只需要发送command
和data
参数即可.客户端监听command
,根据值的不同进行相应的操作.
在整个项目的通信架构下,前端需要搭建好以下两种通信机制.
-
客户端向服务器发送请求,服务器处理请求并返回响应结果,客户端接受响应结果再做后续处理.这种机制模拟了类似于前端
ajax
的通信方式,客户端除了发送请求,还要负责接受该请求的响应. -
服务器主动向客户端推送消息,客户端接受消息.
以上两种机制基本满足了开发的需要,接下来在项目实战中实现以上两种机制(源代码贴在了文章结尾).
登录功能
登录页如下,页面内容很简答,两个输入框,输入账号和密码.还有一个登录按钮.
鼠标点击登录按钮时,dispatch
触发LoginAction
,此时客户端向服务器发起登录请求,请求成功后进入then
的回调函数,打印出登录成功
并且路由跳转到首页home
.
import { LoginAction } from "../../redux/actions/login";
export default function Login() {
const dispatch = useDispatch();
const history = useHistory();
//省略
...
//登录
const login = ()=>{
dispatch(LoginAction()).then(()=>{
console.log("登录成功!");
history.push("/home");
})
}
return (
<div>
<ul>
<li>登录账号:</li>
<li>
<div><input onChange={updateUser} type="text" placeholder="请输入账号" /></div>
</li>
<li>登录密码:</li>
<li>
<div><input onChange={updatePwd} type="text" placeholder="请输入密码" /></div>
</li>
</ul>
<button onClick={login}>立即登录</button>
</div>
)
}
复制代码
现在进入LoginAction
函数的内部实现,探索它是如何实现发起请求 - 接受响应
(代码如下).
LoginAction
内部调用了fetch
函数,action
的type
值为"PUSH_MESSAGE"
.
从这里大概可以推断出fetch
内部调用了dispatch
,派发了一个type
为"PUSH_MESSAGE"
的action
.
另外fetch
函数返回了一个Promise
,在then
回调函数里接受后端返回的响应.
import { fetch } from "./global";
// 组装action数据类型
const loginType = (username,password)=>{
return {
type:"PUSH_MESSAGE", // 实际开发中这里应该用变量替代
value:{
command:"login",
data:{
username,
password
}
}
}
}
export const LoginAction = ()=>(dispatch,getState)=>{
const { username,password } = getState().login;
return fetch({
dispatch,
action:loginType(username,password)
}).then((response:any)=>{
console.log("接受后端响应,请求成功!");
})
}
复制代码
由此可见,fetch
函数它能实现向后端发起请求,并且在返回的then
回调函数里获取响应的能力.
fetch
函数代码如下,调用fetch
时如果传递了第三个参数loading
,就调用openLoading(dispatch)
,从而修改reducer
中定义的一个全局状态loading
,页面就可以根据loaidng
值做加载中的样式变换.
fetch
函数内部主要返回了一个Promise
,核心代码便是将resolve
赋予了action
,那么这将意味着何时调用action.resolve()
,fetch
函数返回的then
回调函数将何时执行.
代码接下来执行dispatch(action)
,将传递给fetch
函数的参数action
派发了.
从上面代码可知,派发的action.type
的值为PUSH_MESSAGE
,那么肯定在redux
中有一个地方会监听到派发的action
,并触发对后端的请求.
/**
* loading 决定本次请求需不需要让页面出现加载中的样式
*/
export const fetch = ({dispatch,action,loading = false}) =>{
loading && openLoading(dispatch); // 加载中
return new Promise((resolve,reject)=>{
action.resolve = resolve;
action.reject = reject;
dispatch(action);
}).finally(()=>{ // 异步请求完成后关闭loading
closeLoading(dispatch);
})
}
//修改全局reducers/global.ts定义的loading状态
const openLoading = (dispatch)=>{
dispatch({
type:"UPDATE_GLOBAL_STATE",
value:{
type:"loading",
value:true
}
})
}
const closeLoading = (dispatch)=>{
dispatch({
type:"UPDATE_GLOBAL_STATE",
value:{
type:"loading",
value:false
}
})
}
复制代码
中间件函数
在哪个地方去监听type
值为PUSH_MESSAGE
的action
呢?这部分代码封装在redux
中间件函数里非常合适.
中间件函数不仅能解析action
的具体参数,它还能将全局要使用的WebSocket
与redux
绑定在一起,最终实现通过派发action
达到运用WebSocket
向后端发起请求的目的.
观察下面中间件函数,即wsMiddleware()
的返回值.redux
的中间件函数会在应用每一次派发action
后,都会执行一遍.
登录action
派发后,线程会进入下面的中间件函数运行.中间件函数里编写了一个switch
,分别监听type
值为CONNECT_READY
、PUSH_MESSAGE
以及DIS_CONNECT
,分别对应建立WebSocket
连接、向后端推送数据以及断开连接三种操作.
登录操作派发的action.type
正是等于PUSH_MESSAGE
,因此会进入第二个case
结构,代表向后端发起请求.
后面我们会设置应用启动时派发type
值为CONNECT_READY
的action
,即创建WebSocket
连接.假设进行登录操作时,WebSocket
连接已经创建好了,变量名为socket
.
在case 'PUSH_MESSAGE'
包裹的代码里,代码首先解析action
的参数command
和data
,并使用uuid
随机生成一个不重复的字符串request_id
.
command
、data
和request_id
组装成参数对象message
.接下来关键的一步代码,如果发现action
携带resolve
参数,说明本次请求是由调用上面fetch
函数发起的,因此需要将该action
缓存到callback_list
.最后调用socket.send
将请求发送给后端.
前后端商议好,凡是带有request_id
的请求,后端处理完后也要将request_id
联合响应返回给前端.这样前端就能知道返回的响应对应着哪一次发起的请求.
现在前端请求已经结束了,后端一旦处理完就会向前端发起推送通知,这时候就会触发onMessage
函数.
onMessage
函数拿到后端推送的消息,取出其中的request_id
,并检查callback_list
是否缓存过该action
,如果缓存了说明该请求是通过fetch
发起,那么此时调用action.resolve(response.data)
,就能触发fetch
返回的回调函数执行并将响应结果一同传递过去.
这样整个过程串联起来就会发现,页面组件先调用action
,而action
调用fetch
,进而fetch
又触发中间件函数使用Websocket
向后端发送数据,并将请求的action
缓存在callback_list
.后端返回响应后,中间件的监听函数从callback_list
里取出缓存的action
,并调用action.resolve
从而顺利触发了fetch
的回调函数执行.因此整个环节便实现了发起请求 - 接受响应
.
const WS = window.require('ws');// 安装基于node构建的websocket库ws,并使用window.require引入
import { messageResolve } from './common';
import { v1 as uuid } from 'uuid';
//请求缓存列表
const callback_list = {};
const wsMiddleware = () => {
let socket = {}; // 存储websocket连接
/**
* 连接成功了
*/
const onOpen = (store) => {
store.dispatch({
type: 'UPDATE_GLOBAL_STATE',
value: {
type: 'connected',
payload: true,
},
});
};
/**
* 收到发送过来的消息
*/
const onMessage = (store, response) => {
if(typeof response === "string"){
response = JSON.parse(response);
}
let action;
if (response.request_id && (action = callback_list[response.request_id])) {
delete callback_list[response.request_id];
// 该请求缓存过了
action.resolve(response.data);
}
messageResolve(store, response);
};
/**
* 连接断开了
*/
const onClose = (store) => {
store.dispatch({
type: 'UPDATE_GLOBAL_STATE',
value: {
type: 'connected',
payload: false,
},
});
};
//定时器
let timer = null;
//返回中间件函数
return (store) => (next) => (action) => {
switch (action.type) {
// 建立连接
case 'CONNECT_READY':
timer = setInterval(() => {
if (socket != null && (socket.readyState == 1 || socket.readyState == 2)) {
//已经连接成功了
timer && clearInterval(timer);
timer = null;
return;
}
socket = new WS('ws://localhost:8080');
socket.on('open', () => {
onOpen(store);
});
socket.on('message', (data: any) => {
onMessage(store, data);
});
socket.on('close', () => {
onClose(store);
});
}, 1000);
break;
// 向后台推送消息
case 'PUSH_MESSAGE':
const { command, data } = action.value;
const message = {
command,
data,
request_id: uuid(),
};
if (action.resolve) {
callback_list[message.request_id] = action;
}
socket.send(JSON.stringify(message)); // 想后端推送消息
break;
// 应用主动发起断开连接的操作
case 'DIS_CONNECT':
socket.close();
onClose(store);
break;
default:
next(action);
}
};
};
export default wsMiddleware();
复制代码
建立连接
上面中间件函数还监听了两种操作,分别是'CONNECT_READY'
对应的建立连接和'DIS_CONNECT'
对应的断开连接.
在看上述操作之前,先在reducers
下面创建一个存储全局通用的状态文件global.js
(代码如下).文件分别定义了四个状态,分别是connected
、token
、is_login
以及loading
.
connected
用来标记当前Websocket
有没有处于连接上,比如突然断网connected
的值会变成false
,那么界面上就可以根据该状态值做相应的视图展现.
token
和is_login
是登录成功后赋予的值,下一次客户端再发起请求时就可以将token
值塞到data
中一起发送给后端做校验.
const defaultState = {
connected: false, // 是否连接上
token: '', // 登录成功返回的token
is_login:false, // 已经登录了吗
loading:false //页面是否显示加载中的样式
};
export default (state = defaultState, action: actionType) => {
switch (action.type) {
case 'UPDATE_GLOBAL_STATE': // 修改全局状态
const { type, payload } = action.value;
return { ...state, [type]: payload };
default:
return state;
}
};
复制代码
全局状态定义了四个,而与中间件函数密切相关的属性是connected
.
case 'CONNECT_READY'
负责监听建立Websocket
连接的操作(代码如下),代码块里首先定义了一个定时器,每过一秒连接一次,直到与后端连接成功为止.
连接建立后,socket
分别监听了三个函数open
、message
和close
.open
函数会在连接建立成功后触发,成功后将全局状态connected
置为true
.
close
断开连接时触发,断开时将全局状态connected
置为false
.
message
监听后端推送的过来的消息.这里的消息分为两种类型.一种是前端发起请求,后端返回响应,另一种是后端主动推送消息.
那何时何地派发type
值为'CONNECT_READY'
的action
来建立Websocket
连接呢?
/**
* 连接成功了
*/
const onOpen = (store) => {
store.dispatch({
type: 'UPDATE_GLOBAL_STATE',
value: {
type: 'connected',
payload: true,
},
});
};
/**
* 收到发送过来的消息
*/
const onMessage = (store, response) => {
if(typeof response === "string"){
response = JSON.parse(response);
}
let action;
if (response.request_id && (action = callback_list[response.request_id])) {
delete callback_list[response.request_id];
// 该请求缓存过了
action.resolve(response.data);
}
messageResolve(store, response);
};
/**
* 连接断开了
*/
const onClose = (store) => {
store.dispatch({
type: 'UPDATE_GLOBAL_STATE',
value: {
type: 'connected',
payload: false,
},
});
};
//省略
...
case 'CONNECT_READY':
timer = setInterval(() => {
if (socket != null && (socket.readyState == 1 || socket.readyState == 2)) {
//已经连接成功了
timer && clearInterval(timer);
timer = null;
return;
}
socket = new WS('ws://localhost:8080');
socket.on('open', () => {
onOpen(store);
});
socket.on('message', (data: any) => {
onMessage(store, data);
});
socket.on('close', () => {
onClose(store);
});
}, 1000);
复制代码
文章其实上面已经提及,建立连接应该发生在应用启动之时,因为只有当Websocket
连接成功了,后面所有的操作才有意义.
新建一个组件WsConnect
执行连接操作(代码如下).组件先判断全局状态connected
值,如果发现没有连接上,随即派发CONNECT_READY
,触发中间件的函数执行创建Websocket
连接的操作.
const WsConnect = (props) => {
const dispatch = useDispatch();
const { connected } = useSelector((state)=>state.global);
//建立websocket连接
if(!connected){
dispatch({
type:"CONNECT_READY"
});
}
return (
<div>
{props.children}
</div>
);
}
export default WsConnect;
复制代码
最后将WsConnect
塞入到react
的根组件App
中,这样就能确保应用在启动之时就会派发action
建立Websocket
连接.
export default function App() {
return (
<Provider store={store}>
<WsConnect>
<Router />
</WsConnect>
</Provider>
);
}
复制代码
登录完成
我们再回到最初讲解的LoginAction
(代码如下),中间件函数内监听到后端响应回来时会执行action.resolve(response.data)
.
这句代码一执行就会触发fetch
返回的then
回调函数执行.
回调函数将后端返回的值赋予了全局状态token
,并将全局状态is_login
设置为true
,代表登录成功了.
const updateGlobalType = (type,value)=>{
return {
type:"UPDATE_GLOBAL_STATE",
value:{
type,
value
}
}
}
export const LoginAction = ()=>(dispatch,getState)=>{
const { username,password } = getState().login;
return fetch({
dispatch,
action:loginType(username,password)
}).then((response)=>{
dispatch(updateGlobalType("token",response.token)); // 存储token值
dispatch(updateGlobalType("is_login",true)); //将全局状态is_login置为true
})
}
复制代码
由于上面fetch
函数前面加了一个return
返回自己的执行结果,因此界面上调用dispatch(LoginAction())
也能返回一个then
回调函数(代码如下).
在回调函数里引用react-router-dom
提供的api
,登录成功后页面立马跳转到首页,至此整个登录流程完结.
import { useHistory } from "react-router-dom";
import { LoginAction } from "../../redux/actions/login";
export default function Login() {
const dispatch = useDispatch();
const history = useHistory();
//省略
...
//登录
const login = ()=>{
dispatch(LoginAction()).then(()=>{
console.log("登录成功!");
history.push("/home");
})
}
return (
<div>
<ul>
<li>登录账号:</li>
<li>
<div><input onChange={updateUser} type="text" placeholder="请输入账号" /></div>
</li>
<li>登录密码:</li>
<li>
<div><input onChange={updatePwd} type="text" placeholder="请输入密码" /></div>
</li>
</ul>
<button onClick={login}>立即登录</button>
</div>
)
}
复制代码
接受通知
登录功能实践了发起请求 - 接受响应
的整体环节,接下来实现服务器主动推送消息的机制.
Demo
最终实现效果图如下.登录成功后,页面跳转到首页.在客户端没发起请求的条件下,应用会连续收到后端发送过来的推送通知,并将推送的数据渲染到首页视图上.
通过上面对中间件函数讲解可知,onMessage
专门负责处理后端推送过来的消息(代码如下).如果是后端主动推送的消息通知,代码会进入messageResolve
函数执行.
import { messageResolve } from './common';
/**
* 收到发送过来的消息
*/
const onMessage = (store, response) => {
if(typeof response === "string"){
response = JSON.parse(response);
}
let action;
if (response.request_id && (action = callback_list[response.request_id])) {
delete callback_list[response.request_id];
// 该请求缓存过了
action.resolve(response.data);
}
messageResolve(store, response);
};
复制代码
messageResolve
函数(代码如下)一方面会派发type
为MESSAGE_INCOMMING
的action
,触发某些页面上定义的监听逻辑.
另一方面它会解析出响应的command
字段,用来判端是否触发一些公共功能.比如全局的消息通知以及版本升级的操作.
/**
* 消息处理
*/
export const messageResolve = (store, response) => {
//将推送的消息广播全局,因为可能某些页面需要监听消息
store.dispatch({
type: 'MESSAGE_INCOMMING',
value: response,
});
//公共功能的开发
switch (response.command) {
case 'message_inform': //消息通知,可以用弹框提醒
console.log(`后端推送一条通知:${JSON.stringify(response.data)}`);
break;
case 'software_upgrading'://版本升级
console.log("触发版本升级的窗口");
break;
}
};
复制代码
首页reducer
一旦监听到messageResolve
派发的action
(代码如下),会解析出command
字段的值,如果发现command
值与"home/add_item"
相等,说明后端想在首页上实时动态添加数据.
最终首页视图会获取reducer
定义的list
状态渲染列表,当后端主动推送一条数据时,页面就会触发重新渲染.
至此后端主动推送的机制便已实现.
const defaultState = {
list: []
};
export default (state = defaultState, action: actionType) => {
switch (action.type) {
case 'MESSAGE_INCOMMING': //监听后端推送过来的消息
if(action.value.command === "home/add_item"){ // 添加一条数据
return {...state,list:[...state.list,action.value.data]};
}
return state;
break;
default:
return state;
}
};
复制代码