├── .gitignore ├── README.md ├── index.js ├── package.json ├── src ├── components │ └── ui.js └── lib │ ├── Plain.js │ ├── PlainComponent.js │ ├── PlainDom.js │ ├── PlainObserver.js │ ├── PlainRenderer.js │ └── utils.js └── ui.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directories 27 | node_modules 28 | jspm_packages 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plainjs 2 | Простой и быстрый Javascript фреймворк уровня представления (View). 3 | 4 | Примеры использования Plainjs можно посмотреть в репозитории https://github.com/oberset/plainjs-test-app 5 | 6 | Plainjs рендерит ваши данные в html-код страницы. Для отрисовки данных на странице используется нативный DOM-интерфейс браузера. Рендеринг осуществляется на основе html-шаблона (т.к сам шаблон представляет из себя кусок обычного html, его парсинг тоже осуществляется с помощью DOM браузера). 7 | 8 | Пример "Hello World" (отрисуем строку текста в браузере): 9 | 10 | ```javascript 11 | import { PlainComponent as Pjs } from 'plainjs'; 12 | 13 | Pjs.render('

', { hello: 'Hello World!!!' }, document.querySelector('.hello')); 14 | ``` 15 | 16 | В приведенном примере мы импортируем класс PlainComponent (в примере используется синтаксис es6) и вызываем у него статический метод *render*. В качестве параметров методу *render* мы передаем html-шаблон, порцию данных и родительский DOM-узел, в который будет осуществляться вставка результата рендеринга. 17 | 18 | Для управления данными можно создать собственный класс. В следующем примере мы создадим простой компонент состоящий из шаблона и класса-обработчика. Компонент будет состоять из поля checkbox и кнопки, при нажатии на которую checkbox будет менять свое состояние checked/unchecked. 19 | 20 | Подключение компонента: 21 | ```javascript 22 | import { PlainComponent as Pjs } from 'plainjs'; 23 | import { Checkbox, CheckboxTemplate } from './components/checkbox/checkbox'; 24 | 25 | Pjs.render(CheckboxTemplate, Checkbox, document.querySelector('.container-checkbox')); 26 | ``` 27 | 28 | Код html-шаблона */components/checkbox/checkbox.html*: 29 | ```html 30 |
31 | 32 | 33 |
34 | ``` 35 | 36 | Код класса обработчика */components/checkbox/checkbox.js*: 37 | ```javascript 38 | import { Plain } from 'plainjs'; 39 | import UI from 'plainjs/ui'; 40 | import template from './checkbox.html'; 41 | 42 | class Checkbox extends Plain { 43 | 44 | constructor() { 45 | super(); 46 | 47 | // установка начального состояния 48 | this.setData({ 49 | className: 'checkbox', 50 | label: 'set checked', 51 | checked: null 52 | }); 53 | } 54 | 55 | onMount(node) { 56 | this.ui = UI(node, { 57 | button: 'button' 58 | }); 59 | 60 | // получаем начальное состояние checkbox 61 | this.checked = this.getData().checked; 62 | 63 | this.ui.button[0].addEventListener('click', (e) => { 64 | this.checked = !this.checked; 65 | 66 | // обновим состояние 67 | this.setData({ 68 | checked: this.checked, 69 | label: this.checked ? 'set unchecked' : 'set checked' 70 | }); 71 | }); 72 | } 73 | 74 | onUnmount() { 75 | this.ui = null; 76 | } 77 | 78 | } 79 | 80 | export { Checkbox, template as CheckboxTemplate }; 81 | ``` 82 | 83 | **При обновлении данных Plainjs не перерисовывает весь шаблон целиком, а обновляет DOM только у измененных фрагментов.** 84 | 85 | ## Примеры работы 86 | 87 | Пример компонента, который меняет содержимое в зависимости от статуса загрузки (ожидание результата выполнения асинхронного кода): 88 | 89 | Код шаблона */components/loader/loader.html*: 90 | ```html 91 |
92 |
93 |
Кликните для начала загрузки.
94 |
Идет загрузка...
95 |
Загрузка завершена!!!
96 |
97 | 98 |
99 | ``` 100 | 101 | Код класса обработчика */components/loader/loader.js*: 102 | ```javascript 103 | import { Plain } from 'plainjs'; 104 | import UI from 'plainjs/ui'; 105 | import template from './loader.html'; 106 | 107 | class Loader extends Plain { 108 | constructor() { 109 | super(); 110 | 111 | // установим начальное состояние 112 | this.setData({ 113 | status: 0 114 | }); 115 | } 116 | 117 | onMount(node) { 118 | this.ui = UI(node, { 119 | button: 'button' 120 | }); 121 | 122 | this.ui.button[0].addEventListener('click', (e) => { 123 | // если еще не производили загрузку 124 | if (this.getData().status < 1) { 125 | this.setData({ status: 1 }); 126 | 127 | // эмуляция асинхронного вызова 128 | setTimeout(() => { 129 | this.setData({ status: 2 }); 130 | }, 2500); 131 | } 132 | }); 133 | } 134 | 135 | onUnmount() { 136 | this.ui = null; 137 | } 138 | } 139 | 140 | export { Loader, template as LoaderTemplate } 141 | ``` 142 | 143 | Рендеринг компонента: 144 | ```javascript 145 | import { PlainComponent as Pjs } from 'plainjs'; 146 | import { Loader, LoaderTemplate } from './components/loader/loader'; 147 | 148 | Pjs.render(LoaderTemplate, Loader, document.querySelector('.container-loader')); 149 | ``` 150 | 151 | Еще один пример: форма регистрации, в которой при вводе данных в поле ID нужно блокировать поля first-name и last-name. 152 | 153 | Код шаблона */components/input/input.html*: 154 | ```html 155 |
156 |
157 | 158 | 159 |
160 |
161 | 162 | 163 |
164 |
165 | 166 | 167 |
168 |
169 | ``` 170 | 171 | Код класса обработчика */components/input/input.js*: 172 | ```javascript 173 | import { Plain } from 'plainjs'; 174 | import UI from 'plainjs/ui'; 175 | import template from './input.html'; 176 | 177 | class Input extends Plain { 178 | 179 | constructor() { 180 | super(); 181 | 182 | // начальное состояние (укажем CSS-класс и параметры полей) 183 | this.setData({ 184 | className: 'input', 185 | id: { 186 | label: 'ID', 187 | placeholder: 'input id', 188 | disabled: null 189 | }, 190 | 'first-name': { 191 | label: 'First name', 192 | placeholder: 'input first name', 193 | disabled: null 194 | }, 195 | 'last-name': { 196 | label: 'Last name', 197 | placeholder: 'input last name', 198 | disabled: null 199 | } 200 | }); 201 | 202 | // какие поля нужно блокировать 203 | this.disabledFields = ['first-name', 'last-name']; 204 | this.disabled = false; 205 | } 206 | 207 | updateFields() { 208 | const changes = {}; 209 | const { disabled } = this; 210 | 211 | this.disabledFields.forEach(field => (changes[field] = { disabled })); 212 | this.setData(changes); 213 | } 214 | 215 | onMount(node) { 216 | this.ui = UI(node, { 217 | inputId: '#id' 218 | }); 219 | 220 | this.ui.inputId[0].addEventListener('input', (e) => { 221 | const val = e.currentTarget.value; 222 | 223 | if (val && !this.disabled) { 224 | this.disabled = true; 225 | this.updateFields(); 226 | 227 | } else if (!val && this.disabled) { 228 | this.disabled = false; 229 | this.updateFields(); 230 | } 231 | }); 232 | } 233 | 234 | onUnmount() { 235 | this.ui = null; 236 | this.disabled = false; 237 | } 238 | } 239 | 240 | export { Input, template as InputTemplate }; 241 | ``` 242 | 243 | Рендеринг компонента: 244 | ```javascript 245 | import { PlainComponent as Pjs } from 'plainjs'; 246 | import { Input, InputTemplate } from './components/input/input'; 247 | 248 | Pjs.render(InputTemplate, Input, document.querySelector('.container-input')); 249 | ``` 250 | 251 | Использование циклов в шаблоне: нарисуем select, при выборе значения из списка будем выводить его в заголовок. 252 | 253 | Код шаблона: 254 | ```html 255 |
256 |

257 | 260 |
261 | ``` 262 | 263 | Код обработчика: 264 | ```javascript 265 | import { Plain } from 'plainjs'; 266 | import UI from 'plainjs/ui'; 267 | import template from './select.html'; 268 | 269 | class Select extends Plain { 270 | constructor() { 271 | super(); 272 | 273 | this.options = [ 274 | {value: 1, label: 'One'}, 275 | {value: 2, label: 'Two'}, 276 | {value: 3, label: 'Three'}, 277 | {value: 4, label: 'Four'}, 278 | {value: 5, label: 'Five'} 279 | ]; 280 | 281 | // установим начальное состояние 282 | this.setData({ 283 | selected: this.options[0].label, 284 | options: this.options 285 | }); 286 | } 287 | 288 | onMount(node) { 289 | this.ui = UI(node, { select: 'select'}); 290 | 291 | this.ui.select[0].addEventListener('change', (e) => { 292 | // меняем содержимое заголовка 293 | this.setData({ 294 | selected: this.options[e.currentTarget.selectedIndex].label 295 | }); 296 | }); 297 | } 298 | 299 | onUnmount() { 300 | this.ui = null; 301 | } 302 | } 303 | 304 | export { Select, template as SelectTemplate } 305 | ``` 306 | 307 | Рендеринг компонента: 308 | ```javascript 309 | import { PlainComponent as Pjs } from 'plainjs'; 310 | import { Select, SelectTemplate } from './components/select/select'; 311 | 312 | Pjs.render(SelectTemplate, Select, document.querySelector('.container-select')); 313 | ``` 314 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import Plain from './src/lib/Plain'; 2 | import PlainComponent from './src/lib/PlainComponent'; 3 | 4 | export { PlainComponent, Plain }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plainjs", 3 | "version": "0.0.4", 4 | "description": "Простой и быстрый Javascript фреймворк уровня представления (View)", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/oberset/plainjs.git" 9 | }, 10 | "keywords": [ 11 | "javascript" 12 | ], 13 | "author": "Yury Oberyukhtin", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/oberset/plainjs/issues" 17 | }, 18 | "homepage": "https://github.com/oberset/plainjs#readme" 19 | } 20 | -------------------------------------------------------------------------------- /src/components/ui.js: -------------------------------------------------------------------------------- 1 | import { isObject } from '../lib/utils'; 2 | import PlainDom from '../lib/PlainDom'; 3 | 4 | export default function ui(node, uiMap) { 5 | if (!PlainDom.isDomNode(node)) { 6 | throw new Error('Param "node" must be a Node object'); 7 | } 8 | 9 | if (!isObject(uiMap)) { 10 | throw new Error('Param "uiMap" must be a plain object'); 11 | } 12 | 13 | let ui = {}; 14 | let keys = Object.keys(uiMap); 15 | 16 | for (let key of keys) { 17 | ui[key] = node.querySelectorAll(uiMap[key]); 18 | } 19 | 20 | return ui; 21 | } -------------------------------------------------------------------------------- /src/lib/Plain.js: -------------------------------------------------------------------------------- 1 | import PlainObserver from './PlainObserver'; 2 | import { isObject, copyObject, mergeObject, isNullOrUndef } from './utils'; 3 | 4 | const storage = new WeakMap(); 5 | 6 | export default class Plain { 7 | 8 | constructor(data) { 9 | let state = {}; 10 | 11 | Object.defineProperty(this, 'data', { 12 | enumerable: true, 13 | configurable: true, 14 | get: () => { 15 | throw new Error('Direct access to the property "data" is not allowed. Use method "setData" to update your data.'); 16 | }, 17 | set: (data) => { 18 | isNullOrUndef(data) && (data = {}); 19 | 20 | if (this.constructor.dataTypes) { 21 | this.convertTypes(data, this.constructor.dataTypes); 22 | } 23 | 24 | let copy = copyObject(this.validate(data)); 25 | mergeObject(copy, state); 26 | } 27 | }); 28 | 29 | storage.set(this, state); 30 | this.data = data; 31 | } 32 | 33 | convertTypes(data, types) { 34 | Object.keys(types).map(key => data[key] = this.convertType(data[key], types[key])); 35 | } 36 | 37 | convertType(value, type) { 38 | switch (type) { 39 | case 'string': 40 | !value && (value = ''); 41 | break; 42 | 43 | case 'object': 44 | !isObject(value) && (value = {}); 45 | break; 46 | 47 | case 'number': 48 | value = parseFloat(value); 49 | break; 50 | 51 | case 'int': 52 | value = parseInt(value, 10); 53 | break; 54 | 55 | case 'boolean': 56 | value = !!value; 57 | break; 58 | 59 | case 'array': 60 | !Array.isArray(value) && (value = []); 61 | break; 62 | } 63 | 64 | return value; 65 | } 66 | 67 | validate(data) { 68 | if (isObject(data)) { 69 | return data; 70 | } else { 71 | throw new Error('"data" must be a plain object'); 72 | } 73 | } 74 | 75 | setData(data) { 76 | this.data = data; 77 | PlainObserver.update(this); 78 | } 79 | 80 | getData() { 81 | return copyObject(storage.get(this)); 82 | } 83 | 84 | onBeforeMount() {} 85 | 86 | onMount() {} 87 | 88 | onBeforeUpdate() {} 89 | 90 | onUpdate() {} 91 | 92 | onBeforeUnmount() {} 93 | 94 | onUnmount() {} 95 | 96 | onDestroy() {} 97 | 98 | } -------------------------------------------------------------------------------- /src/lib/PlainComponent.js: -------------------------------------------------------------------------------- 1 | import PlainDom from './PlainDom'; 2 | import Plain from './Plain'; 3 | import PlainRenderer from './PlainRenderer'; 4 | import PlainObserver from './PlainObserver'; 5 | import { isObject } from './utils'; 6 | 7 | const counter = () => { 8 | let nextValue = 0; 9 | return () => nextValue++; 10 | }; 11 | 12 | class DomUpdater { 13 | constructor(node, provider) { 14 | this.node = node; 15 | this.provider = provider; 16 | this.mountedNode = null; 17 | } 18 | 19 | update(fragment) { 20 | if (fragment.node && !this.mountedNode) { 21 | 22 | this.provider.onBeforeMount(this.node); 23 | PlainDom.appendChild(this.node, fragment.node); 24 | this.provider.onMount(this.node); 25 | this.mountedNode = fragment.node; 26 | 27 | } else if (!fragment.node && this.mountedNode) { 28 | 29 | this.provider.onBeforeUnmount(this.node); 30 | PlainDom.removeChild(this.node, this.mountedNode); 31 | this.provider.onUnmount(this.node); 32 | this.mountedNode = null; 33 | } else { 34 | this.provider.onUpdate(); 35 | } 36 | } 37 | } 38 | 39 | export default class PlainComponent { 40 | 41 | static getNextId = counter(); 42 | 43 | constructor(template, ProviderClass, live = true) { 44 | this.providerClass = ProviderClass; 45 | this.template = template; 46 | this.id = this.constructor.getNextId(); 47 | this.live = live === true; 48 | this.provider = null; 49 | this.fragment = null; 50 | this.node = null; 51 | } 52 | 53 | render(node, data) { 54 | if (!this.isRendered()) { 55 | let fragment = new PlainRenderer(this.template, this.node); 56 | let provider = new this.providerClass(data); 57 | 58 | PlainObserver.register(fragment, new DomUpdater(node, provider)); 59 | PlainObserver.register(provider, fragment); 60 | 61 | this.live || PlainObserver.update(provider); 62 | 63 | this.provider = provider; 64 | this.fragment = fragment; 65 | } 66 | 67 | return this; 68 | } 69 | 70 | replace(node, data) { 71 | this.node = node; 72 | return this.render(PlainDom.getParent(node), data); 73 | } 74 | 75 | update(newData) { 76 | if (this.isRendered()) { 77 | this.provider.setData(newData); 78 | } else { 79 | throw new Error('Component is not rendered'); 80 | } 81 | return this; 82 | } 83 | 84 | destroy() { 85 | if (this.isRendered()) { 86 | this.fragment.deleteFragmentNode(); 87 | this.fragment = null; 88 | 89 | this.provider.onDestroy(); 90 | 91 | PlainObserver.unregister(this.fragment); 92 | PlainObserver.unregister(this.provider); 93 | 94 | this.provider = null; 95 | } 96 | 97 | return this; 98 | } 99 | 100 | isRendered() { 101 | return (this.provider !== null && this.fragment !== null); 102 | } 103 | 104 | getId() { 105 | return this.id; 106 | } 107 | 108 | static render(template, providerClass, node, data) { 109 | if (isObject(providerClass) && data === undefined) { 110 | data = providerClass; 111 | providerClass = Plain; 112 | } 113 | new PlainComponent(template, providerClass, false).render(node, data); 114 | } 115 | 116 | } -------------------------------------------------------------------------------- /src/lib/PlainDom.js: -------------------------------------------------------------------------------- 1 | import { toArray, isNullOrUndef, T_UNDEF } from './utils'; 2 | 3 | const doc = document; 4 | const reContentTypeHTML = /^\s*text\/html\s*(?:;|$)/i; 5 | 6 | let DOMParserClass = null; 7 | 8 | function createHTMLDoc(source) { 9 | let htmlDoc = doc.implementation.createHTMLDocument(); 10 | htmlDoc.body.innerHTML = source; 11 | 12 | return htmlDoc; 13 | } 14 | 15 | function getDOMParser() { 16 | 17 | if (DOMParserClass) { 18 | return new DOMParserClass(); 19 | } 20 | 21 | let nativeParser = window.DOMParser; 22 | let nativeHTMLParser = null; 23 | 24 | if (nativeParser) { 25 | try { 26 | if ((new DOMParser()).parseFromString('', 'text/html')) { 27 | nativeHTMLParser = nativeParser; 28 | } 29 | } catch (ex) {} 30 | } 31 | 32 | if (nativeHTMLParser) { 33 | DOMParserClass = nativeHTMLParser; 34 | } else if (nativeParser) { 35 | 36 | let parseFromString = nativeParser.prototype.parseFromString; 37 | 38 | nativeParser.prototype.parseFromString = function(source, type) { 39 | if (reContentTypeHTML.test(type)) { 40 | return createHTMLDoc(source); 41 | } else { 42 | return parseFromString.apply(this, arguments); 43 | } 44 | }; 45 | 46 | DOMParserClass = nativeParser; 47 | 48 | } else { 49 | DOMParserClass = function () { 50 | this.parseFromString = function (source, type) { 51 | if (reContentTypeHTML.test(type)) { 52 | return createHTMLDoc(source); 53 | } else { 54 | throw new Error('Unknown content-type: "' + type + '"'); 55 | } 56 | } 57 | } 58 | } 59 | 60 | return new DOMParserClass(); 61 | } 62 | 63 | function parseFromString(source) { 64 | return getDOMParser().parseFromString(source, 'text/html').body; 65 | } 66 | 67 | export default class PlainDom { 68 | 69 | static createDocument(source) { 70 | return createHTMLDoc(source); 71 | } 72 | 73 | static createDocumentFragment(source) { 74 | let frag = doc.createDocumentFragment(); 75 | 76 | if (source !== T_UNDEF) { 77 | let content; 78 | 79 | if (this.isDomNode(source)) { 80 | content = source; 81 | } else { 82 | content = parseFromString(source); 83 | } 84 | 85 | frag.appendChild(content); 86 | } 87 | 88 | return frag; 89 | } 90 | 91 | static createElement(name, attributes) { 92 | let elem = doc.createElement(name); 93 | 94 | if (attributes) { 95 | let keys = Object.keys(attributes); 96 | for (let key of keys) { 97 | let value = attributes[key]; 98 | if (!isNullOrUndef(value)) { 99 | elem.setAttribute(key, (value === T_UNDEF ? key : value)); 100 | } 101 | } 102 | } 103 | 104 | return elem; 105 | } 106 | 107 | static createTextNode(str) { 108 | return doc.createTextNode(str); 109 | } 110 | 111 | static setText(textNode, str) { 112 | textNode.nodeValue = str; 113 | } 114 | 115 | static getText(textNode) { 116 | return textNode.nodeValue; 117 | } 118 | 119 | static appendChild(node, child) { 120 | !this.isDomNode(child) && (child = doc.createTextNode(child)); 121 | node.appendChild(child); 122 | } 123 | 124 | static appendChildren(node, children) { 125 | let list = toArray(children); 126 | let frag = this.createDocumentFragment(); 127 | 128 | for (let item of list) { 129 | this.appendChild(frag, item); 130 | } 131 | 132 | node.appendChild(frag); 133 | } 134 | 135 | static removeChild(node, child) { 136 | node.removeChild(child); 137 | } 138 | 139 | static removeChildren(node, children) { 140 | if (!children) { 141 | while (node.firstChild) { 142 | node.removeChild(node.firstChild); 143 | } 144 | } else { 145 | let list = toArray(children); 146 | 147 | for (let item of list) { 148 | this.removeChild(node, item); 149 | } 150 | } 151 | } 152 | 153 | static replaceChild(node, newChild, oldChild) { 154 | return node.replaceChild(newChild, oldChild); 155 | } 156 | 157 | static getChildren(node, type) { 158 | if (!type) { 159 | return toArray(node.childNodes); 160 | } else { 161 | return toArray(node.childNodes).filter(node => { 162 | return node.nodeType === type; 163 | }); 164 | } 165 | } 166 | 167 | static getParent(node) { 168 | return node.parentNode; 169 | } 170 | 171 | static setAttribute(node, name, value) { 172 | name === 'class' ? this.setClassName(node, value) : node.setAttribute(name, value); 173 | } 174 | 175 | static removeAttribute(node, name) { 176 | node.removeAttribute(name); 177 | } 178 | 179 | static getAttributes(node) { 180 | return toArray(node.attributes); 181 | } 182 | 183 | static setAttributes(node, attributes) { 184 | let list = Object.keys(attributes); 185 | 186 | for (let name of list) { 187 | let value = attributes[name]; 188 | 189 | if (name === 'class') { 190 | this.setClassName(node, value); 191 | 192 | } else if (name === 'checked' && node.checked !== T_UNDEF) { 193 | node.checked = !!value; 194 | 195 | } else if (name === 'disabled' && node.disabled !== T_UNDEF) { 196 | node.disabled = !!value; 197 | 198 | } else if (name === 'selected' && node.nodeName === 'option') { 199 | node.selected = !!value; 200 | 201 | } else if (isNullOrUndef(value)) { 202 | node.removeAttribute(name); 203 | } else { 204 | node.setAttribute(name, value); 205 | } 206 | } 207 | } 208 | 209 | static setClassName(node, className) { 210 | node.className = className || ''; 211 | } 212 | 213 | static isDomNode(elem) { 214 | return elem !== null && typeof elem === 'object' && elem.nodeType && elem.nodeType > 0; 215 | } 216 | 217 | static toArray(nodelist) { 218 | return this.isDomNode(nodelist) ? [nodelist] : toArray(nodelist); 219 | } 220 | 221 | } -------------------------------------------------------------------------------- /src/lib/PlainObserver.js: -------------------------------------------------------------------------------- 1 | export default class PlainObserver { 2 | 3 | static list = new WeakMap(); 4 | 5 | static register(object, listener) { 6 | let listeners = this.list.get(object); 7 | if (!Array.isArray(listeners)) { 8 | listeners = [listener]; 9 | } else { 10 | listeners.push(listener); 11 | } 12 | this.list.set(object, listeners); 13 | } 14 | 15 | static unregister(object) { 16 | if (this.list.has(object)) { 17 | this.list.delete(object); 18 | } 19 | } 20 | 21 | static update(object) { 22 | let listeners = this.list.get(object); 23 | listeners && listeners.forEach(listener => { 24 | listener.update(object); 25 | }); 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/lib/PlainRenderer.js: -------------------------------------------------------------------------------- 1 | import PlainDom from './PlainDom'; 2 | import PlainObserver from './PlainObserver'; 3 | import {isObject, copyObject, copyArray, isNullOrUndef, toKeyValue, T_UNDEF} from './utils'; 4 | 5 | const ITEMS_EQUALS = 0; 6 | const ITEMS_TO_DELETE = -1; 7 | const ITEMS_TO_ADD = 1; 8 | const ITEMS_TO_UPDATE = 2; 9 | 10 | export default class PlainRenderer { 11 | 12 | static options = { 13 | content: true, 14 | component: true, 15 | from: true, 16 | to: true, 17 | 'for-each': true, 18 | match: true, 19 | exists: true, 20 | eq: true, 21 | 'not-eq': true, 22 | gt: true, 23 | gte: true, 24 | lt: true, 25 | lte: true, 26 | expression: true 27 | }; 28 | 29 | constructor(template, node) { 30 | this.template = this.createTemplateFromString(template); 31 | this.node = node; 32 | this.fragment = null; 33 | this.data = {}; 34 | this.previousData = {}; 35 | } 36 | 37 | get node() { 38 | return this._node; 39 | } 40 | 41 | set node(node) { 42 | this._node = PlainDom.isDomNode(node) ? node : null; 43 | } 44 | 45 | createTemplateFromString(html) { 46 | return PlainDom.createDocumentFragment(html).firstChild; 47 | } 48 | 49 | update(provider) { 50 | this.data = provider.getData(); 51 | 52 | if (null === this.fragment) { 53 | this.fragment = this.createFragmentFromTemplate(); 54 | 55 | if (this.node) { 56 | let renderedNode = this.node; 57 | let node = this.createFragmentNode(); 58 | 59 | this.replaceNode(renderedNode, node); 60 | this.node = node; 61 | } else { 62 | this.node = this.createFragmentNode(); 63 | } 64 | } else { 65 | this.node = this.updateFragmentNode(); 66 | } 67 | 68 | this.previousData = copyObject(this.data); 69 | 70 | PlainObserver.update(this); 71 | } 72 | 73 | createFragmentFromTemplate(template) { 74 | let root = template || this.template.firstChild; 75 | let result; 76 | 77 | switch (root.nodeType) { 78 | case Node.TEXT_NODE: 79 | result = this.createFragmentFromString(root.nodeValue); 80 | break; 81 | 82 | case Node.ELEMENT_NODE: 83 | result = this.createFragmentFromElement(root); 84 | break; 85 | 86 | case Node.DOCUMENT_FRAGMENT_NODE: 87 | result = this.createFragmentFromElement(root.firstChild); 88 | break; 89 | 90 | case Node.DOCUMENT_NODE: 91 | result = this.createFragmentFromElement(root.documentElement); 92 | break; 93 | } 94 | 95 | return result; 96 | } 97 | 98 | createFragmentFromString(str) { 99 | return { 100 | type: 'string', 101 | value: str.replace(/^(\s?)[\r\n\t]+[\s\t]*/, "$1") || '' 102 | }; 103 | } 104 | 105 | createFragmentFromElement(root) { 106 | let nodeInfo = this.getNodeInfo(root); 107 | 108 | let options = {}; 109 | let attributes = {}; 110 | let attributesData = {}; 111 | let hasAttributesData = false; 112 | let children = []; 113 | 114 | for (let attribute of nodeInfo.attributes) { 115 | let attributeName = attribute.name.toLowerCase(); 116 | let attributeValue = attribute.value; 117 | 118 | if (attributeValue.indexOf(':') === 0) { 119 | attributesData[attributeName] = attributeValue.substring(1); 120 | !hasAttributesData && (hasAttributesData = true); 121 | } else if (PlainRenderer.options[attributeName]) { 122 | options[attributeName] = attributeValue; 123 | } else { 124 | attributes[attributeName] = attributeValue; 125 | } 126 | } 127 | 128 | for (let child of nodeInfo.children) { 129 | let fragment = this.createFragmentFromTemplate(child); 130 | fragment && children.push(fragment); 131 | } 132 | 133 | return { 134 | type: 'element', 135 | name: nodeInfo.name.toLowerCase(), 136 | renderedData: {}, 137 | attributes, 138 | attributesData, 139 | hasAttributesData, 140 | options, 141 | children 142 | }; 143 | } 144 | 145 | createFragmentNode(fragment, data) { 146 | fragment = fragment || this.fragment; 147 | data = data || this.data; 148 | 149 | if (fragment.type === 'string') { 150 | return (fragment.node = PlainDom.createTextNode(fragment.value)); 151 | } 152 | 153 | let options = fragment.options || {}; 154 | 155 | if (options.from) { 156 | data = data[options.from]; 157 | } 158 | 159 | if (options.match && !this.match(options, data[options.match])) { 160 | return null; 161 | } 162 | 163 | if (options.expression && !this.expression(options.expression, data)) { 164 | return null; 165 | } 166 | 167 | this.setAttributesData(fragment, data); 168 | 169 | let node = PlainDom.createElement(fragment.name, fragment.attributes); 170 | 171 | options.content && this.addContent(node, fragment, data[options.content]); 172 | options.component && this.addComponent(node, fragment, data[options.component]); 173 | 174 | if (options['for-each']) { 175 | let to = options['to'] || 'item'; 176 | let list = data[options['for-each']]; 177 | let fragments = []; 178 | 179 | list.forEach((item) => { 180 | let itemChildren = copyArray(fragment.children); 181 | let itemData = Object.assign({}, data); 182 | itemData[to] = item; 183 | 184 | this.addChildren(node, itemData, itemChildren); 185 | fragments.push(itemChildren); 186 | }); 187 | fragment.renderedData.children = fragments; 188 | } else { 189 | this.addChildren(node, data, fragment.children); 190 | } 191 | 192 | return (fragment.node = node); 193 | } 194 | 195 | updateFragmentNode(fragment, data, previousData) { 196 | fragment = fragment || this.fragment; 197 | data = data || this.data; 198 | previousData = previousData || this.previousData; 199 | 200 | if (fragment.type === 'string') { 201 | return null; 202 | } 203 | 204 | let node = fragment.node || (fragment.node = this.createFragmentNode(fragment, data)); 205 | 206 | if (null === node) { 207 | return null; 208 | } 209 | 210 | let options = fragment.options; 211 | 212 | if (options.from) { 213 | data = data[options.from]; 214 | previousData = previousData[options.from]; 215 | } 216 | 217 | if (options.match && !this.match(options, data[options.match])) { 218 | return (fragment.node = null); 219 | } 220 | 221 | if (options.expression && !this.expression(options.expression, data)) { 222 | return (fragment.node = null); 223 | } 224 | 225 | let updatedAttributes = this.getUpdatedAttributesData(fragment, data, previousData); 226 | updatedAttributes && PlainDom.setAttributes(node, updatedAttributes); 227 | 228 | options.content && this.updateContent(node, fragment, data[options.content]); 229 | options.component && this.updateComponent(node, fragment, data[options.component]); 230 | 231 | if (options['for-each']) { 232 | let to = options['to'] || 'item'; 233 | let list = data[options['for-each']]; 234 | let prevList = previousData[options['for-each']]; 235 | let items = this.getUpdatedItems(list, prevList); 236 | let renderedChildren = fragment.renderedData.children ? fragment.renderedData.children : []; 237 | let fragments = []; 238 | 239 | items.forEach((item, i) => { 240 | switch (item.type) { 241 | case ITEMS_TO_DELETE: 242 | (() => { 243 | let itemChildren = renderedChildren[i] || []; 244 | this.deleteChildren(node, itemChildren); 245 | })(); 246 | 247 | break; 248 | 249 | case ITEMS_TO_ADD: 250 | (() => { 251 | let itemChildren = fragment.children; 252 | let itemData = Object.assign({}, data); 253 | itemData[to] = item.data; 254 | 255 | this.addChildren(node, itemData, itemChildren); 256 | fragments.push(itemChildren); 257 | })(); 258 | break; 259 | 260 | case ITEMS_TO_UPDATE: 261 | (() => { 262 | let itemChildren = renderedChildren[i] || []; 263 | let itemData = Object.assign({}, data); 264 | itemData[to] = item.data; 265 | 266 | let itemPreviousData = Object.assign({}, previousData); 267 | itemPreviousData[to] = item.previous; 268 | 269 | this.updateChildren(node, itemChildren, itemData, itemPreviousData); 270 | fragments.push(itemChildren); 271 | })(); 272 | break; 273 | 274 | default: 275 | fragments.push(children); 276 | } 277 | }); 278 | 279 | fragment.renderedData.children = fragments; 280 | } else { 281 | this.updateChildren(node, fragment.children, data, previousData); 282 | } 283 | 284 | return node; 285 | } 286 | 287 | deleteFragmentNode(node, fragment) { 288 | fragment = fragment || this; 289 | 290 | if (fragment.node) { 291 | node = node || PlainDom.getParent(fragment.node); 292 | PlainDom.removeChild(node, fragment.node); 293 | fragment.node = null; 294 | } 295 | } 296 | 297 | getNodeInfo(node) { 298 | switch (node.nodeType) { 299 | case Node.TEXT_NODE: 300 | return { 301 | name: null, 302 | attributes: [], 303 | children: [], 304 | content: node.nodeValue 305 | }; 306 | break; 307 | 308 | case Node.ELEMENT_NODE: 309 | return { 310 | name: node.nodeName.toLowerCase(), 311 | attributes: PlainDom.getAttributes(node), 312 | children: PlainDom.getChildren(node), 313 | content: null 314 | }; 315 | break; 316 | } 317 | } 318 | 319 | replaceNode(source, target) { 320 | PlainDom.replaceChild(PlainDom.getParent(source), target, source); 321 | } 322 | 323 | match(options, data) { 324 | if (options.exists) { 325 | 326 | return (options.exists === 'false' && isNullOrUndef(data)) || (options.exists === 'true' && !isNullOrUndef(data)); 327 | 328 | } else if (options.eq) { 329 | 330 | switch (typeof data) { 331 | case 'string': 332 | case 'boolean': 333 | case 'number': 334 | case 'undefined': 335 | return options.eq === data.toString(); 336 | break; 337 | 338 | case 'object': 339 | return (options.eq === 'null' && data === null) || (options.eq === 'object' && data !== null); 340 | break; 341 | } 342 | 343 | } else { 344 | 345 | for (let type of ['lt', 'gt', 'lte', 'gte']) { 346 | if (T_UNDEF !== options[type]) { 347 | let test = parseFloat(options[type]); 348 | let val = parseFloat(data); 349 | 350 | switch (type) { 351 | case 'lt': 352 | return test > val; 353 | break; 354 | 355 | case 'lte': 356 | return test >= val; 357 | break; 358 | 359 | case 'gt': 360 | return test < val; 361 | break; 362 | 363 | case 'gte': 364 | return test <= val; 365 | break; 366 | } 367 | } 368 | } 369 | } 370 | } 371 | 372 | expression(expr, data) { 373 | return (new Function('data', 'return ' + expr))(data); 374 | } 375 | 376 | getUpdatedItems(items, previousItems) { 377 | !Array.isArray(items) && (items = []); 378 | !Array.isArray(previousItems) && (previousItems = []); 379 | 380 | let changes = []; 381 | 382 | for (var i = 0, len = items.length; i < len; i++) { 383 | if (previousItems[i]) { 384 | changes.push({ 385 | type: ITEMS_TO_UPDATE, 386 | data: items[i], 387 | previous: previousItems[i] 388 | }); 389 | } else { 390 | changes.push({ 391 | type: ITEMS_TO_ADD, 392 | data: items[i] 393 | }) 394 | } 395 | } 396 | 397 | if (previousItems.length > items.length) { 398 | changes.length = previousItems.length; 399 | changes.fill({type: ITEMS_TO_DELETE}, items.length); 400 | } 401 | 402 | return changes; 403 | } 404 | 405 | setAttributesData(fragment, data) { 406 | if (fragment.hasAttributesData) { 407 | let attributes = Object.keys(fragment.attributesData); 408 | for (let key of attributes) { 409 | let value = fragment.attributesData[key]; 410 | fragment.attributes[key] = data[value]; 411 | } 412 | } 413 | } 414 | 415 | getUpdatedAttributesData(fragment, data, previousData) { 416 | let updated = null; 417 | 418 | if (fragment.hasAttributesData) { 419 | let attributes = Object.keys(fragment.attributesData); 420 | if (attributes.length) { 421 | updated = {}; 422 | 423 | for (let key of attributes) { 424 | let value = fragment.attributesData[key]; 425 | if (data[value] !== previousData[value]) { 426 | updated[key] = data[value]; 427 | } 428 | } 429 | 430 | Object.assign(fragment.attributes, updated); 431 | } 432 | } 433 | 434 | return updated; 435 | } 436 | 437 | addChildren(node, data, fragmentChildren) { 438 | let children = fragmentChildren.map( 439 | (item) => this.createFragmentNode(item, data) 440 | ).filter( 441 | (item) => item && true 442 | ); 443 | children.length && PlainDom.appendChildren(node, children); 444 | } 445 | 446 | updateChildren(node, list, data, previousData) { 447 | let updatedNodes = []; 448 | let updated = false; 449 | 450 | for (let child of list) { 451 | if (child.type !== 'element') { 452 | continue; 453 | } 454 | 455 | let currentNode = child.node; 456 | let updatedNode = this.updateFragmentNode(child, data, previousData); 457 | 458 | if (currentNode !== updatedNode) { 459 | !updated && (updated = true); 460 | updatedNode && updatedNodes.push(updatedNode); 461 | } 462 | } 463 | 464 | if (updated) { 465 | PlainDom.removeChildren(node); 466 | PlainDom.appendChildren(node, updatedNodes); 467 | } 468 | } 469 | 470 | deleteChildren(node, list) { 471 | for (let child of list) { 472 | this.deleteFragmentNode(node, child); 473 | } 474 | } 475 | 476 | addContent(node, fragment, content) { 477 | let contentNode = PlainDom.createTextNode(content); 478 | PlainDom.appendChild(node, contentNode); 479 | 480 | fragment.renderedData.content = { 481 | node: contentNode 482 | }; 483 | } 484 | 485 | updateContent(node, fragment, content) { 486 | let textNode = fragment.renderedData.content.node; 487 | PlainDom.getText(textNode) !== content && PlainDom.setText(textNode, content); 488 | } 489 | 490 | addComponent(node, fragment, params) { 491 | let component = params.component; 492 | let data = params.data || {}; 493 | 494 | component.render(node, data); 495 | fragment.renderedData.component = component; 496 | } 497 | 498 | updateComponent(node, fragment, params) { 499 | let newComponent = params.component; 500 | let oldComponent = fragment.renderedData.component; 501 | let update = oldComponent && newComponent.getId() === oldComponent.getId(); 502 | 503 | if (update) { 504 | oldComponent.update(params.data || {}); 505 | } else { 506 | PlainDom.removeChildren(node); 507 | this.addComponent(node, fragment, params); 508 | } 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export const T_UNDEF = void(0); 2 | 3 | function testObject(obj, proto) { 4 | return obj !== null && Object.prototype.toString.call(obj) === '[object ' + proto + ']'; 5 | } 6 | 7 | export function isObject(obj) { 8 | return testObject(obj, 'Object'); 9 | } 10 | 11 | export function copyObject(source, target = {}) { 12 | let keys = Object.keys(source); 13 | for (let key of keys) { 14 | let val = source[key]; 15 | if (isObject(val)) { 16 | val = copyObject(val); 17 | } else if (Array.isArray(val)) { 18 | val = copyArray(val); 19 | } 20 | target[key] = val; 21 | } 22 | return target; 23 | } 24 | 25 | export function copyArray(source, target = []) { 26 | for (var i = 0, len = source.length; i < len; i++) { 27 | if (Array.isArray(source[i])) { 28 | target[i] = copyArray(source[i]); 29 | } else if (isObject(source[i])) { 30 | target[i] = copyObject(source[i]); 31 | } else { 32 | target[i] = source[i]; 33 | } 34 | } 35 | return target; 36 | } 37 | 38 | export function mergeObject(source, target = {}) { 39 | let keys = Object.keys(source); 40 | for (let key of keys) { 41 | let newData = source[key]; 42 | let curData = target[key]; 43 | 44 | if (isObject(curData) && isObject(newData)) { 45 | mergeObject(newData, curData); 46 | } else { 47 | target[key] = newData; 48 | } 49 | } 50 | return target; 51 | } 52 | 53 | export function toArray(list) { 54 | return Array.from(list); 55 | } 56 | 57 | export function toKeyValue(list, key, value) { 58 | let obj = {}; 59 | for (let item of list) { 60 | obj[item[key]] = item[value]; 61 | } 62 | return obj; 63 | } 64 | 65 | export function isNullOrUndef(test) { 66 | return test === null || test === T_UNDEF; 67 | } 68 | -------------------------------------------------------------------------------- /ui.js: -------------------------------------------------------------------------------- 1 | import ui from './src/components/ui'; 2 | export default ui; --------------------------------------------------------------------------------