前言
随着跨端技术的发展,前端开发职能不再局限于浏览器,而是具备了很多客户端开发的能力,比如桌面应用框架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;
}
};
复制代码





















![[桜井宁宁]COS和泉纱雾超可爱写真福利集-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/4d3cf227a85d7e79f5d6b4efb6bde3e8.jpg)

![[桜井宁宁] 爆乳奶牛少女cos写真-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/d40483e126fcf567894e89c65eaca655.jpg)