В настоящее время команда TOAST UI Grid усердно работает над основным обновлением, которым является выпуск версии 4. Цель этого обновления - переписать всю предыдущую базу кода, написанную с помощью Backbone и jQuery, с нуля. . Команда рассчитывает уменьшить ненужную зависимость, сделав новый Grid более компактным и быстрым, чем раньше.

Как способ отметить альфа-выпуск v4, я хотел бы подробнее рассказать о различиях между системой управления состоянием на основе событий, такой как Backbone, и системами реактивности, такими как Vue и MobX, и почему мы создали систему реактивности для себя. , и что вам нужно учитывать, чтобы полностью реализовать системы реактивности с использованием фактического исходного кода.

Что такое система реактивности?

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

Большинство фреймворков, выпущенных после Backbone, действительно поддерживают такой метод, и когда AngularJS стал популярным, выражение привязка данных использовалось часто. Однако, поскольку Vue официально использует слово реактивность, слово реактивность стало символическим описанием Vue и часто используется в отношении методов реализации, замеченных во Vue. Чтобы реализовать вышеупомянутую реактивность, Vue использует геттер / сеттер для регистрации прокси, а в Vue 3, который в настоящее время находится в стадии разработки, реактивность будет реализована с использованием ES2015’s Proxy.

MobX использует систему реактивности, которая очень похожа на Vue, и поскольку он использует геттер / установщик до версии 4 и прокси, начиная с версии 5, вы можете использовать соответствующую версию, которая соответствует необходимым требованиям браузера. Однако, поскольку MobX - это просто библиотека управления состоянием, для представления пользовательского интерфейса вам нужно будет использовать ее с другими фреймворками, такими как React.

Управляемость событиями и реактивность

Теперь давайте рассмотрим несколько простых примеров кода, чтобы лучше сравнить преимущества системы реактивности по сравнению с традиционными методами, управляемыми событиями. Модель Backbone будет использоваться для демонстрации системы, управляемой событиями, а Observable MobX будет использоваться для демонстрации системы реактивности. Поскольку они будут использоваться только для объяснения основных понятий, даже если вы не знакомы с библиотеками, у вас не должно возникнуть проблем с их выполнением.

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

import {Model} from 'BackBone';
const playerA = new Model({
  name: 'A',
  score: 10
});
const playerB = new Model({
  name: 'B',
  score: 20
});
const Board = Model.extend({
  initialize(attrs, options) {
    this.playerA = options.playerA;
    this.playerB = options.playerB;
    
    this.listenTo(playerA, 'change:score', this._updateTotalScore);
    this.listenTo(playerB, 'change:score', this._updateTotalScore);
    this._updateTotalScore();
  },
  
  _updateTotalScore() {
    this.set('totalScore', this.playerA.get('score') 
      + this.playerB.get('score'));
  }
});
const board = new Board(null, {playerA, playerB});
console.log(board.get('totalScore')); // 30
playerA.set('score', 20);
console.log(board.get('totalScore')); // 40
playerB.set('score', 30);
console.log(board.get('totalScore')); // 50

В коде для определения класса Board, чтобы обнаружить изменения в свойстве score для playerA и playerB, мы прослушиваем событие change:score. Если бы мы обнаружили изменения в других свойствах, нам пришлось бы создать новых слушателей.

Сравним это с системой реактивности. Как бы выглядел код, если бы такая же функция была реализована в MobX?

const {observable} = require('mobx');
const playerA = observable({
  name: 'A',
  score: 10
});
const playerB = observable({
  name: 'B',
  score: 20
});
const board = observable({
  get totalScore() {
    return playerA.score + playerB.score;
  }
})
console.log(board.totalScore); // 30
playerA.score = 20;
console.log(board.totalScore); // 40
playerB.score = 30;
console.log(board.totalScore); // 50

Из приведенного выше кода мы ясно видим, что свойство totalScore определено в объекте board как функция получения. Это свойство обнаруживает любые изменения наблюдаемых значений, на которые имеется ссылка внутри функции получения, и если какое-либо изменение обнаруживается, функция снова вызывает функцию получения, чтобы обновить ее значение. Такие свойства также известны как вычисленные или производные свойства, и это определяющая характеристика системы реактивности. В Официальной документации MobX архитектурная философия библиотеки четко изложена в следующем предложении.

Все, что может быть получено из состояния приложения, должно быть производным. Автоматически.

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

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

Почему мы создали собственную систему реактивности

