TOAST UI Grid - это библиотека пользовательского интерфейса, которая позволяет пользователям обрабатывать сложные табличные данные в Интернете, а на прошлой неделе было выпущено крупное обновление версии 4! В версии 4 мы избавились от старых Backbone и jQuery и снова переписали всю кодовую базу с помощью Preact. Preact - это крошечная, но мощная библиотека размером 8 КБ при минификации (3 КБ при сжатии с использованием Gzip), которая предоставляет API, похожий на React, а также виртуальную DOM.

Цель этой статьи - объяснить, почему мы решили использовать Preact для новой версии Grid, и что мы получили от использования Preact. Для тех, кто не хочет изучать статью, я свел основные идеи этой статьи к трем пунктам.

TL;DR

  1. Виртуальная модель DOM позволяет писать краткие и декларативные коды пользовательского интерфейса. Переписав исходную кодовую базу Backbone и jQuery с помощью Preact, мы смогли уменьшить размер пакета с 327 КБ до 154 КБ.
  2. Виртуальная модель DOM никоим образом не медленная. Виртуальная прокрутка, в частности, позволяет реализовать сложный пользовательский интерфейс, который обрабатывает миллионы наборов данных без снижения производительности.
  3. Preact предоставляет очень мощный виртуальный DOM в очень компактном пакете. Preact может быть отличным выбором, если вы пишете библиотеку пользовательского интерфейса, независимую от каких-либо конкретных фреймворков.

Теперь ваше внимание? Давайте сделаем глубокий вдох и нырнем!

Что такое виртуальная модель DOM?

Прежде чем я расскажу о Preact, я считаю, что необходимо предварительное знакомство с виртуальной DOM. Виртуальный DOM - это древовидная структура, идентичная фактическому дереву DOM, и реализованная с помощью объекта JavaScript. Хотя React не был первым, кто реализовал эту идею, в основном он был популярен благодаря React, и теперь многочисленные фреймворки, такие как Vue.js, Cycle.js, Mithril.js и Marko построены на основе идея виртуального DOM.

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

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

Почему Preact?

Основная цель обновления TOAST UI GRID версии 4 состояла в том, чтобы удалить зависимости, которые следовали за старыми кодами Backbone и jQuery. Поэтому нашей первоначальной целью было вообще не использовать какую-либо библиотеку, а кодировать все самостоятельно. Другими словами, наш план состоял в том, чтобы с нуля переписать функции управления состоянием и манипуляции с DOM, которые изначально выполнялись Backbone и jQuery.

Однако мы поняли, что переписывать все, что просто похоже на оригинал, бессмысленно. С момента первой разработки TOAST UI Grid прошло более четырех лет, и мы думали, что крайне важно использовать четырехлетний опыт, чтобы сделать его лучше, чем раньше. Проведя часы мозгового штурма в поисках идей для улучшения, мы решили, что декларативное программирование с виртуальным DOM, таким как React, сделало код пользовательского интерфейса намного более лаконичным, и мы решили реализовать его преимущества в новой Grid.

Однако реализация виртуального движка DOM, такого как React, с нуля никоим образом не была простой задачей. React не только сравнивает виртуальные деревья DOM, чтобы минимизировать манипуляции с DOM, но также отвечает за различные функции, такие как управление локальным состоянием компонента, привязка событий, методы жизненного цикла, JSX и контекст. Соотношение затрат и окупаемости программирования всего с нуля с целью использования этого для одной библиотеки не окупилось.

Preact, с другой стороны, предоставляет все, что мы искали, с невероятно маленьким размером 8 КБ при минификации и 3 КБ при сжатии с помощью Gzip. Исходя из нашего опыта работы с React, мы были уверены, что 8 КБ Preact будет намного меньше, чем кусок кода, который будет заменен декларативными кодами пользовательского интерфейса. Кроме того, поскольку коды, использующие виртуальную модель DOM, намного более интуитивно понятны и короче, чем фактическое управление DOM, мы пришли к выводу, что с точки зрения затрат на обслуживание использование Preact принесет больше преимуществ. Наконец, тот факт, что члены команды уже привыкли к React и не нуждались в дополнительном обучении, также сыграл важную роль в процессе принятия решения.

Есть две легкие библиотеки, которые предоставляют только виртуальную модель DOM: Preact и snabbdom. Однако, как команда, мы решили, что с учетом производительности, размера пакета, удобства использования, проектной активности и размера сообщества Preact имеет преимущество.

Менеджер состояния реактивности + Preact

