preface

With the development of cross-end technology, front-end development functions are no longer limited to browsers, but have a lot of client development capabilities, such as desktop application framework Electorn, mobile App framework React Native.

Generally speaking, the front-end students are very familiar with THE HTTP protocol, and use HTTP to communicate with the back-end in their daily work. However, in the field of native client, such as Android applications developed by Java language, socket is mostly used to communicate with the back end.

As we all know, HTTP connection is a short connection, that is, the client sends a request to the server, the server responds and the connection is broken. The socket connection is a long connection. Theoretically, once the client and server establish a connection, they will not actively break it.

WebSocket creates a persistent connection. The back end can not only normally process the messages sent by the client, but also actively push messages to the client.

The ability to push notifications actively from the back end is important in specific scenarios, such as App notifications, instant messaging messages from friends, and the real-time display of fluctuating financial data on the panel.

Both Electron, the desktop application framework, and React Native, the App development framework, have websockets based on native platform encapsulation. The protocol provided by the native platform is much more stable than WebSocket, which is open on the browser side.

Therefore, when using front-end technology to develop client applications, WebSocket protocol can be completely used as the main way of communication between the front and back ends, and it is no longer necessary to introduce HTTP into the project, because WebSocket can also find an alternative to HTTP.

Next, this paper will introduce how to set up an effective communication mechanism in the project when developing a client application using React Hook, organically combine WebSocket and Redux, and establish full-duplex communication between the client and the server without affecting the programming style of the front-end.

implementation

The data format

The data format that the WebSocket protocol communicates with after the connection is established (see the following table).

{
   request_id,
   command,
   data,  
}
Copy the code
  • request_idIs a randomly generated string that identifies the request made by the client to the serveridValue.
  • commandIs the command keyword, similar to the interfaceurlAccording to this identifier, the back end decides to return interface data.
  • dataIs the parameter to send.

When the front end sends a request to the back end, the preceding three parameters must be carried. Other parameters can be added based on service requirements.

To push messages to the front end, you only need to send command and data parameters. The client listens to the command and performs operations based on the value.

Under the communication architecture of the whole project, the front-end needs to set up the following two communication mechanisms.

  • The client sends a request to the server. The server processes the request and returns the response result. The client receives the response result and performs subsequent processing. This mechanism mimics front-end Ajax-like communication, where the client is responsible for not only sending the request but also receiving the response to that request.

  • The server actively pushes messages to the client, and the client receives the messages.

Both of these mechanisms are basically sufficient for development, and we’ll implement them in the real world (source code is posted at the end of this article).

The login function

The login page is as follows, the page content is very simple, two input boxes, enter the account and password. There is also a login button.

When the login button is clicked, the Dispatch triggers the LoginAction. At this time, the client initiates a login request to the server. After the login request is successful, the then callback function is entered, and the login success is printed out, and the route is redirected to the home page.

import { LoginAction } from ".. /.. /redux/actions/login"; export default function Login() { const dispatch = useDispatch(); const history = useHistory(); / / to omit... / / login const login () = = > {dispatch (LoginAction ()), then (() = > {the console. The log (" login successfully!" ); history.push("/home"); </li> <div><input onChange={updateUser} type="text" placeholder=" placeholder "/> </li> <li> <div><input onChange={updatePwd} type="text" placeholder=" placeholder "/></ li> </ul> <button </button> </div>)}Copy the code

Now go to the internal implementation of the LoginAction function and explore how it implements the request-receive response (code below).

The fetch function is called internally by the LoginAction, which has an action type value of “PUSH_MESSAGE”.

It can be inferred from this that the fetch calls dispatch internally and dispatches an action of type “PUSH_MESSAGE”.

In addition, the fetch function returns a Promise that accepts the response from the back end in the then callback.

import { fetch } from "./global"; 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(" Accept back end response, request successful!"). ); })}Copy the code

Thus, the FETCH function implements the ability to initiate a request from the back end and get the response from the returned THEN callback function.

The code of fetch function is as follows. If the third parameter loading is passed when fetch is called, openLoading(dispatch) is called, so as to modify a global state loading defined in reducer, and the page can be changed according to the value of LOaidng.

The fetch function essentially returns a Promise inside. The core code assigns resolve to the action, which means when action.resolve() will be called and the THEN callback returned by fetch will be executed.

The code then executes dispatch(Action), which dispatches the action argument passed to the FETCH function.

As you can see from the code above, if the action. Type is PUSH_MESSAGE, there must be a place in the Redux that will listen for the action and trigger a request to the back end.

