Вы, наверное, слышали о шаблоне наблюдателя; возможно, вы использовали его, если раньше когда-либо разрабатывали интерфейсную среду 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 всем, кому интересно. Я хотел бы услышать ваши мысли об этом упражнении и о том, что вы бы сделали, чтобы улучшить дизайн этого кода! Пожалуйста, оставьте комментарий ниже и не забудьте подписаться.