Pull to refresh

JavaScript: где мы сейчас и куда двигаться

Reading time 19 min
Views 47K

Привет, хабраюзер. Поскольку, судя по всему, мы уже живем в будущем, то недавно я плотно засел за изучение новых фич ES6, ES7 и новых идей, предлагаемых React и Redux. И написал для своих коллег статью, в которой изложил сублимацию этих своих изысканий. Статья неожиданно получилась довольно объемной, и я решил опубликовать её. Заранее извиняюсь за некоторую непоследовательность изложения и отсылки к проприетарному коду из наших проектов — но думаю, что это всё же может помочь некоторым из нас лучше понять то, куда движется мир JavaScript, и почему не стоит игнорировать происходящее в нём.


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



Итак, поехали. Последние пару дней я беспрерывно изучаю наиболее современные JavaScript технологии, чтобы понимать, что вообще происходит в мире, куда двигаться, на что смотреть. Дело в том, что в фоновом режиме я уже довольно давно посматриваю по сторонам, но до сих пор не мог найти время чтобы углубленно изучить идеи, предлагаемые React или ES7. Тогда как мой опыт показал, что подобное игнорирование окружающего выходит боком — в частности, я когда-то очень много времени в своей работе потратил на возню с коллбеками (наверное, об этом стоит написать в отдельной статье), просто потому что мне впадлу было изучить Promise — хотя про эту идею я в общих чертах знал еще много лет назад. И только когда уже последняя собака стала эту концепцию использовать — я понял, сколько времени потратил зря, игнорируя эту замечательную идею. Причем оправдывая это тем, что якобы у меня нет времени, чтобы про что-то там читать! Это очень иронично. Недостаточно просто быть в курсе про что-то — иногда нужно реально засесть и изучить что-то основательно, чтобы понять это — и научиться использовать в своей работе.


Я бы не хотел чтобы подобное повторилось — тем более, когда речь идет о реализации достаточно серьёзного по сложности проекта, от которого зависит коммерческий успех бизнеса. Время это деньги, и иногда лучше потратить немного времени на research, чем потом однажды обнаружить себя, сидящего посреди мегатонн плохо работающего legacy кода, который проще переписать, чем рефакторить и отлаживать. Именно с такой мотивацией я начинал работать над многими абстракциями из нашего фреймворка несколько лет назад, и это было оправдано тогда. И я очень рад обнаружить сейчас, что все это время мир двигался в точно том же направлении: всё, над чем я работал в последние годы, я обнаруживаю в современных JS технологиях, ставших дико популярными за это время. Компоненты, трейты/миксины, контракты типов, юнит тесты, декларативность/функциональность, etc etc… Сейчас 4 из 5 новых веб-проектов пишутся на React — но еще пару лет назад про React никто не знал. Но всё это автоматически означает, что нужно осмотреться по сторонам и понять, как именно устроены все эти активно развивающиеся технологии, чтобы не наступать на грабли — и не повторять то, что где-то сделано, возможно, лучше.


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


Компоненты


В нашем [прим.: проприетарном] фреймворке все завязано на систему, имитирующую с помощью прототипов семантику классов — и добавляющую к ней дополнительные возможности:


ToggleButton = $component ({

    $depends: [DOMReference],

    $required: {
        label: 'string'
    },

    $defaults: {
        color: 'red'
    },

    onclick: $trigger (),

    toggled: $observable (false),
    checked: $alias ('toggled'), // синоним

    coolMethod: $static ($debounce ($bindable ($memoize (function () { ... })))),

})

Вот эти все штуки — $static, $trigger, $observable это теги, которыми «помечаются» поля в произвольных объектах. При конструировании прототипа эти теги считываются, трансформируя результат. Например $trigger сгенерирует поток событий, к которому можно забиндиться.


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


