2018-01-31
Virtual-DOM
diff 算法
git 有一条命令:
00$ git diff
它会标出你在哪修改了文件,是 modified 还是 add 还是 delete,都会帮你标注,这里面用到了 diff 算法。
diff 操作,本质上是找出对象间的增量差异,比如 diff 如下两个数组:
00let a = [1, 2, 3, 4]; 
01let b = [2, 3, 1]; 
02
03diff(a, b);
diff 结果可以是:
  1. 取出 1 放到最后面
  2. 删除 4
显然 diff 结果可能有很多,diff 问题是一类动态规划问题,找到最优的路径是 diff 算法所追求的。
现在来谈谈虚拟 DOM 的 diff / patch 问题,若读者无意于实现,本篇可以跳过不看了(本篇基本上是 Code)
虚拟DOM 的 diff 算法是他的核心算法,非常直接地决定了性能。,我在这一步卡了很长时间,先是使用了其他人的库,但是有 bug (diff 结果错误),不得已自己实现了一个简单的 diff 算法。

# 虚拟 DOM 里的 diff 操作

那么虚拟 DOM 里的 diff 操作呢?
00<div id="app1">
01    <p>Apple</p>
02    <p>eczn</p>
03    <p>Star</p>
04</div>
05
06<div id="app2">
07    <p>Star</p>
08    <p>eczn</p>
09</div>
app1 跟 app2 的 diff 结果:
  1. 删除 Apple
  2. Star 和 eczn 互换位置

在虚拟 DOM 中为了便于实现,应该加上 vid 去表达节点:
00<div id="app1">
01    <p vid="1">Apple</p>
02    <p vid="2">eczn</p>
03    <p vid="3">Star</p>
04</div>
05
06<div id="app2">
07    <p vid="3">Star</p>
08    <p vid="2">eczn</p>
09</div>
这样问题就化为下面两个数组的 diff 问题了:
00let app1_vids = ["1", "2", "3"]; 
01let app2_vids = ["3", "2"]; 
02
03diff(app1_vids, app2_vids);
解决虚拟 DOM 的 diff 问题其实就是要解决数组的 diff 问题。

# diff 算法的一个实现

本来我打算用其他人的 diff 去做的,但发现使用的过程中有bug (结果不对),因此自己实现了一个(性能不算最好的,但是能用)
我先贴出使用方式,文末会贴出源码:
00let list_diff = require('path/to/list-diff.js'); 
01
02// 打开 debug 模式 
03list_diff.debug = true; 
04
05// 元素为对象 
06let patches_1 = list_diff(
07    [{ id: '1'}, { id: '2' }, { id: '3' }], 
08    [{ id: '3'}, { id: '1' }], 
09    'id' // 标出 id 的字段 
10); 
11
12// 或者一般情况下 
13let patches_2 = list_diff(
14    [1, 2, 3, 4, 5, 6], 
15    [2, 3, 6, 5, 9]
16);
命令行输出的结果为:
00diff A
01当前:
02[ '1', '2', '3' ]
03目标:
04[ '3', '1' ]
05
06
07当前 idx 0 当前 len 3
08[ '1', '2', '3' ]
09a 跟 b 不一样
102 处找到了跟 b 一样的,交换跟当前的一下 3
11
12
13当前 idx 1 当前 len 3
14[ '3', '2', '1' ]
15a 跟 b 不一样
162 处找到了跟 b 一样的,交换跟当前的一下 1
17
18
19当前 idx 2 当前 len 3
20[ '3', '1', '2' ]
21b 不存在, 应该剔除
22
23
24[ '3', '1' ]
25[ '3', '1' ]
26Array {
27  '0': { type: 'REORDER', idx: 0, item: 2 },
28  '1': { type: 'REORDER', idx: 1, item: 2 },
29  '2': { type: 'DELETE', idx: 2 },
30  length: 3 }
31
32DIFF TIME: 9.396ms
33
34
35
36diff B
37当前:
38[ 1, 2, 3, 4, 5, 6 ]
39目标:
40[ 2, 3, 6, 5, 9 ]
41
42
43当前 idx 0 当前 len 6
44[ 1, 2, 3, 4, 5, 6 ]
45a 跟 b 不一样
461 处找到了跟 b 一样的,交换跟当前的一下 2
47
48
49当前 idx 1 当前 len 6
50[ 2, 1, 3, 4, 5, 6 ]
51a 跟 b 不一样
522 处找到了跟 b 一样的,交换跟当前的一下 3
53
54
55当前 idx 2 当前 len 6
56[ 2, 3, 1, 4, 5, 6 ]
57a 跟 b 不一样
585 处找到了跟 b 一样的,交换跟当前的一下 6
59
60
61当前 idx 3 当前 len 6
62[ 2, 3, 6, 4, 5, 1 ]
63a 跟 b 不一样
644 处找到了跟 b 一样的,交换跟当前的一下 5
65
66
67当前 idx 4 当前 len 6
68[ 2, 3, 6, 5, 4, 1 ]
69a 跟 b 不一样
70找不到,应该插入
71
72
73当前 idx 5 当前 len 7
74[ 2, 3, 6, 5, 9, 4, 1 ]
75b 不存在, 应该剔除
76
77
78[ 2, 3, 6, 5, 9, 1 ]
79[ 2, 3, 6, 5, 9 ]
80Array {
81  '0': { type: 'REORDER', idx: 0, item: 1 },
82  '1': { type: 'REORDER', idx: 1, item: 2 },
83  '2': { type: 'REORDER', idx: 2, item: 5 },
84  '3': { type: 'REORDER', idx: 3, item: 4 },
85  '4': { type: 'INSERT', idx: 4, item: 9 },
86  '5': { type: 'DELETE', idx: 5 },
87  '6': { type: 'DELETE', idx: 5 },
88  length: 7 }
89
90DIFF TIME: 5.612ms

