Некоторое время назад я задумался, почему так много UI frameworks для web? Я довольно давно в IT и не помню чтоб UI библиотеки на других платформах рождались и умирали с такой же скоростью как в WEB. Библиотеки для настольных OS, такие как: MFC, Qt, WPF, и т.д. — были монстрами, которые развивались годами и не имели большого количества альтернатив. В Web все не так — frameworks выходят чуть ли не каждую неделю, лидеры меняются — почему так происходит?


Думаю главная причина в том — что резко снизилась сложность написания UI библиотек. Да, для того чтобы написать библиотеку которой многие будут пользоваться — по прежнему требуется значительное время и экспертиза, но чтобы написать прототип — который будучи обернутый в удобный API — будет готов к использованию — требуется совсем немного времени. Если интересно как это можно сделать — читайте дальше.


Зачем эта статья?


В свое время на Хабре была серия статей — написать Х за 30 строк кода на js.


Я подумал — а можно ли написать реакт за 30 строк? Да за 30 строк у меня не получилось, но финальный результат вполне соразмерен с этой цифрой.


Вообще, цель статьи чисто образовательная. Она может помочь немного глубже понять принцип работы UI framework на основе виртуального дома. В этой статье я хочу показать как довольно просто сделать еще один UI Framework на основе виртуального дома.


В начале хочу сказать что я понимаю под UI framework — потому как у многих разное мнения на этот счет. Например некоторые считаю, что Angular и Ember это UI framework а React — это всего лишь библиотека которая позволят легче работать с view частью приложении


Определим UI framework так — это библиотека которая помогает создавать/обновлять/удалять страницы либо отдельные элементы страницы в этом смысле довольно широкий спектр обертка над DOM API может оказаться UI framework, вопрос лишь в вариантах абстракции (API) которые предоставляет эта библиотека для манипуляции с DOM и в эффективности этих манипуляций


В предложенной формулировке — React вполне является UI framework.


Что ж, давайте посмотрим как написать свой React c блэкджеком и прочим. Известно что React использует концепцию виртуального дома. В упрощенном виде она заключается в том что узлы (node) реального DOM строятся в четком соответствии с узлами предварительно построенного дерева виртуального DOM. Прямая манипуляция с реальным DOM не приветствуется, в случае если необходимо внести изменения в реальным DOM, изменения вносятся в виртуальный DOM, потом новая версия виртуальный DOM сравнивается со старой, собираются изменения которые необходимо применить к реальному DOM и они применяются таким образом минимизируется взаимодействие с реальным DOM — что делает работу приложения более оптимальной.


Поскольку дерево виртуального дома это обычный java-script объект — им довольно легко манипулировать — изменять/сравнивать его узлы, под словом легко тут я понимаю что код сборки виртуальных но довольно простой и может быть частично сгенерирован препроцессором из декларативного языка более высокого уровня JSX.


Начнем с JSX


так выглядит пример JSX кода


const Component = () => (
  <div className="main">
    <input />
    <button onClick={() => console.log('yo')}> Submit </button>
  </div>
)

export default Component

нам нужно сделать так чтобы при вызове функции Component создавался такой виртуальный DOM


const vdom = {
  type: 'div',
  props: { className: 'main' },
  children: [
    { type: 'input' },
    {
      type: 'button',
      props: { onClick: () => console.log('yo') },
      children: ['Submit']
    }
  ]
}

Конечно мы не будем писать это преобразование вручную, воспользуемся этим плагином, плагин устарел, но он достаточно прост, чтобы помочь нам понять как все работает. Он использует jsx-transform, который преобразует JSX примерно так:


jsx.fromString('<h1>Hello World</h1>', {
  factory: 'h'
});
// => 'h("h1", null, ["Hello World"])'

так, все что нам нужно — реализовать конструктор vdom узлов h — функцию которая будет рекурсивно создавать узлы виртуального DOM в случае реакт этим занимается функция React.createElement. Ниже примитивная реализация такой функции


export function h(type, props, ...stack) {
  const children = (stack || []).reduce(addChild, [])
  props = props || {}
  return typeof type === "string" ? { type, props, children } : type(props, children)
}

function addChild(acc, node) {
  if (Array.isArray(node)) {
    acc = node.reduce(addChild, acc)
  } else if (null == node || true === node || false === node) {
  } else {
    acc.push(typeof node === "number" ? node + "" : node)
  }
  return acc
}

конечно рекурсия здесь немного усложняет код, но надеюсь он понятен, теперь с помощью этой функции мы можем собрать vdom


'h("h1", null, ["Hello World"])' => { type: 'h1', props:null, children:['Hello World']}

и так для узлов любой вложенности


Отлично теперь наша функция Component — возвращает узел vdom.


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


Возможно можно написать этот код проще, но получилось так я взял за основу код из пакета picodom


