Паттерны проектирования в JS

Самые распространенные патерны

Источник примеров и описаний паттернов: JavaScript Паттерны. Шаблоны проектирования. 17 Примеров
Те же примеры на GitHub.

Шаблон проектирования или паттерн – это повторяемая конструкция кода для решения часто возникающих задач. Но в отличие от какой-то библиотеки, паттерн нельзя скопировать и применить автоматически, это лишь пример шаблона, на основе которого можно реализовать какое-то архитектурное решение.

Паттерны бывают разными в плане глобальности. Например, MVC (model-view-conroller) отвечает за структуру всего проекта. Большинство других же отвечает за решение каких-то относительно мелких задач.

Creational design patterns

Порождающие шаблоны проектирования служат, чтобы нам было проще отслеживать создание объектов и управлять этим процессом.

1. Constructor

Паттерн, который позволяет более легко создавать объекты определенного типа.

// **** Old syntax (with prototype extension) ****

function Server(name, ip) {
    this.name = name;
    this.ip = ip;
}

Server.prototype.getUrl = function () {
    return `https://${this.ip}:80`;
}

const aws = new Server('AWS German', '82.21.21.32');
console.log(aws.getUrl());

// **** Modern syntax ****
class Server2 {
    constructor(name, ip) {
        this.name = name;
        this.ip = ip;
    }

    getUrl() {
        return `https://${this.ip}:80`;
    }
}
const aws2 = new Server2('AWS German', '82.21.21.32');
console.log(aws2.getUrl());

2. Factory

Позволяет создавать однотипные объекты, меняя у них некоторые значения. По своей сути это создание отдельного класса-фабрики, что решает на основании какого другого отдельного класса создать объект, и при этом позволяет добавить этому создаваемому объекту какие-то дополнительные данные или методы (общие для всех конечных объектов), если это необходимо.

В примере ниже у нас есть класс MemberFactory, который, зависимо от применяемого параметра type в методе create выбирает класс из трех других (разных типов подписок), и потом еще и добавляет конечному объекту свойство type и метод define.

class SimpleMembership {
    constructor(name) {
        this.name = name;
        this.cost = 50;
    }
}

class StandardMembership {
    constructor(name) {
        this.name = name;
        this.cost = 150;
    }
}

class PremiumMembership {
    constructor(name) {
        this.name = name;
        this.cost = 500;
    }
}

class MemberFactory {
    static list = {
        simple: SimpleMembership,
        standard: StandardMembership,
        premium: PremiumMembership
    }

    create(name, type = 'simple') {
        const Membership = MemberFactory.list[type] || MemberFactory.list.simple;
        const member = new Membership(name);
        // здесь можно переменную member еще и как-то модифицировать (добавить метаданные, вспомогательные методы и т.п.)
        member.type = type;
        member.define = function () {
            console.log(`${this.name} (${this.type}) : ${this.cost}`)
        }
        return member;
    }
}

const factory = new MemberFactory();
const members = [
  factory.create('Alex', 'simple'),
  factory.create('Kolyan', 'premium'),
  factory.create('Tolik', 'standard')
];

members.forEach(m => m.define());

3. Prototype

Суть в том, что для создания нового объекта мы можем использовать в качестве скелета для его прототипа какие-то другие объекты.

Этот паттерн особенно характерен для JavaScript, так как в здесь все как раз устроенно на прототипах.

В примере ниже в качестве такого “скелета” служит объект car, который помощи Object.create мы используем для создания объекта carWithOwner с дополнительными свойствами.

const car = {
    wheels: 4,

    init() {
        console.log(`I have 4 wheels, my owner is ${this.owner}`)
    }
}

const carWithOwner = Object.create(car, {
    owner: {
        value: 'Jon'
    }
});

console.log(carWithOwner.__proto__ === car); // true
carWithOwner.init();

4. Singleton

Согласно этого паттерна, в приложении может быть только один инстанс конкретного класса. Ну а если он уже был создан, то возвращается и используется он, а не новый инстанс.