TOAST UI Grid - это независимая библиотека, которая не полагается на другие фреймворки. Поскольку он должен иметь возможность взаимодействовать с любыми существующими фреймворками пользовательского интерфейса, Vue даже не рассматривался с самого начала. Однако, поскольку MobX может управлять только состояниями системы, MobX предоставляет гораздо более разнообразные функции, но при этом намного меньше, чем Vue. Например, MobX предоставляет не только повседневные объекты, но также предоставляет функции для перевода различных типов объектов, таких как массивы и карты, в объекты реактивности, различные функции наблюдателя, функции перехвата и наблюдения, асинхронные действия. Итак, мы подумали, а что, если вместо создания отдельного менеджера состояний мы просто будем использовать MobX?

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

1. Зависимость от внешней библиотеки и размер пакета

Одной из основных целей обновления TOAST UI Grid версии 4 было устранение предыдущей зависимости от внешних библиотек (Backbone, jQuery). Использование внешних библиотек оказывает дополнительное давление на размер файла и производительность, поэтому лучше всего минимизировать внешнюю зависимость любого библиотека. Однако, если мы устраним предыдущую зависимость, введя новую зависимость, это лишит цели этого обновления.

Кроме того, размер уменьшенного пакета MobX v4.9.4 составляет около 56 КБ (16 КБ при сжатии с использованием Gzip). Учитывая, что размер уменьшенного файла Backbone составляет около 25 КБ (8 КБ при сжатии с использованием Gzip), MobX почти вдвое больше, чем у Backbone. Было бы иначе, если бы нам понадобилась каждая функция, включенная в MobX, но, поскольку нас интересовала только часть библиотеки, размер ее определенно был неудобным. Следовательно, это будет аналогично попытке похудеть, если каждый день есть только гамбургеры.

2. Проблемы с производительностью при обработке больших объемов данных

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

import { observable } from 'mobx';
const data = observable({
  rawData: [
    { firstName: 'Michael', lastName: 'Jackson' },
    { firstName: 'Michael', lastName: 'Johnson' }
  ],
  get viewData()  {
    return this.rawData.map(({firstName, lastName}) => ({
      fullName: `${firstName} ${lastName}`
    }));
  }
});
console.log(data.viewData[1].fullName); // Michael Jackson
data.rawData[1].lastName = 'Bolton';
console.log(data.viewData[1].fullName); // Michael Bolton
data.rawData.push({firstName: 'Michael', lastName: 'Jordan'})
console.log(data.viewData[2].fullName); // Michael Jordan

В приведенном выше коде data.viewData обновляется каждый раз, когда изменяется data.rawData. Изучив приведенный выше код, мы видим, что data.viewData обновляется для каждой операции в массиве data.rawData, включая любое изменение значений свойств и вставку новых элементов. Однако проблема в том, что каким бы незначительным ни было изменение, весь массив повторяется для создания нового массива. Следовательно, если размер массива значительно велик, это может оказаться очень дорогостоящим.

Например, если rawData имеет 100 тыс. Точек данных, программа отреагирует на одно изменение в rawData повторением сотни тысяч раз для создания нового массива viewData. Чтобы избежать таких проблем, одним из решений является ручная обработка каждого изменения в соответствии с типом измененного свойства с помощью наблюдения, но этот метод снижает эффективность декларативного определения системы реактивности. Более того, это может привести к необходимости писать более длинные коды, чем простое редактирование каждого случая отдельно.

Кроме того, если вы пытаетесь заставить массив следовать модели реактивности, MobX (v4) использует геттер для каждого индекса массива для регистрации прокси, и этот процесс также значительно снижает производительность. Согласно некоторым тестам, которые я проводил на ПК, программе потребовалось около 150 мс для обработки массива из сотен тысяч элементов, и если каждый элемент имеет более тридцати свойств внутри, программе потребовалось более 10 секунд для обработки. Это.

Поскольку цель TOAST UI Grid - ограничить время обработки до менее 500 мс, даже если имеется более ста тысяч точек данных, мы решили, что прямое использование observable MobX неэффективно. Поэтому мы часто сталкивались с ситуацией, когда нам приходилось создавать части реактивности всего массива или когда нам приходилось выполнять сложные операции в соответствии с добавлением / удалением / модификацией элемента. Для приложений, чувствительных к производительности, у нас сложилось впечатление, что создание нашей собственной системы реактивности позволит более гибко подходить к проблеме.

Понимание основ системы реактивности: геттер / сеттер

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

