之前校招面試的時候遇到過一個很有趣的問題:
“假設有一個超超超大型的Web專案,JS原始碼壓縮之後超過10MB(實際怎麼可能會有這麼大的嘛=。=),每次更新之後都需要使用者重新載入JS是不可接受的,那麼怎麼樣從工程的角度解決這種問題?”
一開始立馬想到了好幾點解決方案,比如:
- 抽出基礎的不常更新的模組作為長期快取;
- 如果使用了 React 或者 Vue2.0 這樣支援伺服器端渲染的框架的話,就採用伺服器端渲染然後再逐步分塊載入 JS 的方法;
- 如果是 Hybrid 開發的話,可以考慮使用本地資源載入,類似“離線包”的想法(之前在騰訊實習的時候天天遇到這東西)。
後來在面試官的引導下想到了一種“增量式更新”的解決方案,簡單地說就是在版本更新的時候不需要重新載入資源,只需要載入一段很小的 diff 資訊,然後合併到當前資源上,類似 git merge 的效果。
1、使用者端使用 LocalStorage 或者其它儲存方案,儲存一份原始程式碼+時間戳:
1 2 3 4 |
{ timeStamp: "20161026xxxxxx", data: "aaabbbccc" } |
2、每次載入資源的時候向伺服器傳送這個時間戳;
3、伺服器從接受到時間戳中識別出客戶端的版本,和最新的版本做一次 diff,返回兩者的 diff 資訊:
1 2 3 |
diff("aaabbbccc", "aaagggccc"); // 假設我們的diff資訊這樣表示: // [3, "-3", "+ggg", 3] |
4、客戶端接收到這個 diff 資訊之後,把本地資源和時間戳更新到最新,實現一次增量更新:
1 2 |
mergeDiff("aaabbbccc", [3, "-3", "+ggg", 3]); //=> "aaagggccc" |
二、實踐
下面把這個方案中的核心思想實現一遍,簡單地說就是實現 diff 和 mergeDiff 兩個函式。
今天找到了一個不錯的 diff 演算法:
GitHub – kpdecker/jsdiff: A javascript text differencing implementation.
我們只需要呼叫它的 diffChars 方法來對比兩個字串的差異:
1 2 3 4 5 6 7 8 |
var oldStr = 'aaabbbccc'; var newStr = 'aaagggccc'; JsDiff.diffChars(oldStr, newStr); //=> //[ { count: 3, value: 'aaa' }, // { count: 3, added: undefined, removed: true, value: 'bbb' }, // { count: 3, added: true, removed: undefined, value: 'ggg' }, // { count: 3, value: 'ccc' } ] |
上面的 diff 資訊略有些冗餘,我們可以自定義一種更簡潔的表示方法來加速傳輸的速度:
1 |
[3, "-3", "+ggg", 3] |
整數代表無變化的字元數量,“-”開頭的字串代表被移除的字元數量,“+”開頭的字串代表新加入的字元。所以我們可以寫一個 minimizeDiffInfo 函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function minimizeDiffInfo(originalInfo){ var result = originalInfo.map(info => { if(info.added){ return '+' + info.value; } if(info.removed){ return '-' + info.count; } return info.count; }); return JSON.stringify(result); } var diffInfo = [ { count: 3, value: 'aaa' }, { count: 3, added: undefined, removed: true, value: 'bbb' }, { count: 3, added: true, removed: undefined, value: 'ggg' }, { count: 3, value: 'ccc' } ]; minimizeDiffInfo(diffInfo); //=> '[3, "-3", "+ggg", 3]' |
使用者端接受到精簡之後的 diff 資訊,生成最新的資源:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
mergeDiff('aaabbbccc', '[3, "-3", "+ggg", 3]'); //=> 'aaagggccc' function mergeDiff(oldString, diffInfo){ var newString = ''; var diffInfo = JSON.parse(diffInfo); var p = 0; for(var i = 0; i < diffInfo.length; i++){ var info = diffInfo[i]; if(typeof(info) == 'number'){ newString += oldString.slice(p, p + info); p += info; continue; } if(typeof(info) == 'string'){ if(info[0] === '+'){ var addedString = info.slice(1, info.length); newString += addedString; } if(info[0] === '-'){ var removedCount = parseInt(info.slice(1, info.length)); p += removedCount; } } } return newString; } |
三、實際效果
有興趣的話可以直接執行這個:
GitHub – starkwang/Incremental
使用 create-react-app 這個小工具快速生成了一個 React 專案,隨便改了兩行程式碼,然後對比了一下build之後的新舊兩個版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var JsDiff = require('diff'); var fs = require('fs'); var newFile = fs.readFileSync('a.js', 'utf-8'); var oldFile = fs.readFileSync('b.js', 'utf-8'); console.log('New File Length: ', newFile.length); console.log('Old File Length: ', oldFile.length); var diffInfo = getDiffInfo(JsDiff.diffChars(oldFile, newFile)); console.log('diffInfo Length: ', diffInfo.length); console.log(diffInfo); var result = mergeDiff(oldFile, diffInfo); console.log(result === newFile); |
下面是結果:
可以看到 build 之後的程式碼有 21w 多個字元(212KB),而 diff 資訊卻相當短小,只有151個字元,相比於重新載入新版本,縮小了1000多倍(當然我這裡只改了兩三行程式碼,小是自然的)。
四、一些沒涉及到的問題
上面只是把核心的思路實現了一遍,實際工程中還有更多要考慮的東西:
1、伺服器不可能對每一次請求都重新計算一次 diff,所以必然要對 diff 資訊做快取;
2、使用者端持久化儲存的實現方案,比如喜聞樂見的 LocalStorage、Indexed DB、Web SQL,或者使用 native app 提供的介面;
3、容錯、使用者端和伺服器端的一致性校對、強制重新整理的實現。