03/18 - 20:36 更新了一些的更具體的問題描述
想和大家探討一下最近專案上遇到的一個防抖問題。
問題概述
大致需求是:有一個表格,點選其中任意一行會載入一些與之相關的詳細內容(與表格在同一頁面)。載入這個步驟是一個Promise鏈,會依次從2個不同的伺服器端獲取相關資訊(存在依賴關係無法同時傳送請求)。
在短時間內多次點選時,由於載入的時間每次不一樣,可能會造成最終顯示的不是最後一次點選的內容,且每一次點選都會有DOM操作從而造成瀏覽器效能的損失。
我們認為最合理的當然是載入過程中阻止使用者繼續點選,然而此方案被客戶否決了:使用者不應該被限制自由,假如使用者點錯了,還要等載入完才能改嗎等等o(一︿一+)o
到這裡,我們很自然的想到了利用防抖來進行延遲執行。但問題來了,載入的時間是個很大的區間(幾百毫秒到幾秒都有可能),傳統的防抖在這個情況下並不適用。
舉個例子,我們延遲500毫秒執行,第一次點選載入花了2秒,1秒後我們又點了一次載入,這次只花了500毫秒,結果就是最終先顯示後一次結果,然後被前一次結果覆蓋。如果我們設定一個過大的延遲值,那將會極大的降低使用者體驗。
由此引出今天討論的話題,如何實現當Promise鏈未獲取最終結果前,只有最後一次點選能夠操作DOM改變頁面。
P.S.由於實際工程比較複雜,http請求被封裝在其他的模組中,所以在這裡不考慮通過abort來終止請求以達到更好的優化。
以下為實際問題簡化版:p1、p2、p3形成Promise鏈,可以看到,每次點選都會執行改變頁面。(固定了Promise執行時間,且多加了一個Promise來更好的擴充套件假設有n個Promise的情況)
const p1 = (data) => {
return new Promise(resolve => {
setTimeout(() => resolve(data + 1), 200);
});
};
const p2 = (data) => {
return new Promise(resolve => {
setTimeout(() => resolve(data + 2), 300);
});
};
const p3 = (data) => {
return new Promise(resolve => {
setTimeout(() => resolve(data + 3), 500);
});
};
const onClick = (data) => {
p1(data)
.then(data => p2(data))
.then(data => p3(data))
.then(result => {
// 實際情況為操作返回值改變頁面
console.log(result);
})
.catch(err => {
// 處理錯誤
});
};
// 模擬點選
onClick(1);
setTimeout(() => onClick(2), 400);
setTimeout(() => onClick(3), 2000);
// 7
// 8
// 9
複製程式碼
方案一
我們可以在onClick
上設定一個counter
,每次點選加1,只有當前值匹配counter
時才改變頁面。
// 省略p1,p2,p3申明
let counter = 0;
const onClick = (data) => {
const current = ++counter;
p1(data)
.then(data => p2(data))
.then(data => p3(data))
.then(result => {
if (current === counter) {
// 實際情況為操作返回值改變頁面
console.log(result);
}
})
.catch(err => {
if (current === counter) {
// 處理錯誤
}
});
};
onClick(1);
setTimeout(() => onClick(2), 400);
setTimeout(() => onClick(3), 2000);
// 第一個onClick不會重新整理頁面
// 8
// 9 第三個點選時第二個已經重新整理,所以第三個繼續重新整理頁面
複製程式碼
這個方案解基本解決了問題,但是仔細想想,實際上在每次點選時,所有的Promise鏈還是完全都執行了。
比如在第二個onClick
時,第一個的Promise鏈才執行到p2,那麼能不能不執行p3來達到更好的優化呢?
方案二:在方案一的基礎上進一步優化
通過在每個Promise上巢狀一個函式來實現進一步優化,如果不匹配counter
,直接reject
中斷Promise鏈。
// 省略p1,p2,p3申明
let counter = 0;
const onClick = (data) => {
const current = ++counter;
p1(data)
.then(wrapWithCancel(p2))
.then(wrapWithCancel(p3))
.then(result => {
if (current === counter) {
// 實際情況為操作返回值重新整理頁面
console.log(result);
}
})
.catch(err => {
if (current === counter && err !== 'cancelled') {
// 處理除了cancelled以外的錯誤
}
});
function wrapWithCancel(fn) {
return (data) => {
if (current === counter) {
return fn(data);
} else {
return Promise.reject('cancelled');
}
}
}
};
onClick(1);
setTimeout(() => onClick(2), 100);
setTimeout(() => onClick(3), 400);
// 第一個onClick的p2和p3都不會執行
// 第二個onClick的p3不會執行
// 9
複製程式碼
方案三:加上常規的防抖延遲執行
我們同樣可以在這基礎上加上常規的防抖延遲執行,進一步優化:
// 省略p1,p2,p3申明
let counter = 0;
const onClick = (data) => {
const current = ++counter;
p1(data)
.then(wrapWithCancel(p2))
.then(wrapWithCancel(p3))
.then(result => {
if (current === counter) {
// 實際情況為操作返回值重新整理頁面
console.log(result);
}
})
.catch(err => {
if (current === counter && err !== 'cancelled') {
// 處理除了cancelled以外的錯誤
}
});
function wrapWithCancel(fn) {
return (data) => {
if (current === counter) {
return fn(data);
} else {
return Promise.reject('cancelled');
}
}
}
};
const debounce = function (fn, wait) {
var timer = null;
return function () {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(context, args);
}, wait);
}
};
const debounced = debounce(onClick, 200);
debounced(1);
setTimeout(() => debounced(2), 100);
setTimeout(() => debounced(3), 200);
setTimeout(() => debounced(4), 600);
// 前兩個onClick的p1,p2和p3都不會執行
// 第三個onClick的p3不會執行
// 10
複製程式碼
第一次發文,不足之處還請輕噴,歡迎指出錯誤,如果你有更好的方法,也希望大家一起共同探討共同進步~