Продвинутые подходы разработки SPA

Технопарк, весна, 2024 г.

Продвинутые подходы разработки SPA
Слайды доступны по ссылке
frontend.tech-mail.ru

Немного истории
Как разрабатывали web раньше?

jQuery-style —
весь код приложения в одном файле
Zepto, KnokoutJS, etc...

Такой способ удобен для украшения сайта, но не для разработки крупных web-приложений

MVC —
разделение логики от представления
Backbone.js, Ember.js, etc...

Каждый понимал его по-своему. Не было чёткого подхода к декомпозиции

Как много вопросов, как мало ответов...

Компонентный подход —
смысл в разделении кода приложения на независимые, слабосвязанные и переиспользуемые компоненты
слабые связи и декомпозиция

Компоненты могут быть достаточно сложны внутри, но они должны быть просты для использования снаружи

Компонентом может быть вообще всё что угодно, что выполняет какую-то функцию в вашем приложении

Компоненты делят на два типа

Dummy-компоненты — компоненты, которые либо вообще не содержат никакой логики (чисто визуальные компоненты), либо содержат логику, которая глубоко инкапсулирована внутри компонента

Например, компонент "Текст", компонент "Ссылка", компонент "Чекбокс"... Посложнее: компонент "Выезжающее меню", Компонент "Роутер".

Smart-компоненты — компоненты, которые управляют множеством других компонентов, содержат в себе бизнес-логику и хранят какое-то состояние

Компонент "Форма входа". Компонент "Всё приложение"

Пример dummy-компонента

		class TextComponent {
		    constructor(el) { this.el = el || document.createElement('div') }
		    setText(text) {
		        this.text = text;
		        this.el.innerHTML = this.tmpl();
		    }
		    tmpl() {
		        return `
		            <span>${this.text}</span>
		        `;
		    }
		}
		 
	

Пример smart-компонента

		class CounterComponent {
		    constructor(el) {
		        this.el = el || document.createElement('div');
		        this.count = 0; this.text = new TextComponent();
		        setInterval(() => {
		            this.count++;
		            this.text.setText(this.count);
		            this.el.innerHTML = this.tmpl();
		        }, 1000);
		    }
		    ...
		 
	

Пример smart-компонента

		 
		    ...
		    tmpl() {
		        return `
		            <h1>Счётчик:</h1>
		            ${this.text.tmpl()}
		        `;
		    }
		}
		 
	

Структура приложения

		App — smart
		    Router — dummy
		        Authorization — smart
		            Form — dummy
		                TextInput — dummy
		                Button — dummy
		        About — smart
		            SimpleText — dummy
		            Link — dummy
		 
	

Наивная реализация компонентов

Посмотрим, как это выглядит на практике

Недостатки наивной реализации

Как можно этого избежать?

Самая простая идея —
при изменениях state перерисовывать только то, что действительно необходимо

Связывание данных (Data Binding)

Связывание данных — это процесс, который устанавливает соединение между UI приложения и бизнес-логикой

Различают одностороннее и двустороннее связывание данных — пример

Наивное связывание данных

		class TextComponent {
		    constructor(el) {
		        this.el = el || document.createElement('div');
		        this.el.innerHTML = this.tmpl();
		        this.text = '';
		        this._textEl = this.el.querySelector('.js-text');
		    }
		    setText(text) {
		        this.text = text;
		        this._textEl.textContent = this.text;
		    }
		}
		 
	

Наивное связывание данных

		class TextInputComponent {
		    constructor(el) {
		        this.el = el || document.createElement('div');
		        this.el.innerHTML = this.tmpl();
		        this.value = '';
		        this._inputEl = this.el.querySelector('input[type=text]');
		        this._inputEl.onchange = () => {
		            this.value = this._inputEl.value.trim();
		            this.emit('change', {value: this.value});
		        };
		    }
		}
		 
	

Не самый хороший подход:
— много шаблонного кода, если необходимо связывать много элементов
— недекларативное описание связей

Другой подход к биндингу

		<div>
		    <h1>Привет, <span data-bind="textContent:name"></span></h1>
		    <input
		        type="text"
		        placeholder="Сколько вам лет?"
		        data-bind="value:age"
		    />
		</div>
		 
	

