From 3b2b2011ab4dabfac3f3da7930a3994ed9de0f04 Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Tue, 13 May 2014 23:59:13 +0300 Subject: [PATCH] Optimized each-* binding --- spec/rivets/functional.js | 116 ++++++++++++++++++++++++++++++-- src/binders.coffee | 135 ++++++++++++++++++++++++++++++-------- 2 files changed, 218 insertions(+), 33 deletions(-) diff --git a/spec/rivets/functional.js b/spec/rivets/functional.js index 0e52fefd1..19603acc3 100644 --- a/spec/rivets/functional.js +++ b/spec/rivets/functional.js @@ -63,7 +63,6 @@ describe('Functional', function() { it('should set the text content of the element', function() { el.setAttribute('data-text', 'data:foo'); rivets.bind(el, bindData); - debugger expect(el.textContent || el.innerText).toBe(data.get('foo')); }); @@ -99,7 +98,7 @@ describe('Functional', function() { expect(input.value).toBe(data.get('foo')); }); }); - + describe('Multiple', function() { it('should bind a list of multiple elements', function() { el.setAttribute('data-html', 'data:foo'); @@ -178,13 +177,118 @@ describe('Functional', function() { expect(el.getElementsByTagName('li')[3]).toHaveTheTextContent('last'); }); - it('should allow binding to the iterated element index', function() { - listItem.setAttribute('data-index', 'index'); + it('should unbind and remove elements bound to items removed from the beginning of the collection', function () { + listItem.setAttribute('data-text', 'item.name'); + rivets.bind(el, bindData); + expect(el.getElementsByTagName('li').length).toBe(2); + var collection = data.get('items').slice(); + collection.shift(); + data.set({items: collection}); + // 2 -> 1 + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('b'); + + collection = collection.slice(); + collection.push({name: 'c'}, {name: 'd'}, {name: 'e'}); + data.set({items: collection}); + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.textContent).toBe('bcde'); + collection = collection.slice(); + collection.shift(); + data.set({items: collection}); + // 4 -> 3 + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.textContent).toBe('cde'); + }); + + it('should unbind and remove elements bound to items removed from the end of the collection', function () { + listItem.setAttribute('data-text', 'item.name'); rivets.bind(el, bindData); - expect(el.getElementsByTagName('li')[0].getAttribute('index')).toBe('0') - expect(el.getElementsByTagName('li')[1].getAttribute('index')).toBe('1') + expect(el.getElementsByTagName('li').length).toBe(2); + var collection = data.get('items').slice(); + collection.pop(); + data.set({items: collection}); + // 2 -> 1 + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('a'); + + collection = collection.slice(); + collection.push({name: 'b'}, {name: 'c'}, {name: 'd'}); + data.set({items: collection}); + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.textContent).toBe('abcd'); + collection = collection.slice(); + collection.pop(); + data.set({items: collection}); + // 4 -> 3 + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.textContent).toBe('abc'); }); + it('should create a new element at the beginning of the container element when a new item is prepended to the collection', function () { + listItem.setAttribute('data-text', 'item.name'); + rivets.bind(el, bindData); + expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('a'); + var collection = data.get('items').slice(); + collection.unshift({name: 'start'}); + data.set({items: collection}); + expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('start'); + }); + + it('should create a new element at the end of the container element when a new item is appended to the collection', function () { + listItem.setAttribute('data-text', 'item.name'); + rivets.bind(el, bindData); + var collection = data.get('items').slice(); + collection.push({name: 'end'}); + data.set({items: collection}); + expect(el.getElementsByTagName('li').length).toBe(3); + expect(el.getElementsByTagName('li')[0]).toHaveTheTextContent('a'); + expect(el.getElementsByTagName('li')[1]).toHaveTheTextContent('b'); + expect(el.getElementsByTagName('li')[2]).toHaveTheTextContent('end'); + }); + + it('should sort the bound elements according to order of items in the collection', function () { + listItem.setAttribute('data-text', 'item.name'); + var collection = data.get('items').slice(); + collection.push({name: 'e'}, {name: 'c'}, {name: 'd'}); + data.set({items: collection}); + rivets.bind(el, bindData); + expect(el.getElementsByTagName('li').length).toBe(collection.length); + expect(el.textContent).toBe('abecd'); + + // sort alphabetically + collection = collection.slice().sort(function (a, b) { + return a.name < b.name ? -1 : 1; + }); + data.set({items: collection}); + expect(el.textContent).toBe('abcde'); + + // reverse order + collection = collection.slice().reverse(); + data.set({items: collection}); + expect(el.textContent).toBe('edcba'); + + // move start to end + collection = collection.slice(); + collection.push(collection.shift()); + data.set({items: collection}); + expect(el.textContent).toBe('dcbae'); + + // move end to start + collection = collection.slice(); + collection.unshift(collection.pop()); + data.set({items: collection}); + expect(el.textContent).toBe('edcba'); + }); + + it('should replace all bound elements to match the new collection and sort them according to order of items in the collection', function () { + listItem.setAttribute('data-text', 'item.name'); + rivets.bind(el, bindData); + expect(el.textContent).toBe('ab'); + data.set({items: [{name: 'e'}, {name: 'p'}, {name: 'r'}]}); + expect(el.getElementsByTagName('li').length).toBe(3); + expect(el.textContent).toBe('epr'); + }); }); }); diff --git a/src/binders.coffee b/src/binders.coffee index 55929ffc5..8bd78b182 100644 --- a/src/binders.coffee +++ b/src/binders.coffee @@ -164,25 +164,51 @@ Rivets.binders['each-*'] = routine: (el, collection) -> modelName = @args[0] collection = collection or [] - - if @iterated.length > collection.length - for i in Array @iterated.length - collection.length - view = @iterated.pop() - view.unbind() - @marker.parentNode.removeChild view.els[0] + # mirror is a live copy of the list of all `modelName` models bound to this view + iterated_mirror = (iter.models[modelName] for iter in @iterated) + element_list = [] for model, index in collection - data = {index} - data[modelName] = model + iter_model = iterated_mirror[index] + # optimize the quickest check + if model is iter_model + continue + + # see if we can find this model + iter_index = iterated_mirror.indexOf model + if ~ iter_index + # found it + # we already know that iter_index isnt index + # check if this item in @iterated was removed + if !~ collection.indexOf iter_model + # this item was removed + view = @iterated.splice(index, 1)[0] + view.unbind() + @marker.parentNode.removeChild view.els[0] + # reflect in the mirror + iterated_mirror.splice index, 1 + # correct iter_index if needed + if index < iter_index + iter_index -= 1 + + # check that this index was not corrected by last removal + # or was removed itself from the views list + if iter_index isnt index and iterated_mirror[iter_index] + # move the view to the right index + view = @iterated.splice(iter_index, 1)[0] + @iterated.splice index, 0, view + # remove the view's element and queue it for inserting later + element_list.push { el: @marker.parentNode.removeChild(view.els[0]), idx: index } + # also fix the mirror's order + iterated_mirror.splice(index, 0, iterated_mirror.splice(iter_index, 1)[0]) - if not @iterated[index]? - for key, model of @view.models - data[key] ?= model + else + # it's a new model in the collection, so bind it + data = {} + data[modelName] = model - previous = if @iterated.length - @iterated[@iterated.length - 1].els[0] - else - @marker + for key, view_model of @view.models + data[key] ?= view_model options = binders: @view.options.binders @@ -196,24 +222,79 @@ Rivets.binders['each-*'] = template = el.cloneNode true view = new Rivets.View(template, data, options) view.bind() - @iterated.push view - - @marker.parentNode.insertBefore template, previous.nextSibling - else if @iterated[index].models[modelName] isnt model - @iterated[index].update data + @iterated.splice index, 0, view + + # also add it to the mirror list so it mirrors indices properly + iterated_mirror.splice index, 0, model + + element_list.push { el: template, idx: index } + + # unbind views that no longer exist in collection + while @iterated.length > collection.length + view = @iterated.pop() + view.unbind() + @marker.parentNode.removeChild view.els[0] + + # if we have elements in queue, put them back into the DOM + if element_list.length + element_list.sort (a, b) -> + if a.idx < b.idx then -1 else 1 + + buffers = [] + last_buffer = null + # create buffers + for element in element_list + if not buffers.length + last_buffer = new ElementBuffer(element.el, element.idx) + buffers.push last_buffer + else + if not last_buffer.add element.el, element.idx + last_buffer = new ElementBuffer(element.el, element.idx) + buffers.push last_buffer + + # insert buffers into DOM + for buffer in buffers + index = buffer.first_index + previous = if index and @iterated[index - 1] + @iterated[index - 1].els[0] + else + @marker + @marker.parentNode.insertBefore buffer.fragment, previous.nextSibling if el.nodeName is 'OPTION' for binding in @view.bindings if binding.el is @marker.parentNode and binding.type is 'value' binding.sync() - update: (models) -> - data = {} - - for key, model of models - data[key] = model unless key is @args[0] - - view.update data for view in @iterated +class ElementBuffer + constructor: (element, @last_index) -> + @fragment = element + @first_index = @last_index + @length = 1 + + add: (element, index) -> + if index is @first_index - 1 + @insert element, true + @first_index = index + true + else if index is @last_index + 1 + @insert element + @last_index = index + true + else + false + + insert: (element, preppend) -> + if not @wrapped + fragment = document.createDocumentFragment() + fragment.appendChild @fragment + @fragment = fragment + @wrapped = true + if preppend + @fragment.insertBefore element, @fragment.firstChild + else + @fragment.appendChild element + @length += 1 # Adds or removes the class from the element when value is true or false. Rivets.binders['class-*'] = (el, value) ->