В примере ниже в методе constructor в начале есть проверка – существует ли уже ранее созданный инстанс этого класса. И если да, то вернется он, а не создастся новый. Поэтому при повторной попытке создания метод getData() вернет то же самое, а не данные нового инстанса.

class Database {

    constructor(data) {
        if (Database.exist) {
            return Database.instance;
        }
        Database.instance = this;
        Database.exist = true;
        this.data = data;
    }

    getData() {
        return this.data;
    }
}

const mongo = new Database('MongoDB');
console.log(mongo.getData()); // MongoDB

const mysql = new Database('MySQL');
console.log(mysql.getData()); // MongoDB (not MySQL)

Structural design patterns

Структурные паттерны помогают добавлять функционал для существующих объектов / приложений, не ломая старый функционал, и позволяют выстроить эффективную коммуникацию между различными объектами.

1. Adapter

Адаптер позволяет интегрировать старый интерфейс какого-то класса в новый интерфейс, позволяя работать им совместно. Например, это часто используется при работе с API, когда есть старая версия и новая.

В примере ниже используется класс CalcAdapter, который позволяет принять “старые” аргументы, но выполнить “новые” методы.

class OldCalc {
    operations(t1, t2, operation) {
        switch (operation) {
            case 'add': return t1 + t2;
            case 'sub': return t1 - t2;
            default: return NaN;
        }
    }
}

class NewCalc {
    add(t1, t2) {
        return t1 + t2;
    }

    sub(t1, t2) {
        return t1 - t2;
    }
}

class CalcAdapter {
    constructor() {
        this.calc = new NewCalc();
    }

     operations(t1, t2, operation) {
         switch (operation) {
             case 'add': return this.calc.add(t1, t2);
             case 'sub': return this.calc.sub(t1, t2);
             default: return NaN;
         }
     }
}

const oldCalc = new OldCalc();
console.log(oldCalc.operations(10, 5, 'add'));

const newCalc = new NewCalc();
console.log(newCalc.add(10, 5));

const adapter = new CalcAdapter();
console.log(adapter.operations(10, 5, 'add'));

2. Decorator

Этот паттерн о том, как добавлять новое поведение или функционал для существующих классов. Декораторами являются функции, которые принимают инстанс какого-то класса, модифицируют его и возвращают обратно.

Здесь в примере есть два декоратора: aws и azure. Они принимают инстанс класса Server и добавляют в него какие-то модификации или новые данные, не затрагивая старые название свойств, методов и их поведение.

class Server {
    constructor(ip, port) {
        this.ip = ip;
        this.port = port;
    }

    get url() {
        return `https://${this.ip}:${this.port}`;
    }
}

function aws(server) {
    server.isAWS = true;
    server.awsInfo = () => {
        return server.url;
    }

    return server;
}

function azure(server) {
    server.isAzure = true;
    server.port += 500;
    return server;
}

const s1 = aws(new Server('12.34.56.78', 8080));
console.log(s1.isAWS);
console.log(s1.awsInfo());

const s2 = azure(new Server('98.87.76.12', 1000));
console.log(s2.isAzure);
console.log(s2.url);

3. Facade

Фасад служит для того, чтобы создавать более простой (публичный) интерфейс для взаимодействия с различными классами, объектами. Сам фасад (класс, который создает публичный интерфейс) может при этом и расширять функционал конечных объектов, например, добавлять id.

Известным примером является библиотека jQuery, которая упрощает использование нативных js-методов.

В примере ниже роль фасада играет класс ComplaintRegistry, который упрощает создание жалобы (англ. complaint) на основе других классов.

class Complaints {
    constructor() {
       this.complaints = [];
    }

    replay(complaint) {}

    add(complaint) {
        this.complaints.push(complaint);
        return this.replay(complaint);
    }
}

class ProductComplaints extends Complaints {
    replay({id, customer, details}) {
        return `Product: ${id}: ${customer} (${details})`
    }
}

class ServiceComplaints extends Complaints {
    replay({id, customer, details}) {
        return `Service: ${id}: ${customer} (${details})`
    }
}