# patch 操作

把 diff 的结果应用到对应的数组里。
00let list_diff = require('path/to/list-diff.js'); 
01
02let a = [1, 2, 3, 4, 5, 6]; 
03let b = [2, 3, 6, 5, 9]
04
05// 或者一般情况下 
06let patches = list_diff(a, b); 
07
08patches.to(a); 
09
10console.log(a); 
11// => 
12// [2, 3, 6, 5, 9]

# list-diff.js

以下,是源码,比较长:
000// CONST VAR 
001// 用字符串比数组更直观,但相对的性能稍差点
002const INSERT  = 'INSERT'  || 0  // 插入 
003    , REORDER = 'REORDER' || 1  // 交换 
004    , DELETE  = 'DELETE'  || 2  // 删除
005
006_diff.INSERT  = INSERT ; 
007_diff.REORDER = REORDER; 
008_diff.DELETE  = DELETE ; 
009
010// EXPORT
011module.exports = _diff; 
012
013/**
014 * @description KeyMap, 相当于 arr.map(e => e[key])
015 * @param { Array } arr 
016 * @param { String | Number } key 
017 */
018function keyMap(arr, key){
019    let len = arr.length; 
020    let res = [];
021    for (let i = 0; i < len; i++){
022        res[i] = arr[i][key]; 
023    }
024    return res; 
025}
026
027/**
028 * @description Patches 
029 */
030function Patches(){
031    this.length = 0; 
032}
033
034// 继承自 Array 
035let PP = Object.create(Array.prototype); 
036// 连接 
037Patches.prototype = PP; 
038
039/**
040 * @description 把 Patches 应用到 list_a 
041 * @param   { Array }  list_a 
042 * @returns { Array }  list_a 
043 */
044Patches.prototype.to = function(list_a){
045    this.forEach(patch => {
046        let { type, idx, item } = patch; 
047    
048        if (type === DELETE){
049            list_a.splice(idx, 1); 
050        } else if (type === INSERT){
051            list_a.splice(idx, 0, item); 
052        } else if (type === REORDER){
053            let exIdx = item; 
054            let a = list_a[idx]; 
055            
056            list_a[idx]   = list_a[exIdx]; 
057            list_a[exIdx] = a; 
058        }
059    }); 
060
061    return list_a;
062}
063
064/**
065 * @description 数组 diff 跳板
066 * @param { Array<String | Number> } list_a 
067 * @param { Array<String | Number> } list_b 
068 * @returns { Patches } 返回 patch
069 */
070function _diff(list_a_obj, list_b_obj, key){
071    // 复制数组 
072    let list_a, list_b, list_b_key_map; 
073    if (key){
074        // list_a = list_a_obj.map(e => e[key]); 
075        list_a = keyMap(list_a_obj, key); 
076        // list_b = list_b_obj.map(e => e[key]); 
077        list_b = keyMap(list_b_obj, key); 
078        list_b_key_map = val_2_idx(list_b_obj, key)
079    } else {
080        list_a = list_a_obj; 
081        list_b = list_b_obj; 
082    }
083
084    let list_copy_from_a = list_a.slice(); 
085
086    let patches = new Patches(); 
087    
088    // 确保 diff 的第一个参数 list_a 是副本
089    return diff(list_copy_from_a, list_b, key, list_b_obj, list_b_key_map, patches); 
090}
091
092
093/**
094 * @description 反转数组键值对 
095 * @param { Array<String | Number> } list 
096 * @param { String | Number } key 
097 * @returns { Array< * > }
098 */
099function val_2_idx(list_obj, key){
100    let list = keyMap(list_obj, key); 
101    
102    return list.reduce((acc, cur, idx) => {
103        acc[cur] = list_obj[idx]; 
104        return acc; 
105    }, {}); 
106}
107
108/**
109 * @description 数组 diff 
110 * @param   { Array<String | Number> }  list_a 
111 * @param   { Array<String | Number> }  list_b 
112 * @param   { Patches } patches
113 * @returns { Patches } patches
114 */
115function diff(list_a, list_b, key, list_b_obj, list_b_key_map, patches){   
116    const DEBUG = !!_diff.debug;
117
118    let len = Math.max(list_a.length, list_b.length)
119    
120    // console.log(list_a); 
121    DEBUG && (
122        console.time('DIFF'), 
123        console.log('当前: '),
124        console.log(list_a), 
125        console.log('目标: '),
126        console.log(list_b), 
127        console.log('\n')
128    ); 
129
130    for (let idx = 0; idx < len; idx ++){
131        let a = list_a[idx]; 
132        let b = list_b[idx]; 
133
134        DEBUG && (
135            console.log('当前 idx', idx, '当前 len', len),
136            console.log(list_a)
137        ); 
138        
139        if (!a){
140            DEBUG && (
141                console.log('a 不存在, 应该插入')
142            ); 
143            // a 不存在 
144            // 说明 list_b 比 list_a 长,应插入 b  
145            patches.push({
146                type: INSERT, 
147                idx: idx, 
148                item: key ? list_b_key_map[b] : b
149            }); 
150
151            // 在 list_a 上插入
152            DEBUG && (
153                console.log('b:', b)
154            ); 
155            list_a.push(b); 
156        } else if (!b){
157            DEBUG && (
158                console.log('b 不存在, 应该剔除')
159            ); 
160            // b 不存在 
161            // 说明 list_b 比 list_a 短,a 多余, 应剔除 
162            patches.push({
163                type: DELETE,
164                idx: idx
165            }); 
166            // 在 list_a 上剔除 
167            list_a.splice(idx, 1); 
168        } else {
169            if (b === a){
170                DEBUG && (
171                    console.log('a 刚好根 b 一样')
172                ); 
173                // 命中,跳过 
174                // do nothing 
175
176            } else { // b !== a
177                DEBUG && (
178                    console.log('a 跟 b 不一样')
179                ); 
180                // 这个位置上应该是 b, 但是目前是 a 
181                // 所以首先应该找找看看 list_a 里有没有 b, 有的话就根这里交换位置
182                // 否则插入到这里 
183                let exIdx_relative = list_a.slice(idx).findIndex(e => e === b); 
184                
185                if (~exIdx_relative){ // list_a 之后存在 b, 则交换位置 
186                    let exIdx = exIdx_relative + idx; 
187
188                    DEBUG && (
189                        console.log('在', exIdx, '处找到了跟 b 一样的,交换跟当前的一下', b)
190                    ); 
191                    patches.push({
192                        type: REORDER, 
193                        idx: idx, 
194                        item: exIdx
195                    }); 
196                    list_a[idx]   = list_a[exIdx]; 
197                    list_a[exIdx] = a; 
198                } else {     // 找不到插入  
199                    DEBUG && (
200                        console.log('找不到,应该插入')
201                    ); 
202                    patches.push({
203                        type: INSERT, 
204                        idx: idx, 
205                        item: key ? list_b_key_map[list_b_obj[idx][key]] : b
206                    }); 
207
208                    list_a.splice(idx, 0, b); 
209                }
210            }
211        }
212
213        // 更新一下 
214        len = Math.max(list_a.length, list_b.length); 
215
216        DEBUG && (
217            console.log('\n')
218        ); 
219    }
220
221    // let remain = list_a.slice(list_b.length); 
222    let delta = list_a.length - list_b.length; 
223
224    DEBUG && (
225        // console.log(remain),
226        console.log(list_a),
227        console.log(list_b)
228    ); 
229
230    if (delta > 0){
231        for (let i = delta; i > 0; i--){
232            patches.push({
233                type: DELETE, 
234                idx: list_b.length
235            }); 
236            list_a.pop();
237        }
238    }
239
240    DEBUG && (
241        console.log(patches),
242        console.timeEnd('DIFF')
243    ); 
244
245    return patches; 
246}

# TL;DR

… 总之… diff 很有趣 …
下一篇将会把 list-diff 加入到虚拟 DOM 套餐中 …




回到顶部