活用控制反轉 -- 一大波騷操作
在我初學程式設計的時候,還沒寫過完整點的專案就看過了一些高階概念。在沒有實踐時,這些概念的神奇和強大之處很難被完全體會的。而一旦自己在摸索中應用了,瞬間覺得打開了一扇大門,技能又提升了一個層次。控制反轉(Inversion of Control)就是這些強大概念之一。一年前在 MPJ 老師的頻道上了解到了,但一直沒自己獨立創造場景用過。直到最近在專案中遇到個坑才用起來。
其實控制反轉或者依賴注入(這兩個感覺是同一個東西,看你從什麼角度看)在前端框架中已經大量使用了。最早由 Angular 普及,後續的現代框架都有應用。比如 React 開發中目前最火的元件設計模式 Render Props,就是控制反轉的一種應用。離開框架,在日常開發中,應用這種技巧可以幫助我們解決很多棘手的問題,今天就講下我在開發中的一次應用。
專案場景是這樣的:技術棧是 Nuxt + Vuex。專案需要連線 Web Socket,然後根據 Socket 傳來的資料,對 Vuex 裡面相應的資料進行修改。公司為了節約成本,將 Socket 資料壓縮了,而且不是全量推送,這要求前端收到資料後對資料進行解壓,然後對資料進行遍歷查詢,更新,重新計算和排序,總之對 Socket 資料的處理非常複雜。為了不影響效能,我把 Socket 連線和資料處理放進了 Web Worker。先來看下專案結構。
下面是我封裝的一個 Socket 工廠函式:
// utils/socket.js export default function Socket() { let heartBeat; // 心跳記錄 let lost = 0; // 心跳失敗次數 function decLost() { lost -= 1; } function connect() { const socket = new WebSocket("wss://xx.com"); socket.onopen = () => { heartBeat = setInterval(() => { socket.send(2); lost += 1; if (lost === 3) { // 心跳失敗超過 3 次,斷開重連 clearInterval(heartBeat); socket.close(); connect(); } }, 5000); }; socket.onerror = () => { clearInterval(heartBeat); socket.close(); }; socket.onclose = () => { setTimeout(() => { clearInterval(heart); connect(); }, 3000); }; return socket; } return Object.freeze({ decLost, connect }); } 複製程式碼
Socket 連線實現了心跳機制。 onopen
之後,每隔 5 秒向伺服器傳送 2,並把心跳失敗次數加 1;伺服器收到 2 之後會返回 3,客戶端收到 3 之後再把心跳失敗次數減 1。工廠函式暴露的 decLost
方法是為了外部在收到 3 之後把心跳次數減 1.
在 Web Worker 檔案裡面,呼叫 Socket
工廠函式,並連線 socket
:
// workers/socket.js import Socket from "~/utils/socket"; const socket = Socket(); const socketConnection = socket.connect(); socketConnection.onmessage = ({ data }) => { // 處理 socket 接收到的資料, // 處理完後通過 web worker 介面發出去 postMessage(result); }; // web worker 收到外部的資料後,把資料發給 socket onmessage = ({ data }) => { socketConnection.send(data); }; 複製程式碼
然後在一個 Nuxt 外掛裡,引入 socket worker,收到 worker 裡傳來的資料後,把資料交給 Vuex Store,反之,監聽到相關 Vuex Mutation 後,把 payload 傳給 worker:
// plugins/socket.js // webpack 下匯入 web worker 的方式: import SocketWorker from "worker-loader!~/workers/socket.js"; const socketWorker = new SocketWorker(); export default ({ store }) => { store.subscribe((mutation, state) => { // 監聽到相關 Vuex Mutation }); socketWorker.onmessage = ({ data }) => { //監聽 socket 發來的資料,收到資料後, // 通過 store.commit() 來把資料存入 vuex store }; }; 複製程式碼
這是我一開始寫的 naive 版本,看起來主要功能實現了,而且封裝和 Separation of concerns 做的也不錯。寫完剛跑起來,問題出現了。
挑戰一:等 socket 連線成功後再發起訂閱
當應用開啟後,需要立即訂閱推送資料,包括使用者登入狀態下的私有資料和其它基礎資料等。但是當發起訂閱時,socket 可能連線成功了,也可能還沒連線成功。一開始我想設定個定時器,過兩秒後再發起訂閱。可是想想這種做法也太挫了。第二個思路是在 socket 連線的 onopen 事件裡執行訂閱。可是這樣子會直接把以前的 onopen 覆蓋掉,而且這樣做違反了封裝原則。剩下就一個辦法了,等連線成功後再發請求。來看怎麼做的:
// workers/socket.js // ... // const socketConnection ... const waitForConnection = timeout => new Promise((resolve, reject) => { const check = () => { if (socketConnection.readyState === 1) { resolve(); } else if ((timeout -= 100) < 0) { reject("socket connection timed out"); } else { setTimeout(check, 100); } }; setTimeout(check, 100); }); // ... 其它細節 onmessage = async ({ data }) => { try { await waitForConnection(2000); } catch (e) { console.error(e); return; } socketConnection.send(data); }; 複製程式碼
waitForConnection
函式會每隔 100 ms 檢查 socket 連線狀態,如果連線狀態是 1(成功),則 resolve Promise,否則一直隔 100 ms 檢查一次,直到連線成功或者超過指定時間。
在向 socket 傳送資料之前,先呼叫 waitForConnection
,並指定最多等 2 秒,確保連線成功後再發送資料。
問題看起來解決了。奇淫技巧都用上了,讓我滿意了一會兒。直到……
需求來了!
在 socket 斷開重連後,需要續訂之前的訂閱。而包括使用者 token 等訂閱引數全都在 Vuex Store 裡面。那這下頭疼了,Vuex store 裡面是沒法知道斷開重連的,而 worker 裡面則根本沒法讀取 vuex store。知道這個需求後我內心是崩潰的,這根本沒法寫下去了啊!就在我都快要打算調整架構重寫時,一拍腦袋靈光一閃,試試控制反轉!
首先要讓 Socket 工廠函式有個判斷重連的機制。這個簡單。
// utils/socket.js export default function Socket() { let connectCount = 0; // 連線成功次數 // ...細節,見文章前面 socket.onopen = () => { // 每次連線成功,連線次數加1 connectCount += 1; if (connectCount > 1) { // 若連線次數超過一次,則說明此次是重連 // 在這裡可以做些重連之後的操作了 } }; // ... } 複製程式碼
重連之後具體做什麼事,這可以用依賴注入來實現。先在 worker 檔案裡定義要做的事情,然後在呼叫 Socket 工廠函式時注入方法:
// worker/socket // 通過 postMessage 通知外部重連 const notifyReconnect = () => { postMessage({ type: "reconnect" }); }; const socket = Socket(notifyReconnect); 複製程式碼
然後在 Socket 函式裡接收一下:
// utils/socket.js export default function Socket(notifyReconnect) { // ...細節,見文章前面 socket.onopen = () => { connectCount += 1; if (connectCount > 1) { notifyReconnect(); } }; // ... } 複製程式碼
我以為寫到這裡應該就可以了的,然而我還是太天真了。執行後, plugins/socket
檔案裡能接收到重連訊息,但是一直連線失敗。這個問題很詭異,最後發現還是因為我對 Web Socket 掌握的不深導致的。每次 socket 連線後,生成的連線例項都是新的。而我在 waitForConnection
方法裡監聽的 socketConnection
在關閉後, readyState
一直是 3(關閉狀態),導致 waitForConnection
方法一直報 timeout 錯誤。
剩下的最後問題是每次重連,都更新連線例項。方法如下:
// worker/socket let socketConnection; const notifyReconnect = connection => { postMessage({ type: "reconnect" }); socketConnection = connection; }; const socket = Socket(notifyReconnect); socketConnection = socket.connect(); 複製程式碼
// utils/socket export default function Socket(notifyReconnect) { // ...細節,見文章前面 socket.onopen = () => { connectCount += 1; if (connectCount > 1) { notifyReconnect(socket); } }; // ... } 複製程式碼
Socket 函式在呼叫 notifyReconnect
時,傳入最新的連線例項 socket
。
至此,功能都實現了。完整程式碼如下:
// utils/socket.js export default function Socket(notifyReconnect) { let heartBeat; let lost = 0; let connectCount = 0; function decLost() { lost -= 1; } function connect() { const socket = new WebSocket("wss://xx.com"); socket.onopen = () => { connectCount += 1; if (connectCount > 1) { notifyReconnect(socket); } heartBeat = setInterval(() => { socket.send(2); lost += 1; if (lost === 3) { clearInterval(heartBeat); socket.close(); connect(); } }, 5000); }; socket.onerror = () => { clearInterval(heartBeat); socket.close(); }; socket.onclose = () => { setTimeout(() => { clearInterval(heart); connect(); }, 3000); }; return socket; } return Object.freeze({ decLost, connect }); } 複製程式碼
// workers/socket.js import Socket from "~/utils/socket"; let socketConnection; const notifyReconnect = connection => { postMessage({ type: "reconnect" }); socketConnection = connection; }; const socket = Socket(notifyReconnect); socketConnection = socket.connect(); const waitForConnection = timeout => new Promise((resolve, reject) => { const check = () => { if (socketConnection.readyState === 1) { resolve(); } else if ((timeout -= 100) < 0) { reject("socket connection timed out"); } else { setTimeout(check, 100); } }; setTimeout(check, 100); }); socketConnection.onmessage = ({ data }) => { // 處理完資料後通過 web worker 介面發出去 postMessage(result); }; onmessage = async ({ data }) => { try { await waitForConnection(2000); } catch (e) { console.error(e); return; } socketConnection.send(data); }; 複製程式碼
// plugins/socket.js import SocketWorker from "worker-loader!~/workers/socket.js"; const socketWorker = new SocketWorker(); export default ({ store }) => { store.subscribe((mutation, state) => {}); socketWorker.onmessage = ({ data }) => { if (data.type === "reconnect") { socketWorker.postMessage(/* 訂閱引數 */); } }; }; 複製程式碼
廣告時間
我在準備換工作。座標深圳。想找一個技術實力強點的團隊,想做對技術有挑戰的專案。我工作經驗一年,熟練使用 React, React Native 和 Vue。擅長函數語言程式設計,程式碼質量高。工作經驗確實短,但是成長速度比大多數人要快。歡迎交流,微信:hiimray