Как упоминалось ранее, есть два способа создать систему реактивности. Однако Internet Explorer и другие старые браузеры не поддерживают прокси ES2015. В случае TOAST UI Grid мы использовали геттер / сеттер для совместимости с браузером и объясним то же самое в этой статье.

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

Хотя имена могут отличаться от библиотеки к библиотеке, в этой статье мы будем использовать observable и observe, которые похожи на имена, упомянутые в API MobX. Обратите внимание, что они не имеют отношения к RxJS Observable, так что будьте внимательны, пробуя его на себе.

Прежде чем приступить к реализации, давайте сначала рассмотрим его использование.

const player = observable({
  name: 'A',
  score: 10
});
observe(() => {
  console.log(`${player.name} : ${player.score}`);
});
// A : 10
player.name = 'B';  // B : 10
player.score = 20;  // B : 20

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

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

let currentObserver = null;
function observe(fn) {
  currentObserver = fn;
  fn();
  currentObserver = null;
}

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

function observable(obj) {
  Object.keys(obj).forEach((key) => {
    const propObservers = [];
    let _value = obj[key];
    Object.defineProperty(obj, key, {
      get() {
        if (currentObserver && 
          !propObservers.includes(currentObserver)) {
          propObservers.push(currentObserver);
        }
        return _value;
      },
      set(value) {
        _value = value;
        propObservers.forEach(observer => observer());
      }
    });
  });
  return obj;
}

Ключевой частью кода является то, что мы определили новую переменную _value в области действия функции. Назначение этой переменной - предотвратить попадание функции-установщика в бесконечный цикл, когда функция-установщик регистрирует значение свойства с помощью this[key] = value. В остальном, поскольку код занимает всего около 20 строк, я оставлю это на ваше усмотрение, чтобы вы изучили и выяснили, что функция observable делает для себя.

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

Реализация производных свойств

Теперь приступим к производным свойствам. Есть несколько способов реализовать производные свойства. Мы могли бы использовать декораторы, такие как @computed MobX, или даже отдельно определить computed объект, например Vue. В этой статье мы рассмотрим использование получателя для создания производного свойства из определенных свойств. Как и раньше, давайте сначала посмотрим на использование.

const board = observable({
  score1: 10,
  score2: 20,
  get totalScore() {
    return this.score1 + this.score2;
  }
});
console.log(board.totalScore); // 30;
board.score1 = 20;
console.log(board.totalScore); // 40;

Мы определили значение board.totalScore с помощью геттера, и каждый раз, когда board.score1 и board.score2 изменяются, board.totalScore автоматически пересчитывается и присваивается.

Это может показаться внезапным увеличением сложности. Однако основная философия остается идентичной observe функции, которую мы только что написали. Нам просто нужно вызвать функцию observe изнутри, чтобы каждый раз обновлять соответствующее значение свойства. Для этого нам сначала нужно убедиться, что у свойства есть назначенная ему функция получения, когда мы перебираем объект. Затем мы просто обращаемся к свойству get того, что возвращает Object.getOwnPropertyDescriptor.

const getter = Object.getOwnPropertyDescriptor(obj, key).get;

Теперь, когда мы определили геттер, вместо настройки установщика мы модифицируем внутренние данные, вызывая функцию observe, чтобы изменить внутренние данные с полученными значениями из функции геттера, и вызывая зарегистрированные функции наблюдателя. Однако, поскольку this используется для доступа к объекту изнутри геттера, мы должны использовать call, чтобы предоставить объект в качестве контекста.

if (getter) {
  observe(() => {
    _value = getter.call(obj);
    propObservers.forEach(observer => observer());
  });
}

Окончательный код должен выглядеть примерно так.

function observable(obj) {
  Object.keys(obj).forEach((key) => {
    const getter = Object.getOwnPropertyDescriptor(obj, key).get;
    const propObservers = [];
    let _value = getter ? null : obj[key];
    Object.defineProperty(obj, key, {
      configurable: true,
      get() {
        if (currentObserver && 
          !propObservers.includes(currentObserver)) {
          propObservers.push(currentObserver);
        }
        return _value;
      },
    });
    if (getter) {
      observe(() => {
        _value = getter.call(obj);
        propObservers.forEach(observer => observer());
      });
    } else {
      Object.defineProperty(obj, key, {
        set(value) {
          _value = value;
          propObservers.forEach(observer => observer());
        }
      });
    }
  });
  
  return obj;
}