Другой подход к биндингу

		class AutoBind {
		    constructor(root) {
		        this.binded = [...root.querySelectorAll('[data-bind]')]
		            .map(el => ({
		                el,
		                prop: el['data-dind'].split(':')[0],
		                variable: el['data-dind'].split(':')[1],
		            }));
		    }
		    
		 
	

Другой подход к биндингу

		 
		    getVariable(name) {
		        const entry = this.binded.find(({variable}) => variable === name);
		        return entry ? entry.el[entry.prop] : undefined;
		    }
		    setVariable(name, value) {
		        this.binded.forEach((entry) => {
		            if (entry.variable === name) {
		                entry.el[entry.prop] = value;
		            }
		        });
		    }
		}
		 
	

Реактивное программирование

Реактивное программирование — парадигма программирования, ориентированная на потоки данных и распространение изменений.

В обычном мире, чтобы посчитать значение функции, необходимо вызвать её с необходимыми аргументами

В модном и молодёжном реактивном программировании функция сама пересчитается, когда её аргументы изменятся (как, например, в excel)

Добавим чуть-чуть реактивности

		class AutoReactiveBind {
		    constructor(root, store) {
		        this.store = store;
		        this.binded = ... ;
		        this.binded.forEach(entry => {
		            entry.el.addEventListener('change', () => {
		                this.setVariable(entry.variable, entry.el[entry.prop]);
		            });
		        });
		    }
		}
		 
	

Добавим чуть-чуть реактивности

		 
		    getVariable(name) {
		        return this.store[name];
		    }
		    setVariable(name, value) {
		        this.store[name] = value;
		        this.binded.forEach((entry) => {
		            if (entry.variable === name) {
		                entry.el[entry.prop] = value;
		            }
		        });
		    }
		}
		 
	

А ещё можно по-красивому биндить события

Биндинг событий

		<form data-bind-event="submit:formSubmitted">
		    <input
		        type="text"
		        placeholder="Сколько вам лет?"
		        data-bind-event="input:updateAgeListener"
		    />
		    <input type="submit" />
		</form>
		 
	

Посмотрим, как это выглядит на практике

А можно ещё круче!

Virtual DOM

Virtual DOM

Virtual DOM

Главная проблема DOM — он никогда не был рассчитан для создания динамического пользовательского интерфейса.

Virtual DOM — это техника и набор библиотек / алгоритмов, которые позволяют нам улучшить производительность на клиентской стороне, избегая прямой работы с DOM путем создания и работы c абстракцией, имитирующей DOM-дерево

Virtual DOM

Virtual DOM

Virtual DOM

Virtual DOM

				// before
				{
				    tagName: 'div',
				    classes: ['header'],
				    attributes: {
				        hidden: false
				    }
				}
				 
			
				// after
				{
				    tagName: 'div',
				    classes: ['header'],
				    attributes: {
				        hidden: true
				    }
				}
				 
			
		// Real DOM change
		div.setAttribute('hidden', 'true')
		 
	

Virtual DOM

Такой подход работает быстрее, потому как не включает в себя все тяжеловесные части реального DOM. Но только если мы делаем это правильно. Есть две проблемы:

Когда?

Когда данные изменяются и нуждается в обновлении. Есть два варианта узнать, что данные изменились:

Как?

Что делает этот подход действительно быстрым:

Virtual DOM

Как работает Virtual DOM?

Пишем свой Virtual DOM






https://github.com/8coon/tp-vdom-example

Какие задачи должен решать
Virtual DOM?


Создание ноды

Функция vdom.create()

