paint-brush
Четыре вещи, которые я сделал по-другому при написании фреймворка фронтендак@fpereiro
325 чтения
325 чтения

Четыре вещи, которые я сделал по-другому при написании фреймворка фронтенда

к fpereiro17m2024/08/27
Read on Terminal Reader

Слишком долго; Читать

Четыре идеи, о которых вы, возможно, никогда не слышали, в контексте фронтенд-фреймворков: - Объектные литералы для шаблонизации HTML. - Глобальное хранилище, адресуемое через пути. - События и ответчики для обработки всех мутаций. - Текстовый алгоритм сравнения для обновления DOM.
featured image - Четыре вещи, которые я сделал по-другому при написании фреймворка фронтенда
fpereiro HackerNoon profile picture
0-item
1-item

В 2013 году я решил создать минималистичный набор инструментов для разработки веб-приложений. Возможно, лучшее, что получилось в результате этого процесса, — это gotoB , клиентский, чистый JS-фронтенд-фреймворк, написанный на 2 тыс. строк кода.


Написать эту статью меня побудило чтение интересных статей авторов очень успешных фронтенд-фреймворков:


Что меня воодушевило в этих статьях, так это то, что в них говорится об эволюции идей , лежащих в основе их создания; реализация — это всего лишь способ сделать их реальностью, а единственные обсуждаемые функции — это те, которые настолько важны, что представляют сами идеи.


Безусловно, наиболее интересным аспектом того, что вышло из gotoB, являются идеи, которые развились в результате столкновения с трудностями его создания. Вот что я хочу здесь осветить.


Поскольку я создал фреймворк с нуля и пытался добиться как минимализма, так и внутренней согласованности, я решил четыре проблемы способом, который, по моему мнению, отличается от способа, которым большинство фреймворков решают те же проблемы.


Этими четырьмя идеями я хочу поделиться с вами сейчас. Я делаю это не для того, чтобы убедить вас использовать мои инструменты (хотя вы можете это сделать!), а скорее в надежде, что вас заинтересуют сами идеи.

Идея 1: объектные литералы для решения шаблонизации

Любое веб-приложение должно создавать разметку (HTML) «на лету» в зависимости от состояния приложения.


Лучше всего это объяснить на примере: в сверхпростом приложении списка дел состояние может быть списком дел: ['Item 1', 'Item 2'] . Поскольку вы пишете приложение (а не статическую страницу), список дел должен иметь возможность изменяться.


Поскольку состояние меняется, HTML, который создает пользовательский интерфейс вашего приложения, должен меняться вместе с состоянием. Например, чтобы отобразить ваши задачи, вы можете использовать следующий HTML:

 <ul> <li>Item 1</li> <li>Item 2</li> </ul>


Если состояние изменится и будет добавлен третий элемент, ваше состояние теперь будет выглядеть так: ['Item 1', 'Item 2', 'Item 3'] ; тогда ваш HTML должен выглядеть так:

 <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>


Проблема генерации HTML на основе состояния приложения обычно решается с помощью языка шаблонов , который вставляет конструкции языка программирования (переменные, условные операторы и циклы) в псевдо-HTML, который затем расширяется до настоящего HTML.


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

 // Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>


Мне никогда не нравились эти синтаксисы, которые привносили логику в HTML. Понимая, что шаблонизация требует программирования, и желая избежать отдельного синтаксиса для этого, я решил вместо этого привнести HTML в js, используя объектные литералы . Поэтому я мог просто смоделировать свой HTML как объектные литералы:

 ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]


Если бы я хотел затем использовать итерацию для генерации списка, я мог бы просто написать:

 ['ul', items.map ((item) => ['li', item])]


А затем использовать функцию, которая преобразует этот литерал объекта в HTML. Таким образом, все шаблоны могут быть сделаны в JS, без какого-либо языка шаблонов или транспиляции. Я использую название liths для описания этих массивов, которые представляют HTML.


