Вы, наверное, слышали о шаблоне наблюдателя; возможно, вы использовали его, если раньше когда-либо разрабатывали интерфейсную среду Javascript. В самом простом виде это может выглядеть так:
class Observable { constructor() { this.observers = [] } subscribe(observer) { this.observers.push(observer) } unsubscribe(observer) { this.observers = this.observers.filter(obs => { return obs != observer }) } publish(data) { this.observers.forEach(observer => { observer.update(data) }) } }
По сути, вы получаете объект, на который могут подписаться другие объекты. Когда вы получаете новые данные, вы используете метод publish(data)
, чтобы каждый подписывающийся объект знал, что произошло что-то новое. Это довольно простая концепция.
Однако мысленный скачок от этой простой концепции к наличию работающего веб-приложения, которое обновляется реактивно при изменении состояния, может показаться довольно натянутым. Итак, как мы можем реализовать шаблон наблюдателя для создания веб-приложения с нуля, без помощи каких-либо фреймворков? Эта статья призвана исследовать именно это.
Наблюдаемое состояние
Приведенный выше класс Observable()
может показаться очень абстрактным. Давайте изменим это, добавив в картину какое-нибудь состояние:
class State extends Observable { constructor(state = {}) { super() this.state = state this.publish(state) } setState(propName, newData) { Object.defineProperty(this.state, propName, {value: newData, configurable: true}) this.publish(this) } getState() { return this.state } }
Мы расширяем Observable()
, чтобы создать новый класс State()
, который может поддерживать состояние. В конструкторе задаем начальное состояние. У нас есть setState()
метод, который позволяет нам устанавливать состояние, которое затем автоматически публикует его для наших наблюдателей. Таким образом, мы можем заставить объекты наблюдать за нашим состоянием, устанавливать его, а затем получать уведомления от функции update()
, чтобы они могли соответственно меняться в реальном времени.
Давайте разместим эту функциональность в observable.js
и export { Observable, State}
, чтобы сделать их доступными для использования другими модулями.
Разметка и стили
Давайте создадим контейнер, в который мы будем внедрять наше веб-приложение:
<div> <div class="header"> <h1 class="app_header">My App</h1> </div> <!-- App anchor --> <div class="main" id="app"> </div> <!-- Javascript file --> <script type="module" src="index.js"></script> </div>
Код приложения будет в index.js
, и там он будет подключаться к id="app"
для динамической визуализации нашего HTML. После добавления CSS теперь есть отправная точка:
Рендеринг HTML с помощью Javascript
Каждый раз, когда метод setState()
вызывается для объекта состояния с некоторыми новыми данными, он запускает обратный вызов update()
для каждого подписчика. Когда это происходит, нам нужно, чтобы каждый подписчик отображал некоторый HTML-код и вставлял в него наши данные. Чтобы немного упростить эту задачу, давайте воспользуемся lit-html, чтобы помочь нам эффективно взаимодействовать с DOM:
import {html, render} from 'https://unpkg.com/lit-html?module' const greetingComponent = (name) => { return html`<h1>Hello ${name}</h1>` } render(greetingComponent('Friend'), document.getElementById('app'))
greetingsComponent
вернет шаблон, содержащий тег <h1></h1>
с моим приветствием в нем. Если name
изменится, шаблон повторно отобразит элемент с id="app"
в нашем HTML-документе. Это замечательно, так как все, что нам теперь нужно сделать, это создать компоненты, которые возвращают эти HTML-шаблоны, а затем использовать эти компоненты для составления нашего макета, который, наконец, передается в функцию рендеринга.
Компоненты
Мы можем разделить наше приложение, чтобы сделать его более модульным, создав компоненты. Начиная с компонента App()
, который будет нести наше наблюдаемое состояние, мы настроим некоторые важные функции:
const appState = new State({user: '', friends: []}) class App { constructor(el, state) { this.el = el this.state = state this.state.subscribe(this) this.update(this.state) } loginWithName = (name) => { return this.state.setState('user', name) } prepareTemplate(data) { return html` <div> ${data.user ? homeComponent(data, this.state) : loginComponent(this.loginWithName)} </div> ` } update(state) { render(this.prepareTemplate(state.getState()), this.el) } } const app = new App(document.getElementById('app'), appState)
Этот компонент класса имеет конструктор, который принимает привязку элемента HTML и объект состояния, подписывает экземпляр компонента на состояние и, наконец, запускает начальный update()
на самом себе.
Когда запускается метод обновления, он отображает подготовленный шаблон в наш элемент привязки. В нашем prepareTemplate()
методе мы условно выбираем отображение одного из двух различных компонентов:
- Если в переданном объекте
data
естьuser
, мы отрендеримhomeComponent()
. - Если пользователь недоступен (что имеет место изначально, поскольку мы инициируем наш
appState
, гдеname
является пустой строкой), мы визуализируемloginComponent()
и передадим ему нашloginWithName()
обратный вызов.
Давайте создадим компонент функционального стиля для отображения экрана входа в систему. Это будет функция, которая возвращает некоторый шаблон HTML и принимает функцию обратного вызова, которая запускается, если пользователь входит в систему:
const loginComponent = (handleLogin) => { let name = '' const handleTextInput = (input) => { return name = input } return html` <div class="login"> <input class="input" placeholder="Login with name" @input=${(e) => handleTextInput(e.target.value)}/> <button class="button" @click=${() => handleLogin(name)}>Login</button> </div> ` }
Давайте сделаем что-то подобное, чтобы показать наш домашний экран:
const homeComponent = (data, state) => { let newFriend = '' const handleInput = (input) => { return newFriend = input } const handleClick = () => { if (newFriend.length < 1) return const newFriends = [...data.friends, newFriend] state.setState('friends', newFriends) newFriend = '' } const handleLogout = () => { state.setState('user', '') } return html` <div> ${greetingComponent(data.user)} ${listComponent(data.friends)} <input class="input" @input=${(e) => handleInput(e.target.value)} /> <button class="button" @click=${() => handleClick()}>Add new friend</button> <button class="button logout-button" @click=${() => handleLogout()}>Log Out</button> </div> ` }
Здесь мы также передаем компоненту наше состояние, чтобы компонент мог напрямую изменять состояние, вызывая setState()
в функции handleClick()
. После ввода имени нового друга в поле ввода handleClick()
добавит это имя в список друзей в штате.
Мы хотим отобразить наших друзей в списке, поэтому последнее, что нужно сделать здесь, - это создать listComponent()
, который принимает массив строк и отображает каждую как новый элемент списка. Если списка нет (или он пуст), мы отобразим сообщение.
const listComponent = (list) => { if (!list || list.length < 1) return html`<h1>You have no friends 😔</h1>` return html` <div class="friends-list"> ${list.map(item => html`<div class="friend">My friend ${item}</div>`)} </div> ` }
Теперь у нас должен быть простой экран входа в систему и домашний экран, на котором мы можем добавлять наших друзей:
И, как мы видим, приложение реагирует на наши входные данные и соответственно обновляется:
Заключение
Довольно аккуратно, правда? Я оставлю код на GitHub всем, кому интересно. Я хотел бы услышать ваши мысли об этом упражнении и о том, что вы бы сделали, чтобы улучшить дизайн этого кода! Пожалуйста, оставьте комментарий ниже и не забудьте подписаться.