Backbone не только предоставляет шаблон и структуру, необходимые для отображения пользовательского интерфейса, но также предоставляет функции управления состоянием, управляемым событиями. Однако Preact, с другой стороны, обрабатывает только пользовательский интерфейс, поэтому были ограничения на использование только функции управления локальным состоянием компонента, найденной в Preact, для управления сложным состоянием, таким как состояние Grid. Несмотря на ограничения, использование системы управления состоянием, управляемой событиями, такой как Backbone, не соответствует философии программирования Preact.

Нам нужно было найти более декларативного менеджера состояний.

Redux и MobX, широко используемые в React, представляют собой декларативные менеджеры состояний, которые используют неизменяемые объекты, а также объекты реактивности. Такие библиотеки можно легко адаптировать с помощью Preact, но использование большего количества библиотек, следовательно, большего количества зависимостей, когда мы уже скомпрометировали и использовали Preact, доставляло нам неудобства. Не говоря уже о том, что Grid создан для работы с огромной загрузкой данных, и такие характеристики создают проблемы, которые невозможно решить с помощью обычных библиотек. Поэтому мы решили с нуля создать систему управления состоянием реактивности, аналогичную MobX.

Есть еще одна статья, документирующая это решение и процесс, так что если вам интересно, не стесняйтесь ее проверить!

Интеграция системного менеджера реактивности с Preact не была сложной задачей. Мы разработали диспетчер состояний, чтобы иметь единую структуру хранилища, как у Redux, поэтому все, что нам нужно было сделать, это внедрить соответствующее хранилище в контекст и написать компонент высшего порядка (HOC) с именем connect для доставки необходимых значений компоненту. как реквизит. Функция connect фактически идентична функции connect из react-redux и может использоваться следующим образом.

function MyComponent({name, score}) {
  return (
    <div>Hello {name}, you've got {score} points.</div>
  )
}
export connect((state) => ({
  name: state.player.name,
  score: state.player.score
}))(MyComponent);

Сама реализация не менее проста. В нашем диспетчере состояний есть функция наблюдения, которая принимает функцию обратного вызова в качестве параметра. Когда функция обратного вызова выполняется, обнаруживаются атрибуты наблюдаемых объектов, к которым осуществляется доступ изнутри функции обратного вызова. Когда наблюдаемые объекты изменились (или когда изменение было обнаружено), функция обратного вызова, которую мы передали в качестве параметра, выполняется снова.

Поскольку все значения в одном хранилище являются наблюдаемыми объектами, вы можете просто вызвать метод setState () с результирующим значением функции селектора, которое будет передано в функцию подключения в качестве параметра изнутри функции наблюдения, и доставить this.state как Props.

export function connect(selector) {
  return function(WrappedComponent) {
    return class extends Component {
      constructor() {
        const {store} = this.context;
        this.unobserve = observe(() => {
          const selectedProps = selector(store, ownProps);
          if (!this.state) {
            this.state = selectedProps;
          } else {
            this.setState(selectedProps);
          }
        });
      }
      componentWillReceiveProps(nextProps) {
        this.setState(selector(this.context.store, nextProps));
      }
      componentWillUnmount() {
        if (this.unobserve) {
          this.unobserve();
        }
      }
      public render() {
        return <WrappedComponent {...this.props} {...this.state} />;
      }
    };
  };
}

Сравнение кодов магистрали с кодами Preact

Теперь давайте посмотрим на реальные коды, чтобы увидеть, какие преимущества дает использование жизненной модели DOM, предоставляемой Preact, по сравнению с традиционным методом прямого управления DOM. Приведенный мной пример кода представляет собой фрагмент фактического пользовательского интерфейса TOAST Grid, который отображает таблицы. Для удобства чтения я исключил некоторые ненужные коды.

Исходный код: Backbone (+ jQuery)

Ниже приведен предыдущий код, написанный на Backbone и jQuery.

const TableContainerView = View.extends({
  initialize(options) {
    this.data = options.data;
    this.columns = options.columns;
    this.dimension = options.dimension;
    // (1) Detecting the change event from the model to directly change the UI
    this.listenTo(this.dimension, 'change:bodyHeight', this._updateBodyHeight);
  },
  template: _.template(`
    <div class="tui-grid-table-container">
      <table class="tui-grid-table"></table>
    </div>
  `),
  
  _updateBodyHeight(model, bodyHeight) {
    this.$el.height(bodyHeight);
  },
  render() {
    // (2) Initial rendering with template
    this.$el.html(this.template());
    // (3) Add as child nodes after constructing view objects
    const colGroupView = new ColGroupView(this.columns);
    const tableBodyView = new TableBodyView(this.data, this.columns);
    const $table = this.$el.find('tui-grid-table');
    $table.append(colGroupView.render().el);
    $table.append(tableBodyView.render().el);
    
    return this;
  }
});