Поле $depends определяет список traits (миксинов), от которых зависит компонент. Трейты позволяют разбивать сложные компоненты на много маленьких кусочков, представляя компоненты как алгебраическую сумму их составляющих. В нашем фреймворке трейты очень продвинуты, позволяя склеивать структуры вроде $defaults на произвольную глубину, а также склеивать методы и обработчики событий в аспектно-ориентированной манере. Скажем, в одном трейте вы можете определить метод recalcLayout — а в другом подбиндиться к нему, просто определив метод afterRecalcLayout. Подобным образом можно биндиться также на потоки событий и observables. Traits могут зависеть от других traits. При сборке финального прототипа дерево зависимостей уплощается с помощью алгоритма топологической сортировки графа — подобно тому как разрешаются директивы require в системах сборки зависимостей. Это очень мощная и хорошо зарекомендовавшая себя абстракция, с её помощью построена архитектура многих ключевых вещей в нашем проекте.


Также все методы компонентов автоматически забиндены на this — таким образом их легко передавать куда-нибудь в качестве обработчиков событий:


button.touched (this.doLogout)

ES6


Изначально я делал вышеупомянутую систему исключительно потому, что в JavaScript не было классов — было неудобно определять акцессоры свойств, например. Ни о каких миксинах и декораторах речи изначально не шло. И если бы в JS были классы с самого начала, я бы вообще не стал это делать! И вот, в JavaScript наконец-то появились классы. Казалось бы — ага — наконец-то можно выкинуть этот самодельный костыль, который я делал все это время. Но не тут-то было. Классы в ES6 ущербны.


С помощью классов из ES6 вышеописанное сделать невозможно, поскольку определения классов в нём не используют объектную нотацию (JSON), и всё что мы можем определить — просто методы и методы-акцессоры, а возможности определять произвольные пары ключ-значения, как в объектах, мы лишены. То есть, мы не можем завернуть метод класса в $memoize, подобно тому как это можно сделать у нас. Мы даже не можем добавить поле $depends — в ES6 классах попросту нет такой возможности, вы не можете написать $depends: [...], это невалидный синтаксис.


Недавно я узнал, что не мне одному не нравятся классы в ES6. Оказывается, наиболее влиятельные и известные разработчики в мире JavaScript просто-таки ненавидят классы, призывая исключить class и new из стандарта вообще! Это не шутка, вот только лишь некоторые из статей с которыми я ознакомился недавно:



TL/DR: в классах нет никакого смысла, потому что всё что они делают, это предоставляют убогий синтаксический враппер над прототипами и Object.create. Убогий потому, что плодит сущности без необходимости: инстанциирование требует ключевого слова new вместо простого вызова функции, и раньше у нас были функции и объекты — добавились еще и «классы», несовместимые с функциями и объектами синтаксически.


Пример нашей разработки показывает, что можно достичь всего того, что дают ES6 классы — и намного больше — на чисто библиотечном (embedded DSL) уровне, не прибегая к модификации языка.


ES7


Продолжив изучение новых тенденций в JS, мне показалось, что классы всё же рано списывать со счетов. Некоторые языковые абстракции, предлагаемые для будущего стандарта, могли бы существенно облегчить пресловутый компилятор прототипов, представив его функциональность в виде набора нативных декораторов — поддерживаемых на уровне языка:


Button = @component class {

    @trigger onclick () {}
    @observable toggled = false

    @debounce @bindable @memoize static coolMethod () {}
}

Каждый такой декоратор это просто функция, которая трансформирует дескрипторы (определения), к которым применяется. Механика работы практически полностью идентична той, что применяется у нас.


Декораторы основательно захватили мое воображение. В попытках понять, как функциональность наших компонентов могла бы быть разложена с помощью нативных классов и декораторов, я замутил небольшой исследовательский проектик — прикрутив декораторы к ассертам из библиотеки Chai Spies. Не очень полезно, но помогло разобраться с декораторами и некоторыми другими ключевыми фичами ES6/ES7, связанными с метапрограммированием (Proxy Objects, Symbols).


К сожалению, со штуками вроде $required и $depends по-прежнему непонятно, как быть. Дело в том, что в JavaScript нельзя в классах делать подобные вещи, просто нет такого синтаксиса:


class Button {

    depends: [DOMReference]

    @required label: 'string'
    @required color: 'red'

Я погуглил, и наткнулся на черновик class public fields, где предлагается что-то такое:


class Button {

    depends = [DOMReference]

