Pull to refresh

UI framework за 5 минут

Reading time 6 min
Views 21K

Некоторое время назад я задумался, почему так много 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, часть кода взята оттуда.


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

Tags:
Hubs:
+16
Comments 23
Comments Comments 23

Articles