Приведенный выше код выполняет три основные задачи (как я проиллюстрировал в комментариях). Первая (1) цель - обнаружить изменение модели, чтобы напрямую управлять DOM, а вторая (2) цель - использовать шаблон для выполнения начальный рендеринг. Хотя шаблон может быть полезен в декларативном представлении структуры DOM, если вся DOM изменяется с использованием шаблона каждый раз, это также влияет на дочерние элементы DOM. Например, повторная визуализация всей структуры DOM только потому, что было обнаружено изменение высоты, было бы неэффективным программированием. Поэтому при использовании Backbone для представления обычной практикой является использование кодов, которые напрямую управляют DOM и шаблоном бок о бок.

Последний раздел (3) создает объект View и добавляет его в существующую структуру в качестве дочернего. Тем, кто знаком с последними линиями компонентных фреймворков, это кажется примитивным, и поскольку механизм шаблонов, предоставляемый Backbone, на самом деле невероятно прост, трудно полагаться исключительно на шаблон для составления различных деревьев представлений. Более того, поскольку родительское представление должно создавать дочернее представление, все модели, которые должен иметь дочерний узел, должны поступать непосредственно из родительского узла. Чтобы избежать этой проблемы, мы обычно должны создать отдельную фабрику, которая содержит все необходимые модели, необходимые для построения представления.

Новый код: Preact

Теперь давайте посмотрим на новый код, написанный с помощью Preact.

function TableContainer({height}) {
  return {
    <div class="tui-grid-table-container" style={{height}}>
      <table class="tui-grid-table">
        <ColGroup />
        <TableBody />
      </table>
    </div>
  }
}
export connect((state) => ({
  height: state.dimension.bodyHeight
}))(TableContainer);

Прежде всего, очевидно, что код был сокращен примерно до половины исходной длины. Кроме того, следует отметить следующий аспект: новый код использует объект стиля для декларативного присвоения значения высоты визуализации. Как упоминалось ранее, при работе с компонентами вам просто нужно возвращать виртуальное дерево DOM, представленное с помощью JSX, каждый раз, когда представление отображается, а Preact ищет разницу и применяет только измененный аспект к фактической DOM. Следовательно, если бы высота изменилась, это затронуло бы только значение height объекта стиля, а дочерний DOM не затронул бы вообще.

Второе заметное отличие состоит в том, что нам больше не нужно обнаруживать изменение состояния модели с помощью событий. Изменения в каждом значении хранилища, к которому обращается параметр для функции connect, функции выбора, обнаруживаются автоматически. Как мы видели ранее, каждое изменение передается как Props setState(), вызываемым изнутри компонентом, созданным connect, поэтому в приведенном выше коде каждый раз, когда dimension.bodyHeight изменяется, компонент повторно отрисовывается и отображается на экране.

Последний интересный момент - это композиция между компонентами. Поскольку Preact может использовать JSX для представления межкомпонентных отношений с декларативным программированием, всю структуру дерева компонентов легче понять. Более того, поскольку компоненты ColGroup и TableBody получают необходимые значения как свойства через свои собственные connect функции, родительским компонентам больше не нужно беспокоиться о значениях для дочерних компонентов.

Сравнение окончательного размера пакета

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

Для самой последней версии 3.8.0 TOAST UI Grid размер минимизированного кода без Backbone, подчеркивания и jQuery составляет 193 КБ, но размер минимизированного кода недавно написанной v4.0.0 без Preact составляет 132 КБ. Общий размер файла уменьшился примерно на 30%.

Однако это еще одно несправедливое сравнение. Причина в том, что недавно написанные коды содержат все, включая управление состоянием Backbone, служебные функции Underscore, запросы Ajax jQuery и все функции, которые ранее обрабатывались внешними зависимостями. В таком случае самым справедливым сравнением было бы сравнение окончательного размера пакета всего, включая зависимости.

Версия v3.8.0, включая все зависимости, при минимизации и объединении приблизилась к 327 КБ. Однако для версии 4.0.0 окончательный размер уменьшенного файла пакета, включая Preact, составляет всего 145 КБ. Несмотря на добавление дополнительных функций, окончательный размер пакета проекта был сокращен вдвое. Даже если учесть, что внешние библиотеки содержат кучу неиспользуемых кодов, разница в размере значительна.

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

