javascript

Управление состоянием в Polymer 2.0. За пределами parent/child биндингов

  • пятница, 29 декабря 2017 г. в 03:12:56
https://habrahabr.ru/post/345808/
  • JavaScript


Организуем общее состояние между разделенными DOM-элементами без Redux


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


Когда речь заходит об управлении состоянием это становится особенно очевидным, и Polymer здесь, естественно, не исключение. Типичные примеры, которые вы увидите чаще всего, включают в себя один родительский элемент, один или более дочерних и некий биндинг между ними. Но что, если все не так просто? Как передавать состояние между разными частями приложения? Нужно ли начать добавлять Redux, чтобы сделать это?

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

Для некоторых фреймворков были разработаны специфичные решения этой проблемы, иногда довольно тяжеловесные:

Angular 2, мм… 2+, или 4(тот, что сейчас последний) предлагают хранить состояние в сервисах, доступных через dependency injection для компонентов, которым эти сервисы нужны.

React подталкивает к использованию единого центрального хранилища с помощью Redux и view- компонентов, которые могут подписаться на его обновления.

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

Браузер уже предоставляет нам коллекцию синглтонов, таких как window, и мы можем положить наше состояние сюда. Это прекрасно работает как пространство имен до тех пор, пока вы не выбираете что-нибудь такое, что должно быть уникальным для вашего приложения(использовать “googleMaps” для вашей собственной библиотеки может оказаться не очень хорошей идеей). Так почему бы не пользоваться этим способом? Ну, возникает проблема синхронизации — если мы изменяем значение, то как кто-то где-то в другом месте узнает, что оно изменилось? Мы точно не хотим опрашивать источник состояния по таймеру, несколько громоздко было бы и эмитить/подписываться на слишком большое количество событий.

Оставим разговоры о теории и взглянем на конкретный пример, чтобы изучить возможные варианты. Допустим, мы хотим, чтобы у нас была панель ‘Options’ в нашем app-drawer-layout из Polymer Starter Kit(выделена красным):

image

Хотя view-элементы, получающие и отображающие значение из ‘Options’ расположены так близко на экране(“смотри, код, это же прямо ЗДЕСЬ!”), они в миллионах миль друг от друга с точки зрения DOM с различными iron-pages, app-drawer, header-ами и другими частями между ними.

Вот код панели ‘Options’:

<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/paper-checkbox/paper-checkbox.html">

<dom-module id="my-options">
  <template>
    <style>
      :host {
        display: block;
        padding: 16px;
      }
      h3, p {
        margin: 8px 0;
      }
    </style>
    <h3>Options</h3>
    <p>
      <paper-checkbox checked="{{ options.subscribe }}">Send Notifications</paper-checkbox>
    </p>
  </template>

  <script>
    class MyOptions extends Polymer.Element {
      static get is() { return 'my-options'; }

      static get properties() {
        return {
          options: {
            type: Object,
            value: () => ({
              subscribe: false
            })
          }
        }
      }
    }

    window.customElements.define(MyOptions.is, MyOptions);
  </script>
</dom-module>

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

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

Одна из причин полюбить Polymer это то, что он построен на возможностях платформы и мы увидим, чего можно добиться просто используя их + немного чистого JavaScript-а.

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

(function() {
  // existing code
}());

Это фактически не меняет ничего, кроме того что скрывает наш класс MyOptions от внешнего мира(это не важно, так как единственная важная для нас вещь это то, что он вызывает window.customElements.define).

В нашем приложении будет лишь один экземпляр MyOptions, а другим элементам понадобится доступ к нему, поэтому добавим переменную, ссылающуюся на него и установим ее корректное значение в конструкторе, когда элемент будет создан:

let optionsInstance = null;

// in class definition:
constructor() {
  super();
  if (!optionsInstance) optionsInstance = this;
} 

optionsInstance все еще скрыт внутри нашей IIFE, но теперь чтобы мы в него не положили, оно будет иметь доступ к инициализированному экземпляру MyOptions.

Мы хотим, чтобы этот экземпляр отвечал за значения, поэтому нам нужно сделать так, чтобы он следил за заинтересованными в изменениях подписчиками. Чтобы сделать это, добавим свойство-массив для отслеживания подписчиков и методы экземпляра, которые они могут использовать для регистрации и отмены регистрации:

// in properties:
subscribers: {
  type: Array,
  value: () => []
}

// in class definition:
register(subscriber) {
  this.subscribers.push(subscriber);
  subscriber.options = this.options;
  subscriber.notifyPath('options');
}

unregister(subscriber) {
  var i = this.subscribers.indexOf(subscriber);
  if (i > -1) this.subscribers.splice(i, 1)
}

Обратите внимание, что когда подписчик регистрируется, мы добавляем его в список, а также инициализируем в нем локальную переменную, указывающую на объект options. Здесь мы также сталкиваемся с детекцией изменений Polymer-а — установка свойства сама по себе не уведомляет подписчика о том, что это произошло, поэтому нам нужен вызов notifyPath. Также мы хотим уведомить всех подписчиков всякий раз когда какие-либо свойства объекта options изменяются(например, если ‘subscribe’ был вызван, а не только когда ссылка на объект изменяется) и для этого мы используем observer со звездочкой, чтобы сказать что нас интересуют “все изменения”:

static get observers() {
  return [
    'optionsChanged(options.*)'
  ]
}

optionsChanged(change) {
  for(var i = 0; i < this.subscribers.length; i++) {
    this.subscribers[i].notifyPath(change.path);
  }
}

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

Теперь у нас есть хуки и нотификации, которые нужны нам для подписчиков, и у нас есть два варианта. Создадим элемент доступа(accessor element), который будет внутри той же самой IIFE(а значит он будет иметь доступ к optionsInstance):

class MyOptionsValue extends Polymer.Element {
  static get is() { return 'my-options-value'; }

  static get properties() {
    return {
      options: {
        type: Object,
        notify: true
      }
    }
  }

  connectedCallback() {
    super.connectedCallback();
    optionsInstance.register(this);
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    optionsInstance.unregister(this);
  }
}

window.customElements.define(MyOptionsValue.is, MyOptionsValue);

сonnected- и disconnected-коллбеки прекрасно подходят для регистрации и отмены регистрации экземпляров. Это означает, что элементы, которые могут находиться очень далеко друг от друга в DOM-дереве, могут иметь прямые ссылки друг на друга и тем самым избегать цепочки property-биндингов, если мы ограничены использованием DOM-структурой для коммуникации.

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

<link rel="import" href="my-options.html">

и установив с ним связь посредством биндинга:

<my-options-value options="{{ options }}"></my-options-value>
<p>Send notifications option is: <b>[[ options.subscribe ]]</b></p>

Нам нужно выставить notify: true в декларации свойств элемента из-за двунаправленного биндинга(child-to-parent), обозначенного фигурными скобками. Экземпляр MyOptions сообщает экземпляру(или экземплярам) MyOptionsValue об изменении, и им, в свою очередь, нужно уведомить об этом элемент, в котором они находятся.

Это работает, и мы можем включить или выключить чекбокс и наблюдать обновления, но у нас есть дополнительный элемент, дополнительный биндинг, и мы должны добавлять свойство options в каждый view-элемент, если мы хотим видеть предупреждения от линтера про неопределенные свойства:

class MyView extends Polymer.Element {
  static get is() { return 'my-view'; }

  static get properties() {
    return {
      options: {
        type: Object
      }
    }
  }
}

ох, еще одно свойство ‘options’...

Один из способов чуть упростить ситуацию — использовать миксин. Миксин похож на наследование классов и дает возможность комбинировать определения элементов, таким образом код можно переиспользовать вместо дублирования(ранее, в Polymer 1.0, миксины были известны как behaviors).

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

class MyView extends MyOptionsMixin(Polymer.Element) {
  static get is() { return 'my-view'; }
}

Нам все еще нужно импортировать my-options.html, но наш view-элемент стал проще и не требует промежуточного элемента доступа:

<p>Send notifications option is: <b>[[ options.subscribe ]]</b></p>

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

У данного подхода на самом деле есть название, он называется “mono-state” паттерн. Есть уже существующие элементы, вроде iron-meta, обеспечивающие общий подход, но на мой взгляд проще, чище и быстрее создавать специфичные для конкретного приложения реализации — часто они проще в адаптации для специфических случаев и кажутся более понятными, чем использование промежуточных компонентов.

