Реалізація патерну Prototype в JavaScript

Якщо вам коли-небудь доводилося створювати багато схожих об'єктів, не копіюючи і не вставляючи код кожного разу, ви потрапили в правильне місце. Сьогодні ми розглянемо патерн Prototype — один з найбільш цікавих патернів в JavaScript, і, чесно кажучи, один з тих, що вбудований безпосередньо в саму мову.

Серія статей про патерни проектування в JavaScript

Git репозиторій: https://github.com/akhrienov/javascript-design-patterns/blob/main/patterns/creational/prototype/prototype.implementation.js

Що таке патерн Prototype?

Подумайте про патерн Prototype як про підхід "клонування та налаштування". Замість того, щоб створювати об'єкти з нуля кожного разу, ви створюєте шаблон об'єкта (прототип) і потім робите його копії, коли вам потрібні нові об'єкти. Це схоже на наявність форми для печива, якою ви можете зробити печиво, але кожне печиво може мати свої власні добавки.

Вбудована система прототипів JavaScript

JavaScript особливий тим, що прототипне успадкування є частиною його ДНК. Давайте подивимося, як це працює:

// Ось як працює система прототипів JavaScript за лаштунками  
const animal = {  
 makeSound() {  
 console.log(this.sound);  
 }  
};  

// Створення нового об'єкта з використанням прототипу animal  
const dog = Object.create(animal);  
dog.sound = 'Гав!';  

dog.makeSound(); // Виводить: Гав!  

// Ви можете перевірити ланцюг прототипів  
console.log(Object.getPrototypeOf(dog) === animal); // true

Уявіть систему прототипів JavaScript як сімейне дерево, де об'єкти можуть успадковувати риси (методи та властивості) від своїх "батьківських" об'єктів.

У цьому прикладі ми створюємо батьківський об'єкт animal з методом makeSound, а потім створюємо "дитячий" об'єкт dog, який успадковує цей метод і додає свою властивість sound — це схоже на те, як у реальному житті діти можуть успадковувати загальні риси від батьків, маючи при цьому власні унікальні характеристики.

Коли ми викликаємо makeSound() на об'єкті dog, JavaScript спочатку шукає цей метод на самому об'єкті dog, а якщо не знаходить його там, він переходить до ланцюга прототипів і знаходить метод на об'єкті animal.

Ця вбудована система потужна, але для патерну Prototype часто хочеться мати більше контролю над процесом клонування.
Давайте подивимося, чому.

Глибоке клонування: Іноді вам потрібна ідеальна копія

При роботі зі складними об'єктами, поверхневе клонування не завжди достатньо.

Давайте побудуємо надійну систему глибокого клонування:

function deepClone(obj) {  
 // Обробка примітивних типів та null  
 if (obj === null || typeof obj !== 'object') {  
 return obj;  
 }  

 // Обробка об'єктів Date  
 if (obj instanceof Date) {  
 return new Date(obj.getTime());  
 }  

 // Обробка об'єктів Array  
 if (Array.isArray(obj)) {  
 return obj.map(item => deepClone(item));  
 }  

 // Обробка звичайних об'єктів  
 const clonedObj = {};  
 Object.keys(obj).forEach(key => {  
 clonedObj[key] = deepClone(obj[key]);  
 });  

 return clonedObj;  
}  

// Давайте подивимося на реальний приклад  
const originalDocument = {  
 metadata: {  
 created: new Date(),  
 author: 'John Doe'  
 },  
 content: ['Introduction', 'Main Body', 'Conclusion'],  
 settings: {  
 isPublic: true,  
 tags: ['tutorial', 'javascript']  
 }  
};  

const clonedDocument = deepClone(originalDocument);  
console.log(clonedDocument.metadata.created instanceof Date); // true

Глибоке клонування в JavaScript створює повністю незалежну копію складного об'єкта, обробляючи вкладені об'єкти, масиви та спеціальні типи, такі як Date — на відміну від поверхневого клонування, яке просто створює нову посилання на вкладені об'єкти.

Функція deepClone рекурсивно перебирає кожен рівень об'єкта, створюючи нові копії кожного вкладеного елемента, гарантуючи, що зміни в клоні не вплинуть на оригінальний об'єкт.

У нашому прикладі ми створюємо клон об'єкта документа з різними вкладеними структурами, і навіть об'єкт Date всередині належним чином клоновано як новий екземпляр, а не просто скопійовано посилання.

