Прототипи та прототипне наслідування в JavaScript
Прототипне наслідування (prototypal inheritance) у JavaScript означає, що об'єкти делегують пошук властивостей іншим об'єктам через ланцюг живих посилань, а не копіюють поведінку під час створення.
Теорія
TL;DR
- Аналогія: просиш батька рецепт пасти. Не пам'ятає - питає у свого батька. Ланцюг закінчується на
null. - Головна відмінність від класичного ООП: делегування під час виконання, а не копіювання під час створення.
- V8 зберігає
[[Prototype]]як прихований вказівник і проходить ланцюг при кожному зверненні. Object.create()для явного делегування, класи для читабельних ієрархій (під капотом той самий механізм).- Ланцюги глибше 10 рівнів помітно уповільнюють пошук у V8. Тримай мілко.
Швидкий приклад
const parent = { greeting: 'Hi' };
const child = Object.create(parent); // [[Prototype]] child = parent
console.log(child.greeting); // 'Hi' — делегує вгору по ланцюгу
child.surname = 'Smith';
console.log(child.surname); // 'Smith' — власна властивість
console.log(parent.surname); // undefined — parent не змінивсяУ child немає власного greeting, тому JS піднімається до parent і знаходить його там. Запис child.surname створює власну властивість на child, а не на parent. Батьківський об'єкт залишається незайманим.
Головна відмінність від класичного наслідування
У Java або C++ батьківська поведінка копіюється в кожного нащадка під час створення. Нащадок з того моменту самодостатній. JavaScript робить навпаки: нічого не копіюється. Нащадок тримає живе посилання на батьківський об'єкт, і рушій проходить це посилання в момент виконання при кожному зверненні. Зміни в батькові одразу видно в нащадках - якщо ті не перекрили властивість своєю.
Коли що використовувати
- Спільні методи для багатьох екземплярів (як
Array.prototype.map) - прототипи. - Фіксовані ієрархії з чіткою структурою і приватним станом - класи (вони все одно використовують прототипи всередині).
- Динамічне розширення під час виконання для плагінів або mixins -
Object.create(). - Критичні для продуктивності ділянки коду - уникай ланцюгів глибше 5 рівнів як безпечна межа. V8 документує деградацію після 10+ рівнів.
Як це працює у V8
V8 зберігає [[Prototype]] як прихований вказівник на кожному об'єкті. При зверненні до властивості V8 запускає GetProperty, спочатку перевіряє власні ключі через inline-кеш (LoadIC). При промаху переходить по вказівнику до наступного об'єкта. Так до null, який повертає undefined.
Є дві речі, що вбивають продуктивність. Виклик Object.setPrototypeOf() на існуючому об'єкті скасовує inline-кеші V8 і змушує рушій переоптимізовувати. Роби це один раз при створенні, не в циклах. Ланцюги глибше 10 об'єктів переводять inline-кеш у мегаморфний стан і уповільнюють пошук у 5-10 разів.
Типові помилки
Мутація спільного прототипу в розрахунку на ізоляцію екземплярів.
const proto = { count: 0 };
const a = Object.create(proto);
const b = Object.create(proto);
proto.count++; // і a, і b бачать цю зміну
console.log(a.count, b.count); // 1 1Прототип спільний за посиланням, не за копією. Якщо кожен екземпляр повинен мати своє значення, встанови його безпосередньо на екземплярі: a.count = 0.
for...in проходить весь ланцюг.
const obj = Object.create({ inherited: 'сюрприз' });
obj.own = 'моє';
for (let key in obj) console.log(key); // 'own', потім 'inherited'Для власних властивостей використовуй Object.keys(), або додай Object.prototype.hasOwnProperty.call(obj, key) всередині циклу.
Object.setPrototypeOf() у циклі.
let obj = {};
for (let i = 0; i < 100; i++) {
Object.setPrototypeOf(obj, {}); // скасовує кеші V8 на кожній ітерації
}Створюй ланцюг прототипів один раз через Object.create(). Повторна мутація - це V8-пастка для продуктивності.
Припущення, що instanceof перевіряє лише один рівень.
const plain = Object.create(null); // немає Object.prototype взагалі
console.log(plain instanceof Object); // falseObject.create(null) створює чистий словник без жодного ланцюга. Корисно для hashmaps без ризику prototype pollution, але instanceof, toString() та подібні методи на ньому не спрацюють.
Де зустрічається
- React: синтетичні події (synthetic events) делегують до спільного прототипу для повторного використання пулу (ReactDOMSyntheticEvent).
- Node.js:
http.Serverуспадковує відnet.Serverчерез ланцюг прототипів - саме так методи EventEmitter доходять до HTTP-обробників. - Express: делегування через прототипи для спільних валідаторів і перевірки авторизації.
- Vue.js: екземпляри компонентів розділяють спільний прототип опцій.
- Кожен масив делегує
.map(),.filter(),.reduce()зArray.prototype.
Питання на співбесіді
Q: Що виведе цей код?
const a = {};
const b = Object.create(a);
a.x = 1;
delete a.x;
console.log(b.x);
A: undefined. Після delete a.x властивість зникла з a. b делегує до a, нічого не знаходить, повертає undefined.
Q: Яка різниця між __proto__ і [[Prototype]]?
A: [[Prototype]] - внутрішній слот зі специфікації. __proto__ - геттер/сеттер на Object.prototype для доступу до нього. В продакшені вважається застарілим. Використовуй Object.getPrototypeOf().
Q: Як ES6-класи пов'язані з прототипами?
A: Класи - це синтаксичний цукор над тим самим механізмом. class Person { greet() {} } створює конструкторську функцію і записує greet в Person.prototype. Object.getPrototypeOf(new Person()) === Person.prototype завжди true.
Q: Для чого корисний Object.create(null)?
A: Створює об'єкт без жодного ланцюга прототипів. Немає успадкованих toString, hasOwnProperty, жодних прихованих ключів. Чистий словник, безпечний для довільних ключів без ризику prototype pollution.
Q: (Senior) V8 оптимізує мілкі ланцюги. На якій глибині відбувається деоптимізація і яка реальна вартість?
A: Приблизно 10-15 рівнів переводять inline-кеш у мегаморфний стан. Блог V8 документує уповільнення в ~5 разів на ланцюгу з 20 рівнів порівняно з 2-рівневим. Якщо будуєш систему плагінів з динамічними Object.create()-ланцюгами, виміряй це на реальних даних.
Приклади
Базове делегування через ланцюг прототипів
const vehicle = {
type: 'vehicle',
describe() {
console.log(`Це ${this.type}`);
}
};
const car = Object.create(vehicle);
car.type = 'car'; // перекриває vehicle.type
const sportsCar = Object.create(car);
// sportsCar не має власного type, делегує до car
sportsCar.describe(); // 'Це car'
console.log(Object.getPrototypeOf(sportsCar) === car); // true
console.log(Object.getPrototypeOf(car) === vehicle); // truesportsCar не має describe, тому рушій піднімається до car. У car теж немає, піднімається до vehicle, знаходить. Всередині виклику this.type вирішується як car.type, бо sportsCar делегує до car, де є ця власна властивість.
Конструкторські функції і спільні методи
Так спільні методи працювали до класів ES6. І це точний механізм, який класи використовують всередині.
function User(name, role) {
this.name = name;
this.role = role;
}
User.prototype.canEdit = function() {
return this.role === 'admin';
};
User.prototype.greet = function() {
console.log(`Привіт, я ${this.name}`);
};
const alice = new User('Alice', 'admin');
const bob = new User('Bob', 'viewer');
alice.greet(); // 'Привіт, я Alice'
console.log(alice.canEdit()); // true
console.log(bob.canEdit()); // false
// Обидва використовують одні й ті самі функції
console.log(alice.canEdit === bob.canEdit); // true - одне посиланняnew User(...) створює простий об'єкт і встановлює його [[Prototype]] на User.prototype. Функції живуть один раз на цьому об'єкті, не дублюються для кожного екземпляра. Саме в цьому аргумент щодо пам'яті: 1000 екземплярів розділяють один примірник кожного методу.
Сюрприз від мутації прототипу в продакшені
Цей баг зустрічається в реальних кодових базах, коли розробники змішують спільний стан прототипу з логікою конкретного екземпляра.
const SyntheticEventProto = {
bubbles: false,
preventDefault() {
this.defaultPrevented = true;
}
};
const clickEvent = Object.create(SyntheticEventProto);
clickEvent.type = 'click';
clickEvent.preventDefault();
console.log(clickEvent.defaultPrevented); // true
const mouseEvent = Object.create(SyntheticEventProto);
// Хтось мутує спільний прототип напряму
SyntheticEventProto.bubbles = true;
console.log(clickEvent.bubbles); // true - несподіваний спільний стан
console.log(mouseEvent.bubbles); // true - той самий прототип, та сама змінаclickEvent.defaultPrevented безпечний: preventDefault() встановлює this.defaultPrevented, що створює власну властивість на екземплярі. Але пряма мутація SyntheticEventProto.bubbles змінює спільний об'єкт. Кожен екземпляр без власного bubbles тепер бачить true. Реалізація React (ReactDOMSyntheticEvent) використовує схоже пулювання через прототипи - саме тому читання властивостей події асинхронно після React 16 могло повертати несподівані значення.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.