├── .gitignore ├── .jshintrc ├── LICENSE ├── README.md ├── dist └── js │ └── app.js ├── index.html ├── package.json └── src ├── app.js ├── intents └── items.js ├── models └── items.js ├── renderer.js ├── utils ├── binder.js └── replicate.js └── views └── items.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "predef": ["window"] 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 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 13 | all 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 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mvi-example 2 | =========== 3 | 4 | A demo of the Model-View-Intent architecture with Virtual DOM renderer, for single-page apps. 5 | 6 | [OPEN THE DEMO](http://staltz.com/mvi-example/) 7 | 8 | **If MVI interests you, then you might also find [Cycle.js](https://github.com/staltz/cycle) (a framework) to be valuable.** 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MVI Example 8 | 9 | 10 |
11 | 12 |
13 |

14 | Model-View-Intent architecture with Virtual DOM renderer. Source code 15 |
16 | Compare with React 17 |

18 | 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mvi-example", 3 | "version": "0.1.0", 4 | "author": "Andre Staltz", 5 | "dependencies": { 6 | "rx": "2.3.12", 7 | "virtual-dom": "0.0.19", 8 | "virtual-hyperscript": "4.4.0", 9 | "dom-delegator": "9.0.1", 10 | "vdom-virtualize": "0.0.4" 11 | }, 12 | "devDependencies": { 13 | "browserify": "~2.36.0", 14 | "jscs": "^1.7.3", 15 | "jshint": "^2.5.10", 16 | "watchify": "^2.1.1" 17 | }, 18 | "scripts": { 19 | "lint": "jshint src/", 20 | "jscs": "jscs src/", 21 | "check": "npm run lint && npm run jscs", 22 | "browserify": "browserify src/app.js -o dist/js/app.js", 23 | "dev": "watchify src/app.js -o dist/js/app.js", 24 | "preinstall": "rm -rf build && rm -rf node_modules", 25 | "postinstall": "ln -s ../src node_modules/mvi-example" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var binder = require('mvi-example/utils/binder'); 3 | var renderer = require('mvi-example/renderer'); 4 | var ItemsModel = require('mvi-example/models/items'); 5 | var ItemsView = require('mvi-example/views/items'); 6 | var ItemsIntent = require('mvi-example/intents/items'); 7 | 8 | window.onload = function () { 9 | binder(ItemsModel, ItemsView, ItemsIntent); 10 | renderer.init(); 11 | }; 12 | -------------------------------------------------------------------------------- /src/intents/items.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * ItemsIntent. Interprets raw user input and outputs model-friendly user 4 | * intents. 5 | */ 6 | var Rx = require('rx'); 7 | var replicate = require('mvi-example/utils/replicate'); 8 | 9 | var inputAddOneClicks$ = new Rx.Subject(); 10 | var inputAddManyClicks$ = new Rx.Subject(); 11 | var inputRemoveClicks$ = new Rx.Subject(); 12 | var inputItemColorChanged$ = new Rx.Subject(); 13 | var inputItemWidthChanged$ = new Rx.Subject(); 14 | 15 | function observe(ItemsView) { 16 | replicate(ItemsView.addOneClicks$, inputAddOneClicks$); 17 | replicate(ItemsView.addManyClicks$, inputAddManyClicks$); 18 | replicate(ItemsView.removeClicks$, inputRemoveClicks$); 19 | replicate(ItemsView.itemColorChanged$, inputItemColorChanged$); 20 | replicate(ItemsView.itemWidthChanged$, inputItemWidthChanged$); 21 | } 22 | 23 | var addItem$ = Rx.Observable.merge( 24 | inputAddOneClicks$.map(function () { return 1; }), 25 | inputAddManyClicks$.map(function () { return 1000; }) 26 | ); 27 | 28 | var removeItem$ = inputRemoveClicks$.map(function (clickEvent) { 29 | return Number(clickEvent.currentTarget.attributes['data-item-id'].value); 30 | }); 31 | 32 | var colorChanged$ = inputItemColorChanged$ 33 | .map(function (inputEvent) { 34 | return { 35 | id: Number(inputEvent.currentTarget.attributes['data-item-id'].value), 36 | color: inputEvent.currentTarget.value 37 | }; 38 | }); 39 | 40 | var widthChanged$ = inputItemWidthChanged$ 41 | .map(function (inputEvent) { 42 | return { 43 | id: Number(inputEvent.currentTarget.attributes['data-item-id'].value), 44 | width: Number(inputEvent.currentTarget.value) 45 | }; 46 | }); 47 | 48 | module.exports = { 49 | observe: observe, 50 | addItem$: addItem$, 51 | removeItem$: removeItem$, 52 | colorChanged$: colorChanged$, 53 | widthChanged$: widthChanged$ 54 | }; 55 | -------------------------------------------------------------------------------- /src/models/items.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * ItemsModel. 4 | * As output, Observable of array of item data. 5 | * As input, ItemsIntent. 6 | */ 7 | var Rx = require('rx'); 8 | var replicate = require('mvi-example/utils/replicate'); 9 | 10 | var intentAddItem$ = new Rx.Subject(); 11 | var intentRemoveItem$ = new Rx.Subject(); 12 | var intentWidthChanged$ = new Rx.Subject(); 13 | var intentColorChanged$ = new Rx.Subject(); 14 | 15 | function observe(ItemsIntent) { 16 | replicate(ItemsIntent.addItem$, intentAddItem$); 17 | replicate(ItemsIntent.removeItem$, intentRemoveItem$); 18 | replicate(ItemsIntent.widthChanged$, intentWidthChanged$); 19 | replicate(ItemsIntent.colorChanged$, intentColorChanged$); 20 | } 21 | 22 | function createRandomItem() { 23 | var hexColor = Math.floor(Math.random() * 16777215).toString(16); 24 | while (hexColor.length < 6) { 25 | hexColor = '0' + hexColor; 26 | } 27 | hexColor = '#' + hexColor; 28 | var randomWidth = Math.floor(Math.random() * 800 + 200); 29 | return {color: hexColor, width: randomWidth}; 30 | } 31 | 32 | function reassignId(item, index) { 33 | return {id: index, color: item.color, width: item.width}; 34 | } 35 | 36 | var addItemMod$ = intentAddItem$.map(function(amount) { 37 | var newItems = []; 38 | for (var i = 0; i < amount; i++) { 39 | newItems.push(createRandomItem()); 40 | } 41 | return function(listItems) { 42 | return listItems.concat(newItems).map(reassignId); 43 | }; 44 | }); 45 | 46 | var removeItemMod$ = intentRemoveItem$.map(function (id) { 47 | return function(listItems) { 48 | return listItems.filter(function (item) { return item.id !== id; }) 49 | .map(reassignId); 50 | }; 51 | }); 52 | 53 | var colorChangedMod$ = intentColorChanged$.map(function(x) { 54 | return function(listItems) { 55 | listItems[x.id].color = x.color; 56 | return listItems; 57 | }; 58 | }); 59 | 60 | var widthChangedMod$ = intentWidthChanged$.map(function (x) { 61 | return function(listItems) { 62 | listItems[x.id].width = x.width; 63 | return listItems; 64 | }; 65 | }); 66 | 67 | var itemModifications = addItemMod$.merge(removeItemMod$).merge(colorChangedMod$).merge(widthChangedMod$); 68 | 69 | var items$ = itemModifications.startWith( 70 | [{id: 0, color: 'red', width: 300}] 71 | ).scan(function(listItems, modification) { 72 | return modification(listItems); 73 | }); 74 | 75 | module.exports = { 76 | observe: observe, 77 | items$: items$ 78 | }; 79 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * Renderer component. 4 | * Subscribes to vtree observables of all view components 5 | * and renders them as real DOM elements to the browser. 6 | */ 7 | var h = require('virtual-hyperscript'); 8 | var VDOM = { 9 | createElement: require('virtual-dom/create-element'), 10 | diff: require('virtual-dom/diff'), 11 | patch: require('virtual-dom/patch') 12 | }; 13 | var DOMDelegator = require('dom-delegator'); 14 | var ItemsView = require('mvi-example/views/items'); 15 | 16 | var delegator; 17 | 18 | function renderVTreeStream(vtree$, containerSelector) { 19 | // Find and prepare the container 20 | var container = window.document.querySelector(containerSelector); 21 | if (container === null) { 22 | console.error('Couldn\'t render into unknown \'' + containerSelector + '\''); 23 | return false; 24 | } 25 | container.innerHTML = ''; 26 | // Make the DOM node bound to the VDOM node 27 | var rootNode = window.document.createElement('div'); 28 | container.appendChild(rootNode); 29 | vtree$.startWith(h()) 30 | .bufferWithCount(2, 1) 31 | .subscribe(function (buffer) { 32 | try { 33 | var oldVTree = buffer[0]; 34 | var newVTree = buffer[1]; 35 | rootNode = VDOM.patch(rootNode, VDOM.diff(oldVTree, newVTree)); 36 | } catch (err) { 37 | console.error(err); 38 | } 39 | }); 40 | return true; 41 | } 42 | 43 | function init() { 44 | delegator = new DOMDelegator(); 45 | renderVTreeStream(ItemsView.vtree$, '.js-container'); 46 | } 47 | 48 | module.exports = { 49 | init: init 50 | }; 51 | -------------------------------------------------------------------------------- /src/utils/binder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * Important Model-View-Intent binding function. 4 | */ 5 | module.exports = function (model, view, intent) { 6 | if (view) { view.observe(model); } 7 | if (intent) { intent.observe(view); } 8 | if (model) { model.observe(intent); } 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/replicate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * Utility functions 4 | */ 5 | 6 | /** 7 | * Forwards all notifications from the source observable to the given subject. 8 | * 9 | * @param {Rx.Observable} source the origin observable 10 | * @param {Rx.Subject} subject the destination subject 11 | * @return {Rx.Disposable} a disposable generated by a subscribe method 12 | */ 13 | function replicate(source, subject) { 14 | if (typeof source === 'undefined') { 15 | throw new Error('Cannot replicate() if source is undefined.'); 16 | } 17 | return source.subscribe( 18 | function replicationOnNext(x) { 19 | subject.onNext(x); 20 | }, 21 | function replicationOnError(err) { 22 | console.error(err); 23 | } 24 | ); 25 | } 26 | 27 | module.exports = replicate; 28 | -------------------------------------------------------------------------------- /src/views/items.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * ItemsView. 4 | * As output, Observable of vtree (Virtual DOM tree). 5 | * As input, ItemsModel. 6 | */ 7 | var Rx = require('rx'); 8 | var h = require('virtual-hyperscript'); 9 | var replicate = require('mvi-example/utils/replicate'); 10 | 11 | var modelItems$ = new Rx.BehaviorSubject(null); 12 | var itemWidthChanged$ = new Rx.Subject(); 13 | var itemColorChanged$ = new Rx.Subject(); 14 | var removeClicks$ = new Rx.Subject(); 15 | var addOneClicks$ = new Rx.Subject(); 16 | var addManyClicks$ = new Rx.Subject(); 17 | 18 | function observe(ItemsModel) { 19 | replicate(ItemsModel.items$, modelItems$); 20 | } 21 | 22 | function vrenderTopButtons() { 23 | return h('div.topButtons', {}, [ 24 | h('button', 25 | {'ev-click': function (ev) { addOneClicks$.onNext(ev); }}, 26 | 'Add New Item' 27 | ), 28 | h('button', 29 | {'ev-click': function (ev) { addManyClicks$.onNext(ev); }}, 30 | 'Add Many Items' 31 | ) 32 | ]); 33 | } 34 | 35 | function vrenderItem(itemData) { 36 | return h('div', { 37 | style: { 38 | 'border': '1px solid #000', 39 | 'background': 'none repeat scroll 0% 0% ' + itemData.color, 40 | 'width': itemData.width + 'px', 41 | 'height': '70px', 42 | 'display': 'block', 43 | 'padding': '20px', 44 | 'margin': '10px 0px' 45 | }}, [ 46 | h('input', { 47 | type: 'text', value: itemData.color, 48 | 'attributes': {'data-item-id': itemData.id}, 49 | 'ev-input': function (ev) { itemColorChanged$.onNext(ev); } 50 | }), 51 | h('div', [ 52 | h('input', { 53 | type: 'range', min:'200', max:'1000', value: itemData.width, 54 | 'attributes': {'data-item-id': itemData.id}, 55 | 'ev-input': function (ev) { itemWidthChanged$.onNext(ev); } 56 | }) 57 | ]), 58 | h('div', String(itemData.width)), 59 | h('button', { 60 | 'attributes': {'data-item-id': itemData.id}, 61 | 'ev-click': function (ev) { removeClicks$.onNext(ev); } 62 | }, 'Remove') 63 | ] 64 | ); 65 | } 66 | 67 | var vtree$ = modelItems$ 68 | .map(function (itemsData) { 69 | return h('div.everything', {}, [ 70 | vrenderTopButtons(), 71 | itemsData.map(vrenderItem) 72 | ]); 73 | }); 74 | 75 | module.exports = { 76 | observe: observe, 77 | vtree$: vtree$, 78 | removeClicks$: removeClicks$, 79 | addOneClicks$: addOneClicks$, 80 | addManyClicks$: addManyClicks$, 81 | itemColorChanged$: itemColorChanged$, 82 | itemWidthChanged$: itemWidthChanged$ 83 | }; 84 | --------------------------------------------------------------------------------