Поскольку мы определили геттер и сеттер отдельно, мы должны установить configurable в true при регистрации получателя, если мы хотим добавить к нему сеттер. В остальном код практически идентичен исходному коду. Кроме того, поскольку производные свойства используют геттер, созданный для прокси, а не определяемый пользователем, он может обрабатывать последовательно производные свойства, как показано ниже.

const board = observable({
  score1: 10,
  score2: 40,
  get totalScore() {
    return this.score1 + this.score2;
  },
  get ratio1() {
    return this.score1 / this.totalScore;
  }
});
console.log(board.ratio1); // 0.2
board.score1 = 60;
console.log(board.ratio1); // 0.6

Еще о чем следует подумать

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

1. Исключенный код из начального выполнения функции наблюдения.

Функция observe сохраняет введенную функцию в currentObserver только при первом запуске. Другими словами, каждый наблюдатель может быть обнаружен только получателем при начальном выполнении, поэтому, если функция имеет внутри себя условные операторы, часть кода может не быть включена в список наблюдателей свойств наблюдаемого объекта.

const board = observable({
  score1: 10,
  score2: 20
});
observe(() => {
  if (board.score1 === 10) {
    console.log(`score1 : ${board.score1}`);
  } else {
    console.log(`score2 : ${board.score2}`);
  }
});
// score1 : 10
board.score1 = 20; // score2 : 20;
board.score2 = 30; // No Reaction

Функция наблюдателя, которую мы передали в observe, обращается к board.score2 из блока else, но поскольку эта часть кода не выполняется при первом запуске кода, любые изменения, внесенные в board.score2, не могут быть обнаружены с использованием того, что у нас есть. . Один из способов решения этой проблемы - настраивать currentObserver каждый раз, когда выполняется функция наблюдателя. Затем, поскольку часть кода, вызывающая includes с целью устранения избыточных наблюдателей, может вызвать проблемы с производительностью, лучше использовать Set вместо массива. Если вы не можете использовать набор из-за ограничений среды, вам следует назначить уникальный идентификатор каждому наблюдателю и использовать объект для управления идентификаторами.

Во-вторых, поскольку вы должны каждый раз проверять, существует ли наблюдатель, если вы сталкиваетесь с последовательно производными свойствами или если цепочка вызовов наблюдателя происходит изнутри наблюдателя, current Observer может быть установлено в null. Чтобы решить эту проблему, вы должны использовать тип данных массива для управления currentObserver как стеком.

2. Функция unobserve

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

const board = observable({
  score: 10
});
const unobserve = observe(() => {
  console.log(board.score);
});
// 10
board.score = 20;  // 20
unobserve();
board.score = 30;  // No Reaction

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

3. Реактивность массивов

Я уже объяснял, как значительно падает производительность, когда системы реактивности сталкиваются с массивными массивами. Хотя мы пробовали разные методы его учета, в конечном итоге мы решили не использовать систему реактивности для массивов в TOAST UI Grid. Стоимость воссоздания массива из пары десятков элементов относительно невелика, и большинство массивов содержат менее сотни элементов. Другими словами, в большинстве случаев не имеет значения, что массивы следуют реактивности, если вы обновляете объект, который имеет массив, каждый раз, когда массив изменяется, наблюдатель будет реагировать так же.

const data = obaservable({
  nums: [1, 2, 3],
  get squareNums() {
    return this.nums.map(num => num * num);
  }
});
console.log(squareNums); // [1, 4, 9]
data.nums = [...nums, 4];
console.log(squareNums); // [1, 4, 9, 16]

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

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

const data = observbable({
  nums: [1, 2, 3],
  get squareNums() {
    return this.nums.map(num => num * num);
  }
});
console.log(squareNums); // [1, 4, 9]
data.nums.push(4);
notify(data, 'num');
console.log(squareNums); // [1, 4, 9, 16]

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

Резюме

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

Весь исходный код написан на TypeScript и доступен в репозитории Github. Если исключить информацию о типе, это примерно 100 строк кода, 1,3 КБ при минимизации и менее 0,7 КБ при сжатии с использованием Gzip. По сравнению с 56 КБ минимизированного MobX, это огромная разница. Допустим, что он менее разнообразен по функциональным возможностям, чем MobX, для целей TOAST UI Grid v4 эти сотни строк кода до сих пор работают для нас надлежащим образом (не говоря уже о том, что все в команде довольны этой утилитой).

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

Мы все еще усердно работаем над официальным выпуском TOAST UI Grid v4. Будьте в курсе с более компактной и настраиваемой сеткой с Официальным еженедельным и TOAST UI Twitter!



Изначально опубликовано на Toast Meetup, автор: DongWoo Kim.