export function patch(parent, oldNode, newNode) {
  return patchElement(parent, parent.children[0], oldNode, newNode)
}
function patchElement(parent, element, oldNode, node, isSVG, nextSibling) {
  if (oldNode == null) {
    element = parent.insertBefore(createElement(node, isSVG), element)
  } else if (node.type != oldNode.type) {
    const oldElement = element
    element = parent.insertBefore(createElement(node, isSVG), oldElement)
    removeElement(parent, oldElement, oldNode)
  } else {
    updateElement(element, oldNode.props, node.props)

    isSVG = isSVG || node.type === "svg"
    let childNodes = []
      ; (element.childNodes || []).forEach(element => childNodes.push(element))
    let oldNodeIdex = 0
    if (node.children && node.children.length > 0) {
      for (var i = 0; i < node.children.length; i++) {
        if (oldNode.children && oldNodeIdex <= oldNode.children.length &&
          (node.children[i].type && node.children[i].type === oldNode.children[oldNodeIdex].type ||
            (!node.children[i].type && node.children[i] === oldNode.children[oldNodeIdex]))
        ) {
          patchElement(element, childNodes[oldNodeIdex], oldNode.children[oldNodeIdex], node.children[i], isSVG)
          oldNodeIdex++
        } else {
          let newChild = element.insertBefore(
            createElement(node.children[i], isSVG),
            childNodes[oldNodeIdex]
          )
          patchElement(element, newChild, {}, node.children[i], isSVG)
        }
      }
    }
    for (var i = oldNodeIdex; i < childNodes.length; i++) {
      removeElement(element, childNodes[i], oldNode.children ? oldNode.children[i] || {} : {})
    }
  }
  return element
}

Эта наивная реализация, она ужасно не оптимальна, не принимает во внимание идентификаторы элементов (key, id) — чтобы корректно обновлять нужные элементы в списках, но в примитивных случаях она работает норм.


Реализацию функций createElement updateElement removeElement я тут не привожу она приметивна, кого заинтересует можно посмотреть исходники тут.


Там есть единственный нюанс — когда обновляются свойства value для input элементов то сравнение нужно делать не со старой vnodе а с атрибутом value в реальном доме — это предотвратит обновление этого свойства у активного элемента (поскольку оно там уже и так обновлено) и предотвратит проблемы с курсором и выделением.


Ну вот и все теперь нам осталось только собрать эти кусочки вместе и написать UI Framework
Уложимся в 5 строк.


  1. Как в React чтобы собрать приложение нам нужно 3 параметра
    export function app(selector, view, initProps) {
    selector — корневой селектор dom в который будет смонтировано приложение (по умолчанию 'body')
    view — функция которая конструирует корневой vnode
    initProps — начальные свойства приложения
  2. Берем корневой элемент в DOM
    const rootElement = document.querySelector(selector || 'body')
  3. Собираем vdom c начальными свойствами
    let node = view(initProps)
  4. Монтируем полученный vdom в DOM в качестве старой vdom берем null
    patch(rootElement, null, node)
  5. Возвращаем функцию обновления приложения с новыми свойствами
    return props => patch(rootElement, node, (node = view(props)))

Framework готов!


‘Hello world’ на этом Framework будет выглядеть таким образом:


import { h, app } from "../src/index"

function view(state) {
  return (
    <div>
      <h2>{`Hello ${state}`}</h2>
      <input value={state} oninput={e => render(e.target.value)} />
    </div>
  )
}

const render = app('body', view, 'world')

Эта библиотека так же как React поддерживает композицию компонент, добавление, удаление компонент в момент исполнения, так что ее можно считать полноценным UI Framework. Чуть более сложный пример использования можно посмотреть тут ToDo example.


Конечно в этой библиотеке много чего нет: событий жизненного цикла (хотя их не трудно прикрутить, мы же сами управляем созданием/обновлением/удалением узлов), отдельного обновления дочерних узлов по типу this.setState (для этого нужно сохранять ссылки на DOM элементы для каждого узла vdom — это немного усложнит логику), код patchElement ужасно неоптимальный, будет плохо работать на большом количестве элементов, не отслеживает элементы с идентификатором и т.д.


В любом случае, библиотека разрабатывалась в образовательных целях — не используйте ее в продакшене :)


PS: на эту статья меня вдохновила великолепная библиотека Hyperapp, часть кода взята оттуда.


Удачного кодинга!

