JavaScript: де ми зараз і куди рухатися

Привіт, хабраюзер. Оскільки, судячи з усього, ми вже живемо в майбутньому, то нещодавно я щільно засів за вивчення нових фіч 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: $вами (false),
checked: $alias ('toggled') // синонім

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

})

Ось ці всі штуки —
$static
,
$trigger
,
$вами
це теги, якими «позначаються» поля в довільних об'єктах. При конструюванні прототипу ці теги зчитуються, трансформуючи результат. Наприклад
$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 () {}
@вами 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'

Я погуглив, і наткнувся на чернетку public class 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. Вони параметризуются чистими даними, без яких-небудь вами-обгорток — тобто, їх не потрібно «розв'язати»
Це означає, що всі наші дані зберігаються у великому тупому об'єкті, типу:
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 — в результаті осмислення яких у мене і народилася ця стаття.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.