Насколько мне известно, ни один другой фреймворк JS не подходит к шаблонизации таким образом. Я немного покопался и нашел JSONML , который использует почти такую же структуру для представления HTML в объектах JSON (которые почти такие же, как литералы объектов JS), но не нашел фреймворка, построенного вокруг него.


Mithril и Hyperapp довольно близки к подходу, который использовал я, но они все равно используют вызовы функций для каждого элемента.

 // Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])


Подход с использованием объектных литералов хорошо зарекомендовал себя для HTML, поэтому я распространил его на CSS и теперь генерирую весь свой CSS также с помощью объектных литералов.


Если по какой-то причине вы находитесь в среде, где вы не можете транспилировать JSX или использовать язык шаблонов, и вы не хотите объединять строки, вы можете использовать этот подход.


Я не уверен, что подход Mithril/Hyperapp лучше моего; я обнаружил, что при написании длинных литералов объектов, представляющих лит, я иногда забываю где-то запятую, и ее иногда бывает сложно найти. Кроме этого, на самом деле никаких жалоб. И мне нравится тот факт, что представление для HTML — это и 1) данные, и 2) в JS. Это представление на самом деле может функционировать как виртуальный DOM, как мы увидим, когда доберемся до идеи № 4.


Бонусная деталь: если вы хотите сгенерировать HTML из объектных литералов, вам нужно решить только следующие две проблемы:

  1. Преобразование строк в сущностные (т.е. экранирование специальных символов).
  2. Знайте, какие теги закрывать, а какие нет.

Идея 2: глобальное хранилище, адресуемое через пути, для хранения всех состояний приложения

Я никогда не любил компоненты. Структурирование приложения вокруг компонентов требует размещения данных, принадлежащих компоненту, внутри самого компонента. Это затрудняет или даже делает невозможным обмен этими данными с другими частями приложения.


В каждом проекте, над которым я работал, я обнаруживал, что мне всегда нужно, чтобы некоторые части состояния приложения были общими для компонентов, которые находятся довольно далеко друг от друга. Типичным примером является имя пользователя: оно может понадобиться вам в разделе учетной записи, а также в заголовке. Так где же находится имя пользователя?


Поэтому я решил на раннем этапе создать простой объект данных ( {} ) и поместить туда все свое состояние. Я назвал его хранилищем . Хранилище содержит состояние для всех частей приложения и, следовательно, может использоваться любым компонентом.


Такой подход считался несколько еретическим в 2013–2015 годах, но с тех пор стал распространенным и даже доминирующим.


Что я считаю все еще довольно новым, так это то, что я использую пути для доступа к любому значению внутри хранилища. Например, если хранилище:

 { user: { firstName: 'foo' lastName: 'bar' } }


Я могу использовать путь для доступа (например) к lastName , написав B.get ('user', 'lastName') . Как вы видите, ['user', 'lastName'] — это путь к 'bar' . B.get — это функция, которая обращается к хранилищу и возвращает определенную его часть, указанную путем, который вы передаете функции.


В отличие от вышесказанного, стандартный способ доступа к реактивным свойствам — это ссылка на них через переменную JS. Например:

 // Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');


Однако это требует от вас хранить ссылку на firstName и lastName (или userStore ) везде, где вам нужно это значение. Подход, который я использую, требует только доступа к хранилищу (которое глобально и доступно везде) и позволяет вам иметь к нему детальный доступ без определения переменных JS для них.


Immutable.js и Firebase Realtime Database делают что-то гораздо более близкое к тому, что сделал я, хотя они работают с отдельными объектами. Но вы могли бы потенциально использовать их для хранения всего в одном месте, к которому можно было бы обращаться гранулярно.

 // Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });


Наличие моих данных в глобально доступном хранилище, к которому можно получить гранулярный доступ через пути, является шаблоном, который я нахожу чрезвычайно полезным. Всякий раз, когда я пишу const [count, setCount] = ... или что-то в этом роде, это кажется избыточным. Я знаю, что я мог бы просто сделать B.get ('count') всякий раз, когда мне нужно получить к этому доступ, без необходимости объявлять и передавать count или setCount .