class ComplaintRegistry {
    register(customer, type, details) {
        //здесь можно создавать дополнительные данные, которые привяжутся к основным
        const id = Date.now().toString();
        let complaint;

        if (type === 'service') {
            complaint = new ServiceComplaints;
        } else {
            complaint = new ProductComplaints;
        }

        return complaint.add({id, customer, details});
    }
}

const registry = new ComplaintRegistry();
console.log(registry.register('Alex', 'service', 'Not available')); // Service: 1710847029873: Alex (Not available)
console.log(registry.register('Kolyan', 'produce', 'See error')); // Product: 1710847029874: Kolyan (See error)

4. Flyweight

Помогает эффективно передавать и работать с данными через различные типы объектов. Проще говоря, позволяет улучшить производительность, возвращая мгновенно то, что ранее уже было создано, загружено и т.п.

Примером является загрузка изображений в современных браузерах, когда не происходит загрузка изображений, которые уже были загружены. Сюда также относится кеширование, сохранение памяти и т.п.

class Car {
    constructor(model, price) {
        this.model = model;
        this.price = price;
    }
}

class CarFactory {
    constructor() {
        this.cars = [];
    }

    create(model, price) {
        const candidate = this.getCar(model);
        if (candidate) {
            return candidate;
        } else {
            const newCar = new Car(model, price);
            this.cars.push(newCar);
            return newCar;
        }
    }

    getCar(model) {
        return this.cars.find(car => car.model === model);
    }
}

const factory = new CarFactory();
const bmwX6 = factory.create('bmw', 10000);
const audi = factory.create('audi', 12000);
const bmwX3 = factory.create('bmw', 8000);

console.log(bmwX6);
console.log(bmwX3); // 10000 (not 8000)
console.log(bmwX3 === bmwX6); // true (get from cache)

5. Proxy

Прокси позволяет нам ставить какие-то ловушки, например на поля объектов или вызов функции, что помогает очень гибко опредлять работу приложения в дальнейшем. Примером результата может быть избавление от лишних запросов на сервер.

В JS есть встроенный одноименный объект Proxy, который позволяет все это делать. И объект Reflect, упрощяющий создание Proxy.

Оба эти объекта показаны ниже в примере для реализации “кеширования” ответа с сервера и избегания повторых одинаковых запросов.

function networkFetch(url) {
    // mock of real request
    return `${url} - Server response`;
}

const cache = new Set();
const proxyFetch = new Proxy(networkFetch, {
    apply(target, thisArg, argArray) {
        const url = argArray[0];
        if (cache.has(url)) {
            return `${url} - Cache response`;
        } else {
            cache.add(url);
            return Reflect.apply(target, thisArg, argArray);
        }
    }
});

console.log(proxyFetch('angular.io')); // angular.io - Server response
console.log(proxyFetch('angular.io')); // angular.io - Cache response

 

Behavioral design patterns

Поведенческие паттерны служат для налаживания коммуникации между различными классами, сущностями и т.д.

1. Chain of responsibility

Позволяет последовательно у одного и того же объекта вызывать какой-то набор операций и при этом последовательно модифицировать результат.

Здесь снова характерынм примером может быть библиотека jQuery.

В примере ниже цепочка, выводимая в console.log() и есть реализацией данного паттерна.

class MySum {
    constructor(initialValue = 28) {
        this.sum = initialValue;
    }

    add(value) {
        this.sum += value;
        return this;
    }
}

const sum1 = new MySum();
console.log(sum1.add(8).add(10).add(1).add(50)); // 97

2. Command

Этот патерн позволяет создавать определенную абстрактуню оболочку над функционалом существующого класса, чтобы взаемодействовать с ним, но уже через другой “оборачивающи” объект. И тем самым записывая в память предыдущие состояния (значения, методы или т.п.), которые уже были вызваны.

Примером служит Redux или NgRx.

class MyMath {
    constructor(initialValue = 0) {
        this.num = initialValue;
    }

    square() {
        return this.num ** 2;
    }