Функция vdom.create()

		function create(vnode) {
		
		
		
	

Функция create() - продолжение

		
		
		
	

Создание ноды - пример

		const node = vdom.create({
			tag: 'DIV',
			attrs: {
				style: 'background-color: red;',
				'tab-index': 0
		
		
		

Успех!

Обновление ноды

Функция vdom.update()

Что могло поменяться?


Обновление атрибутов ноды

Функция updateAttrs()

		function updateAttrs(node, prevAttrs, attrs) {
		
		
		}
	

Что могло поменяться?


Ключ — специальный строковый атрибут, семантически идентифицирующий элемент среди соседних.

Обновление дочерних нод

(Возможная реализация)

Обновление дочерних нод

Обновление дочерних нод

Обновление дочерних нод

Обновление дочерних нод

Обновление дочерних нод

Обновление дочерних нод

Обновление дочерних нод

Обновление дочерних нод


Функция updateChildren()

		function updateChildren(node, prevChildren, children) {
		
			for (let i = 0, j = 0; i < childLength; i++, j++) {
		
		
		
	

Функция updateChildren()
(продолжение)

		
	

Функция updateChildren()
(продолжение 2)

			const curChildLength = node.childNodes.length;
		
	

Обновление ноды - пример

		const node = vdom.create({
		
		
		
		
		
	

Всё отлично?

Нет!

При компонентном подходе
возникает ряд вопросов:


Надо программировать

Создание ноды с компонентом

		
	

Создание компонента - пример

		
	

Обновление ноды с компонентом

		
	

Обновление ноды с компонентом
(продолжение)

		
	

При компонентном подходе
возникает ряд вопросов:


Решение:
Жизненный цикл компонента

Жизненный цикл компонента

		
	

willDestroy() ?

Прибираемся за компонентом

		
	

Теперь всё отлично?

Да.
Но можно лучше!

JSX

Возможные улучшения


Поддержка событий.

Демо-приложение

Перерыв

Веб-компоненты

Современные веб-компоненты

Веб-компоненты — технология, которая позволяет создавать многократно используемые компоненты в веб-приложениях. Веб-компоненты поддерживаются веб-браузерами напрямую и не требуют дополнительных библиотек для работы . Модель веб-компонентов подразумевает инкапсуляцию и совместимость отдельных элементов

На данный момент частичная поддержка существует в браузерах Chrome, Firefox, Opera и Safari. Для браузеров не поддерживающих веб-компоненты реализованы полифиллы

Современные веб-компоненты

Веб-компоненты включают в себя 3 технологии:

Shadow DOM v1

Shadow DOM — инструмент инкапсуляции HTML. Shadow DOM позволяет изменять внутреннее представление HTML элементов, оставляя внешнее представление неизменным

Shadow DOM v1

<input type="range">

Shadow DOM

		// Shadow DOM v0
		const root = element.createShadowRoot();
		 
		// Shadow DOM v1
		const root = element.attachShadow({ mode: 'open' });
		// const root = element.attachShadow({ mode: 'close' });
		 
		element.shadowRoot === root; // true
		 
		root.innerHTML = ' ... ';
		 
	

Shadow DOM (пример 1)

		<!-- html -->
		<div id="element1">
		    <h2>Subheader h2</h2>   <p>lorem ipsum</p>
		    <h2>Subheader h2</h2>   <p>Lorem ipsum dolor sit.</p>
		</div>
	
		const root1 = element1.attachShadow({ mode: 'open' });
		root1.innerHTML = `
		    <section>
		        <h1>Main header</h1>
		        <slot></slot>
		    </section>
		`;
		 
	

Shadow DOM (пример 1)

		<div id="element1">
		    <section>
		        <h1>Main header</h1>
		        <h2>Subheader h2</h2>   <p>Lorem ipsum.</p>
		        <h2>Subheader h2</h2>   <p>Lorem ipsum dolor sit.</p>
		    </section>
		</div>
		 
	

Shadow DOM (пример 2)

		<!--html-->
		<div id="element2">
		    <span slot="header">Header</span>
		    <span slot="content">Lorem ipsum dolor sit.</span>
		</div>
	
		const root2 = element2.attachShadow({ mode: 'open' });
		root2.innerHTML = `
		    <section>
		        <h1> <slot name="header"></slot> </h1>
		        <p> <slot name="content"></slot> </p>
		    </section>
		`;
		 
	

Shadow DOM (пример 2)

		<div id="element2">
		    <section>
		        <h1> <span>Header</span> </h1>
		        <p> <span>Lorem ipsum dolor sit.</span> </p>
		    </section>
		</div>
		 
	

Shadow DOM

		#shadow-root
		<style>
		    :host {
		        opacity: 0.4;
		        will-change: opacity;
		        transition: opacity 300ms ease-in-out;
		    }
		    :host(:hover) {
		        opacity: 1;
		    }
		    ::slotted(<selector>) { ... }
		</style>
		 
	

HTML Templates

HTML Templates — это механизм для отложенного рендера клиентского контента, который не отображается во время загрузки, но может быть инициализирован при помощи JavaScript. Содержимое тегов <template> парсится браузером, но отрабатывает только в момент вставки шаблона в DOM

HTML Templates

HTML Templates

		<!--html-->
		<template id="tmpl">
		    <h3>Заголовок: <slot name="title"></slot></h3>
		    <img src="image.png" alt="My Image">
		    <script>
		        alert(1);
		    </script>
		</template>
		 
	

HTML Templates

		<!--html-->
		<div id="source">
		    <span slot="title">Hello, World!</span>
		</div>
		 
	

HTML Templates

		const target = document.getElementById('source');
		const tmpl = document.getElementById('tmpl');
		 
		// two way
		target.appendChild(tmpl.content.cloneNode(true));
		/*  or  */
		target.appendChild(document.importNode(tmpl.content, true));
		 
	

Ссылка на — пример

HTML Templates

		<!-- result -->
		<div id="source">
		    <span slot="title">Hello, World!</span>
		 
		    <h3>Заголовок: <slot name="title"></slot></h3>
		    <img src="image.png" alt="My Image">
		    <script>
		        alert(1);
		    </script>
		</div>
		 
	

HTML Templates

		const target = document.getElementById('source');
		const tmpl = document.getElementById('tmpl');
		 
		/*  or  */
		const root = target.attachShadow({mode:'open'});
		root.appendChild(tmpl.content.cloneNode(true));
		 
	

Ссылка на — пример

HTML Templates

		<!-- result -->
		<div id="source">
		    <h3>Заголовок: <slot name="title">Hello, World!</slot></h3>
		    <img src="image.png" alt="My Image">
		    <script>
		        alert(1);
		    </script>
		</div>
		 
	

Custom Elements API v1

Custom Elements API — позволяют создавать и определять API собственных HTML элементов

Custom Elements API v1

Custom Elements

		customElements.define(tagName, constructor, options);
		 
		class MyHTMLElement extends HTMLElement {}
		window.customElements.define('my-element', MyHTMLElement);
		 
	
		<!-- PROFIT -->
		<my-element></my-element>
		 
	

Custom Elements

		// "super" is not a valid custom element name
		document.createElement('super')
		                instanceof HTMLUnknownElement; // true
		 
		// "x-super" is a valid custom element name
		document.createElement('x-super')
		                instanceof HTMLElement; // true
		 
	

Custom Elements styling

		x-super:defined {
		    display: block;
		}
		x-super:not(:defined) {
		    display: none;
		}
		x-super {
		    color: black;
		}
		 
	

Custom Elements

		customElements.define('bigger-img', class extends Image {
		    constructor(width=50, height=50) {
		        super(width * 10, height * 10);
		    }
		}, {extends: 'img'});
		 
	
		<!-- This <img> is a bigger img. -->
		<img is="bigger-img" width="15" height="20">
		 
	

Custom Elements

		const BiggerImage = customElements.get('bigger-img');
		const image = new BiggerImage(15, 20);
		console.assert(image.width === 150);
		console.assert(image.height === 200);
		 
		customElements.whenDefined('bigger-img')
		    .then(() => {
		        console.log('`bigger-img` ready!');
		    });
		 
	

Custom Elements

		customElements.define('my-element', class extends HTMLElement {
		    constructor() {
		        super();
		    }
		    connectedCallback() { ... }
		    disconnectedCallback() { ... }
		    adoptedCallback() { ... }
		    ...
		 
	

Custom Elements

		    ...
		    get disabled() {
		        return this.hasAttribute('disabled');
		    }
		    set disabled(value) {
		        if (value) {
		            return this.setAttribute('disabled', '');
		        }
		        this.removeAttribute('disabled');
		    }
		    ...
		 
	

Custom Elements

		    ...
		    attributeChangedCallback(attrName, oldVal, newVal) { ... }
		    static get observedAttributes() {
		        return ['open', 'disabled'];
		    }
		});
		 
	

Пример

Polymer

Полезные ссылки

Всем спасибо!