/ / export const fetch = ({dispatch,action,loading = false}) =>{loading && openLoading(dispatch); Return new Promise((resolve,reject)=>{action. Resolve = resolve; action.reject = reject; dispatch(action); }).finally(()=>{// closeLoading closeLoading(dispatch); })} // Modify the loading state defined by the global reducers/global.ts 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 } }) }Copy the code

Middleware function

Where do I listen for action whose type is PUSH_MESSAGE? This part of the code is perfectly encapsulated in the Redux middleware function.

The middleware function can not only parse the specific parameters of the action, but also bind the global WebSocket to Redux, and finally realize the purpose of using WebSocket to initiate the request through distributing the action.

Observe the return value of the following middleware function, wsMiddleware(). Redux’s middleware function executes after each action dispatched by the application.

After logging in to the action dispatch, the thread will run in the middleware function below. A switch is programmed in the middleware function, which listens for CONNECT_READY, PUSH_MESSAGE and DIS_CONNECT, respectively, corresponding to the three operations of establishing WebSocket connection, pushing data to the back end and disconnecting.

The action. Type sent by the login operation is exactly the same as PUSH_MESSAGE, so it enters the second case structure, representing the request to the back end.

Later we will set the action that will be issued with type CONNECT_READY when the application is started, that is, to create a WebSocket connection. Assume that the WebSocket connection has already been created and the variable name is socket when you log in.

In the case ‘PUSH_MESSAGE’ wrapped code, the code first parses the action parameters Command and data and randomly generates a unique string request_id using the UUID.

Command, data, and request_id are assembled into the parameter object Message. The next critical step, if the action is found to carry resolve, indicates that the request was initiated by calling the fetch function above, so the action needs to be cached in the Callback_list. Finally, a call to socket.send sends the request to the back end.

All requests with a request_ID must be returned to the front end after being processed. This allows the front end to know which response the returned response corresponds to.

Now that the front-end request has finished, the back-end sends a push notification to the front-end once it’s finished processing, which triggers the onMessage function.

Resolve (response.dat) onMessage retrieves the request_ID from the back-end push message and checks whether callback_list has cached the action. If it has cached the action, call action.resolve(response.dat) A), triggers the callback function returned by fetch to execute and passes the result of the response.

As a result, the page component calls the action, which in turn calls the FETCH, which in turn triggers the middleware function to send data to the back end using Websocket and cache the requested action to the Callback_list. When the back end returns the response, the middleware listener retrieves the cached action from callback_list and calls Action. resolve, which triggers the fetch callback smoothly. Therefore, the whole process implements the request-receive response.

const WS = window.require('ws'); Import {messageResolve} from './common'; import { v1 as uuid } from 'uuid'; // Request cache list const callback_list = {}; const wsMiddleware = () => { let socket = {}; // Const onOpen = (store) => {store. Dispatch ({type: 'UPDATE_GLOBAL_STATE', value: { type: 'connected', payload: true, }, }); }; /** * const onMessage = (store, const onMessage) 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]; // This request is cached 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) {// Establish connection case 'CONNECT_READY': timer = setInterval(() => { if (socket ! = null && (socket. The readyState = = 1 | | socket. The readyState = = 2)) {/ / has been successful connection 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)); // Push message break; Case 'DIS_CONNECT': socket.close(); onClose(store); break; default: next(action); }}; }; export default wsMiddleware();Copy the code

Establish a connection

The above middleware function also listens for two operations, establishing a connection for ‘CONNECT_READY’ and disconnecting for ‘DIS_CONNECT’.

Before looking at the above, create a global.js state file under reducers to store globally generic (code below). The file defines four states: Connected, Token, is_login, and Loading.

Connected is used to mark whether the Websocket is currently connected. For example, if the network is suddenly disconnected, the value of Connected will change to false, and corresponding view display can be performed on the interface according to the value of connected.

Token and is_login are values assigned after successful login. The next time the client sends a request, the token value can be inserted into data and sent to the back end for verification.

Const defaultState = {connected: false, // whether the token is connected: Loading :false // Whether the page is displaying a loading style}; export default (state = defaultState, action: actionType) => { switch (action.type) { case 'UPDATE_GLOBAL_STATE': // Change the global state const {type, payload} = action.value; return { ... state, [type]: payload }; default: return state; }};Copy the code

Global state defines four, and the property that is closely related to the middleware function is Connected.

Case ‘CONNECT_READY’ listens for the Websocket connection (code below). The code block first defines a timer that connects every second until the connection to the back end is successful.

After the connection is established, the socket listens for the three functions open, message and close respectively. The open function is triggered when the connection is established successfully, and the connected state is set to true after the connection is established successfully.

Close Indicates that connected is triggered when the connection is disconnected. When connected is disconnected, the global status is set to False.

Message listens for incoming messages pushed by the back end. There are two types of messages. One is that the front end initiates a request and the back end returns a response, and the other is that the back end actively pushes messages.

When and where to send an action of type ‘CONNECT_READY’ to establish a Websocket connection?

/ / const onOpen = (store) => {store. Dispatch ({type: 'UPDATE_GLOBAL_STATE', value: {type: 'connected', payload: true, }, }); }; /** * const onMessage = (store, const onMessage) 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]; // This request is cached action.resolve(response.data); } messageResolve(store, response); }; / / const onClose = (store) => {store. Dispatch ({type: 'UPDATE_GLOBAL_STATE', value: {type: 'connected', payload: false, }, }); }; / / to omit... case 'CONNECT_READY': timer = setInterval(() => { if (socket ! = null && (socket. The readyState = = 1 | | socket. The readyState = = 2)) {/ / has been successful connection 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);Copy the code

In fact, as mentioned above, the connection should be established when the application is started, because all subsequent operations only make sense if the Websocket connection is successful.

Create a new component WsConnect to perform the connection operation (code below). The component determines the connected value of the global state and if it finds no connection, it issues CONNECT_READY, triggering the middleware function to create a Websocket connection.

const WsConnect = (props) => { const dispatch = useDispatch(); const { connected } = useSelector((state)=>state.global); // Set up websocket connection if(! connected){ dispatch({ type:"CONNECT_READY" }); } return ( <div> {props.children} </div> ); } export default WsConnect;Copy the code

Finally, plug WsConnect into the React root App. This ensures that the App will send an action to establish a Websocket connection at startup.

export default function App() {
  return (
    <Provider store={store}>
      <WsConnect>
        <Router />
      </WsConnect>
    </Provider>
  );
}

Copy the code

Login to complete

Back to the original LoginAction(code below), the middleware function executes action.resolve(Response.data) when it listens for a back end response.

The execution of this code triggers the execution of the THEN callback returned by fetch.

The callback function assigns the value returned by the back end to the global status token and sets the global status is_login to true, indicating a successful login.

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)); // Store token dispatch(updateGlobalType("is_login",true)); // set global state is_login to true})}Copy the code

Since the fetch function above is preceded by a return that returns its own execution result, a call to dispatch(LoginAction()) on the interface also returns a then callback (code below).

The API provided by react-router-dom is referenced in the callback function. After successful login, the page is immediately redirected to the home page, and the whole login process is completed.

import { useHistory } from "react-router-dom"; import { LoginAction } from ".. /.. /redux/actions/login"; export default function Login() { const dispatch = useDispatch(); const history = useHistory(); / / to omit... / / login const login () = = > {dispatch (LoginAction ()), then (() = > {the console. The log (" login successfully!" ); history.push("/home"); </li> <div><input onChange={updateUser} type="text" placeholder=" placeholder "/> </li> <li> <div><input onChange={updatePwd} type="text" placeholder=" placeholder "/></ li> </ul> <button </button> </div>)}Copy the code

Acceptance notice

The login function practices the whole link of initiating request and receiving response, and then realizes the mechanism of server actively pushing messages.

The final Demo effect picture is as follows. After successful login, the home page is displayed. If the client does not initiate a request, the application will continuously receive push notifications from the back end and render the pushed data to the home page view.

As you can see from the middleware functions described above,onMessage is specifically responsible for handling messages pushed from the back end (code below). If the notification is actively pushed by the backend, the code will be executed in the messageResolve function.

import { messageResolve } from './common'; /** * const onMessage = (store, const onMessage) 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]; // This request is cached action.resolve(response.data); } messageResolve(store, response); };Copy the code

On the one hand, the messageResolve function (code below) will issue an action of type MESSAGE_INCOMMING that triggers the listening logic defined on some pages.

On the other hand, it will parse out the command field of the response, which is used to determine whether the end triggers some common function. Such as global message notification and version upgrade operations.

/** * message handling */ export const messageResolve = (store, response) => { 'MESSAGE_INCOMMING', value: response, }); // switch (response.mand) {case 'message_inform': ${json.stringify (response.data)} '); break; Case 'software_upgrade ':// upgrade console.log(" upgrade window "); break; }};Copy the code

If the command value is equal to “home/add_item”, it indicates that the back end wants to add data dynamically on the home page.

Finally, the home view will obtain the list state rendering list defined by reducer. When the back end actively pushes a data, the page will trigger re-rendering.

At this point, the active push mechanism of the back end has been realized.

const defaultState = { list: [] }; export default (state = defaultState, action: actionType) => { switch (action.type) { case 'MESSAGE_INCOMMING': Action.value.com mand === "home/add_item"){return {... state,list:[...state.list,action.value.data]}; } return state; break; default: return state; }};Copy the code

The source code

The source address