Идея 3: каждое изменение выражается через события

Если Идея №2 (глобальное хранилище, доступное через пути) освобождает данные от компонентов, Идея №3 — это то, как я освободил код от компонентов. Для меня это самая интересная идея в этой статье. Вот она!


Наше состояние — это данные, которые по определению изменяемы (для тех, кто использует неизменяемость, аргумент остается в силе: вы все равно хотите, чтобы последняя версия состояния изменилась, даже если вы храните снимки более старых версий состояния). Как мы изменим состояние?


Я решил использовать события. У меня уже были пути к хранилищу, поэтому событие могло быть просто комбинацией глагола (например, set , add или rem ) и пути. Так что, если бы я хотел обновить user.firstName , я мог бы написать что-то вроде этого:

 B.call ('set', ['user', 'firstName'], 'Foo')


Это определенно более многословно, чем писать:

 user.firstName = 'Foo';


Но это позволило мне написать код, который будет реагировать на изменение user.firstName . И это ключевая идея: в пользовательском интерфейсе есть разные части, которые зависят от разных частей состояния. Например, у вас могут быть такие зависимости:

  • Заголовок: зависит от user и currentView
  • Раздел «Учетная запись»: зависит от user
  • Список дел: зависит от items


Большой вопрос, с которым я столкнулся, был: как мне обновить заголовок и раздел учетной записи, когда user меняется, но не когда меняются items ? И как мне управлять этими зависимостями без необходимости делать специальные вызовы, такие как updateHeader или updateAccountSection ? Эти типы специальных вызовов представляют собой "программирование jQuery" в его наиболее неподдерживаемом виде.


Мне показалось более удачной идеей сделать что-то вроде этого:

 B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });


Итак, если для user вызывается set событие, система событий уведомит все представления, заинтересованные в этом изменении (заголовок и раздел учетной записи), оставив другие представления (список дел) нетронутыми. B.respond — это функция, которую я использую для регистрации ответчиков (которые обычно называются «прослушивателями событий» или «реакциями»). Обратите внимание, что ответчики являются глобальными и не привязаны ни к каким компонентам; однако они прослушивают только set события на определенных путях.


Итак, как вообще вызывается событие change ? Вот как я это сделал:

 B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });


Я немного упрощаю, но по сути именно так это и работает в gotoB.


Что делает систему событий более мощной, чем простые вызовы функций, так это то, что вызов события может выполнить 0, 1 или несколько фрагментов кода, тогда как вызов функции всегда вызывает ровно одну функцию . В приведенном выше примере, если вы вызываете B.call ('set', ['user', 'firstName'], 'Foo'); , выполняются два фрагмента кода: тот, который изменяет заголовок, и тот, который изменяет представление учетной записи. Обратите внимание, что вызов обновления firstName «не заботится» о том, кто его слушает. Он просто делает свое дело и позволяет ответчику подхватить изменения.


События настолько мощны, что, по моему опыту, они могут заменить вычисляемые значения, а также реакции. Другими словами, их можно использовать для выражения любого изменения, которое должно произойти в приложении.


Вычисленное значение может быть выражено с помощью ответчика событий. Например, если вы хотите вычислить fullName и не хотите использовать его в хранилище, вы можете сделать следующее:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });


Аналогично реакции могут быть выражены с помощью респондента. Рассмотрим это:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });


Если на минуту отвлечься от вызывающей содрогание конкатенации строк для генерации HTML, то выше вы увидите, что ответчик выполняет «побочный эффект» (в данном случае — обновление DOM).


(Примечание: каково хорошее определение побочного эффекта в контексте веб-приложения? Для меня это сводится к трем вещам: 1) обновление состояния приложения; 2) изменение DOM; 3) отправка вызова AJAX).


