├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── Morphed.js └── helpers.js ├── test └── MorphedSpec.js └── wallaby.conf.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["defaults"], 3 | "env": { 4 | "node": true, 5 | "mocha": true, 6 | "browser": true 7 | }, 8 | "rules": { 9 | "semi": [2, "always"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Internet Systems Consortium license 2 | =================================== 3 | 4 | Copyright (c) 2015, Drew Schrauf 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose 7 | with or without fee is hereby granted, provided that the above copyright notice 8 | and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 12 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 14 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 15 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 16 | THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Morphed 2 | 3 | Morphed lets you mimic pure rendering of a DOM node while still letting you use your favourite DOM manipulation library. Each time your `update` callback is invoked it receives a fresh copy of the initial node, allowing you to make your changes from scratch each time. Behind the scenes, [morphdom](https://github.com/patrick-steele-idem/morphdom) is used to perform the minimal set of DOM manipulations between the current state and the new state. 4 | 5 | Morphed expects to be running in a Browserify, Webpack or similar environment. 6 | 7 | ## Basic Usage 8 | 9 | jQuery is used here for demonstration purposes but it is not required by Morphed. 10 | ```javascript 11 | var $ = require('jquery'); 12 | var Morphed = require('morphed'); 13 | 14 | // get our element and set up some state 15 | var $module = $('#my-module'); 16 | var counter = 0; 17 | 18 | // set up an instance of Morphed with the DOM node and an update function 19 | var morphed = new Morphed($module[0], function(node) { 20 | $('.display', node).text('Count: ' + counter); 21 | }); 22 | 23 | // attach a click handler to an increment button 24 | $module.on('click', '.increment', function() { 25 | counter++; 26 | morphed.update(); // invoke the update 27 | }); 28 | ``` 29 | 30 | ## Motivation 31 | 32 | Working with DOM manipulation libraries can let state get out of hand very quickly. jQuery and friends make it simple to toggle a class here or add add a few nodes there, but before long it's impossible to separate out the business logic from the UI code. Some frameworks work around this issue by rendering the entire app to a virtual DOM and only updating the real DOM with the differences. This is great for many cases but sometimes you're working with a prerendered element (say, from a CMS) and just want to be able to tweak what's already there. 33 | 34 | Morphed attempts to get the best of both worlds by passing a copy of the same initial node to your update function every time it is invoked. You can manipulate this element as required to get it to reflect your application's current state and Morphed will replace the old node with this up to date version. This means that you never need to try and "reverse" a DOM manipulation, you simply don't perform it in your update function. Using morphdom behind the scenes allows Morphed to swap out this new version of the node for the old version with the fewest manipulations possible. 35 | 36 | ## API 37 | 38 | ### new Morphed(node, updateFunction, options) : Morphed 39 | 40 | The `Morphed` constructor supports the following arguments: 41 | 42 | - _node_ (`Node`, required) - The node to be updated on each call to `update`. 43 | - _updateFunction_ (`Function(Node[, state])`, required) - The update function to be invoked on each call to update. The first parameter, `Node`, will be always be the initial node. The second parameter, `state`, is optional and its usage is described in the State API section. All manipulations applied to the passed `Node` will be applied to the DOM. 44 | - _options_ (`Object`, optional) - See below for supported options. 45 | 46 | Supported options: 47 | 48 | - _morphdom_ (`Object`) - This object gets passed through to morphdom. See the [morphdom API documentation](https://github.com/patrick-steele-idem/morphdom#api) for supported options. 49 | - _ignoreAttribute_ (`String`, default `morphed-ignore`) - Change the attribute used to flag a node to be ignored by morphdom. See the Caveats section for the usage of the parameter. 50 | - _initialState_ (`Object`) - Provide an initial state for use with the State API. See the State API section for details. 51 | - _clone_ (`Boolean`, default `true`) - Set this to false to avoid the overhead of cloning DOM nodes. You must instead return a node from the `updateFunction`. Use this with a templating library to generate elements that don't need to be prerendered in the page's source. The `updateFunction` callback will not be passed a node, the state becomes the first parameter. 52 | 53 | ### update() 54 | 55 | Invokes the `updateFunction` callback with a copy of the initial DOM node and the current state. All manipulations performed on the node will be applied to the DOM using morphdom. 56 | 57 | ## State API 58 | 59 | Morphed provides a simple state API to handle state tracking for you. It is modeled on the React Component State API and will cause an `update` each time a state function is called. You can access the Morphed instance's current state with `.state`. 60 | 61 | ### setState(state) 62 | 63 | Merges the passed state with the current state. This will add any new keys and replace existing keys. Old keys will be kept. Calling this function will trigger an `update`. 64 | 65 | - _state_ (`Object`) - The new state to merge into the current state. 66 | 67 | ### replaceState(state) 68 | 69 | Replaces the current state with the passed state. Calling this function will trigger an `update`. 70 | 71 | - _state_ (`Object`) - The new state. 72 | 73 | ## State API Usage 74 | 75 | Here is the same example as above using `setState` to track the count. 76 | 77 | ```javascript 78 | var $ = require('jquery'); 79 | var Morphed = require('morphed'); 80 | 81 | var $module = $('#my-module'); 82 | 83 | var morphed = new Morphed($module[0], function(node, state) { 84 | $('.display', node).text('Count: ' + state.count); 85 | }, { 86 | initialState: { 87 | count: 0 88 | } 89 | }); 90 | 91 | $module.on('click', '.increment', function() { 92 | morphed.setState({count: morphed.state.count + 1}); 93 | }); 94 | ``` 95 | 96 | ## Caveats 97 | 98 | Using Morphed isn't without its pitfalls but many of them have simple workarounds. 99 | 100 | Depending on the type of DOM manipulations you are making, some cached queries or event handlers may be pointing to nodes that are no longer rendered. The key here is to use event delegation to handle events. In jQuery, this can be achieved using `.on` and attaching the handler to the same node that is passed to Morphed, or one wrapping it. For example: 101 | 102 | ```javascript 103 | $myNode = $('#my-node'); 104 | var morphed = new Morphed($myNode[0], function() {...}); 105 | 106 | // this could break if the button node is morphed 107 | $('button', $myNode).on('click', function() {...})); 108 | 109 | // this should continue to work regardless of what happens to the DOM 110 | $myNode.on('click', 'button', function() {...}); 111 | ``` 112 | 113 | Some elements are stateful and will lose their current value if they are rerendered. The most obvious examples of this are form input controls. There are two things you can do here to work around this issue. The first is to add a `change` listener to the input and store the value yourself. This is a common (in fact, required) pattern in React and allows you to simply update the element with the real value on each update callback. If you don't want to add a host of callbacks for your inputs, you can tell Morphed to ignore any manipulations made to a particular element by adding the attribute `morphed-ignore`. Any changes made in the update callback to an element with this attribute will not be reflected in the real DOM. All modifications to this element will need to be performed outside of the Morphed update callback. 114 | 115 | Due to the way morphdom works, jQuery animations will no longer work. Most of the time these animations can be replaced with CSS animations triggered by a class change. When you find yourself in a situation where this won't work and absoloutely must use a javascript animation, make sure you ignore the animated node with `morphed-ignore`. Be aware, children of this node will be ignored too. 116 | 117 | ## Contribute 118 | 119 | Pull Requests welcome. Please make sure tests pass with: 120 | 121 | npm test 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "morphed", 3 | "repository": "drewschrauf/morphed", 4 | "version": "0.2.1", 5 | "description": "Mimic pure rendering with a cloned DOM node", 6 | "main": "src/Morphed.js", 7 | "scripts": { 8 | "test": "mocha --recursive test", 9 | "lint": "eslint src" 10 | }, 11 | "author": "Drew Schrauf", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "chai": "^3.3.0", 15 | "eslint": "^1.6.0", 16 | "eslint-config-defaults": "^7.0.1", 17 | "jsdom": "^6.5.1", 18 | "mocha": "^2.3.3", 19 | "mocha-jsdom": "^1.0.0", 20 | "rewire": "^2.3.4", 21 | "sinon": "^1.17.1", 22 | "sinon-chai": "^2.8.0" 23 | }, 24 | "dependencies": { 25 | "morphdom": "^1.0.2", 26 | "object-assign": "^4.0.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Morphed.js: -------------------------------------------------------------------------------- 1 | var morphdom = require('morphdom'); 2 | var objectAssign = require('object-assign'); 3 | var helpers = require('./helpers'); 4 | 5 | var Morphed = function(node, updateFunc, options) { 6 | if (!node) { 7 | throw new Error('Node is required.'); 8 | } 9 | if (!node.nodeName) { 10 | throw new Error('First argument must be a Node.'); 11 | } 12 | if (!updateFunc) { 13 | throw new Error('Update function is required.'); 14 | } 15 | 16 | var defaultOpts = { 17 | clone: true, 18 | ignoredAttribute: 'morphed-ignore', 19 | morphdom: {} 20 | }; 21 | 22 | this.node = node; 23 | this.updateFunc = updateFunc; 24 | this.options = objectAssign({}, defaultOpts, options); 25 | 26 | // set up the custom ignore handler 27 | var passedOBME = this.options.morphdom.onBeforeMorphEl; 28 | this.options.morphdom.onBeforeMorphEl = function(fromNode, toNode) { 29 | var shouldUpdate = helpers.shouldUpdate(fromNode, this.options.ignoredAttribute); 30 | if (shouldUpdate && passedOBME) shouldUpdate = passedOBME(fromNode, toNode); 31 | return shouldUpdate; 32 | }.bind(this); 33 | 34 | // set up initial state 35 | if (this.options.clone) { 36 | // set ids on ignored elements 37 | helpers.setupIgnored(this.node, this.options.ignoredAttribute); 38 | 39 | // clone the initial dom node 40 | this.initialDom = this.node.cloneNode(true); 41 | } 42 | this.state = objectAssign({}, this.options.initialState); 43 | 44 | // force an initial update 45 | this.update(); 46 | }; 47 | 48 | Morphed.prototype.update = function() { 49 | var newNode; 50 | if (this.options.clone) { 51 | // Morphed updates do clone the initial dom and allow mutations 52 | newNode = this.initialDom.cloneNode(true); 53 | this.updateFunc(newNode, this.state); 54 | } else { 55 | // pure udpates do not clone, just return an element 56 | newNode = this.updateFunc(this.state); 57 | } 58 | this.node = morphdom(this.node, newNode, this.options.morphdom); 59 | }; 60 | 61 | Morphed.prototype.setState = function(state) { 62 | this.state = objectAssign({}, this.state, state); 63 | this.update(); 64 | }; 65 | 66 | Morphed.prototype.replaceState = function(state) { 67 | this.state = objectAssign({}, state); 68 | this.update(); 69 | }; 70 | 71 | module.exports = Morphed; 72 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | // set an ID on every ignored element 2 | exports.setupIgnored = function(node, attribute) { 3 | var ignored = node.querySelectorAll('[' + attribute + ']'); 4 | if (ignored.length) { 5 | for (var i = 0; i < ignored.length; i++) { 6 | var el = ignored[i]; 7 | if (!el.id) { 8 | el.id = 'morphed-' + Math.ceil(Math.random() * 10000); 9 | } 10 | } 11 | } 12 | }; 13 | 14 | exports.shouldUpdate = function(node, attribute) { 15 | return !node.attributes.hasOwnProperty(attribute); 16 | }; 17 | -------------------------------------------------------------------------------- /test/MorphedSpec.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | var expect = chai.expect; 3 | var sinon = require('sinon'); 4 | var sinonChai = require('sinon-chai'); 5 | chai.use(sinonChai); 6 | 7 | var rewire = require('rewire'); 8 | var jsdom = require('mocha-jsdom'); 9 | var Morphed = require('../src/Morphed'); 10 | 11 | var noop = function() {}; 12 | 13 | describe('Morphed', function() { 14 | jsdom(); 15 | var root; 16 | beforeEach(function() { 17 | var el = document.createElement('div'); 18 | el.innerHTML = 'Old Text'; 19 | root = el; 20 | }); 21 | 22 | describe('#constructor', function() { 23 | it('can be instantiated', function() { 24 | expect(new Morphed(root, noop)).to.not.be.undefined; 25 | }); 26 | 27 | it('should copy input parameters', function() { 28 | var f = new Morphed(root, noop); 29 | expect(f.node).to.not.be.undefined; 30 | expect(f.updateFunc).to.be.a('function'); 31 | expect(f.options).to.be.an('object'); 32 | }); 33 | 34 | it('should throw if node is not passed', function() { 35 | expect(function() { 36 | new Morphed(); 37 | }).to.throw('Node is required'); 38 | }); 39 | 40 | it('should throw if node is not a Node', function() { 41 | expect(function() { 42 | new Morphed('test'); 43 | }).to.throw('First argument must be a Node'); 44 | }); 45 | 46 | it('should throw if update function is not passed', function() { 47 | expect(function() { 48 | new Morphed(root); 49 | }).to.throw('Update function is required'); 50 | }); 51 | 52 | it('should set up initial state', function() { 53 | var f = new Morphed(root, noop, {initialState: {test: 'test'}}); 54 | expect(f.state).to.eql({test: 'test'}); 55 | }); 56 | }); 57 | 58 | describe('#update', function() { 59 | var morphed; 60 | var update; 61 | beforeEach(function() { 62 | update = sinon.spy(function(node) { 63 | node.innerHTML = 'New Text'; 64 | }); 65 | morphed = new Morphed(root, update, {initialState: {test: 'testing'}}); 66 | }); 67 | 68 | it('should provide an update function', function() { 69 | expect(morphed.update).to.be.a('function'); 70 | }); 71 | 72 | it('should invoke the passed updateFunc', function() { 73 | morphed.update(); 74 | expect(update).to.have.been.called; 75 | }); 76 | 77 | it('should apply the results of the updateFunc', function() { 78 | morphed.update(); 79 | expect(root.innerHTML).to.equal('New Text'); 80 | }); 81 | 82 | it('should not replace the original element', function() { 83 | var oldRoot = root; 84 | morphed.update(); 85 | expect(root).to.equal(oldRoot); 86 | }); 87 | 88 | it('should always pass the original node to the updateFunc', function() { 89 | var el = document.createElement('div'); 90 | el.innerHTML = 'Old Text'; 91 | 92 | var morphed = new Morphed(el, function(node) { 93 | expect(node.innerHTML).to.equal('Old Text'); 94 | node.innerHTML = 'New Text'; 95 | }); 96 | 97 | // update multiple times 98 | expect(el.innerHTML).to.equal('New Text'); 99 | morphed.update(); 100 | }); 101 | 102 | it('should pass the current state to the update function', function() { 103 | morphed.update(); 104 | expect(update.args[0][1]).to.eql({test: 'testing'}); 105 | }); 106 | 107 | describe('update without clone', function() { 108 | beforeEach(function() { 109 | update = sinon.spy(function() { 110 | var el = document.createElement('div'); 111 | el.innerHTML = 'New Text'; 112 | return el; 113 | }); 114 | morphed = new Morphed(root, update, {initialState: {test: 'testing'}, clone: false}); 115 | }); 116 | 117 | it('should pass the state as the first parameter to the updateFunc', function() { 118 | morphed.update(); 119 | expect(update.args[0][0]).to.eql({test: 'testing'}); 120 | }); 121 | 122 | it('should render returned element', function() { 123 | morphed.update(); 124 | expect(root.innerHTML).to.equal('New Text'); 125 | }); 126 | }); 127 | 128 | describe('ignored', function() { 129 | var complexRoot; 130 | beforeEach(function() { 131 | var el = document.createElement('div'); 132 | el.innerHTML = '
Normal
' + 133 | '
Ignored
' + 134 | '
Ignored
' + 135 | '
Ignored
'; 136 | complexRoot = el; 137 | }); 138 | var updateFunc = function(node) { 139 | node.querySelector('[morphed-ignore]').innerHTML = 'Changed'; 140 | node.querySelector('[custom-ignore]').innerHTML = 'Changed'; 141 | }; 142 | 143 | it('should attach ids to ignored elements', function() { 144 | morphed = new Morphed(complexRoot, noop); 145 | expect(complexRoot.querySelector('[morphed-ignore]').id).to.not.be.empty; 146 | }); 147 | 148 | it('should not attach ids to ignored elements that already have ids', function() { 149 | morphed = new Morphed(complexRoot, noop, {ignoredAttribute: 'existing-id'}); 150 | expect(complexRoot.querySelector('[existing-id]').id).to.equal('test'); 151 | }); 152 | 153 | it('should not apply modifications to ignored elements', function() { 154 | morphed = new Morphed(complexRoot, updateFunc); 155 | expect(complexRoot.querySelector('[morphed-ignore]').innerHTML).to.equal('Ignored'); 156 | expect(complexRoot.querySelector('[custom-ignore]').innerHTML).to.equal('Changed'); 157 | }); 158 | 159 | it('should allow the ignored data attribute to be overridden', function() { 160 | morphed = new Morphed(complexRoot, updateFunc, { 161 | ignoredAttribute: 'custom-ignore' 162 | }); 163 | expect(complexRoot.querySelector('[morphed-ignore]').innerHTML).to.equal('Changed'); 164 | expect(complexRoot.querySelector('[custom-ignore]').innerHTML).to.equal('Ignored'); 165 | }); 166 | 167 | it('should still invoke a custom onBeforeMorphEl', function() { 168 | var obme = sinon.spy(function() { 169 | return false; 170 | }); 171 | morphed = new Morphed(complexRoot, updateFunc, { 172 | morphdom: { 173 | onBeforeMorphEl: obme 174 | } 175 | }); 176 | expect(complexRoot.querySelector('[morphed-ignore]').innerHTML).to.equal('Ignored'); 177 | expect(complexRoot.querySelector('[custom-ignore]').innerHTML).to.equal('Ignored'); 178 | expect(obme).to.have.been.called; 179 | }); 180 | }); 181 | 182 | describe('morphdom args', function() { 183 | var morphedr; 184 | var morphdom; 185 | beforeEach(function() { 186 | var MorphedRewired = rewire('../src/Morphed'); 187 | morphdom = sinon.spy(); 188 | MorphedRewired.__set__('morphdom', morphdom); 189 | morphedr = new MorphedRewired(root, noop, {morphdom: {test: 'test'}}); 190 | }); 191 | 192 | it('should pass morphdom arguments to morphdom', function() { 193 | morphedr.update(); 194 | expect(morphdom).to.have.been.called; 195 | expect(morphdom.args[0][2].test).to.not.be.undefined; 196 | }); 197 | }); 198 | }); 199 | 200 | describe('#setState', function() { 201 | var morphed, update; 202 | beforeEach(function() { 203 | update = sinon.spy(); 204 | morphed = new Morphed(root, update, {initialState: {test: 'testing'}}); 205 | }); 206 | 207 | it('should extend the current state with the new state', function() { 208 | morphed.setState({foo: 'bar'}); 209 | expect(morphed.state).to.eql({test: 'testing', foo: 'bar'}); 210 | }); 211 | 212 | it('should replace keys on the current state', function() { 213 | morphed.setState({test: 'again'}); 214 | expect(morphed.state).to.eql({test: 'again'}); 215 | }); 216 | 217 | it('should cause an update when the state is changed', function() { 218 | morphed.setState({}); 219 | expect(update).to.have.been.called; 220 | }); 221 | }); 222 | 223 | describe('#replaceState', function() { 224 | var morphed, update; 225 | beforeEach(function() { 226 | update = sinon.spy(); 227 | morphed = new Morphed(root, update, {initialState: {test: 'testing'}}); 228 | }); 229 | 230 | it('should replace the current state with the new state', function() { 231 | morphed.replaceState({foo: 'bar'}); 232 | expect(morphed.state).to.eql({foo: 'bar'}); 233 | }); 234 | 235 | it('should cause an update when the state is changed', function() { 236 | morphed.replaceState({}); 237 | expect(update).to.have.been.called; 238 | }); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /wallaby.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | files: [ 4 | 'src/**/*.js' 5 | ], 6 | tests: [ 7 | 'test/**/*Spec.js' 8 | ], 9 | env: { 10 | type: 'node', 11 | runner: 'node' 12 | } 13 | }; 14 | }; 15 | --------------------------------------------------------------------------------