Хоча це користувацьке впровадження deepClone() чудово підходить для навчальних цілей і підтримки застарілих систем, сучасним додаткам (браузери та Node.js 17.0.0+) варто розглянути використання вбудованого методу structuredClone(). Цей метод пропонує кращу продуктивність, автоматично обробляє кругові посилання та підтримує ширший діапазон вбудованих типів, таких як Map, Set та складніші об'єкти. Це користувацьке впровадження залишається корисним для розуміння механізмів глибокого клонування та для середовищ, де structuredClone() недоступний.

const clonedDocument = structuredClone(originalDocument);

Сучасна реалізація за допомогою класів ES6

Тепер давайте реалізуємо патерн Prototype за допомогою сучасних класів ES6.
Створимо систему шаблонів документів:

class DocumentTemplate {  
 constructor(template) {  
 this.template = template;  
 }  

 clone() {  
 // Використовуємо наше глибоке клонування  
 return deepClone(this.template);  
 }  

 customize(options) {  
 const cloned = this.clone();  
 return { ...cloned, ...options };  
 }  
}  

// Реальний приклад: система шаблонів для блог-постів  
class BlogPostTemplate extends DocumentTemplate {  
 constructor() {  
 super({  
 title: '',  
 content: '',  
 metadata: {  
 author: '',  
 date: new Date(),  
 tags: [],  
 readingTime: 0  
 },  
 seoSettings: {  
 metaDescription: '',  
 keywords: []  
 }  
 });  
 }  

 // Додаємо спеціалізовані методи для блог-постів  
 calculateReadingTime(content) {  
 // Середня швидкість читання: 200 слів на хвилину  
 const words = content.split(' ').length;  
 return Math.ceil(words / 200);  
 }  

 createPost(options) {  
 const post = this.customize(options);  
 post.metadata.readingTime = this.calculateReadingTime(post.content);  
 return post;  
 }  
}  

// Приклад використання  
const blogTemplate = new BlogPostTemplate();  

const newPost = blogTemplate.createPost({  
 title: 'Розуміння прототипів в JavaScript',  
 content: 'Прототипи в JavaScript — це потужний інструмент...',  
 metadata: {  
 author: 'Jane Developer',  
 tags: ['javascript', 'programming']  
 }  
});  

console.log(newPost);

Цей код демонструє, як створити гнучку систему шаблонів документів за допомогою класів ES6, де DocumentTemplate служить базовим класом для створення та налаштування шаблонів документів з можливістю глибокого клонування.

Клас BlogPostTemplate розширює цю функціональність спеціально для блог-постів, додаючи корисні функції, такі як автоматичний розрахунок часу на читання, і дозволяє швидко створювати нові блог-пости, гарантуючи, що кожен пост є незалежною копією, яка включає як стандартні значення шаблону, так і індивідуальний контент.

Функціональний підхід: Іноді класи — це не ваш вибір

Якщо ви віддаєте перевагу функціональному програмуванню, ось як ми можемо реалізувати той самий патерн:

const createDocumentPrototype = (defaultTemplate) => {  
 const clone = () => deepClone(defaultTemplate);  

 const customize = (options) => ({  
 ...clone(),  
 ...options  
 });  

 return {  
 clone,  
 customize  
 };  
};  

// Реальний приклад: система шаблонів для електронних листів  
const createEmailTemplate = () => {  
 const prototype = createDocumentPrototype({  
 subject: '',  
 body: '',  
 metadata: {  
 sender: '',  
 recipient: '',  
 timestamp: null  
 },  
 tracking: {  
 openRate: 0,  
 clickRate: 0  
 }  
 });  

 // Додаємо функціональність для електронних листів  
 const createEmail = (options) => {  
 const email = prototype.customize(options);  
 email.metadata.timestamp = new Date();  
 return email;  
 };  

 return {  
 ...prototype,  
 createEmail  
 };  
};  

// Приклад використання  
const emailTemplate = createEmailTemplate();  
const welcomeEmail = emailTemplate.createEmail({  
 subject: 'Ласкаво просимо на нашу платформу!',  
 body: 'Ми раді, що ви з нами...',  
 metadata: {  
 sender: '[email protected]',  
 recipient: '[email protected]'  
 }  
});

Реєстр шаблонів: Управління кількома шаблонами

Коли ви працюєте з кількома шаблонами, корисно мати центральний реєстр:

class TemplateRegistry {  
 constructor() {  
 this.templates = new Map();  
 }  

 register(name, template) {  
 this.templates.set(name, template);  
 }  

 unregister(name) {  
 this.templates.delete(name);  
 }  

