├── .babelrc ├── .gitignore ├── .npmignore ├── Makefile ├── Readme.md ├── package.json ├── src └── index.js └── test ├── benchmark.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | lib -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashaffer/dift/476ff62e9a6cd30f1517b217c9ecc53761a5f2b4/.npmignore -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Vars 3 | # 4 | 5 | BIN = ./node_modules/.bin 6 | .DEFAULT_GOAL := all 7 | 8 | # 9 | # Tasks 10 | # 11 | 12 | node_modules: package.json 13 | @npm install 14 | @touch node_modules 15 | 16 | test: node_modules 17 | ${BIN}/babel-tape-runner test/*.js 18 | 19 | benchmark: node_modules 20 | ${BIN}/babel-tape-runner test/benchmark.js 21 | 22 | validate: node_modules 23 | @${BIN}/standard 24 | 25 | all: validate test 26 | 27 | # 28 | # Phony 29 | # 30 | 31 | .PHONY: test validate 32 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # dift 3 | 4 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) 5 | 6 | Super fast list diff algorithm. Highly optimized for operations common in virtual DOM based UI systems, specifically: prepend, append, reverse, remove all, create all. However, it performs very well in the worst case (random rearrangement) as well. 7 | 8 | Without really researching it or benchmarking, i'm going to invoke [Cunningham's Law](https://meta.wikimedia.org/wiki/Cunningham%27s_Law) and say that it is the fastest key-based list diff algorithm in existence for this particular application in javascript. 9 | 10 | ## Installation 11 | 12 | $ npm install dift 13 | 14 | ## Usage 15 | 16 | Params: 17 | 18 | * `prev` - The old list 19 | * `next` - The new list 20 | * `effect` - A function that receives operations. You can execute your operations in here, or aggregate them into some buffer to be executed elsewhere, that is up to you. 21 | * `key` - Return a value used to compare two list items to determine whether they are equal. 22 | 23 | ### Effects 24 | 25 | * `CREATE` - Receives `(type = CREATE, prev = null, next = newItem, pos = positionToCreate)` 26 | * `UPDATE` - Receives `(type = UPDATE, prev = oldItem, next = newItem)` 27 | * `MOVE` - Receives `(type = MOVE, prev = oldItem, next = newItem, pos = newPosition)` 28 | * `REMOVE` - Receives `(type = REMOVE, prev = oldItem)` 29 | 30 | ## Example 31 | 32 | ```javascript 33 | import diff, {CREATE, UPDATE, MOVE, REMOVE} from 'dift' 34 | 35 | function diffChildren (prevList, nextList, node) { 36 | diff(prevList, nextList, function (type, prev, next, pos) { 37 | switch (type) { 38 | case CREATE: 39 | node.insertBefore(create(next), node.childNodes[pos] || null)) 40 | break 41 | case UPDATE: 42 | update(prev, next, prev.element) 43 | break 44 | case MOVE: 45 | node.insertBefore(update(prev, next), prev.element) 46 | break 47 | case REMOVE: 48 | node.removeChild(prev.element) 49 | break 50 | } 51 | }) 52 | } 53 | ``` 54 | 55 | ## Correctness 56 | 57 | List diff is extremely tricky to implement correctly. There are a lot of subtle edge cases and many of them are hard to think of a priori. To deal with this, dift has not just extensive tests but *exhaustive* tests. dift's tests explore the entire state space of roughly 8-10 item lists and ensure that the operations generated correctly produce the desired output. 58 | 59 | ## License 60 | 61 | The MIT License 62 | 63 | Copyright © 2015, Weo.io <info@weo.io> 64 | 65 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 66 | 67 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 68 | 69 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dift", 3 | "description": "Super fast list diff algorithm", 4 | "repository": "git://github.com/ashaffer/dift.git", 5 | "version": "0.1.12", 6 | "license": "MIT", 7 | "main": "lib/index.js", 8 | "jsnext:main": "src/index.js", 9 | "scripts": { 10 | "prepublish": "rm -rf lib && babel src --out-dir lib", 11 | "postpublish": "rm -rf lib" 12 | }, 13 | "dependencies": { 14 | "bit-vector": "^0.1.0" 15 | }, 16 | "devDependencies": { 17 | "array-permutation": "^0.2.0", 18 | "babel-preset-es2015": "^6.1.2", 19 | "babel-tape-runner": "^2.0.0", 20 | "babelify": "^7.2.0", 21 | "powerset": "0.0.1", 22 | "tape": "^4.2.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imports 3 | */ 4 | 5 | import {createBv, setBit, getBit} from 'bit-vector' 6 | 7 | /** 8 | * Actions 9 | */ 10 | 11 | const CREATE = 0 12 | const UPDATE = 1 13 | const MOVE = 2 14 | const REMOVE = 3 15 | 16 | /** 17 | * dift 18 | */ 19 | 20 | function dift (prev, next, effect, key) { 21 | let pStartIdx = 0 22 | let nStartIdx = 0 23 | let pEndIdx = prev.length - 1 24 | let nEndIdx = next.length - 1 25 | let pStartItem = prev[pStartIdx] 26 | let nStartItem = next[nStartIdx] 27 | 28 | // List head is the same 29 | while (pStartIdx <= pEndIdx && nStartIdx <= nEndIdx && equal(pStartItem, nStartItem)) { 30 | effect(UPDATE, pStartItem, nStartItem, nStartIdx) 31 | pStartItem = prev[++pStartIdx] 32 | nStartItem = next[++nStartIdx] 33 | } 34 | 35 | // The above case is orders of magnitude more common than the others, so fast-path it 36 | if (nStartIdx > nEndIdx && pStartIdx > pEndIdx) { 37 | return 38 | } 39 | 40 | let pEndItem = prev[pEndIdx] 41 | let nEndItem = next[nEndIdx] 42 | let movedFromFront = 0 43 | 44 | // Reversed 45 | while (pStartIdx <= pEndIdx && nStartIdx <= nEndIdx && equal(pStartItem, nEndItem)) { 46 | effect(MOVE, pStartItem, nEndItem, (pEndIdx - movedFromFront) + 1) 47 | pStartItem = prev[++pStartIdx] 48 | nEndItem = next[--nEndIdx] 49 | ++movedFromFront 50 | } 51 | 52 | // Reversed the other way (in case of e.g. reverse and append) 53 | while (pEndIdx >= pStartIdx && nStartIdx <= nEndIdx && equal(nStartItem, pEndItem)) { 54 | effect(MOVE, pEndItem, nStartItem, nStartIdx) 55 | pEndItem = prev[--pEndIdx] 56 | nStartItem = next[++nStartIdx] 57 | --movedFromFront 58 | } 59 | 60 | // List tail is the same 61 | while (pEndIdx >= pStartIdx && nEndIdx >= nStartIdx && equal(pEndItem, nEndItem)) { 62 | effect(UPDATE, pEndItem, nEndItem, nEndIdx) 63 | pEndItem = prev[--pEndIdx] 64 | nEndItem = next[--nEndIdx] 65 | } 66 | 67 | if (pStartIdx > pEndIdx) { 68 | while (nStartIdx <= nEndIdx) { 69 | effect(CREATE, null, nStartItem, nStartIdx) 70 | nStartItem = next[++nStartIdx] 71 | } 72 | 73 | return 74 | } 75 | 76 | if (nStartIdx > nEndIdx) { 77 | while (pStartIdx <= pEndIdx) { 78 | effect(REMOVE, pStartItem) 79 | pStartItem = prev[++pStartIdx] 80 | } 81 | 82 | return 83 | } 84 | 85 | let created = 0 86 | let pivotDest = null 87 | let pivotIdx = pStartIdx - movedFromFront 88 | const keepBase = pStartIdx 89 | const keep = createBv(pEndIdx - pStartIdx) 90 | 91 | const prevMap = keyMap(prev, pStartIdx, pEndIdx + 1, key) 92 | 93 | for(; nStartIdx <= nEndIdx; nStartItem = next[++nStartIdx]) { 94 | const oldIdx = prevMap[key(nStartItem)] 95 | 96 | if (isUndefined(oldIdx)) { 97 | effect(CREATE, null, nStartItem, pivotIdx++) 98 | ++created 99 | } else if (pStartIdx !== oldIdx) { 100 | setBit(keep, oldIdx - keepBase) 101 | effect(MOVE, prev[oldIdx], nStartItem, pivotIdx++) 102 | } else { 103 | pivotDest = nStartIdx 104 | } 105 | } 106 | 107 | if (pivotDest !== null) { 108 | setBit(keep, 0) 109 | effect(MOVE, prev[pStartIdx], next[pivotDest], pivotDest) 110 | } 111 | 112 | // If there are no creations, then you have to 113 | // remove exactly max(prevLen - nextLen, 0) elements in this 114 | // diff. You have to remove one more for each element 115 | // that was created. This means once we have 116 | // removed that many, we can stop. 117 | const necessaryRemovals = (prev.length - next.length) + created 118 | for (let removals = 0; removals < necessaryRemovals; pStartItem = prev[++pStartIdx]) { 119 | if (!getBit(keep, pStartIdx - keepBase)) { 120 | effect(REMOVE, pStartItem) 121 | ++removals 122 | } 123 | } 124 | 125 | function equal (a, b) { 126 | return key(a) === key(b) 127 | } 128 | } 129 | 130 | function isUndefined (val) { 131 | return typeof val === 'undefined' 132 | } 133 | 134 | function keyMap (items, start, end, key) { 135 | const map = {} 136 | 137 | for (let i = start; i < end; ++i) { 138 | map[key(items[i])] = i 139 | } 140 | 141 | return map 142 | } 143 | 144 | /** 145 | * Exports 146 | */ 147 | 148 | export default dift 149 | export { 150 | CREATE, 151 | UPDATE, 152 | MOVE, 153 | REMOVE 154 | } 155 | -------------------------------------------------------------------------------- /test/benchmark.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imports 3 | */ 4 | 5 | import test from 'tape' 6 | import diff, {CREATE, UPDATE, MOVE, REMOVE} from '../src' 7 | 8 | /** 9 | * Benchmarks 10 | */ 11 | 12 | test('benchmark random permutation', t => { 13 | const a = generate(100000) 14 | trial(a, permute(a)) 15 | t.end() 16 | }) 17 | 18 | test('benchmark reverse', t => { 19 | const a = generate(100000) 20 | trial(a, a.slice(0).reverse()) 21 | t.end() 22 | }) 23 | 24 | test('benchmark insertFirst', t => { 25 | const a = generate(100000) 26 | const b = generate(1).concat(a) 27 | trial(a, b) 28 | t.end() 29 | }) 30 | 31 | function key (a) { 32 | return a.key 33 | } 34 | 35 | function trial (a, b) { 36 | const deltas = [] 37 | 38 | for (let i = 0; i < 100; i++) { 39 | const t = +new Date() 40 | diff(a, b, noop, key) 41 | deltas.push((+new Date) - t) 42 | } 43 | 44 | console.log('mean', mean(deltas)) 45 | console.log('variance', variance(deltas)) 46 | } 47 | 48 | function noop () {} 49 | 50 | function generate (n) { 51 | const a = [] 52 | 53 | for (let i = 0; i < n; i++) { 54 | a.push({key: i}) 55 | } 56 | 57 | return a 58 | } 59 | 60 | function permute (list) { 61 | const newList = [] 62 | list = list.slice(0) 63 | for (let i = 0, len = list.length; i < len; i++) { 64 | const r = Math.floor(Math.random() * 100000) % list.length 65 | newList.push(list[r]) 66 | list.splice(r, 1) 67 | } 68 | 69 | return newList 70 | } 71 | 72 | function mean (list) { 73 | return list.reduce(plus, 0) / list.length 74 | } 75 | 76 | function variance (list) { 77 | const sum = list.reduce(plus, 0) 78 | const sqSum = list.reduce(squareSum, 0) 79 | 80 | return (sqSum - ((sum * sum) / list.length)) / list.length 81 | } 82 | 83 | function plus (a, b) { 84 | return a + b 85 | } 86 | 87 | function squareSum (a, b) { 88 | return a + b * b 89 | } 90 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imports 3 | */ 4 | 5 | import test from 'tape' 6 | import diff, {CREATE, UPDATE, MOVE, REMOVE} from '../src' 7 | import powerset from 'powerset' 8 | import permutations from 'array-permutation' 9 | 10 | /** 11 | * Tests 12 | * 13 | * Taken from: https://github.com/joshrtay/key-diff 14 | */ 15 | 16 | test('add', t => { 17 | const a = [] 18 | const b = [{key: 'foo', val: 'bar'}] 19 | 20 | t.deepEqual(b, run(a, b)) 21 | t.end() 22 | }) 23 | 24 | test('add many', t => { 25 | const a = [] 26 | const b = [{key: 'foo', val: 'bar'}, {key: 'bat', val: 'box'}] 27 | 28 | t.deepEqual(b, run(a, b)) 29 | t.end() 30 | }) 31 | 32 | test('add before/after', t => { 33 | const a = [{key: 'bar', val: 'two'}] 34 | const b = [{key: 'foo', val: 'one'}, {key: 'bar', val: 'two'}, {key: 'baz', val: 'three'}] 35 | 36 | t.deepEqual(b, run(a, b)) 37 | t.end() 38 | }) 39 | 40 | test('add middle', t => { 41 | const a = [{key: 'foo', val: 'one'}, {key: 'baz', val: 'four'}] 42 | const b = [{key: 'foo', val: 'one'}, {key: 'bar', val: 'five'}, {key: 'baz', val: 'four'}] 43 | 44 | t.deepEqual(b, run(a, b)) 45 | t.end() 46 | }) 47 | 48 | test('remove', t => { 49 | const a = [{key: 'foo', val: 'bar'}] 50 | const b = [] 51 | 52 | t.deepEqual(b, run(a, b)) 53 | t.end() 54 | }) 55 | 56 | test('remove many', t => { 57 | const a = [{key: 'foo', val: 'bar'}, {key: 'bat', val: 'box'}] 58 | const b = [] 59 | 60 | t.deepEqual(b, run(a, b)) 61 | t.end() 62 | }) 63 | 64 | test('remove one', t => { 65 | const a = [{key: 'bar', val: 'two'}, {key: 'foo', val: 'one'}] 66 | const b = [{key: 'bar', val: 'two'}] 67 | 68 | t.deepEqual(b, run(a, b)) 69 | t.end() 70 | }) 71 | 72 | test('remove complex', t => { 73 | const a = [{key: 'bar', val: 'one'}, {key: 'foo', val: 'two'}, {key: 'bat', val: 'three'}, {key: 'baz', val: 'four'}, {key: 'quz', val: 'five'}] 74 | const b = [{key: 'foo', val: 'two'}, {key: 'baz', val: 'four'}] 75 | 76 | t.deepEqual(b, run(a, b)) 77 | t.end() 78 | }) 79 | 80 | 81 | test('update', t => { 82 | const a = [{key: 'foo', val: 'bar'}] 83 | const b = [{key: 'foo', val: 'box'}] 84 | 85 | t.deepEqual(b, run(a, b)) 86 | t.end() 87 | }) 88 | 89 | test('update/remove', t => { 90 | const a = [{key: 'foo', val: 'one'}, {key: 'bar', val: 'two'}, {key: 'baz', val: 'three'}] 91 | const b = [{key: 'foo', val: 'one'}, {key: 'baz', val: 'four'}] 92 | 93 | t.deepEqual(b, run(a, b)) 94 | t.end() 95 | }) 96 | 97 | test('update/remove 2', t => { 98 | const a = [{key: 'foo', val: 'one'}, {key: 'bar', val: 'five'}, {key: 'baz', val: 'four'}] 99 | const b = [{key: 'foo', val: 'one'}, {key: 'bar', val: 'span'}] 100 | 101 | t.deepEqual(b, run(a, b)) 102 | t.end() 103 | }) 104 | 105 | test('update/remove 3', t => { 106 | const a = [{key: 'bar', val: 'span'}, {key: 'foo', val: 'one'}] 107 | const b = [{key: 'foo', val: 'span'}] 108 | 109 | t.deepEqual(b, run(a, b)) 110 | t.end() 111 | }) 112 | 113 | test('swap', t => { 114 | const a = [{key: 'foo', val: 'bar'}, {key: 'bat', val: 'box'}] 115 | const b = [{key: 'bat', val: 'box'}, {key: 'foo', val: 'bar'}] 116 | 117 | t.deepEqual(b, run(a, b)) 118 | t.end() 119 | }) 120 | 121 | test('reverse', t => { 122 | const a = [{key: 'foo', val: 'one'}, {key: 'bat', val: 'two'}, {key: 'baz', val: 'three'}, {key: 'qux', val: 'four'}] 123 | const b = [{key: 'qux', val: 'four'}, {key: 'baz', val: 'three'}, {key: 'bat', val: 'two'}, {key: 'foo', val: 'one'}] 124 | const c = clone(a) 125 | const patch = update(c) 126 | const log = [] 127 | 128 | diff(a, b, function (...args) { 129 | log.push(args[0]) 130 | patch(...args) 131 | }, key) 132 | 133 | t.deepEqual(log, [MOVE, MOVE, MOVE, MOVE]) 134 | t.deepEqual(c, b) 135 | 136 | t.end() 137 | }) 138 | 139 | test('complex', t => { 140 | const a = [{key: 'foo', val: 'one'}, {key: 'bar', val: 'two'}, {key: 'baz', val: 'three'}] 141 | const b = [{key: 'bar', val: 'two'}, {key: 'foo', val: 'one'}, {key: 'bat', val: 'four'}] 142 | 143 | t.deepEqual(b, run(a, b)) 144 | t.end() 145 | }) 146 | 147 | test('insert (3), rearrange', t => { 148 | for (let i = 0; i < 1000; i++) { 149 | const a = range(0, 10) 150 | const b = randomize(range(0, 10).concat(range(11, 14))) 151 | 152 | t.deepEqual(b, run(a, b)) 153 | } 154 | 155 | t.end() 156 | }) 157 | 158 | test('remove (3), rearrange', t => { 159 | for (let i = 0; i < 1000; i++) { 160 | const a = range(0, 13) 161 | const b = randomize(range(0, 10)) 162 | 163 | t.deepEqual(b, run(a, b)) 164 | } 165 | t.end() 166 | }) 167 | 168 | test('remove (3), insert (3), rearrange', t => { 169 | for (let i = 0; i < 1000; i++) { 170 | const a = range(0, 13) 171 | const b = randomize(range(0, 10).concat(14, 17)) 172 | 173 | t.deepEqual(b, run(a, b)) 174 | } 175 | t.end() 176 | }) 177 | 178 | test('empty initial', t => { 179 | const a = [] 180 | const b = range(0, 10) 181 | 182 | t.deepEqual(b, run(a, b)) 183 | t.end() 184 | }) 185 | 186 | test('reversed sides, middle rearranged', t => { 187 | const a = range(0, 10) 188 | const b = [13, 3, 2, 9, 5, 8, 7, 12, 11, 6, 4, 1, 0].map(i => ({key: i})) 189 | 190 | t.deepEqual(b, run(a, b)) 191 | t.end() 192 | }) 193 | 194 | test('exhaustive - same items', t => { 195 | const a = range(0, 10) 196 | const ps = powerset(range(0, 8)) 197 | 198 | for (let i = 0; i < ps.length; i++) { 199 | for (let b of permutations(ps[i])) { 200 | t.deepEqual(b, run(a, b)) 201 | } 202 | } 203 | 204 | t.end() 205 | }) 206 | 207 | test('exhaustive - mixed items', t => { 208 | const a = range(0, 10) 209 | const ps = powerset(range(7, 15)) 210 | 211 | for (let i = 0; i < ps.length; i++) { 212 | for (let b of permutations(ps[i])) { 213 | t.deepEqual(b, run(a, b)) 214 | } 215 | } 216 | 217 | t.end() 218 | }) 219 | 220 | test('exhaustive - permutations', t => { 221 | const a = range(0, 8) 222 | 223 | for (let b of permutations(range(0, 8))) { 224 | t.deepEqual(b, run(a, b)) 225 | } 226 | 227 | t.end() 228 | }) 229 | 230 | function run (a, b) { 231 | const c = a.slice() 232 | diff(a, b, update(c), key) 233 | return c 234 | } 235 | 236 | function key (a) { 237 | return a.key 238 | } 239 | 240 | function randomize (list) { 241 | const newList = [] 242 | 243 | for (let i = 0, len = list.length; i < len; i++) { 244 | const j = Math.floor(Math.random() * 100000) % list.length 245 | newList.push(list[j]) 246 | list.splice(j, 1) 247 | } 248 | 249 | return newList 250 | } 251 | 252 | function range (begin, end) { 253 | const r = [] 254 | 255 | for (let i = begin; i < end; i++) { 256 | r.push({key: i}) 257 | } 258 | 259 | return r 260 | } 261 | 262 | function update (list) { 263 | return function(type, prev, next, pos) { 264 | switch(type) { 265 | case CREATE: 266 | insertAt(list, pos, next) 267 | break 268 | case REMOVE: 269 | remove(list, prev) 270 | break 271 | case MOVE: 272 | patch(list, prev, next) 273 | move(list, pos, prev) 274 | break 275 | case UPDATE: 276 | patch(list, prev, next) 277 | break 278 | } 279 | } 280 | } 281 | 282 | function insertAt (list, idx, item) { 283 | if (list[idx]) { 284 | list.splice(idx, 0, item) 285 | } else { 286 | list.push(item) 287 | } 288 | } 289 | 290 | function indexOf (list, item) { 291 | let i = 0 292 | for (; i < list.length; ++i) { 293 | if (list[i] === item) { 294 | return i 295 | } 296 | } 297 | return -1 298 | } 299 | 300 | function remove (list, item) { 301 | list.splice(indexOf(list, item), 1) 302 | } 303 | 304 | function move(list, idx, item) { 305 | const oldIdx = indexOf(list, item) 306 | insertAt(list, idx, item) 307 | list.splice(oldIdx < idx ? oldIdx : oldIdx + 1, 1) 308 | } 309 | 310 | function patch(list, pItem, nItem) { 311 | for (let key in pItem) { 312 | delete pItem[key] 313 | } 314 | for (let key in nItem) { 315 | pItem[key] = nItem[key] 316 | } 317 | return pItem 318 | } 319 | 320 | function clone (list) { 321 | return list.slice(0) 322 | } 323 | --------------------------------------------------------------------------------