Вот финальный, полный код для наших классов, который, я надеюсь, выглядит проще. Мне стоит также извиниться за использование “subscribe” в качестве имени, это можно перепутать с подписками экземпляра. Изначально я использовал название “notify”, что было еще хуже(так как это имя одного из свойств Polymer):

<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/paper-checkbox/paper-checkbox.html">

<dom-module id="my-options">
  <template>
    <style>
      :host {
        display: block;
        padding: 16px;
      }
      h3, p {
        margin: 8px 0;
      }
    </style>
    <h3>Options</h3>
    <p>
      <paper-checkbox checked="{{ options.subscribe }}">Send Notifications</paper-checkbox>
    </p>
  </template>

  <script>
    (function() {

      let optionsInstance = null;

      class MyOptions extends Polymer.Element {
        static get is() { return 'my-options'; }

        static get properties() {
          return {
            options: {
              type: Object,
              value: () => ({
                subscribe: false
              })
            },
            subscribers: {
              type: Array,
              value: () => []
            }
          }
        }

        static get observers() {
          return [
            'optionsChanged(options.*)'
          ]
        }

        constructor() {
          super();

          if (!optionsInstance) optionsInstance = this;
        }

        register(subscriber) {
          this.subscribers.push(subscriber);
          subscriber.options = this.options;
          subscriber.notifyPath('options');
        }

        unregister(subscriber) {
          var i = this.subscribers.indexOf(subscriber);
          if (i > -1) this.subscribers.splice(i, 1)
        }

        optionsChanged(change) {
          for(var i = 0; i < this.subscribers.length; i++) {
            this.subscribers[i].notifyPath(change.path);
          }
        }
      }

      window.customElements.define(MyOptions.is, MyOptions);

      MyOptionsMixin = (superClass) => {
        return class extends superClass {
          static get properties() {
            return {
              options: {
                type: Object
              }
            }
          }

          connectedCallback() {
            super.connectedCallback();
            optionsInstance.register(this);
          }

          disconnectedCallback() {
            super.disconnectedCallback();
            optionsInstance.unregister(this);
          }
        }
      }
    }());
  </script>
</dom-module>

View-элемент, потребитель:

<link rel="import" href="../bower_components/polymer/polymer-element.html">

<link rel="import" href="my-options.html">
<link rel="import" href="shared-styles.html">

<dom-module id="my-view2">
  <template>
    <style include="shared-styles">
      :host {
        display: block;
        padding: 10px;
      }
    </style>

    <div class="card">
      <div class="circle">2</div>
      <h1>View Two</h1>
      <p>Ea duis bonorum nec, falli paulo aliquid ei eum.</p>
      <p>Id nam odio natum malorum, tibique copiosae expetenda mel ea.Detracto suavitate repudiandae no eum. Id adhuc minim soluta nam.Id nam odio natum malorum, tibique copiosae expetenda mel ea.</p>

      <p>Send notifications option is: <b>[[ options.subscribe ]]</b></p>
    </div>
  </template>

  <script>
    class MyView2 extends MyOptionsMixin(Polymer.Element) {
      static get is() { return 'my-view2'; }
    }

    window.customElements.define(MyView2.is, MyView2);
  </script>
</dom-module>

Обратите внимание: пример из этого поста работает потому, что UI панели ‘Options’ всегда первый по счету в DOM, поэтому подписчики элемента доступа всегда могут найти существующий экземпляр. Если это не так, то достаточно несложно использовать вместо этого функцию, чтобы первый вызывающий создавал единственный экземпляр — взгляните на iron-a11y-announcer, в котором это реализовано.

Кроме того, на тот случай, если это недостаточно понятно, хотя MyOptionsMixin определен внутри IIFE, на самом деле находится в области видимости window, поэтому другие элементы за пределами IIFE могут видеть и использовать его(если бы мы написали var MyOptionsMixin…, тогда это бы не работало, он был бы видим только внутри IIFE). Мне следовало использовать window.MyOptionsMixin чтобы сделать это понятнее, либо, как более распространено, использовать глобальное пространство имен(дочерний объект в window) также, как это делает сам Polymer. У вас оно уже может быть — они полезны для хранения конфигурационных свойств. Безопасный способ проверить и добавить что-либо в него выглядит примерно так:

MyApp = windows.MyApp || { }
MyApp.MyOptionsMixin = ...

(после чего вы всегда можете использовать MyApp.MyOptionsMixin, ссылаясь на него).