 getTemplate(name) {  
 const template = this.templates.get(name);  

 if (!template) {  
 throw new Error(`Шаблон "${name}" не знайдений!`);  
 }  

 return template;  
 }  

 createFromTemplate(name, options) {  
 const template = this.getTemplate(name);  
 return template.customize(options);  
 }  
}  

// Приклад використання  
const registry = new TemplateRegistry();  

// Реєструємо різні шаблони  
registry.register('blogPost', new BlogPostTemplate());

registry.register('email', createEmailTemplate());  

// Створення документів з шаблонів  
const newBlogPost = registry.createFromTemplate('blogPost', {  
 title: 'Мій новий пост',  
 content: 'Ось зміст...'  
});  

const newEmail = registry.createFromTemplate('email', {  
 subject: 'Привіт!',  
 body: 'Це електронний лист...'  
});

Клас TemplateRegistry діє як централізований менеджер для зберігання та отримання різних типів шаблонів документів, використовуючи Map для зберігання колекції шаблонів та надаючи методи для реєстрації, скасування реєстрації та створення нових документів із цих шаблонів.

Використання цього патерну реєстрації дозволяє легко управляти кількома шаблонами у великій програмі, дозволяючи створювати нові документи з будь-якого зареєстрованого шаблону, зберігаючи наш код організованим та легким для підтримки.

Врахування продуктивності

Під час роботи з патерном Prototype пам'ятайте про наступні поради щодо продуктивності:

Глибина клонування: Глибоке клонування може бути дорогим для дуже вкладених об'єктів.

Розгляньте, чи справді вам потрібне глибоке клонування, або ж поверхневого буде достатньо.

// Порівняння продуктивності  
const shallowClone = Object.assign({}, originalObject);  
const deepClonedObject = deepClone(originalObject);  

console.time('shallow');  
for (let i = 0; i < 1000; i++) {  
 Object.assign({}, originalObject);  
}  
console.timeEnd('shallow');  

console.time('deep');  
for (let i = 0; i < 1000; i++) {  
 deepClone(originalObject);  
}  
console.timeEnd('deep');

Використання пам'яті: Тримайте ваші шаблони прототипів легкими. Не зберігайте непотрібні дані безпосередньо в шаблоні.

Кешування: Для об'єктів, що часто використовуються, розгляньте можливість реалізації кешу:

class CachedTemplateRegistry extends TemplateRegistry {  
 constructor() {  
 super();  
 this.cache = new Map();  
 }  

 createFromTemplate(name, options) {  
 const cacheKey = `${name}-${JSON.stringify(options)}`;  

 if (this.cache.has(cacheKey)) {  
 return this.cache.get(cacheKey);  
 }  

 const newObject = super.createFromTemplate(name, options);  
 this.cache.set(cacheKey, newObject);  

 return newObject;  
 }  

 clearCache() {  
 this.cache.clear();  
 }  
}

CachedTemplateRegistry розширює базовий реєстр, додаючи шар кешування, зберігаючи раніше створені документи на основі їх шаблону та параметрів, щоб уникнути непотрібного створення однакових документів.

Ця оптимізація може значно покращити продуктивність, коли ті самі шаблони часто запитуються з однаковими параметрами, оскільки вона повертає кешовану версію, а не створює нову копію кожного разу.

Реальні випадки використання

Патерн Prototype дуже корисний у багатьох реальних сценаріях:

Системи управління документами:

  • Створення документів на основі шаблонів
  • Генератори форм
  • Побудовники звітів

Розробка ігор:

  • Створення кількох подібних ігрових об'єктів
  • Спавнінг ворогів або предметів
  • Генерація елементів рівнів

UI компоненти:

  • Створення варіацій базових компонентів
  • Реалізації тем
  • Динамічне створення форм

Підсумки

Патерн Prototype надзвичайно корисний, коли вам потрібно:

  • Створювати об'єкти на основі шаблонів
  • Уникати дублювання ініціалізаційного коду
  • Підтримувати сім'ю об'єктів, які можна створювати динамічно

Пам'ятайте: Вбудована система прототипів JavaScript потужна, але патерн Prototype дає вам більше контролю над процесом клонування та створенням об'єктів.

Виберіть стиль реалізації (на основі класів чи функціональний), який найкраще підходить для потреб вашого проєкту, і не забувайте враховувати наслідки для продуктивності при роботі зі складними об'єктами.

Щасливого кодування!

Перекладено з: Prototype Pattern Implementation in JavaScript