Комментарии (22)


  1. denis-isaev
    02.07.2018 01:51

    Заглавная картинка и содержание напомнили об этом :)

    image


  1. skoder
    02.07.2018 07:28
    +2

    Вы написали не фреймворк, а лишь view компонент, на идее реакта jsx -> vdom -> dom


    1. mangelov Автор
      02.07.2018 07:58

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


      1. AxisPod
        02.07.2018 09:22
        +1

        То что вы определили своё понятие термина framework ещё не говорит о том, что это истина. Всё же есть официальное определение термина, это не парадигма, чтобы трактовать как-то иначе. Если я курицу назову орлом, орлом от этого она не станет. У вас именно библиотека, фреймворк задаёт архитектуру приложения и это главная отличительная черта от библиотеки. У вас архитектура никак не задана. Архитектура поразумевает не только отображение данных.


        1. AstarothAst
          02.07.2018 09:39

          фреймворк задаёт архитектуру приложения и это главная отличительная черта от библиотеки.

          Мне больше нравиться такое определение: «В случае библиотеки — код вызывает библиотеку. В случае фреймворка — фреймворк вызывает код».


          1. AxisPod
            02.07.2018 09:43

            В случае фреймворка — фреймворк вызывает код

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


            1. AstarothAst
              02.07.2018 09:51

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


              1. DexterHD
                02.07.2018 14:28
                +1

                Читайте дядюшку Боба…


          1. Focushift
            02.07.2018 09:46

            Т.е. если либа просто дергает твой код и что-то вставляет в страницу, то это уже фреймворк?
            И для такого «фреймворка» нужна еще куча костылей чтобы написать работающее приложение.


            1. AstarothAst
              02.07.2018 09:53

              Это как «либа просто дергает твой код»? Откуда она узнала, что нужно что-то дернуть? Наверное ты сначала отдал этой либе лямбду, которую она и дернула, правильно? Не отдал бы — не дернула. В случае фреймворка вполне нормальна ситуация, что ты обязан предоставить такие лямбды, иначе он просто не заработает. Это, в общем-то, и есть то, что называют «фреймворк определяет архитектуру».


              1. Focushift
                02.07.2018 09:59

                Еще раз, этот фреймворк умеет делать только эти 2 вещи, но приложение на нем не напишешь, почему вы продолжаете называть «это» фреймворком?
                Только из-за одного свойства?
                Тогда что такое .Net Framework? Супер фреймворк? Потому что он умеет не только вызывать ваш код?


                1. AstarothAst
                  02.07.2018 10:22

                  Какой «этот» фреймворк? Вы о чем уже? Я перестаю вас понимать.
                  Все, что я сказал — уточнил различие между библиотекой и фреймворком. Абстрактной библиотекой и абстрактным фреймворком. Отличие, наверняка, не единственное, но одно из, как мне кажется, важных. Если вы с этим не согласны, то ок, я не настаиваю. Было бы, конечно, любопытно услышать ваше мнение по поводу того, в чем фундаментальное отличие библиотеки от фреймворка, но если нет, то нет.


                  1. Focushift
                    02.07.2018 11:42

                    Под «это» я подразумеваю пример в статье и Реакт.

                    Тогда по вашей логике, если взять jQuery, научить запускать указанный код при вставке куска HTML в страницу, то можно смело объявить это фреймворком?
                    Условие выполняется, библиотека запускает ваш код, можно объявлять фреймворком?


                    1. AstarothAst
                      02.07.2018 12:42

                      Тогда по вашей логике, если взять jQuery, научить запускать указанный код при вставке куска HTML в страницу, то можно смело объявить это фреймворком?

                      Зачем вы пытаетесь приписать мне какую-то ерунду? Давайте сойдемся на том, что вы правы, я — нет, ок?


                      1. Focushift
                        02.07.2018 13:08

                        Я просто хочу понять логику, почему люди начинают называть что-то чем-то, если оно таким не является.

                        Или тогда уже надо исправить формулировку, что фреймворк предоставляет Широкий функционал, тогда уже это будет правильно.


                        1. AstarothAst
                          02.07.2018 13:12

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


  1. Focushift
    02.07.2018 09:36

    +1 думающий что манипуляция с элементами страницы, без набора хелперов и компонентов, это уже фреймворк(Реакт туда же).


  1. AstarothAst
    02.07.2018 09:38


  1. justboris
    02.07.2018 10:09
    +2

    В Web все не так — frameworks выходят чуть ли не каждую неделю, лидеры меняются — почему так происходит?

    Этот абзац вы еще в 2015 году писать начали? За последние два года никаких новых лидеров у нас не появилось.


    А вообще статья полезная, объясняет, что делают фреймворки под капотом, что никакой магии в них нет.


  1. JCHouse
    02.07.2018 11:36

    Быстро устаревает это как? Потому что ангулар вышел в 2010, реакт не помню точно, но вроде в 2013, как бы в мире где еще в 2001 все верстали под IE6 это очень много времени.


  1. Druu
    03.07.2018 09:37
    -1

    Думаю главная причина в том — что резко снизилась сложность написания UI библиотек.

    Да нет, просто в контексте веба под "гуи фреймворком" понимают тоненький рендер слой, а в контексте десктопа — полноценную библиотеку компонент с архитектурной обвязкой. Фактически, единственный продукт, который хоть с натяжкой тянет на полноценный гуи-фреймворк — это ExtJs. Все остальное — ну, наколеночные поделки по меркам десктопа. Конечно же, лепить наколеночные поделки можно легко и быстро.


  1. Diaskhan
    03.07.2018 10:34

    роза пахнет розой, хоть розой назови хоть нет ))