先上Demo動圖,效果如下:
基本思路
由於redux更改資料是dispatch(action),所以很自然而然想到以action作為基本單位在服務端和客戶端進行傳送,在客戶端和服務端用陣列來存放action,那麼只要當客戶端和服務端的action佇列的順序保持一樣,reducer是純函式的特性可以知道計算得到的state是一樣的。
一些約定
本文中C1,C2...Cn表示客戶端,S表示服務端,a1,a2,a3...an表示aciton,服務端是使用koa + socket.io來編寫的(作為一個前端,服務端的知識幾乎為0勿噴)。
整體思路
當客戶端發起一個action的時候,服務端接收到這個action,服務端做了3件事:
- 把action推進棧中
- 把該客戶端a1之前的action傳送該客戶端(類似git push之前有個git pull的過程)
- 將a1傳送給其他的客戶端
不過上面的思路是比較籠統的,細想會發現許多問題:
- 如果a1到達S端的時候,C2、C3還有action在派發怎麼處理?
- 如果a1到達S端的時候,C1正在接收其他客戶端傳送的action怎麼處理?
- 如果a1傳送之前,C1正在傳送前一個action怎麼處理? 後面我們一一解決。
服務端派發action機制
服務端設立了2個概念:target、member指編輯物件(可能是報告、流程圖等)和編輯使用者,當你要傳送兩個action:a1、a2的時候,因為網路傳送先後的不確定性,所以應該是先傳送a1,然後等待客戶端接收到,再傳送a2,這樣才能在客戶端中保證a1和a2的順序。因此,每個member會有變數pending表示在不在傳送,index表示傳送的最新的action在action佇列中的索引。
當服務端接收到客戶端的aciton的時候
this.socket.on('client send action', (action) => {
//目標將action放入佇列中,並返回該action的索引
let index = this.target.addAction(action);
if (this.pending) {
this.queue.push({
method: 'sendBeforeActions',
args: [index]
})
} else {
this.sendBeforeActions(index);
}
this.target.receiveAction({
member: this,
index
})
})
複製程式碼
這就是上面講的當服務端收到a1的時候做的3件事情。只是這裡會去判斷該member是不是正在執行傳送任務,如果是,那麼就將傳送a1前面的aciton這個動作存入到一個動作佇列中,並告知target,我這個member傳送了一個action。
sendBeforeActions
sendBeforeActions(refIndex) {
let {actions} = this.getCurrent(refIndex);
actions = actions.slice(0, -1);
this.pending = true;
this.socket.emit('server send before actions', { actions }, () => {
this.pending = false;
this.target.setIndex(this, refIndex);
this.sendProcess();
});
}
複製程式碼
這個函式接收一個索引,這個索引在上面的程式碼中是這個member接收到的action在佇列中的索引,所以getCurrent(refIndex)指到refIndex為止,還沒傳送給這個member的所有的action(可能為空),所以要剔除本身後actions.slice(0, -1)傳送給客戶端。 回撥中終止傳送狀態,設定member最新的action的index,然後執行sendProcess函式去看看,在自己本身傳送的過程中,是不是有後續的動作存入到傳送佇列中了
sendProcess() {
if (this.queue.length > 0 && !this.pending) {
let current = this.queue.shift();
let method = this[current.method];
method.apply(this, current.args);
}
}
複製程式碼
如果你注意到剛才的:
if (this.pending) {
this.queue.push({
method: 'sendBeforeActions',
args: [index]
})
}
複製程式碼
你就會發現,如果剛才想傳送before action的時候這個member在傳送其他action,那麼會等待這個action傳送完後才觸發sendProcess去執行這個傳送。
還要將這個action傳送給其他使用者
在剛才的程式碼中
//this指某個member物件
this.target.receiveAction({
member: this,
index
})
複製程式碼
就是這個觸發了其他客戶端的傳送
//this指某個target物件
receiveAction({member, index}) {
this.members.forEach(m => {
if (m.id !== member.id) {
m.queue.push({
method: 'sendActions',
args: [index]
});
m.sendProcess();
}
})
}
複製程式碼
如果members中存在傳送方的member,那麼會將傳送動作存入member的傳送佇列中,執行sendProcess
sendActions
sendActions(refIndex) {
let {actions} = this.getCurrent(refIndex);
if (actions.length) {
this.pending = true;
this.socket.emit('server send actions', {actions}, () => {
this.pending = false;
this.target.setIndex(this, refIndex);
this.sendProcess();
})
}
}
複製程式碼
這個函式和sendBeforeActions幾乎一樣,只差要不要剔除最新的action,這樣,就保證了服務端的傳送action順序
客戶端IO中介軟體
在客戶端中,將io有關的操作都封裝在一箇中介軟體中
module.exports = store => next => action => {
if (action.type === 'connection') {
//連線初始化一些事件
return initIo(action.payload)
}
if (action.type === 'disconnection') {
return socket.disconnect(action.payload)
}
if (['@replace/state'].indexOf(action.type.toLowerCase()) === -1 && !action.escapeServer && !action.temporary) {
//將action給定userId、targetId
action = actionCreator(action);
//得到新的action佇列,並計算actions,然後更新到state上
let newCacheActions = [...cacheActions, action];
mapActionsToState(newCacheActions);
//傳送給服務端
return delieverAction(action);
}
//這樣就只允許replace state 的action進入到store裡面,這個是我這個思路在實現undo、redo的一個要求,後面會講到
next();
}
複製程式碼
一些全域性變數
具體作用後面會用到
let cacheActions = []; //action佇列,這個和服務端的action佇列保持一致
let currentActions = []; //根據cacheActions計算的action
let redoActions = {}; //快取每個使用者的undo後拿掉的action
let pending = false; //是否在傳送請求
let actionsToPend = []; //快取傳送佇列
let beforeActions = []; //快取pull下來的actions
let currentAction = null;//當前傳送的action
let user, tid; //使用者名稱和targetId
let initialState; //初始的state
let timeline = []; //快取state
複製程式碼
客戶端整體思路圖
主要講兩個地方:
(1)在computeActions的時候,碰到undo拿掉該使用者的最後一個action,並把倒數第二個action提升到最後的原因是因為假如在該使用者倒數第二個action之後還有其他使用者的action發生,那麼可能其他使用者會覆蓋掉這個使用者action的設定值,那麼這個使用者undo的時候就無法回到之前的狀態了,這時候提升相當於是undo後做了新的action,這個action就是前一次的action。這個演算法是有bug的,當一個使用者undo的時候,由於我們會提升他倒數第二的action,這樣會導致與這個action衝突的action的修改被覆蓋。這個解決衝突的策略有點問題。如果沒有提升,那麼如果該使用者undo的時候,如果他上一個action被其他使用者的action覆蓋了,那麼他就無法undo回去了。這個是個痛點,我還在持續探索中,歡迎大神指教。
(2)在使用者pending的時候收到了actions,這個時候相當於是before actions。 下面貼幾個主要函式的程式碼
initIo
function initIo(payload, dispatch) {
user = payload.user;
tid = parseInt(payload.tid, 10);
//初始化socket
let socket = cache.socket = io(location.protocol + '//' + location.host, {
query: {
user: JSON.stringify(user),
tid
}
});
//獲取初始資料
socket.on('deliver initial data', (params) => {
...獲取初始的state,actions
})
//傳送action會等待pull之前的actions
socket.on('server send before actions', (payload, callback) => {
pending = false;
callback && callback();
let {actions} = payload;
actions = [...actions, ...beforeActions, currentAction];
cacheActions = [...cacheActions, ...actions];
if (actions.length > 1) {
//證明有前面的action,需要根據actions重新計算state
mapActionsToState();
}
if (actionsToPend.length) {
let action = actionsToPend.shift();
sendAction(action);
}
})
//接收actions
socket.on('server send actions', (payload, callback) => {
let {actions} = payload;
callback && callback();
if (pending) {
beforeActions = [...beforeActions, ...actions];
} else {
cacheActions = [...cacheActions, ...actions];
mapActionsToState();
}
})
}
複製程式碼
mapActionsToState
function mapActionsToState(actions) {
actions = actions || cacheActions;
if (actions.length === 0) {
return replaceState(dispatch)(initialState);
}
let {newCurrentActions, newRedoActions} = computeActions(actions);
let {same} = diff(newCurrentActions);
let state = initialState;
if (timeline[same]) {
state = timeline[same];
timeline = timeline.slice(0, same + 1);
}
if (same === -1) {
timeline = [];
}
let differentActions = newCurrentActions.slice(same + 1);
differentActions.forEach(action => {
state = store.reducer(state, action);
timeline.push(state);
});
currentActions = newCurrentActions;
redoActions = newRedoActions;
store.canUndo = () => currentActions.some(action => action.userId === user.id);
store.canRedo = () => !!(redoActions[user.id] || []).length;
return replaceState(dispatch)(state);
}
複製程式碼
computeActions
function computeActions(actions) {
let newCurrentActions = [];
let newRedoActions = {};
actions.forEach(action => {
let type = action.type.toLowerCase();
newRedoActions[action.userId] = newRedoActions[action.userId] || [];
if (type !== 'redo' && type !== 'undo') {
newCurrentActions.push(action);
newRedoActions[action.userId] = [];
}
if (type === 'undo') {
let indexes = [];
for (let i = newCurrentActions.length - 1; i >= 0; i--) {
if (newCurrentActions[i].userId === action.userId) {
indexes.push(i);
}
if (indexes.length === 2) {
break;
}
}
if (indexes.length > 0) {
let redo = newCurrentActions.splice(indexes[0], 1)[0];
newRedoActions[action.userId].push(redo);
}
if (indexes.length > 1) {
let temp = newCurrentActions.splice(indexes[1], 1);
newCurrentActions.push(temp[0]);
}
}
if (type === 'redo') {
let redo = newRedoActions[action.userId].pop();
newCurrentActions.push(redo);
}
});
return {
newCurrentActions,
newRedoActions
}
}
複製程式碼
diff
function diff(newCurrentActions) {
let same = -1;
newCurrentActions.some((action, index) => {
let currentAction = currentActions[index];
if (currentAction && action.id === currentAction.id) {
same = index;
return false;
}
return true;
});
return {
same
}
}
複製程式碼
結束語
講了一堆,不知道有沒有將自己的思路講清楚,自己的demo也執行了起來,測試只用了兩個瀏覽器來模擬測試,總感覺一些併發延時出現還會有bug,後面會持續優化這個想法,新增一些自動化測試來驗證,另外,對於服務端的儲存也還沒考慮,先在只在記憶體中跑,會思考儲存方案。希望對這方面有興趣的大神可以指導一下