    cube() {
        return this.num ** 3;
    }
}

class Command {
    constructor(subject) {
        this.subject = subject;
        this.commandsExecuted = [];
    }

    execute(command) {
        this.commandsExecuted.push(command);
        return this.subject[command]();
    }
}

const x = new Command(new MyMath(2));
console.log(x.execute('square'));
console.log(x.execute('cube'));
console.log(x.commandsExecuted); // [ 'square', 'cube' ]

3. Iterator

Создание какого-то объекта / класса для последовательного получения доступа к определенной информации.

Symbol.iterator появился ES6 синтаксисе.

class MyIterator {
    constructor(data) {
        this.index = 0;
        this.data = data;
    }

    [Symbol.iterator]() {
        return {
            next: () => {
                if (this.index < this.data?.length) {
                    return {
                        value: this.data[this.index++],
                        done: false
                    }
                } else {
                    this.index = 0;
                    return {
                        done: true,
                        value: void(0) //undefined
                    }
                }
            }
        }
    }
}

const iterator = new MyIterator(['This', 'is', 'iterator']);

for (const val of iterator) {
    console.log('Value: ', val);
}
// Value:  This
// Value:  is
// Value:  iterator

Схожего функционала можно добиться также и при помощи генератора:

function* generator(collection) {
    let index = 0;

    while (index < collection.length) {
        yield collection[index++];
    }
}

const gen = generator(['This', 'is', 'iterator']);

console.log(gen.next().value);
console.log(gen.next().value);
console.log(gen.next().value);
// This
// is
// iterator

4. Mediator

Этот патерн позволяет выстраивать очень плотную комуникацию между объектами разного типа. При этом он предоставляет некую централизованую абстракцию, которая позволяет взаемодействовать группе объектов через друг друга.

Ниже имитация чата с комнатами. Роль медиатор играет класс ChatRoom, где происходит отправка сообщений между инстансами созданных юзеров (если имя получателя не указано, то все юзеры комнаты его получают).

class User {
    constructor(name) {
        this.name = name;
        this.room = null;
    }

    send(message, to) {
        this.room.send(message, this, to);
    }

    receive(message, from) {
        console.log(`${from.name} => ${this.name}: ${message}`);
    }
}

class ChatRoom {
    constructor() {
        this.users = {};
    }

    register(user) {
        this.users[user.name] = user;
        user.room = this;
    }

    send(message, from, to) {
        if (to) {
            to.receive(message, from)
        } else {
            Object.keys(this.users).forEach(key => {
                if (this.users[key] !== from) {
                    this.users[key].receive(message, from);
                }
            })
        }
    }
}

const u1 = new User('Jon');
const u2 = new User('Piter');
const u3 = new User('Ben');

const room = new ChatRoom();

room.register(u1);
room.register(u2);
room.register(u3);

u1.send('Hello', u2); // Jon => Piter: Hello
u3.send('Hello Everyone'); // Ben => Jon: Hello Everyone && Ben => Piter: Hello Everyone

5. Observable (Publisher subscriber, Dispatcher Listener etc.)

Этот паттерн формирует зависимости один к многим (one to many dependencies). Суть в том, что у нас есть один объект, в котором можем затригерить изменения, и потом все другие подписанные на эти изменения объекты получают эти обновления.

Примером является библиотека RxJs, которая в корне использует этот паттерн.

В примере при вызове метода изменений у стрима (при помощи метода fire) меняется state у все подписчиков (observable). Своего рода реализация шины событий (Event Bus), которую то называют отдельным паттерном, то комбинацией из троих (Singleton, Observer и Mediator).

class Subject {
    constructor() {
        this.observers = [];
    }

    subscribe(observer) {
        this.observers.push(observer);
    }

    unsubscribe(observer) {
        this.observers = this.observers.filter(obs => obs !== observer)
    }

    fire(action) {
        this.observers.forEach(observer => {
            observer.update(action);
        })
    }
}

class Observer {
    constructor(state = 1) {
        this.state = state;
        this.initialState = state;
    }

