JavaScript Design Patterns
Патерни — це перевірені рішення, які забезпечують надійні підходи до розв’язання проблем у розробці з використанням перевірених методів. Їх можна легко використовувати повторно та без особливих складнощів адаптувати до інших потреб.
Патерни допомагають нам писати код більш структуровано та організовано, уникаючи необхідності його рефакторингу для забезпечення чистоти в майбутньому. Окрім того, коли код на патернах — ми можемо менше часу витрачати на занепокоєння його структурою і зосередитися на загальному рішенні. Використання патернів ще й допомагає іншим розробникам краще розуміти чужий код та швидше адаптуватися до нового проєкту.
Під час вивчення патернів проєктування нерідко зустрічається термін «прото-патерн». Патерн, про який ще не відомо, що він проходить тести на патерн, зазвичай називають прото-патерном. Прототипи можуть бути результатом роботи когось, хто розробив конкретне рішення, яким варто поділитися зі спільнотою, але, можливо, ще не мав можливості пройти серйозну перевірку через дуже молодий вік.
JavaScript Design Patterns
Патерни програмування можна розділити на кілька категорій: породжувальні (creational), структурні (sctructural) та поведінковий (behavoiur). В кожній категорії патернів більше, ніж ми будемо описувати в цій статті, але ми розглянемо ті, які часто використовуються саме в Javascript практиці.
Creational патерни
Основна ціль цих патернів — створення нових об’єктів. До них відносяться:
- Constructor
- Module
- Factory
- Singletion
Constructor
Конструктор — це спеціальний патерн, який використовується для ініціалізації новоствореного об’єкта після того, як для нього виділено пам’ять.
В Javascript є такі способи створення об’єктів:
var newObject = {};
var newObject = Object.create( Object.prototype );
var newObject = new Object();
Потім існує чотири способи присвоєння ключів та значень об’єкту:
// ECMAScript 3 compatible approaches
// 1. Dot syntax
// Set properties
newObject.someKey = "Hello World";
// Get properties
var value = newObject.someKey;
// 2. Square bracket syntax
// Set properties
newObject["someKey"] = "Hello World";
// Get properties
var value = newObject["someKey"];
// ECMAScript 5 only compatible approaches
// 3. Object.defineProperty
// Set properties
Object.defineProperty( newObject, "someKey", {
value: "for more control of the property's behavior",
writable: true,
enumerable: true,
configurable: true
});
// If the above feels a little difficult to read, a short-hand could
// be written as follows:
var defineProp = function ( obj, key, value ){
var config = {
value: value
writable: true,
enumerable: true,
configurable: true
};
Object.defineProperty( obj, key, config );
};
// To use, we then create a new empty "person" object
var person = Object.create( Object.prototype );
// Populate the object with properties
defineProp( person, "car", "Delorean" );
defineProp( person, "dateOfBirth", "1981" );
defineProp( person, "hasBeard", false );
console.log(person);
// Outputs: Object {car: "Delorean", dateOfBirth: "1981", hasBeard: false}
// 4. Object.defineProperties
// Set properties
Object.defineProperties( newObject, {
"someKey": {
value: "Hello World",
writable: true
},
"anotherKey": {
value: "Foo bar",
writable: false
}
});
// Getting properties for 3. and 4. can be done using any of the
// options in 1. and 2.
Як ми бачили раніше, JavaScript не підтримує концепції класів, але підтримує спеціальні функції-конструктори, які працюють з об’єктами. Просто додавши до виклику функції-конструктора префікса ключове слово «new», ми можемо повідомити JavaScript, що ми хотіли б, щоб функція поводилася як конструктор і створювала екземпляр нового об’єкта з членами, визначеними цією функцією.
Всередині конструктора ключове слово this посилається на новий об’єкт, що створюється. Повертаючись до створення об’єкта, базовий конструктор може виглядати так:
function Car( model, year, miles ) {
this.model = model;
this.year = year;
this.miles = miles;
this.toString = function () {
return this.model + " has done " + this.miles + " miles";
};
}
// Usage:
// We can create new instances of the car
var civic = new Car( "Honda Civic", 2009, 20000 );
var mondeo = new Car( "Ford Mondeo", 2010, 5000 );
// and then open our browser console to view the
// output of the toString() method being called on
// these objects
console.log( civic.toString() );
console.log( mondeo.toString() );
Вищезгадана проста версія шаблону конструктора страждає на деякі проблеми. По-перше, це ускладнює успадкування, а по-друге, такі функції, як toString(), перевизначаються для кожного з нових об’єктів, створених за допомогою конструктора Car. Це не дуже оптимально, оскільки в ідеалі функція повинна спільно використовувати всі екземпляри типу Car.
Функції, як і багато об’єктів JavaScript, містять об’єкт-прототип. Коли ми викликаємо конструктор JavaScript для створення об’єкта, всі властивості прототипу конструктора стають доступними для нового об’єкта. Таким чином можна створити кілька об’єктів Car, які звертаються до одного і того ж прототипу. Таким чином, ми можемо розширити вихідний приклад таким чином:
function Car( model, year, miles ) {
this.model = model;
this.year = year;
this.miles = miles;
}
// Note here that we are using Object.prototype.newMethod rather than
// Object.prototype so as to avoid redefining the prototype object
Car.prototype.toString = function () {
return this.model + " has done " + this.miles + " miles";
};
// Usage:
var civic = new Car( "Honda Civic", 2009, 20000 );
var mondeo = new Car( "Ford Mondeo", 2010, 5000 );
console.log( civic.toString() );
console.log( mondeo.toString() );
Module
JavaScript шаблон модуля використовується для подальшої імітації концепції класів таким чином, щоб ми могли включати як загальнодоступні / приватні методи, так і змінні всередині одного об’єкта, тим самим захищаючи певні частини від глобальної області. Це призводить до зменшення ймовірності конфлікту імен наших функцій з іншими функціями, визначеними у додаткових сценаріях на сторінці.
Шаблон модуля інкапсулює «конфіденційність», стан та організацію за допомогою замикань. Він надає спосіб об’єднання загальнодоступних та приватних методів та змінних, захищаючи частини від витоку в глобальну область видимості та випадкового зіткнення з інтерфейсом іншого розробника. За допомогою цього шаблону повертається лише загальнодоступний API, а все інше залишається закритим.
Це дає нам чисте рішення для екранування логіки, що виконує важку роботу, відкриваючи тільки інтерфейс, який ми хочемо використовувати в інших частинах нашої програми.
var basketModule = (function () {
var myPrivateVar, myPrivateMethod;
// A private counter variable
var basket = [];
// A private function which logs any arguments
myPrivateMethod = function( foo ) {
console.log( foo );
};
return {
// A public variable
myPublicVar: "foo",
// A public function utilizing privates
addItem: function( values ) {
basket.push(values);
},
getItemCount: function () {
return basket.length;
},
};
})();
Сам модуль повністю автономен у глобальній змінній з ім’ям BasketModule. Масив кошика в модулі залишається закритим, тому інші частини нашої програми не можуть його безпосередньо прочитати. Він існує лише із закриттям модуля, тому єдині методи, які можуть отримати до нього доступ, — це ті, у яких є доступ до його області (наприклад, addItem(), getItemCount() тощо).
Singleton
Singleton відомий, оскільки він обмежує створення екземпляра класу одним об’єктом. Класично шаблон Singleton може бути реалізований шляхом створення класу з методом, який створює новий екземпляр класу, якщо він не існує, а якщо екземпляр вже існує, він просто повертає посилання на цей об’єкт.
Сінглтони відрізняються від статичних класів (або об’єктів), оскільки ми можемо відкласти їхню ініціалізацію, як правило, тому, що їм потрібна деяка інформація, яка може бути недоступна під час ініціалізації.
Ми можемо реалізувати синглтон так:
var mySingleton = (function () {
// Instance stores a reference to the Singleton
var instance;
function init() {
// Singleton
// Private methods and variables
function privateMethod(){
console.log( "I am private" );
}
var privateVariable = "Im also private";
var privateRandomNumber = Math.random();
return {
// Public methods and variables
publicMethod: function () {
console.log( "The public can see me!" );
},
publicProperty: "I am also public",
getRandomNumber: function() {
return privateRandomNumber;
}
};
};
return {
// Get the Singleton instance if one exists
// or create one if it doesn't
getInstance: function () {
if ( !instance ) {
instance = init();
}
return instance;
}
};
})();
Factory
Шаблон Factory – ще один creational шаблон, пов’язаний з поняттям створення об’єктів. Він відрізняється від інших патернів у своїй категорії тим, що не вимагає від нас використання конструктора. Натомість Factory може надати спільний інтерфейс для створення об’єктів, де ми можемо вказати тип об’єкта Factory, який хочемо створити.
Цей шаблон часто використовується, коли нам потрібно керувати або маніпулювати наборами об’єктів, які відрізняються, але мають багато подібних характеристик.
class BallFactory {
constructor() {
this.createBall = function(type) {
let ball;
if (type === 'football' || type === 'soccer') ball = new Football();
else if (type === 'basketball') ball = new Basketball();
ball.roll = function() {
return `The ${this._type} is rolling.`;
};
return ball;
};
}
}
class Football {
constructor() {
this._type = 'football';
this.kick = function() {
return 'You kicked the football.';
};
}
}
class Basketball {
constructor() {
this._type = 'basketball';
this.bounce = function() {
return 'You bounced the basketball.';
};
}
}
// creating objects
const factory = new BallFactory();
const myFootball = factory.createBall('football');
const myBasketball = factory.createBall('basketball');
console.log(myFootball.roll()); // The football is rolling.
console.log(myBasketball.roll()); // The basketball is rolling.
console.log(myFootball.kick()); // You kicked the football.
console.log(myBasketball.bounce()); // You bounced the basketball.
Structural патерни
Структурні патерни пов’язані з тим, як класи та об’єкти компонуються для формування більших структур. До них відносяться:
- Adapter
- Decorator
- Facade
Adapter
Адаптер забеспечує перетворення інтерфейсу класу в інший інтерфейс, який очікують клієнти. Іншими словами, він дозволяє класам, які попередньо несумісні за інтерфейсами, працювати разом.
Вам потрібно використовувати Адаптер, якщо:
- ви хочете використовувати існуючий клас, а його інтерфейс не відповідає тому, який вам потрібен.
- ви хочете створити багаторазовий клас, який співпрацює з непов’язаними або непередбаченими класами, тобто класами, які не обов’язково мають сумісні інтерфейси.
- вам потрібно використовувати кілька існуючих підкласів, але непрактично адаптувати їхній інтерфейс шляхом створення підкласів кожного. Об’єктний адаптер може адаптувати інтерфейс свого батьківського класу.
Реалізація адаптеру може виглядати так:
// Our array of cities
const citiesHabitantsInMillions = [
{ city: "London", habitants: 8.9 },
{ city: "Rome", habitants: 2.8 },
{ city: "New york", habitants: 8.8 },
{ city: "Paris", habitants: 2.1 },
]
// The new city we want to add
const BuenosAires = {
city: "Buenos Aires",
habitants: 3100000
}
// Our adapter function takes our city and converts the habitants property to the same format all the other cities have
const toMillionsAdapter = city => { city.habitants = parseFloat((city.habitants/1000000).toFixed(1)) }
toMillionsAdapter(BuenosAires)
// We add the new city to the array
citiesHabitantsInMillions.push(BuenosAires)
// And this function returns the largest habitants number
const MostHabitantsInMillions = () => {
return Math.max(...citiesHabitantsInMillions.map(city => city.habitants))
}
console.log(MostHabitantsInMillions()) // 8.9
Decorator
Декоратори динамічно накладають на об’єкт додаткові можливості. Вони пропонують гнучку альтернативу підкласам для розширення функціональності.
Іноді ми хочемо додати обов’язки до окремих об’єктів, а не до всього класу. Розглянемо приклад: набір інструментів для графічного інтерфейсу користувача повинен дозволити вам додавати такі властивості, як рамку або поведінку, наприклад прокручування, до будь-якого компонента інтерфейсу користувача. Один зі способів додати обов’язки – наслідування.
Наслідування рамки від іншого класу розміщує рамку навколо кожного екземпляра підкласу, однак це негнучко, оскільки вибір рамки робиться статично. Клієнт не може контролювати, як і коли прикрашати компонент рамкою. Більш гнучкий підхід полягає в тому, щоб включити компонент в інший об’єкт, який додає рамку. Об’єкт, що охоплює, називається декоратором.
Окрім того, декоратор можна перевикористовувати для декількох об’єктів і вже не буде потреби дублювати логіку з одного класу в інший.
import { useState } from 'react'
import Context from './Context'
const ContextProvider: React.FC = ({children}) => {
const [darkModeOn, setDarkModeOn] = useState(true)
const [englishLanguage, setEnglishLanguage] = useState(true)
return ({children})
}
export default ContextProvider
Facade
Facade дозволяє забеспечити уніфікований інтерфейс для набору інтерфейсів у підсистемі, іншими словами, він визначає інтерфейс вищого рівня, який полегшує використання підсистеми.
Використовуйте патерн Facade, коли
- ви хочете надати простий інтерфейс для складної підсистеми. Підсистеми часто стають складнішими в міру розвитку. Більшість шаблонів, коли вони застосовані, призводять до більшої кількості менших класів. Це робить підсистему більш багаторазовою та легшою для налаштування, але її також стає важче використовувати для клієнтів, яким не потрібно її налаштовувати. Фасад може забезпечити простий вигляд підсистеми за замовчуванням, який є достатнім для більшості клієнтів. Лише клієнтам, які потребують більшої кількості налаштувань, доведеться дивитися за межі фасаду.
- існує багато залежностей між клієнтами та класами реалізації абстракції. Введіть фасад, щоб відокремити підсистему від клієнтів та інших підсистем, сприяючи таким чином незалежності та переносимості підсистеми.
- ви хочете розшарувати свої підсистеми. Використовуйте фасад, щоб визначити точку входу до кожного рівня підсистеми. Якщо підсистеми є залежними, ви можете спростити залежності між ними, змусивши їх спілкуватися одна з одною виключно через їхні фасади.
function createData(
name: string,
calories: number,
fat: number,
carbs: number,
protein: number,
) {
return { name, calories, fat, carbs, protein };
}
const rows = [
createData('Frozen yoghurt', 159, 6.0, 24, 4.0),
createData('Ice cream sandwich', 237, 9.0, 37, 4.3),
createData('Eclair', 262, 16.0, 24, 6.0),
createData('Cupcake', 305, 3.7, 67, 4.3),
createData('Gingerbread', 356, 16.0, 49, 3.9),
];
export default function BasicTable() {
return (
Dessert (100g serving)
Calories
Fat (g)
Carbs (g)
Protein (g)
{rows.map((row) => (
{row.name}
{row.calories}
{row.fat}
{row.carbs}
{row.protein}
))}
);
}
Behavior патерни
Патерни поведінки пов’язані з алгоритмами та розподілом обов’язків між об’єктами. Вони описують не тільки моделі об’єктів або класів, але й моделі спілкування між ними, а також характеризують складний потік керування, який важко прослідкувати під час виконання. До них відносяться:
- Mediator
- Memento
- Observer
Mediator
Медіатор визначає об’єкт, який інкапсулює взаємодію набору об’єктів, виконуючи роль посередника. Він сприяє слабкому зв’язку, не даючи об’єктам посилатися один на одного в явному вигляді, і дозволяє варіювати їхню взаємодію незалежно.
class TrafficTower {
constructor() {
this._airplanes = [];
}
register(airplane) {
this._airplanes.push(airplane);
airplane.register(this);
}
requestCoordinates(airplane) {
return this._airplanes.filter(plane => airplane !== plane).map(plane => plane.coordinates);
}
}
class Airplane {
constructor(coordinates) {
this.coordinates = coordinates;
this.trafficTower = null;
}
register(trafficTower) {
this.trafficTower = trafficTower;
}
requestCoordinates() {
if (this.trafficTower) return this.trafficTower.requestCoordinates(this);
return null;
}
}
// usage
const tower = new TrafficTower();
const airplanes = [new Airplane(10), new Airplane(20), new Airplane(30)];
airplanes.forEach(airplane => {
tower.register(airplane);
});
console.log(airplanes.map(airplane => airplane.requestCoordinates()))
// [[20, 30], [10, 30], [10, 20]]
Memento
Memento дозволяє захоплювати та зберігати внутрішній стан об’єкта не порушуючи інкапсуляцію, щоб об’єкт можна було відновити до цього стану пізніше.
Така поведінка може бути необхідна під час впровадження контрольних точок і механізмів скасування, які дозволяють користувачам виходити з попередніх операцій або відновлюватися після помилок.
var Person = function (name, street, city, state) {
this.name = name;
this.street = street;
this.city = city;
this.state = state;
}
Person.prototype = {
hydrate: function () {
var memento = JSON.stringify(this);
return memento;
},
dehydrate: function (memento) {
var m = JSON.parse(memento);
this.name = m.name;
this.street = m.street;
this.city = m.city;
this.state = m.state;
}
}
var CareTaker = function () {
this.mementos = {};
this.add = function (key, memento) {
this.mementos[key] = memento;
},
this.get = function (key) {
return this.mementos[key];
}
}
function run() {
var mike = new Person("Mike Foley", "1112 Main", "Dallas", "TX");
var john = new Person("John Wang", "48th Street", "San Jose", "CA");
var caretaker = new CareTaker();
// save state
caretaker.add(1, mike.hydrate());
caretaker.add(2, john.hydrate());
// mess up their names
mike.name = "King Kong";
john.name = "Superman";
// restore original state
mike.dehydrate(caretaker.get(1));
john.dehydrate(caretaker.get(2));
console.log(mike.name);
console.log(john.name);
}
Observer
Observer використовується для визначення залежностей «один-до-багатьох» між об’єктами, щоб, коли один об’єкт змінює стан, усі залежні від нього сповіщення отримували й оновлювалися автоматично.
// Controller 1
$scope.$on('nameChanged', function(event, args) {
$scope.name = args.name;
});
...
// Controller 2
$scope.userNameChanged = function(name) {
$scope.$emit('nameChanged', {name: name});
};
Ми можемо створити свій власний Observer в js:
var Subject = function() {
this.observers = [];
return {
subscribeObserver: function(observer) {
this.observers.push(observer);
},
unsubscribeObserver: function(observer) {
var index = this.observers.indexOf(observer);
if(index > -1) {
this.observers.splice(index, 1);
}
},
notifyObserver: function(observer) {
var index = this.observers.indexOf(observer);
if(index > -1) {
this.observers[index].notify(index);
}
},
notifyAllObservers: function() {
for(var i = 0; i < this.observers.length; i++) {
this.observers[i].notify(i);
};
}
};
};
var Observer = function() {
return {
notify: function(index) {
console.log("Observer " + index + " is notified!");
}
}
}
var subject = new Subject();
var observer1 = new Observer();
var observer2 = new Observer();
var observer3 = new Observer();
var observer4 = new Observer();
subject.subscribeObserver(observer1);
subject.subscribeObserver(observer2);
subject.subscribeObserver(observer3);
subject.subscribeObserver(observer4);
subject.notifyObserver(observer2); // Observer 2 is notified!
subject.notifyAllObservers();
// Observer 1 is notified!
// Observer 2 is notified!
// Observer 3 is notified!
// Observer 4 is notified!
Антипатерни
Якщо ми вважаємо, що шаблон є кращою практикою, анти-шаблон являє собою викладений урок. Термін «антипатерни» був винайдений в 1995 році Ендрю Кенігом.
Хоча дуже важливо знати шаблони проектування, не менш важливо розуміти антишаблони. Давайте уточнимо причину цього. При створенні програми життєвий цикл проекту починається з конструювання, проте, як тільки закінчите початковий випуск, його необхідно підтримувати. Якість остаточного рішення буде гарною або поганою, залежно від рівня навичок та часу, який команда вклала в нього. Тут хороше і погане розглядаються в контексті – «ідеальний» дизайн може вважатися антипаттерном, якщо застосовується у неправильному контексті.
Підсумовуючи, анти-патерн – це поганий дизайн, який варто задокументувати.
Приклади анти-патернів у JavaScript наступні:
- Забруднення глобального простору імен шляхом визначення великої кількості змінних у глобальному контексті
- Передача рядків, а не функцій або в setTimeout, або в setInterval, оскільки це викликає внутрішнє використання eval().
- Зміна прототипу класу Object
- Використання document.write там, де більш доречні власні альтернативи DOM, такі як document.createElement. Протягом багатьох років document.write сильно неправильно використовувався і має ряд недоліків, у тому числі те, що якщо він виконується після завантаження сторінки і може фактично перезаписати сторінку, на якій ми знаходимося, в той час як document.createElement – ні. Він також не працює з XHTML, що є ще однією причиною вибору більш зручних для DOM методів, таких як document.createElement.
Джерела:
- JavaScript Patterns: Build Better Applications with Coding and Design Patterns
- Design Patterns Elements of Reusable Object-Oriented Software
- freecodecamp.org
- digitalocean.com
- betterprogramming.pub
- dofactory.com