    @required label = 'string'
    @required color = 'red'

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


Но прикол в том, что декораторы вполне применимы к полям в объектах. То есть, синтаксис вида @required label: 'string' в объекте выглядит вполне рабочим. Так может правы все эти чуваки из статей выше, и классы всё же нахрен не нужны? В общем, даже с учетом предлагаемых в обозримом будущем технологий, ту часть нашего проекта, что компилирует конструкторы типов из объектов — списывать со счетов пока не стоит...


Babel...


… это ответ на вопрос, почему же я сейчас так крепко задумался о будущих JavaScript-фичах, которые еще даже не в стадии стандарта. Это не имело бы никакого смысла, если бы не Babel — о котором я раньше тоже лишь слышал, но попробовать его в деле у меня руки не доходили. И очень зря!


Вкратце — это транспилятор JavaScript, превращающий ES6/ES7/ES.Next код в старый добрый JS, поддерживаемый в современных браузерах — которые и слыхом не слыхивали про эти ваши декораторы. Он обладает модульной архитектурой, позволяющей подключать новый синтаксис как плагины. К примеру, чтобы в моём коде заработали декораторы или упомянутые public class fields — мне достаточно было подключить соответствующие плагины к нему. Babel позволяет разработчикам использовать новейший экспериментальный синтаксис еще даже до его стандартизации — обкатывая его и генерируя фидбек. Именно таким образом мне удалось опробовать новые декораторы в деле.


Используя Babel, можно забыть о синтаксисе ES5 как о страшном сне — и не потерять аудиторию IE. Достаточно лишь настроить скрипт сборки, включив в него этот компилятор.


Ранее я избегал Babel еще и по той причине, что не хотел, чтобы код, запускаемый в браузере как-то отличался от кода, который я правлю в редакторе. Иначе непонятно, к какой строчке кода относится вылетевший эксепшен. Но с тех пор как я узнал про source maps — специальные файлы, которые указывают отладочным инструментам браузера, каким строчкам транслированного кода соответствуют строчки оригинала — это не видится мне большой проблемой.


React


Затем я пошел посмотреть, как делаются компоненты в React. Эта разработанная в недрах Facebook технология решает задачу построения пользовательских интерфейсов из наборов данных — и является наиболее популярной и многообещающей на сегодняшний день, из подобных. Изучая её, я обнаружил, что их компоненты сделаны похожим образом: там тоже есть сборщик прототипов, миксины, контракты типов...


Button = React.createClass ({

  mixins: [SomeMixin]

  propTypes: {
    label: React.PropTypes.string.isRequired
  },

  render () { ... }
})

По сравнению с нашим проектом, у React очень примитивные классы — не позволяющие навешивать какую-то мета-информацию на поля и расширять поведение компилятора прототипов с её помощью. То есть, в React просто захардкодены наиболее распространенные паттерны — и этого пользователям Реакта хватает. Причем это абсолютно резонно, поскольку грядущие нативные декораторы решают 90% проблем, решаемых нашими наколенными «декораторами» — и эти нативные декораторы можно использовать уже сейчас, с помощью Babel. Поэтому Реакту нет никакой необходимости заморачиваться с этим.


Но на самом деле, React вообще не про классы! Чем больше я читал про Реакт, тем очевиднее мне становилась мысль, что эти чуваки пытаются достичь того же, чего и я — избавиться от классов, перейдя на чистые функции. Именно осознание этого заставило меня основательно забуриться в React и его механизмы, чтобы выяснить, как же, черт возьми, они хотят это сделать. Потому что это и есть сверхзадача, которую я поставил перед собой когда-то давно — назад начав реализовывать в нашем проекте механизмы для функционального реактивного программирования. Но если у меня это все находится всё еще в стадии зачатков — я куда больше внимания уделял элементам метапрограммирования, чем этой задаче — то парни из React, похоже, достигли серьёзных успехов на этом поприще. Причем, несмотря на название, «реактивность» они реализовали совершенно не таким образом, как обычно подразумевается в мире FRP… Я все еще пытаюсь понять, насколько это хуже или лучше — но как бы там ни было, это и сделало их проект столь ошеломительно популярным в мире. На самом деле, никому не интересны классы, как самоцель — интересно сделать так, чтобы дорога от данных к интерфейсу была наикратчайшей. В идеале — просто функцией.


Интерфейс как функция от данных


Если вдуматься, то большая часть наших так называемых «компонентов» делает что-то такое:


ToggleButton = ({ label, toggled }) => <button toggled="{toggled}">{label}</button>

ToggleButton ({ label: 'Рили?', toggled: true }) // <button toggled="true">Рили?</button>

То есть, идеализированный интерфейс — это просто чистая функция от состояния, выплевывающая DOM. Проекция. Меняются данные — меняется интерфейс. Зачем же вообще понадобились какие-то классы, WTF? Почему бы не обойтись просто функциями изначально, ведь функции были в JavaScript c самого его начала?


Давайте посмотрим на «классный» вариант ToggleButton (псевдокод):


ToggleButton = $component ({

    $required: { label: 'string' },
    $defaults: { toggled: false },

    render () { return <button checked="{this.toggled}">{this.label}</button> }
})

new ToggleButton ({ label: 'Это лучше, что-ли?', toggled: false }).render ()

Это то, как большинство компонентов пишется сейчас. Вы не видите здесь нечто подозрительное, сравнивая это с предыдущим вариантом? Да это же обычная чертова функция! Просто мы вызываем её очень странным образом (с помощью new), странным образом обращаемся с её параметрами — и странным образом получаем результат вычисления. Раньше мне никогда не приходило в голову так смотреть на компоненты — но упомянутые ранее ненавистники ES6 классов помогли мне наконец-то увидеть эту симметрию.


Вы скажете — но ведь в вышеприведённом компоненте у нас есть возможность задавать дефолтные значения параметров и контракты типов. Стоп. А разве в функциях так делать нельзя? Во-первых, дефолтные значения в ES6 можно указывать как для параметров функций, так и в destructuring-выражениях:


ToggleButton ({ label, toggled = false }) => ...

Во-вторых, буквально сегодня мне попалось на глаза вот это: Flow vs TypeScript. Посмотрите. Это пятиминутная презентация, рассказывающая о современных достижениях в области статического анализа типов в JavaScript. Это невероятно. Flow позволяет задавать и статически (на этапе компиляции) проверять контракты типов для свойств в JavaScript — так что наш ToggleButton мог бы выглядеть так:


ToggleButton = ({ label : String, toggled = false }) => <button toggled="{toggled}">{label}</button>

И при этом обязательность и тип свойства label проверялись бы еще до запуска программы! Что не снилось этим «классам» — с их вручную реализованными проверками, происходящими в run-time. Таким образом, никаких run-time исключений — вы просто не сможете скомпилировать подобный код:


ToggleButton ({ toggled: false }) // забыли указать label

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


Дело в том, что такой чисто функциональный подход предполагает, что отрисовка интерфейса работает как что-то типа вызова console.log — вы не можете поменять нарисованное на экране никаким другим образом, кроме как вызвав функцию отрисовки заново, с новыми данными. Но мы не можем просто перерисовывать весь долбаный интерфейс при малейших изменениях в исходных данных — это как покупать новый автомобиль каждый раз, когда приходит время сменить масло в коробке! Разумеется, это сработает — но это безумно дорого, и в реальной жизни никто себе не может это позволить. Вы не можете пересоздавать заново весь чертов интерфейс фейсбука, когда в маленьком текстовом поле ввода чата добавляется один новый символ. Вам нужно поменять только тот участок в DOM, который связан с этим символом.


И тут мы подходим к тому, для чего вообще понадобились классы — для инкапсуляции скрытого состояния, кэширующего результат вычисления. Типичный компонент в «классическом» подходе хранит в себе ссылки на DOM-узлы и занимается менеджментом их жизненного цикла — создает, уничтожает, обновляет их состояние при изменениях в данных. Фактически, это и есть то, чем занимались UI-программисты большую часть своего рабочего времени — вновь и вновь решали задачу синхронизации отрендеренного DOM с актуальными данными в минимальное количество шагов. То есть, если где-то глубоко в JSON-структурах, описывающих наши данные, поменялось то маленькое одинокое поле, что связано с атрибутом toggled у конкретной нарисованной кнопки, нам нет никакой нужды заменять весь чертов DOM всего нарисованного интерфейса — достаточно вызвать setAttribute ('toggled', toggled) у этой конкретной кнопки. Но вот понять, как именно изменение в ваших данных должно быть связано с этим конкретным вызовом — это и есть самая сложная задача, которую успешно решает React, сводя её практически до абсурда в своей кажущейся простоте: вы просто «перерисовываете» весь интерфейс, как будто бы это и правда работает как что-то вроде console.log:


ToggleButton = ({ label, toggled }) => <button toggled="{toggled}">{label}</button>

То есть, компоненты в React реально могут выглядеть как чистые функции. Но при этом работают так же производительно, как и вручную (ad-hoc) сделанные компоненты с ручным менеджментом обновлений… и даже намного производительнее, что самое удивительное! Потому что React решает эту задачу в общем, для всей программы. Тогда как ручная реализация несовершенна и постоянно «срезает углы»: там где вы предпочтете не морочиться и сделать тупой «тотальный ре-рендер» того или иного участка интерфейса при обновлениях в данных — скажем, какой-нибудь список лайков к посту — React сделает инкрементальный апдейт. При том, что в коде это будет выглядеть столь же абсурдно просто. Это реально очень круто и гигантский шаг вперед. Примерно такой же гигантский, как концепция «отложенных значений» в асинхронном программировании, или как статический анализ типов. Это нельзя игнорировать, потому что это позволяет наконец-то сфокусироваться на продукте — а не на том, как мы его делаем — полностью абстрагируя вас от этой скучной хреноты.


Но как же он, блин, это делает?


В нашем проекте я пытался решить эту задачу с помощью концепции channels или «динамических значений» — расширяя идею «отложенных значений». Мне даже удалось сделать примитив, интерфейсно похожий на Promise и обладающий совместимой с ним семантикой — но позволяющий проталкивать последующие изменения значения по цепочке. Представьте, что label и toggled в предыдущем выражении это не просто значения, а некие «потоки значений», каналы. И наши методы работы с DOM понимают такие типы значений, создавая биндинги. То есть, setAttribute ('toggled', toggled) не просто однократно изменит значение атрибута, но создаст связь, позволяющую интерактивно обновлять атрибут при изменениях связанного с ним значения. Всю программу в таком случае можно рассмотреть как вычислительный граф, по которому пушатся изменения. Для таких динамических значений можно написать библиотеку алгоритмов типа map, filter, reduce и работать с ними на прикладном уровне как с обычными значениями — с массой оговорок… ведь для таких «динамических значений» в языке нет специального «сахара», аналогичного async/await для промисов. И всё это становится очень нетривиально, когда речь заходит об обновлениях сложных структур данных — поэтому я ограничился лишь частными случаями. В результате, итоговый код на прикладном уровне был всё так же очень далек от идеала «чистых функций» — немалую его часть по-прежнему занимало разруливание задачи обновления интерфейса. Но это все равно был большой шаг вперед по сравнению с полностью «ручным управлением».


Так и что же React? Так вот, он устроен нифига не так! Это-то меня и поразило, потому что я настолько заморочился по observables, что даже не предполагал, что может существовать и более простое (для пользователя) — и менее инвазивное решение, не требующее модификации семантики работы со значениями и специальной библиотеки алгоритмов над ними.


Попробуем воссоздать ход мыслей разработчиков React, исходя из двух простых изначальных тезисов:


  1. Компоненты это чистые функции — которые не только выглядят так, но и реально являются ими
  2. Они параметризуются чистыми данными, без каких-либо observable-оберток — то есть, их не нужно «разыменовывать»

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


data = { todos: [ { toggled: true, label: 'Тудушка' }, { toggled: false, label: 'Ещё тудушка' } ] }

А интерфейс представляет из себя алгоритм сопоставления частей этого объекта с DOM-конструкциями:


TodoList = ({ todos }) =>
    <h1>Тудушки</h1>
    <content>{ todos.map (ToggleButton) }</content>

И вывод его на экран выглядит так:


render (TodoList (data)) // а-ля console.log

Очевидно, что вся «магия» должна происходить в render. При этом всём функции вроде ToggleButton и TodoList не должны возвращать реальный DOM — потому что они реально вызываются каждый раз при изменениях в данных. Если у какой-то тудушки поменяется свойство toggled, то функция ToggleButton будет вызвана заново — и она вернет новый результат. Но если это не DOM, то что? Вместо этого функции в React возвращают легкий "blueprint" (в React это называется Virtual DOM) — лишь описывающий тот DOM, который мы хотели бы увидеть на экране — но не являющийся им реально. Что-то типа:


{ type: 'button', props: { toggled: true, children: ['Тудушка'] } }

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


{ type: 'button', props: { toggled: true, children: ['Тудушка'] } }

{ type: 'button', props: { toggled: false, children: ['Тудушка'] } }

… выразится в действии setAttribute ('toggled', false) на соответствующем DOM узле. Смысл в том, что создавать JavaScript-объекты в сотни и тысячи раз быстрее, чем создавать DOM узлы. Поэтому даже со сравнениями и вычислением разницы — это уже быстрее, чем просто генерить новый DOM.


React очень буквальным образом пытается решить задачу обновления интерфейса при изменениях в данных: он буквально вычисляет эти обновления как разницу между новыми данными и старыми данными. Делая это на всём интерфейсе сразу. И как же он умудряется делать это быстро?


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