Я обнаружил, что на самом деле нет необходимости в отдельном жизненном цикле, который обновляет DOM. В gotoB есть некоторые функции ответчика, которые обновляют DOM с помощью некоторых вспомогательных функций. Таким образом, когда user вносит изменения, любой ответчик (или, точнее, функция представления , поскольку это название я даю ответчикам, которым поручено обновление части DOM), который зависит от него, будет выполнен, что создаст побочный эффект, который в конечном итоге обновит DOM.


Я сделал систему событий предсказуемой, заставив ее запускать функции ответчика в том же порядке и по одной за раз. Асинхронные ответчики могут работать как синхронные, а ответчики, которые идут «после» них, будут их ждать.


Более сложные шаблоны, где вам нужно обновить состояние без обновления DOM (обычно в целях производительности), можно добавить, добавив глаголы отключения звука , например mset , которые изменяют хранилище, но не запускают никаких ответчиков. Кроме того, если вам нужно что-то сделать с DOM после перерисовки, вы можете просто убедиться, что этот ответчик имеет низкий приоритет и запускается после всех остальных ответчиков:

 B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });


Подход выше, имеющий систему событий, использующую глаголы и пути, а также набор глобальных ответчиков, которые сопоставляются (выполняются) определенными вызовами событий, имеет еще одно преимущество: каждый вызов событий может быть помещен в список. Затем вы можете анализировать этот список при отладке своего приложения и отслеживать изменения в состоянии.


В контексте интерфейса события и ответчики позволяют следующее:

  • Обновлять части хранилища с помощью очень небольшого количества кода (чуть более многословного, чем простое назначение переменных).
  • Для автоматического обновления частей DOM при изменении частей хранилища, от которых зависит эта часть DOM.
  • Не допускать автоматического обновления какой-либо части DOM, когда это не нужно.
  • Чтобы иметь возможность вычислять значения и реакции, не связанные с обновлением DOM, выраженные в виде респондентов.


Вот без чего они (по моему опыту) позволяют обойтись:

  • Методы или крючки жизненного цикла.
  • Наблюдаемые.
  • Неизменность.
  • Мемоизация.


Это все на самом деле просто вызовы событий и ответчики, некоторые ответчики просто связаны с представлениями, а другие связаны с другими операциями. Все внутренние части фреймворка просто используют пространство пользователя .


Если вам интересно, как это работает в gotoB, вы можете ознакомиться с этим подробным объяснением .

Идея 4: алгоритм сравнения текстов для обновления DOM

Двусторонняя привязка данных сейчас звучит довольно устаревшей. Но если вы вернетесь на машине времени в 2013 год и возьметесь с первых принципов за проблему перерисовки DOM при изменении состояния, что будет звучать более разумно?

  • Если HTML изменится, обновите свое состояние в JS. Если состояние в JS изменится, обновите HTML.
  • Каждый раз, когда состояние в JS меняется, обновляйте HTML. Если HTML меняется, обновите состояние в JS, а затем повторно обновите HTML, чтобы он соответствовал состоянию в JS.


Действительно, вариант 2, представляющий собой однонаправленный поток данных из состояния в DOM, звучит более запутанно, а также неэффективно.


Давайте теперь сделаем это очень конкретно: в случае интерактивного <input> или <textarea> , который находится в фокусе, вам нужно воссоздавать части DOM с каждым нажатием клавиши пользователем! Если вы используете однонаправленные потоки данных, каждое изменение во вводе вызывает изменение в состоянии, которое затем перерисовывает <input> , чтобы он точно соответствовал тому, каким должен быть.


Это устанавливает очень высокую планку для обновлений DOM: они должны быть быстрыми и не мешать взаимодействию пользователя с интерактивными элементами. Это непростая задача.


Теперь, почему однонаправленные данные из состояния в DOM (JS в HTML) победили? Потому что так проще рассуждать. Если состояние меняется, неважно, откуда пришло это изменение (это может быть обратный вызов AJAX, приносящий данные с сервера, может быть взаимодействие с пользователем, может быть таймер). Состояние изменяется (или, скорее, мутирует ) всегда одинаково. И изменения из состояния всегда перетекают в DOM.


