├── .gitignore ├── LICENSE ├── README.md ├── circle.yml ├── index.js ├── lib └── diff.js ├── package.json └── test └── list-diff.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | coverage -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Livoras 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | list-diff 2 | ================= 3 | 4 | [![build](https://circleci.com/gh/livoras/list-diff/tree/master.png?style=shield)](https://circleci.com/gh/livoras/list-diff) [![codecov.io](https://codecov.io/github/livoras/list-diff/coverage.svg?branch=master)](https://codecov.io/github/livoras/list-diff?branch=master) [![npm version](https://badge.fury.io/js/list-diff2.svg)](https://badge.fury.io/js/list-diff2) [![Dependency Status](https://david-dm.org/livoras/list-diff.svg)](https://david-dm.org/livoras/list-diff) 5 | 6 | [![js-standard-style](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard) 7 | 8 | ## Introduction 9 | 10 | Diff two lists in time O(n). 11 | I 12 | The algorithm finding the minimal amount of moves is [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) which is O(n*m). This algorithm is not the best but is enougth for front-end DOM list manipulation. 13 | 14 | This project is mostly influenced by [virtual-dom](https://github.com/Matt-Esch/virtual-dom/blob/master/vtree/diff.js) algorithm. 15 | 16 | ## Install 17 | 18 | $ npm install list-diff2 --save 19 | 20 | ## Usage 21 | 22 | ```javascript 23 | var diff = require("list-diff2") 24 | var oldList = [{id: "a"}, {id: "b"}, {id: "c"}, {id: "d"}, {id: "e"}] 25 | var newList = [{id: "c"}, {id: "a"}, {id: "b"}, {id: "e"}, {id: "f"}] 26 | 27 | var moves = diff(oldList, newList, "id") 28 | // `moves` is a sequence of actions (remove or insert): 29 | // type 0 is removing, type 1 is inserting 30 | // moves: [ 31 | // {index: 3, type: 0}, 32 | // {index: 0, type: 1, item: {id: "c"}}, 33 | // {index: 3, type: 0}, 34 | // {index: 4, type: 1, item: {id: "f"}} 35 | // ] 36 | 37 | moves.moves.forEach(function(move) { 38 | if (move.type === 0) { 39 | oldList.splice(move.index, 1) // type 0 is removing 40 | } else { 41 | oldList.splice(move.index, 0, move.item) // type 1 is inserting 42 | } 43 | }) 44 | 45 | // now `oldList` is equal to `newList` 46 | // [{id: "c"}, {id: "a"}, {id: "b"}, {id: "e"}, {id: "f"}] 47 | console.log(oldList) 48 | ``` 49 | 50 | ## License 51 | 52 | The MIT License (MIT) 53 | 54 | Copyright (c) 2015 Livoras 55 | 56 | Permission is hereby granted, free of charge, to any person obtaining a copy 57 | of this software and associated documentation files (the "Software"), to deal 58 | in the Software without restriction, including without limitation the rights 59 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 60 | copies of the Software, and to permit persons to whom the Software is 61 | furnished to do so, subject to the following conditions: 62 | 63 | The above copyright notice and this permission notice shall be included in all 64 | copies or substantial portions of the Software. 65 | 66 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 67 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 68 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 69 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 70 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 71 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 72 | SOFTWARE. 73 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 4 4 | dependencies: 5 | pre: 6 | - npm install -g istanbul 7 | - npm install -g standard 8 | test: 9 | override: 10 | - standard && istanbul cover ./node_modules/mocha/bin/_mocha -- -R spec && cat ./coverage/coverage.json | ./node_modules/codecov.io/bin/codecov.io.js -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/diff').diff 2 | -------------------------------------------------------------------------------- /lib/diff.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Diff two list in O(N). 3 | * @param {Array} oldList - Original List 4 | * @param {Array} newList - List After certain insertions, removes, or moves 5 | * @return {Object} - {moves: } 6 | * - moves is a list of actions that telling how to remove and insert 7 | */ 8 | function diff (oldList, newList, key) { 9 | var oldMap = makeKeyIndexAndFree(oldList, key) 10 | var newMap = makeKeyIndexAndFree(newList, key) 11 | 12 | var newFree = newMap.free 13 | 14 | var oldKeyIndex = oldMap.keyIndex 15 | var newKeyIndex = newMap.keyIndex 16 | 17 | var moves = [] 18 | 19 | // a simulate list to manipulate 20 | var children = [] 21 | var i = 0 22 | var item 23 | var itemKey 24 | var freeIndex = 0 25 | 26 | // first pass to check item in old list: if it's removed or not 27 | while (i < oldList.length) { 28 | item = oldList[i] 29 | itemKey = getItemKey(item, key) 30 | if (itemKey) { 31 | if (!newKeyIndex.hasOwnProperty(itemKey)) { 32 | children.push(null) 33 | } else { 34 | var newItemIndex = newKeyIndex[itemKey] 35 | children.push(newList[newItemIndex]) 36 | } 37 | } else { 38 | var freeItem = newFree[freeIndex++] 39 | children.push(freeItem || null) 40 | } 41 | i++ 42 | } 43 | 44 | var simulateList = children.slice(0) 45 | 46 | // remove items no longer exist 47 | i = 0 48 | while (i < simulateList.length) { 49 | if (simulateList[i] === null) { 50 | remove(i) 51 | removeSimulate(i) 52 | } else { 53 | i++ 54 | } 55 | } 56 | 57 | // i is cursor pointing to a item in new list 58 | // j is cursor pointing to a item in simulateList 59 | var j = i = 0 60 | while (i < newList.length) { 61 | item = newList[i] 62 | itemKey = getItemKey(item, key) 63 | 64 | var simulateItem = simulateList[j] 65 | var simulateItemKey = getItemKey(simulateItem, key) 66 | 67 | if (simulateItem) { 68 | if (itemKey === simulateItemKey) { 69 | j++ 70 | } else { 71 | // new item, just inesrt it 72 | if (!oldKeyIndex.hasOwnProperty(itemKey)) { 73 | insert(i, item) 74 | } else { 75 | // if remove current simulateItem make item in right place 76 | // then just remove it 77 | var nextItemKey = getItemKey(simulateList[j + 1], key) 78 | if (nextItemKey === itemKey) { 79 | remove(i) 80 | removeSimulate(j) 81 | j++ // after removing, current j is right, just jump to next one 82 | } else { 83 | // else insert item 84 | insert(i, item) 85 | } 86 | } 87 | } 88 | } else { 89 | insert(i, item) 90 | } 91 | 92 | i++ 93 | } 94 | 95 | //if j is not remove to the end, remove all the rest item 96 | var k = simulateList.length - j 97 | while (j++ < simulateList.length) { 98 | k-- 99 | remove(k + i) 100 | } 101 | 102 | 103 | function remove (index) { 104 | var move = {index: index, type: 0} 105 | moves.push(move) 106 | } 107 | 108 | function insert (index, item) { 109 | var move = {index: index, item: item, type: 1} 110 | moves.push(move) 111 | } 112 | 113 | function removeSimulate (index) { 114 | simulateList.splice(index, 1) 115 | } 116 | 117 | return { 118 | moves: moves, 119 | children: children 120 | } 121 | } 122 | 123 | /** 124 | * Convert list to key-item keyIndex object. 125 | * @param {Array} list 126 | * @param {String|Function} key 127 | */ 128 | function makeKeyIndexAndFree (list, key) { 129 | var keyIndex = {} 130 | var free = [] 131 | for (var i = 0, len = list.length; i < len; i++) { 132 | var item = list[i] 133 | var itemKey = getItemKey(item, key) 134 | if (itemKey) { 135 | keyIndex[itemKey] = i 136 | } else { 137 | free.push(item) 138 | } 139 | } 140 | return { 141 | keyIndex: keyIndex, 142 | free: free 143 | } 144 | } 145 | 146 | function getItemKey (item, key) { 147 | if (!item || !key) return void 666 148 | return typeof key === 'string' 149 | ? item[key] 150 | : key(item) 151 | } 152 | 153 | exports.makeKeyIndexAndFree = makeKeyIndexAndFree // exports for test 154 | exports.diff = diff 155 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "list-diff2", 3 | "version": "0.1.4", 4 | "description": "Diff two list", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "standard && npm run cover", 8 | "cover": "istanbul cover ./node_modules/mocha/bin/_mocha -- -R spec", 9 | "dev": "nodemon --exec mocha" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/livoras/list-diff.git" 14 | }, 15 | "keywords": [ 16 | "diff" 17 | ], 18 | "author": "Livoras", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/livoras/list-diff/issues" 22 | }, 23 | "homepage": "https://github.com/livoras/list-diff", 24 | "devDependencies": { 25 | "chai": "^3.4.1", 26 | "mocha": "^2.3.4", 27 | "codecov.io": "^0.1.6" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/list-diff.spec.js: -------------------------------------------------------------------------------- 1 | /* global it, describe */ 2 | var diff = require('../lib/diff.js') 3 | var chai = require('chai') 4 | chai.should() 5 | 6 | describe('List diff', function () { 7 | function perform (list, moves) { 8 | moves.moves.forEach(function (move) { 9 | if (move.type) { 10 | list.splice(move.index, 0, move.item) 11 | } else { 12 | list.splice(move.index, 1) 13 | } 14 | }) 15 | return list 16 | } 17 | 18 | function assertListEqual (after, before) { 19 | after.forEach(function (item, i) { 20 | after[i].should.be.deep.equal(before[i]) 21 | }) 22 | } 23 | 24 | function random (len) { 25 | return Math.floor(Math.random() * len) 26 | } 27 | 28 | it('Making map from list with string key', function () { 29 | var list = [{key: 'id1'}, {key: 'id2'}, {key: 'id3'}, {key: 'id4'}] 30 | var map = diff.makeKeyIndexAndFree(list, 'key') 31 | map.keyIndex.should.be.deep.equal({ 32 | id1: 0, 33 | id2: 1, 34 | id3: 2, 35 | id4: 3 36 | }) 37 | }) 38 | 39 | it('Making map from list with function', function () { 40 | var list = [{key: 'id1'}, {key: 'id2'}, {key: 'id3'}, {key: 'id4'}] 41 | var map = diff.makeKeyIndexAndFree(list, function (item) { 42 | return item.key 43 | }) 44 | map.keyIndex.should.be.deep.equal({ 45 | id1: 0, 46 | id2: 1, 47 | id3: 2, 48 | id4: 3 49 | }) 50 | }) 51 | 52 | it('Removing items', function () { 53 | var before = [{id: 1}, {id: 2}, {id: 3}, {id: 4}, {id: 5}, {id: 6}] 54 | var after = [{id: 2}, {id: 3}, {id: 1}] 55 | var diffs = diff.diff(before, after, 'id') 56 | diffs.moves.length.should.be.equal(5) 57 | perform(before, diffs) 58 | diffs.children.should.be.deep.equal([{id: 1}, {id: 2}, {id: 3}, null, null, null]) 59 | assertListEqual(after, before) 60 | }) 61 | 62 | it('Removing items in the middel', function () { 63 | var before = [{id: 1}, {id: 2}, {id: 3}, {id: 4}, {id: 5}, {id: 6}] 64 | var after = [{id: 1}, {id: 2}, {id: 4}, {id: 6}] 65 | var diffs = diff.diff(before, after, 'id') 66 | perform(before, diffs) 67 | diffs.children.should.be.deep.equal([{id: 1}, {id: 2}, null, {id: 4}, null, {id: 6}]) 68 | diffs.moves.length.should.be.equal(2) 69 | assertListEqual(after, before) 70 | }) 71 | 72 | it('Inserting items', function () { 73 | var before = ['a', 'b', 'c', 'd'] 74 | var after = ['a', 'b', 'e', 'f', 'c', 'd'] 75 | var diffs = diff.diff(before, after, function (item) { return item }) 76 | diffs.moves.length.should.be.equal(2) 77 | diffs.children.should.be.deep.equal(['a', 'b', 'c', 'd']) 78 | perform(before, diffs) 79 | assertListEqual(after, before) 80 | }) 81 | 82 | it('Moving items from back to front', function () { 83 | var before = ['a', 'b', 'c', 'd', 'e', 'f'] 84 | var after = ['a', 'b', 'e', 'f', 'c', 'd', 'g', 'h'] 85 | var diffs = diff.diff(before, after, function (item) { return item }) 86 | diffs.moves.length.should.be.equal(6) 87 | diffs.children.should.be.deep.equal(['a', 'b', 'c', 'd', 'e', 'f']) 88 | perform(before, diffs) 89 | assertListEqual(after, before) 90 | }) 91 | 92 | it('Moving items from front to back', function () { 93 | var before = ['a', 'b', 'c', 'd', 'e', 'f'] 94 | var after = ['a', 'c', 'e', 'f', 'b', 'd'] 95 | var diffs = diff.diff(before, after, function (item) { return item }) 96 | diffs.moves.length.should.be.equal(4) 97 | diffs.children.should.be.deep.equal(['a', 'b', 'c', 'd', 'e', 'f']) 98 | perform(before, diffs) 99 | assertListEqual(after, before) 100 | }) 101 | 102 | it('Miscellaneous actions', function () { 103 | var before = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] 104 | var after = ['h', 'i', 'a', 'c', 'd', 'u', 'e', 'f', 'g', 'j', 'b', 'z', 'x', 'y'] 105 | var diffs = diff.diff(before, after, function (item) { return item }) 106 | diffs.children.should.be.deep.equal(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']) 107 | perform(before, diffs) 108 | assertListEqual(after, before) 109 | }) 110 | 111 | it('Randomly moving', function () { 112 | var alphabet = 'klmnopqrstuvwxyz' 113 | for (var i = 0; i < 20; i++) { 114 | var before = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] 115 | var after = before.slice(0) 116 | var pos, character 117 | 118 | // move 119 | var j = 0 120 | var len = +(Math.random() * 4) 121 | for (j = 0; j < len; j++) { 122 | // random removing item 123 | pos = random(after.length) 124 | character = after[pos] 125 | after.splice(pos, 1) 126 | 127 | // random insert item 128 | pos = random(after.length) 129 | after.splice(pos, 0, character) 130 | } 131 | 132 | // remove 133 | j = 0 134 | len = +(Math.random() * 4) 135 | for (j = 0; j < len; j++) { 136 | pos = random(after.length) 137 | after.splice(pos, 1) 138 | } 139 | 140 | // insert 141 | j = 0 142 | len = +(Math.random() * 10) 143 | for (j = 0; j < len; j++) { 144 | pos = random(after.length) 145 | var newItemPos = random(alphabet.length) 146 | character = alphabet[newItemPos] 147 | after.splice(pos, 0, character) 148 | } 149 | 150 | var diffs = diff.diff(before, after, function (item) { return item }) 151 | perform(before, diffs) 152 | assertListEqual(after, before) 153 | } 154 | }) 155 | 156 | it('Test with no key: string item and removing', function () { 157 | var before = ['a', 'b', 'c', 'd', 'e'] 158 | var after = ['c', 'd', 'e', 'a'] 159 | var diffs = diff.diff(before, after) 160 | diffs.moves.length.should.be.equal(1) 161 | diffs.children.should.be.deep.equal(['c', 'd', 'e', 'a', null]) 162 | perform(before, diffs) 163 | before.should.be.deep.equal(['a', 'b', 'c', 'd']) 164 | }) 165 | 166 | it('Test with no key: string item and inserting', function () { 167 | var before = ['a', 'b', 'c', 'd', 'e'] 168 | var after = ['c', 'd', 'e', 'a', 'g', 'h', 'j'] 169 | var diffs = diff.diff(before, after) 170 | diffs.moves.length.should.be.equal(2) 171 | diffs.children.should.be.deep.equal(['c', 'd', 'e', 'a', 'g']) 172 | perform(before, diffs) 173 | before.should.be.deep.equal(['a', 'b', 'c', 'd', 'e', 'h', 'j']) 174 | }) 175 | 176 | it('Test with no key: object item', function () { 177 | var before = [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}] 178 | var after = [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'}] 179 | var diffs = diff.diff(before, after) 180 | diffs.children.should.be.deep.equal([{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}]) 181 | diffs.moves.length.should.be.equal(1) 182 | perform(before, diffs) 183 | assertListEqual(after, before) 184 | }) 185 | 186 | it('Mix keyed items with unkeyed items', function () { 187 | var before = [{id: 'a'}, {id: 'b'}, {key: 'c'}, {key: 'd'}, {id: 'e'}, {id: 'f'}, {id: 'g'}, {id: 'h'}] 188 | var after = [{id: 'b', flag: 'yes'}, {key: 'c'}, {id: 'e'}, {id: 'f'}, {id: 'g'}, {key: 'd'}] 189 | var diffs = diff.diff(before, after, 'id') 190 | diffs.children.should.be.deep.equal([null, {id: 'b', flag: 'yes'}, {key: 'c'}, {key: 'd'}, {id: 'e'}, {id: 'f'}, {id: 'g'}, null]) 191 | perform(before, diffs) 192 | before[0] = {id: 'b', flag: 'yes'} // because perform only operates on origin list 193 | assertListEqual(after, before) 194 | }) 195 | 196 | it('Test example', function () { 197 | var diff = require('../index') 198 | var oldList = [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}] 199 | var newList = [{id: 'c'}, {id: 'a'}, {id: 'b'}, {id: 'e'}, {id: 'f'}] 200 | 201 | var moves = diff(oldList, newList, 'id').moves 202 | moves.forEach(function (move) { 203 | if (move.type === 0) { 204 | oldList.splice(move.index, 1) 205 | } else { 206 | oldList.splice(move.index, 0, move.item) 207 | } 208 | }) 209 | assertListEqual(newList, oldList) 210 | }) 211 | }) 212 | --------------------------------------------------------------------------------