Этапы разработки TOAST UI Grid 4 состояли в основном из постоянных наблюдений за тем, насколько чистыми и лаконичными по сравнению с предыдущим кодом являются новые коды, написанные в Preact. Хотя первоначальной целью было просто воспроизвести существующие функции, мы наблюдали множество случаев, когда новые коды превосходили старые коды. Другими словами, мы не только снизили стоимость обслуживания, но и повысили производительность первоначального черчения.

Сравнение выступлений

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

Осторожно, спойлеры! Нисколько. Preact, как и React, использует shouldComponentUpdate метод для блокировки ненужного рендеринга. Кроме того, хотя это не было упомянуто в предыдущих примерах кода, если мы установим setState, который будет вызываться изнутри connect, только если возвращаемое значение функции селектора отличается от предыдущего, мы сможем предотвратить ненужные сравнения виртуальных DOM без особых усилий. .

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

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

Виртуальная прокрутка - одна из старейших функций, поддерживаемых TOAST UI Grid. Если вы используете виртуальную модель DOM, виртуальную прокрутку становится еще проще. Приведенный ниже код, хотя и упрощен для пояснения, представляет собой фрагмент кода, который реализует виртуальную прокрутку внутри компонента без изменения части кода, относящейся к состоянию сохранения.

function TableBody({rows, columns}) {
  return (
    <tbody>
      {rows.map((row) => (
        <BodyRow
          key={row.rowKey}
          rowData={row}
          columns={columns}
        />
      ))}
    </tbody>
  }
}
export default connect(({ viewport }) => ({
  rows: viewport.rows,
  columns: viewport.columns
}))(TableBody);

viewport - это объект, который содержит объекты видимых в данный момент столбцов и строк в виде массивов. Если вы примените приведенные выше коды, прокрутка изменится, и каждый раз, когда массив rows и массив columns в viewport изменяется, TableBody визуализируется снова. Затем Preact сравнивает текущее виртуальное дерево DOM с предыдущим виртуальным деревом DOM, чтобы напрямую применить изменения в строках и столбцах к фактической DOM.

Раньше нам приходилось вручную кодировать что-то, почти идентичное тому, что сейчас делает Preact. Однако при использовании виртуальной DOM код манипуляции с DOM, необходимый для реализации виртуальной прокрутки, практически бесплатный. Поэтому в версии 4.0.0 мы легко смогли реализовать виртуальную прокрутку по столбцам, которая была недоступна раньше, и это сделало Grid еще быстрее.

Резюме

Сейчас золотой век фреймворков. Большинство приложений являются одностраничными приложениями (SPA), и стало трудно найти приложение, которое не использует ни React, Vue, ни Angular. В наше время создание библиотеки пользовательского интерфейса означает принятие трудных решений - использовать фреймворк или нет.

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

Если вы создаете библиотеку, независимую от каких-либо фреймворков, вы можете предоставить разные оболочки для разных фреймворков, будь то React или Vue, чтобы понравиться всем пользователям JavaScript. Однако вам придется вручную решать проблемы, связанные с управлением состоянием, привязкой данных, механизмом шаблонов, манипуляциями с DOM и т. Д., И вы рискуете создать тяжелую библиотеку с несущественными кодами. Затем ваша библиотека теряет преимущество перед другими библиотеками, которые специализируются на определенных фреймворках.

Preact может быть святым Граалем для решения таких дилемм. Он легкий, быстрый и знакомый пользователям React. Это правда, что у нас были сомнения, когда мы впервые пытались внедрить Preact в наш проект, из-за нашего неловкого ощущения необходимости полагаться на другую библиотеку и небольшого сомнения в результатах. Однако, как вы уже видели, мы смогли достичь беспрецедентных результатов, которые легко превзошли наши самые смелые ожидания, и без Preact это было бы невозможно.

Фактически, поскольку TOAST UI Grid явно не перечисляет Preact как внешнюю зависимость и поставляется вместе с Preact в комплекте, пользователям даже не нужно знать, что они используют Preact. В конечном итоге пользователи могут использовать новую версию библиотеки, которая свободна от внешних зависимостей, но меньшего размера. Если есть другие разработчики, которые вырывают себе волосы из-за подобных проблем, мы рекомендуем вам попробовать Preact.

В TOAST UI Grid 4 внесено намного больше изменений, чем упомянуто в этой статье. Вся кодовая база была написана на основе TypeScript и добавлены новые спецификации, такие как настраиваемый рендерер и настраиваемые редакторы, чтобы пользователи могли использовать продукт более гибко и расширяемо.

Все изменения и информация о будущих выпусках тщательно задокументированы в официальном примечании к выпуску. Мы призываем вас взглянуть и надеемся, что вы будете в курсе последних новостей о TOAST UI Grid для более увлекательных экспериментов!