Итак, как можно эффективно выполнять обновления DOM, не мешая при этом взаимодействию с пользователем? Обычно это сводится к выполнению минимального количества обновлений DOM, которое позволит выполнить работу. Обычно это называется «сравнение», потому что вы создаете список различий, которые вам нужны, чтобы взять старую структуру (существующий DOM) и преобразовать ее в новую (новый DOM после обновления состояния).


Когда я начал работать над этой проблемой где-то в 2016 году, я схитрил, взглянув на то, что делает React. Они дали мне важное понимание того, что не существует обобщенного алгоритма с линейной производительностью для сравнения двух деревьев (DOM — это дерево). Но, будучи упрямым, я все равно хотел иметь алгоритм общего назначения для сравнения. Что мне особенно не нравилось в React (или почти в любом фреймворке, если на то пошло), так это настойчивость в том, что вам нужно использовать ключи для смежных элементов:

 function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }


Для меня директива key была излишней, поскольку она не имела никакого отношения к DOM; это была просто подсказка для фреймворка.


Затем я подумал о том, чтобы попробовать текстовый алгоритм diff на сглаженных версиях дерева. Что, если я сглажен оба дерева (старый кусок DOM, который у меня был, и новый кусок DOM, которым я хотел его заменить) и вычислить diff на нем (минимальный набор правок), чтобы я мог перейти от старого к новому за меньшее количество шагов?


Итак, я взял алгоритм Майерса , тот, который вы используете каждый раз, когда запускаете git diff , и применил его к моим сглаженным деревьям. Давайте проиллюстрируем это на примере:

 var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];


Как вы видите, я работаю не с DOM, а с литеральным представлением объекта, которое мы видели в Идее 1. Теперь вы заметите, что нам нужно добавить новый <li> в конец списка.


Вот так выглядят сплющенные деревья:

 var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];


O означает "открытый тег", L означает "литерал" (в данном случае, какой-то текст), а C означает "закрытый тег". Обратите внимание, что каждое дерево теперь является списком строк, и больше нет никаких вложенных массивов. Вот что я имею в виду под выравниванием.


Когда я запускаю diff для каждого из этих элементов (рассматривая каждый элемент массива как единое целое), я получаю:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];


Как вы, вероятно, поняли, мы сохраняем большую часть списка и добавляем <li> ближе к концу. Это те записи add , которые вы видите.


Если бы мы теперь изменили текст третьего <li> с Item 3 на Item 4 и выполнили сравнение, то получили бы:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];


Я не знаю, насколько этот подход математически неэффективен, но на практике он работал довольно хорошо. Он плохо работает только при сравнении больших деревьев, между которыми много различий; когда это случается, я прибегаю к тайм-ауту в 200 мс, чтобы прервать сравнение и просто полностью заменить проблемную часть DOM. Если бы я не использовал тайм-аут, все приложение остановилось бы на некоторое время, пока сравнение не завершится.


Удачным преимуществом использования diff Майерса является то, что он отдает приоритет удалениям над вставками: это означает, что если есть одинаково эффективный выбор между удалением элемента и добавлением элемента, алгоритм сначала удалит элемент. На практике это позволяет мне захватить все удаленные элементы DOM и иметь возможность повторно использовать их, если они мне понадобятся позже в diff. В последнем примере последний <li> повторно используется путем изменения его содержимого с Item 3 на Item 4 Повторно используя элементы (вместо создания новых элементов DOM), мы улучшаем производительность до такой степени, что пользователь не осознает, что DOM постоянно перерисовывается.


Если вам интересно, насколько сложно реализовать этот механизм выравнивания и сравнения, который применяет изменения к DOM, мне удалось сделать это в 500 строках ES5 javascript, и он даже работает в Internet Explorer 6. Но, надо признать, это был, пожалуй, самый сложный кусок кода, который я когда-либо писал. Упрямство имеет свою цену.

Заключение

Вот четыре идеи, которые я хотел представить! Они не совсем оригинальные, но я надеюсь, что они будут и новыми, и интересными для некоторых. Спасибо за чтение!