  1. Проблема «перерендера всего» при малых изменениях в данных вроде бы не решается, а просто «прячется под ковёр». Ведь мы всё так же при изменении галочки на тудушке вынуждены заново генерить интерфейс. Просто теперь это не DOM, а легковесный VDOM, но проблема-то та же — что если у нас настолько тяжелый интерфейс, что даже этот VDOM сгенерировать из данных окажется слишком ресурсоёмкой задачей?


  2. Дифф между старым и новым VDOM считается точно таким же образом — по всему VDOM, для всего интерфейса, даже если у нас поменялась какая-то маленькая хренюлина из тысяч. Разве это может быть быстро?

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


Во-первых, diff считается очень быстро благодаря всего нескольким простым эвристикам — за линейное время от количества узлов. Но что если узлов много? Все же нехорошо выполнять 10000 операций сравнения чтобы выяснить, что у нас поменялся один булевый флаг.


Но ведь нам не обязательно сравнивать узлы в VDOM по значению — нам достаточно сравнить ссылки, чтобы отсечь неизменившиеся поддеревья. И наши рендеринг-функции (TodoList, ToggleButton) не обязаны каждый раз возвращать новый VDOM-объект — они могут возвращать старую ссылку на VDOM, если инпут не изменился — не генеря новый объект. Этот механизм называется мемоизация. Таким образом, если мы поменяем что-то в глубине VDOM, то функции сравнения при обходе будут «забуриваться» только в те поддеревья, где произошли изменения — отбрасывая неизменившиеся, просто сравнив ссылки на них. Ведь если ссылка не поменялась, то и внутри ничего не поменялось — незачем туда залезать. Разумеется, эта мощная эвристика работает только с чистыми функциями, не имеющими побочных эффектов — гарантирующими неизменность результата и однозначность соответствия его входным параметрам. Вот почему функциональная чистота так важна для хорошей производительности! Хотя это и контринтуитивно на первый взгляд — ведь функциональный код обычно наоборот ассоциируется с плохой производительностью и избыточными действиями (на низком уровне). Тогда как именно функциональный подход и позволяет от избыточных действий (на высоком уровне) эффективно избавится в данном случае. И выигрыш от этого перекрывает пенальти от низкоуровневой избыточности на порядки! Вот какой оксюморон — просто мы не видим леса за деревьями обычно...


Но как нам эффективно мемоизовать эти наши ToggleButton? То есть, у нас на входе кучка данных, и как понять, изменились они или нет? Нужно ли считать новый VDOM, или вернуть старую ссылку? Гы. Вы ведь уже догадались, что это та же самая задача, что и с вычислением разницы между VDOM? И решение здесь то же самое — просто сравнивать ссылки! Но из этого следует очень важный вывод...


Наши данные должны быть иммутабельные


Подобно результирующему VDOM, чтобы убер-эффективный механизм вычисления разницы через сравнения ссылок мог работать — нам нужно, чтобы эти объекты были неизменяемые. То есть, мы не можем просто залезть внутрь массива данных и сделать todos[1].toggled = true — это скомпрометирует весь наш механизм сравнения ссылок. Ведь, в таком случае, функция TodoList посчитает, что todos не изменились — раз не изменилась ссылка на них — и ничего не будет перерендерено. Поэтому если мы хотим «залезать» внутрь данных и менять внутренности как вздумается, то придется отказаться от сравнения ссылок и мемоизации. И это приведет к тому, что нам придется перерендеривать весь VDOM при изменениях в одной галочке! Теперь вы должны понимать, почему иммутабельность это не какая-то «прихоть» функциональных языков — а ключевое и необходимое условие для того, чтобы этот подход вообще мог работать и быть эффективным.


Иммутабельные структуры данных только на первый взгляд кажутся неэффективными по сравнению с обычными массивами и словарями — на деле они дают гигантский прирост производительности — просто не в тех задачах, в которых вы применяете обычные мутабельные структуры… Но за это приходится расплачиваться некоторыми неудобствами на прикладном уровне, скажем, мы не можем пользоваться оператором присваивания свойству [1].toggled = true, вместо этого придется сделать что-то вроде:


todos = todos.set (1, Object.assign ({}, todos[1], { toggled: true }))

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


То есть, мы таким образом (заменой ссылок) как бы «показываем путь» нашим процедурам обхода дерева, объясняя им дорогу — «сначала иди сюда, потом сворачивай налево, а потом вот на это свойство обрати внимание».


Разумеется, для облегчения работы с иммутабельными структурами есть готовая библиотека, она называется Immutable.js. Что интересно, в JavaScript даже есть возможность запретить произвольному объекту меняться — Object.freeze — исключив, таким образом, возможные ошибки связанные со случайным изменением таких коллекций.


На самом деле, с помощью нового механизма Proxy Objects, недавно появившегося в JavaScript, можно реализовать предыдущую конструкцию таким образом, цепочечно перехватывая обращения к свойствам:


todos = todos[1].toggled.set (true) // вернет новый todos

Так что это синтаксически почти не будет отличаться от «мутабельного» доступа!


Redux


До того момента, как я осознал глубинную роль неизменяемости данных в проблеме обновления интерфейса, Redux был для меня чем-то малопонятным. Он интриговал меня тем, что с его помощью легко достичь вещей вроде сохранения истории изменений и undo/redo — это как раз та задача, которую мы пытались решить в прошлом году для нашего проекта — но мне был непонятен весь этот хайп вокруг этого — как и то, почему это почти всегда упоминается рядом с React.


Но ведь если вдуматься в то, что мы осмыслили про React и то, как это обуславливает паттерны работы с нашими данными — то из осознания этого естественным образом вытекает то, чем является Redux. Итак, поскольку:


  1. Операции над данными — чистые функции (previous state → new state)
  2. Операции изменения данных (бизнес-логика) обычно изолируют от интерфейса

То выходит, что наш обособленный от интерфейса набор операций это что-то вот такое:


addTodo (oldState, label) => ({ todos: oldState.todos.concat ({ label: label, checked: false }) })
checkTodo (oldState, index) => ({ todos: ...

И прикрутив к этому динамический диспатчинг метода (ну например чтобы по сети вызовы передавать, или в историю сохранять), получаем Redux:


reduce (oldState, action) => {
    switch (action.type) {
        case ADD_TODO: return { todos: oldState.todos.concat ({ label: action.label, checked: false }
        case CHECK_TODO: return { ...

Как видите, всё очень просто… наверное.


Вывод


Возможно, классы все-таки действительно не нужны — и можно обойтись одними чистыми функциями. Однако, в этой заметке я описал «идеализированный» React — такой, каким он стремится быть — тогда как в реальном React есть и классы, и состояния. Потому что иногда все же нужно хранить какое-то локальное состояние, или как-то вручную что-то контролировать в жизненном цикле DOM узлов. Совершенно непонятно также, как в «чисто функциональном» подходе предлагается решать проблему анимаций. Сейчас я читаю статью React Fiber Architecture, которая появилась буквально на днях, и описывает какой-то их супер-новый подход, над которым они работали последние два года, который якобы эту проблему решает. Но пока я не продвинулся дальше первых абзацев, едва осилив раздел Prerequisites — в результате осмысления которых у меня и родилась эта статья.

Tags:
Hubs:
+62
Comments 137
Comments Comments 137

Articles