    update(action) {
        switch (action.type) {
            case 'INCREMENT':
                this.state = ++this.state;
                break;
            case 'DECREMENT':
                this.state = --this.state;
                break;
            case 'ADD':
                this.state += action.payload;
                break;
            default:
                this.state = this.initialState;
        }
    }
}

const stream$ = new Subject();

const obs1 = new Observer();
const obs2 = new Observer(28);

stream$.subscribe(obs1);
stream$.subscribe(obs2);

console.log(obs1.state); // 1
console.log(obs2.state); // 28

stream$.fire({type: 'INCREMENT'});
stream$.fire({type: 'ADD', payload: 10});

console.log(obs1.state); // 12
console.log(obs2.state); // 39

6. State

Суть его в создании классов, которые будут являться элементами стейта, и делегировании изменения состояний этих классов на какой-то отдельный общий клас, который будет является общим стейтом.

Может быть удобно для реализации роутинга, например.

Вся логика в примере построена на этом верхнеуровневом классе (TrafficLight), в котором происходит переключение между другими классами в виде изменения сигналов светлофора.

class Light {
    constructor(light) {
        this.light = light;
    }
}

class RedLight extends Light {
    constructor() {
        super('red');
    }
    sign() {
        return 'STOP';
    }
}

class YellowLight extends Light {
    constructor() {
        super('yellow');
    }
    sign() {
        return 'GETTING READY';
    }
}

class GreenLight extends Light {
    constructor() {
        super('green');
    }
    sign() {
        return 'GO';
    }
}

class TrafficLight {
    constructor() {
        this.states = [
            new RedLight(),
            new YellowLight(),
            new GreenLight()
        ]
        this.current = this.states[0];
    }

    change() {
        const total = this.states.length;
        let index = this.states.findIndex(light => light === this.current);
        if (index + 1 < total) {
            this.current = this.states[index + 1];
        } else {
            this.current = this.states[0];
        }
    }

    sign() {
        return this.current.sign();
    }
}

const traffic = new TrafficLight();
console.log(traffic.sign()); // STOP
traffic.change();
console.log(traffic.sign()); // GETTING READY

7. Strategy

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

В примере создается оболочка в виде класса Commute (англ. “время на дорогу”), которая взаемодействует с различными стратегиями (другими классами) через один интерфейс.

class Vehicle {
    travelTime() {
        return this.timeTaken;
    }
}

class Bus extends Vehicle {
    constructor() {
        super();
        this.timeTaken = 10;
    }
}

class Taxi extends Vehicle {
    constructor() {
        super();
        this.timeTaken = 5;
    }
}

class Car extends Vehicle {
    constructor() {
        super();
        this.timeTaken = 3;
    }
}

class Commute {
    travel(transport) {
        return transport.travelTime();
    }
}

const commute = new Commute();
console.log(commute.travel(new Taxi())); // 5

8. Template

Этот паттерн определяет некую структуру (скелет), а дочерние классы уже реализуют конкретный функционал, при этом не изменяя поведение базового класса.

class Employee {
    constructor(name, salary) {
        this.name = name;
        this.salary = salary;
    }
    responsibilities() {

    }
    work() {
        return `${this.name} do ${this.responsibilities()}`;
    }
    getPaid() {
        return `${this.name} have salary ${this.salary}`;
    }
}

class Developer extends Employee {
    constructor(name, salary) {
        super(name, salary);
    }

    responsibilities() {
        return 'Create application';
    }
}

class Tester extends Employee {
    constructor(name, salary) {
        super(name, salary);
    }

    responsibilities() {
        return 'Write tests';
    }
}

const dev = new Developer('Alex', 100000);
console.log(dev.getPaid()); // Alex have salary 100000
console.log(dev.work()); // Alex do Create application

const tester = new Tester('Ben', 90000);
console.log(tester.getPaid()); // Ben have salary 90000

Это, конечно, не все паттерны, но одни из самых основных. Есть также еще много паттернов при работе с различными библиотеками и фреймворками.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *