Справжні принципи JavaScript: прототипи, делегування та функції
Чому класи є синтаксичним «цукром» порівняно з прототипами, чим відрізняється делегування від класичної спадковості, де це призводить до проблем та які переваги надає функціональний стиль у JavaScript.
Джерела: The True Blueprints to JavaScript
Забудьте про жорсткі об’єктно-орієнтовані схеми, імпортовані з інших мов. Справжня архітектура JavaScript давно ґрунтується на функціональному програмуванні та прототипному підході. Боротьба з цими принципами робить мову некоректною; використання їх робить її універсальною.
Приблизно десять років JavaScript виглядала як звичайна мова об’єктно-орієнтованого програмування. Ви вводите class, extends, constructor та super(), і при уважному погляді вона нагадує Java чи C#. Ця схожість — це навмисний маскарад. Під нею рунтайм виконує зовсім інші функції. У момент, коли хтось забуває про цей маскарад — особливо той, хто спочатку вивчав класичне об’єктно-орієнтоване програмування — з’являються „неможливі“ баги. JavaScript ніколи не була класичною. Єдиний спосіб довіряти тому, що обіцяє поверхневий вигляд, — це побачити те, що знаходиться під синтаксисом.
Ключове слово class — це лише „костюм“
До ECMAScript 2015 (ES6) у JavaScript не існувало ключового слова class. Розробники все ще писали код об’єктно-орієнтованого стилю за допомогою функцій-конструкторів та ручного присвоєння прототипів. Коли з’явився class, його представили як синтаксичний „цукор“: більш зручну форму запису того самого механізму, а не як нову модель успадкування.
TC39, комітет зі стандартизації, був чіткий: JavaScript-class — це не шаблон, який наноситься на екземпляри так само, як клас у Java. Це функція, одягнена в інший „костюм“.
Кілька рядків демонструють це:
class Dog {
constructor(name) {
this.name = name;
}
bark() {
return `${this.name} says woof`;
}
};
// Then just ask JS what Dog actually is.
console.log(typeof Dog); // "function"
Метод bark() знаходиться не в таблиці абстрактних класів — він знаходиться у Dog.prototype, саме там, де знаходився б метод до 2015 року. Концептуально кожен клас все ще компілюється у функцію разом із об’єктом-прототипом, до якого додані методи. Ключове слово змінилося; але механізм залишився незмінним.
Це має значення, тому що класичні очікування приховують гарантії, яких JavaScript ніколи не пропонував: фіксована ієрархія, яка визначається під час компіляції, приватні поля за замовчуванням та механізм виклику методів, який діє однаково незалежно від способу їх виклику. За синтаксисом class JavaScript пропонує динамічну, змінювану ланцюг пошуку на час виконання, яка *нагадує* ці гарантії, поки не з’являється ситуація, коли це схожість закінчується.
Це не недолік дизайну, за який потрібно вибачатися. Це костюм, який виконує свою функцію: дозволяє іншому створінню пересуватися по приміщенні, побудованому для чогось іншого. Проблеми виникають тоді, коли костюм сприймають як анатомічну структуру. Справжнім зразком для наслідування є делегування, а не класичне успадкування.
Що насправді означає делегування
Прототипне делегування та класичне успадкування звучать схоже, але функціонують по-різному. У класичних мовах структура підкласу фіксується під час визначення або компіляції — екземпляр Dog за своєю структурою є копією того, що визначено у Animal.
У JavaScript об’єкт не отримує копію. Він отримує живий посилання на інший об’єкт (його прототип). Коли властивість відсутня, двигун не збивається з роботи миттєво; він просувається по цьому посиланню та шукає її там. Цей процес називається делегуванням:
- Не так: “Містить фотокопію поведінки батьківського об’єкта”
- Натомість: “Запитує батьківський об’єкт, якщо відповідь відсутня локально”
Алгоритм пошуку є точним: спочатку перевіряються власні властивості об’єкта; якщо їх немає, слідується внутрішня посилання [[Prototype]] (раніше використовувалося як __proto__, а правильно — через Object.getPrototypeOf()); процес повторюється до тих пір, доки властивість не знайдеться або ланцюжок не досягне Object.prototype, прототипом якого є null. Це динамічний ланцюжок „запитати наступний об’єкт“, а не статична ієрархія, яка зафіксована назавжди.
Оскільки посилання на прототип є динамічним та змінним — це не вбудована копія — ви можете додати метод до спільного прототипа навіть після того, як вже існує тисяча об’єктів, і кожен з них одразу його отримає. Вони мають посилання, а не фотокопію. Ви навіть можете переналаштувати прототип об’єкта в режимі роботи за допомогою Object.setPrototypeOf(), і подальші пошуки будуть виконуватися за новим ланцюжком.
Класичні мови не можуть цього зробити без перекомпіляції. Модель JavaScript — це не слабша класична спадкуваність; це інший механізм, достатньо гнучкий, щоб за потреби імітувати класичну спадкуваність.
Object.create() демонструє делегування без додаткових елементів:
const animal = {
speak() {
return '...';
},
};
const dog = Object.create(animal);
// creates dog w/animal set directly as its pro
Немає конструктора, немає new, немає прихованих ключових слів, які б діяли «замість вас». dog.speak() працює тому, що пошук делегується до animal, коли у dog немає власної функції speak. Саме цей механізм class та extends обгортають у більш зрозумілу синтаксис.
Де руйнується ілюзія
Команди, які використовують C# чи Java, часто виявляють цю проблему під час підключення подій або викликів таймера, а не під час звичайних викликів методів. Принцип програмування навчає, що «методи належать до екземплярів», тому втрата змінної this сприймається як помилка мови, а не як порушення правил місця виклику. Раннє задокументування цих правил допомагає уникнути тижнів безглуздого використання конструкції .bind(this) без розуміння її суті.
Проблема приватного стану також проявляється у бібліотеках, які експортують «безпечні» об’єкти, але водночас залишають можливість зміни їхнього вмісту. Якщо користувач може змінити значення певного поля, тоді ті інваріанти, які здавалися внутрішніми, насправді є лише умовними. Коли необхідно зберегти інваріант, краще використовувати фабричні конструкції чи змінні типу #; якщо ж дані призначені для спільного використання, краще обирати звичайні об’єкти.
Прості навчальні матеріали допомагають зберегти структуру костюма недоторканою. Шви з’являються тоді, коли this використовується для щось складнішого, ніж пряме викликання методу на видимому об’єкті. У JavaScript this ніколи не був прив’язаний до „інстанції“ у класичний спосіб. Механізм виклику визначає об’єкт, який отримує метод — а не лексичне місце розташування функції чи блок класу, де був написаний метод.
Найпоширеніша проблема: передача методу як калебеку:
setTimeout(instance.greet, 1000);
// or, wiring up an event listener directly
const el = document.getElementById('btn');
el.addEventListener('click', instance.handleClick);
// NOTE: The `this` inside that function is no longer the instance
// you extracted it from.
У класичній мові на кшталт C# метод, прив’язаний до об’єкта, несе з собою цей об’єкт. У JavaScript метод є звичайною функцією; відокремлена від місця виклику, яке надає змінну this, вона втрачає доступ до об’єкта. setTimeout та слухачі DOM — це поширені випадки такого відокремлення. Способи вирішення проблеми включають використання методу .bind, функцій-стрілок чи перепроєктування коду так, щоб місце виклику все одно надавало правильний об’єкт приймача — причому нічого з цього класичні мови не змушують враховувати для звичайних методів.
Тихіший варіант — це справжнє приватне становище. Підкреслення у конвенціях (this._internal) є соціальними домовленостями, а не механізмами примусу. Сучасні поля #private дещо зменшують цю прогалину, але вони з’явилися пізно та все ще існують поруч із культурою публічних змінних властивостей. Очікування інкапсуляції у стилі Java за замовчуванням призводить до здивування, коли код ззовні змінює те, що здавалося „внутрішнім“.
Побудова класу з прототипів вручну
Найшвидший спосіб інтерналізувати модель — це відтворити ту саму поведінку без class, використовуючи звичайні функції-конструктори та об’єкти-прототипи — стиль до 2015 року, який двигун досі використовує під „цукровою оболонкою“. Документація MDN щодо основних функцій скриптингу та складних прототипів об’єктів залишається корисним джерелом інформації під час практики цих патернів.
// function (using the `this` keyword)
function Dog(name) {
this.name = name;
};
// prototype (doing the same thing)
Dog.prototype.bark = function() {
return `${this.name} says woof`;
};
// assigning a variable to a new instance
const rex = new Dog('Rex');
console.log(rex.bark());
// Output: "Rex says woof"
Проходження крок за кроком через приклад new Dog('Rex') роз’яснює ключове слово, яке багато хто використовує роками, не знаючи процедури: створити новий порожній об’єкт; встановити значення [[Prototype]] цього об’єкта як Dog.prototype; викликати функцію Dog з параметром this, прив’язаним до нового об’єкта; повернути цей об’єкт, якщо тільки конструктор не повертає інший об’єкт. Не існує прихованого реєстру класів чи скомпільованих схем — лише створення об’єкта, посилання на прототип та виклик функції з певним значенням this.
Спадкування без ключового слова extends виглядає саме так — це точна форма, до якої компілюється використання extends:
// an extension of the Dog
function Puppy(name) {
Dog.call(this, name);
};
Puppy.prototype = Object.create(Dog.prototype);
Puppy.prototype.constructor = Puppy;
// This line here doesn't work, because arrow functions can't use `this`.
// Puppy.prototype.bark = () => { return `${this.name} whines instead of barks`; };
// Use a standard function like this instead:
Puppy.prototype.bark = function() {
return `${this.name} whines instead of barks`;
};
const buddy = new Puppy('Buddy');
console.log(buddy.bark());
// Output: "Buddy whines instead of barks"
Object.create(Dog.prototype) робить те саме, що автоматизує extends Dog: під’єднує [[Prototype]] об’єкта Puppy.prototype до Dog.prototype, щоб відсутні властивості передавалися догору. Dog.call(this, name) виконує ту саму роботу, що й super(name): запускає ініціалізатор батьківського класу для тієї самої інстанції. Як тільки це під’єднання будується вручну кілька разів, class більше не є чимось магічним, а стає просто скороченою формою відомого механізму.
Функції завжди були природною мовою
Мислення у термінах функцій також сприяє більш зрозумілому проектуванню модулів. Експорт кількох фокусованих функцій з файлу часто є зрозумілішим, ніж експорт класу, методи якого переважно ігнорують спільні поля екземпляра. Коли у „класу“ немає значущого стану на рівні окремого екземпляра, це просто ім’я простору імен у обгортці об’єктно-орієнтованого програмування. Модуль функцій передає ту саму інформацію без зайвих формальностей та несподіванок щодо this.
Клозури ідеально підходять до такого підходу: фабрика може використовувати конфігурацію як контекст та повертати невеликий об’єкт API, методи якого не потребують класичної ієрархії. У місці виклику (api.save()) результат все одно виглядає об’єктно-орієнтованим, тоді як реалізація залишається лексичною, а не на основі прототипів, коли це більш доцільно.
Коли класичний підхід виглядає необов’язковим, виникає ще одне гостре питання:
Як виглядає справжня нативна структура JavaScript зсередини?
Відповідь полягає у тому, що функції діють як значення першого класу — їх можна передавати, зберігати та повертати, на відміну від ієрархій класів. У цьому сенсі функції існували з самого початку розробки JavaScript у 1995 році. Класи з’явилися приблизно через двадцять років. Більша частина історії цієї мови майже не стосувалася об’єктно-орієнтованого програмування. Якщо шукати основну функцію, навколо якої будувалася JavaScript, то це ніколи не була ієрархія об’єктів — це була функція.
Серед історичних впливів, які часто згадуються у цьому контексті, є:
- Scheme — мова з родини Lisp, яка наголошує на функціях першого класу
- Self — мова на основі прототипів, яка сформувала модель делегування
- Java — мова з певною синтаксичною структурою, яка згодом зробила використання конструкції
classпривабливим з комерційної точки зору
Закриті області імен природно випливають з функцій першого класу: функція зберігає доступ до лексичного середовища, в якому була створена. Саме це збереження дозволяє «фабрикам» приховувати стан без використання класичних приватних полів, а хукам та мідлвейру — зберігати контекст кожного запиту без підкласів.
Що насправді дає функціональне програмування
На практиці команди поступово запроваджують ці принципи. Зберігайте вхідні та вихідні дані на периферії — у мережі, на диску, у DOM — а середню частину програми розглядайте як перетворення простих даних. Вже це розділення усуває багато ситуацій конкурентного доступу, які виникають, коли події користувацького інтерфейсу змінюють одну й ту саму деревоподібну структуру об’єктів з боку кількох обробників.
Допоміжні засоби для забезпечення незмінності (розтягування даних, structuredClone чи бібліотеки) є тактиками; стратегія полягає у тому, щоб ускладнити створення незаконних проміжних станів. Дискриміновані союзи та функції оновлення чудово поєднуються з вищезазначеними принципами прототипів: об’єкти залишаються корисними, але шляхи оновлення стають чіткими значеннями замість тихого записування полів.
Дебагування також змінює свою форму. За допомогою чистих функцій оновлення можна записувати вхідні та вихідні дані певної операції та відтворювати проблеми поза мережею. При складних змінах екземплярів часто потрібна повна сесія додатку, щоб відтворити саме ту ситуацію з полями, яка спричинила баг.
Якщо позбутися академічних рамок та концепцій функціонального програмування, то на практиці це означає створення програм переважно з чистих функцій: однаковий вхід — однаковий вихід, без побічних ефектів поза значенням, яке повертається. Програми складають ці функції в ланцюжки трансформацій, замість того щоб з часом мутувати поля екземплярів через методи.
Першою перевагою є можливість тестування. Чиста функція не потребує складних процедур підготовки чи очищення, не вимагає імітації змінних типу this та ретельно налаштованих екземплярів. Достатньо надати вхідні дані та перевірити результат. Багато чого з того, що робить тести ООП ненадійними, — це процедури, необхідні для переведення об’єкта у правильний внутрішній стан перед тим, як перевірити потрібну поведінку. Чисті функції уникають цих процедур, оскільки не існує прихованого статусу екземпляра, який потрібно спочатку налаштувати.
Друга перевага безпосередньо відповідає розділу this: спільний змінний стан є причиною більшості класичних проблем з прив’язкою. Метод, який читає та записує спільні поля екземпляра, може зазнати впливу будь-чого іншого, що торкалося цього екземпляра, у порядку, який місце виклику не робить очевидним. Чиста функція не має спільного змінного стану, тому зміна порядку викликів не призводить до тихого зміни значення.
Третя перевага — це композитність. Крихітні чисті допоміжні функції добре піддаються поєднанню: кожне значення, що повертається, живить наступний крок, утворюючи потоки обробки даних, які працюють знизу вгору та дозволяють проводити тести для кожного кроку. Складні ієрархії класів заважають такому поєднанню, оскільки поведінка розподіляється між ланцюгами успадкування та змінними полями, а не знаходиться у окремих одиницях, які можна незалежно тестувати.
Це зовсім не забороняє використання об’єктів чи методів. Це лише змінює підхід до їх використання: як до меж та просторів імен, а не як до стандартного місця, де повинна знаходитися кожна частина логіки.
Останній момент
Навчання цієї моделі новачкам скорочує час від копіювання посібників з класами до розуміння причин, чому обробник натискань втрачає свого отримувача. Це також пом’якшує „релігійні війни“: класичні шаблони залишаються доступними як синтаксичний засіб для уточнення меж домену, тоді як щоденне використання масивних конвеєрів, редукторів та хуків зберігає видимістю власних особливостей мови.
Під час перегляду коду запитайте себе, чи новий class моделює реальну ідентичність з терміном існування та незмінними властивостями, чи просто групує функції. Якщо це останнє, зазвичай краще використовувати модуль функцій або окремий фабрикатор. Якщо це перше, залиште клас, але пам’ятайте, що він все одно базується на прототипах та значенні this у місці виклику, і напишіть тести, які будуть тестувати відокремлені керуючі функції, коли такі місця виклику існують у продакшені.
У повсякденному використанні JavaScript жоден з цих аспектів не є теоретичним. Методи масивів, такі як map, filter та reduce, є функціональними інструментами та належать до найчастіше використовуваних API цієї мови. Перехід React від класових компонентів до функційних компонентів та хуків — починаючи з React 16.8 та продовжуючись у наступних основних версіях — був свідомим кроком у напрямку розгляду компонента як чистої функції від параметрів та стану, що полегшує розуміння порівняно з класами, які мають справу з методами життєвого циклу та змінними полями.
Редюсери Redux дотримуються принципу чистоти, описаного у офіційному посібнику з основ стану, дій та редюсерів: ідентичні вхідні дані стану та дій мають забезпечувати однаковий наступний стан, не змінюючи попередній безпосередньо — саме тому можлива налагодка з використанням механізму „подорожей у часі“. Для отримання стандартного формулювання дивіться посібник з основ Redux. Багато команд також віддають перевагу легшим системам зберігання даних, таким як Zustand, для керування станом додатку; ті самі принципи чистоти залишаються актуальними там, де оновлення мають залишатися прогнозованими.
Пайплайни обробки потоків та подій — це бібліотеки, які комбінують невеликі трансформації для послідовностей подій, що відбуваються, — це функціональне програмування, застосоване до однієї з найскладніших проблем фронтенду: асинхронних, безперервних введень користувача.
Висновок: JavaScript ніколи не був класичним. Патерни ООП все ще можуть допомогти на межах, але первинні принципи цього мовлення завжди були функціональними. Протягом двох десятиліть мова носила „костюм“, який створював враження чогось іншого — настільки переконливе, що ціле покоління вивчало її через цей костюм, включаючи всі помилки. Під ним вона залишалася мовою функцій та делегування — ближчою до Scheme у „костюмі“ Java, ніж до самого Java, — що означає, що функціональний стиль у JavaScript ніколи не був чимось запозиченим. Це завжди був його власний стиль.
Іншими словами: вивчіть синтаксис костюму, щоб співпраця залишалась легкою, вивчіть анатомію, щоб помилки під час розробки мали сенс. Команди, які лише запам’ятовують синтаксис class, постійно знаходять відокремлені калебули та несподівані зміни прототипів. Команди, які також практикують використання Object.create, функцій-конструкторів та чистих оновлень, витрачають менше часу на суперечки з системою виконання та більше часу на формування даних області призначення.
З такою основою class стає необов’язковим елементом, а не визначенням мови — і відлагоджування починається з місць виклику та посилань на прототипи, а не з припущень, запозичених із класичних систем виконання.
Рецензенти, які заздалегідь прослягають про це розрізнення, запобігають виникненню випадкових «бог-об’єктів», прикиданих під моделі області призначення.