Pull to refresh

Чистый javascript.Классы

Reading time 8 min
Views 37K
Original author: Ryan McDermott
Перевод книги Райана Макдермота clean-code-javascript

Оглавление:




Принцип единственной ответственности (SRP)


Как написано в clean code, «Должна быть лишь одна причина для изменения класса» (There should never be more than one reason for a class to change). Заманчиво всё засунуть в один класс, как в дорожный чемодан. Проблема в том, что ваш класс не будет концптуально связан, и вы будете часто измененять его на каждый чих. Очень важно минимизировать изменения в классе. Когда вы вносите изменения в класс с огромным функционалом, тяжело отследить последствия ваших измений.

Плохо:

class UserSettings {
  constructor(user) {
    this.user = user;
  }

  changeSettings(settings) {
    if (this.verifyCredentials()) {
      // ...
    }
  }

  verifyCredentials() {
    // ...
  }
}

Хорошо:

class UserAuth {
  constructor(user) {
    this.user = user;
  }

  verifyCredentials() {
    // ...
  }
}


class UserSettings {
  constructor(user) {
    this.user = user;
    this.auth = new UserAuth(user);
  }

  changeSettings(settings) {
    if (this.auth.verifyCredentials()) {
      // ...
    }
  }
}

Принцип открытости/закрытости (OCP)


Как заявил Бертран Мейер, «сущности (классы, модули, функции и т.д.) должны быть открыты для расширения, но закрыты для модификации» (software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification). Что это значит? Это значит что вы должны давать возможность расширить функциональность сущности не изменяя существующий код.

Плохо:

class AjaxAdapter extends Adapter {
  constructor() {
    super();
    this.name = 'ajaxAdapter';
  }
}

class NodeAdapter extends Adapter {
  constructor() {
    super();
    this.name = 'nodeAdapter';
  }
}

class HttpRequester {
  constructor(adapter) {
    this.adapter = adapter;
  }

  fetch(url) {
    if (this.adapter.name === 'ajaxAdapter') {
      return makeAjaxCall(url).then((response) => {
        // обрабатываем ответ
      });
    } else if (this.adapter.name === 'httpNodeAdapter') {
      return makeHttpCall(url).then((response) => {
        // обрабатываем ответ
      });
    }
  }
}

function makeAjaxCall(url) {
  // делаем запрос и возвращаем промис
}

function makeHttpCall(url) {
  // делаем запрос и возвращаем промис
}

Хорошо:

class AjaxAdapter extends Adapter {
  constructor() {
    super();
    this.name = 'ajaxAdapter';
  }

  request(url) {
    // делаем запрос и возвращаем промис
  }
}

class NodeAdapter extends Adapter {
  constructor() {
    super();
    this.name = 'nodeAdapter';
  }

  request(url) {
    // делаем запрос и возвращаем промис
  }
}

class HttpRequester {
  constructor(adapter) {
    this.adapter = adapter;
  }

  fetch(url) {
    return this.adapter.request(url).then((response) => {
      // обрабатываем ответ
    });
  }
}

Принцип подстановки Барбары Лисков


Это страшный термин для очень простой концепции.

Определение:

«Пусть q(x) является свойством верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.» Wikipedia Определение ещё хуже, чем название.

Суть заключается в том, что если у вас есть родительский и дочерний классы, то они могут взаимозаменятся без ошибок. Это по-прежнему может сбивать с толку, так что давайте посмотрим на классический пример площади прямоугольника. Математически квадрат это прямоугольник, но если вы решите эту задачу с помощию наследования, то у вас будут проблемы. Более детально про принцип можно почитать здесь.

Плохо:

class Rectangle {
  constructor() {
    this.width = 0;
    this.height = 0;
  }

  setColor(color) {
    // ...
  }

  render(area) {
    // ...
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  setWidth(width) {
    this.width = width;
    this.height = width;
  }

  setHeight(height) {
    this.width = height;
    this.height = height;
  }
}

function renderLargeRectangles(rectangles) {
  rectangles.forEach((rectangle) => {
    rectangle.setWidth(4);
    rectangle.setHeight(5);
    const area = rectangle.getArea(); // BAD: Will return 25 for Square. Should be 20.
    rectangle.render(area);
  });
}

const rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);

Хорошо:

class Shape {
  setColor(color) {
    // ...
  }

  render(area) {
    // ...
  }
}

class Rectangle extends Shape {
  constructor() {
    super();
    this.width = 0;
    this.height = 0;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor() {
    super();
    this.length = 0;
  }

  setLength(length) {
    this.length = length;
  }

  getArea() {
    return this.length * this.length;
  }
}

function renderLargeShapes(shapes) {
  shapes.forEach((shape) => {
    switch (shape.constructor.name) {
      case 'Square':
        shape.setLength(5);
        break;
      case 'Rectangle':
        shape.setWidth(4);
        shape.setHeight(5);
    }

    const area = shape.getArea();
    shape.render(area);
  });
}

const shapes = [new Rectangle(), new Rectangle(), new Square()];
renderLargeShapes(shapes);

Принцип разделения интерфейса (ISP)


В javascript отсутсвуют интенфейсы, так что этот принцип не получится использовать в полной мере. Тем не менее важно его использовать, даже при отсутствии системы типов javascript.

ISP утверждает, что «Пользователи не должны зависеть от классов, которые они не используют» (Clients should not be forced to depend upon interfaces that they do not use). Интерфейсы это условные соглашения в JavaScript из-за неявной типизации. Хорошим примером в javascript могут быть классы с большыми конфигами. Не заставляйте пользователей вашего класса вводить кучу конфигов. Они, как правило, не будут использовать их все. У вас не будет «жирного интерфейса», если вы их сделаете опциональными.

Плохо:

class DOMTraverser {
  constructor(settings) {
    this.settings = settings;
    this.setup();
  }

  setup() {
    this.rootNode = this.settings.rootNode;
    this.animationModule.setup();
  }

  traverse() {
    // ...
  }
}

const $ = new DOMTraverser({
  rootNode: document.getElementsByTagName('body'),
  animationModule() {} // Чаще вам не нужна анимация при движении.
  // ...
});

Хорошо:

class DOMTraverser {
  constructor(settings) {
    this.settings = settings;
    this.options = settings.options;
    this.setup();
  }

  setup() {
    this.rootNode = this.settings.rootNode;
    this.setupOptions();
  }

  setupOptions() {
    if (this.options.animationModule) {
      // ...
    }
  }

  traverse() {
    // ...
  }
}

const $ = new DOMTraverser({
  rootNode: document.getElementsByTagName('body'),
  options: {
    animationModule() {}
  }
});

Принцип инверсии зависимости (DIP)


Этот принцип гласит две важные вещи:

1. Модули высшего уровня не должны зависеть от модулей низшего уровня. Оба должны зависеть от абстракций.
2. В абстракциях не должно быть деталей. Детали должны быть в дочерних классах.

Сначала трудно понять этот принцип. Но если вы работали с Angular.js, вы видели реализацию этого принципа в виде Dependency Injection (DI). Несмотря на то, что они не являются идентичными понятиями, DIP даёт возможность отграничить модули высокого уровня от деталей модулей низкого уровня и установки их. Он может сделать это через DI. Этот принцип уменьшает связь между модулями. Если ваши модули тесно связаны, их тяжело рефакторить.

Абстракции и есть неявными соглашениями, которые представляют интерфейсы в JavaScript. То есть методы и свойства, что объект/класс предоставляет другому объекту/классу. В приведенном ниже примере каждый экземпляр класса InventoryTracker будет иметь метод requestItems.

Плохо:

class InventoryRequester {
  constructor() {
    this.REQ_METHODS = ['HTTP'];
  }

  requestItem(item) {
    // ...
  }
}

class InventoryTracker {
  constructor(items) {
    this.items = items;

    // Плохо то, что мы создали зависимость от конкретной реализации запроса.
    // теперь наш метод requestItems не абстрактный и зависит от этой реализации
    this.requester = new InventoryRequester();
  }

  requestItems() {
    this.items.forEach((item) => {
      this.requester.requestItem(item);
    });
  }
}

const inventoryTracker = new InventoryTracker(['apples', 'bananas']);
inventoryTracker.requestItems();

Хорошо:

class InventoryTracker {
  constructor(items, requester) {
    this.items = items;
    this.requester = requester;
  }

  requestItems() {
    this.items.forEach((item) => {
      this.requester.requestItem(item);
    });
  }
}

class InventoryRequesterV1 {
  constructor() {
    this.REQ_METHODS = ['HTTP'];
  }

  requestItem(item) {
    // ...
  }
}

class InventoryRequesterV2 {
  constructor() {
    this.REQ_METHODS = ['WS'];
  }

  requestItem(item) {
    // ...
  }
}

// Сформировав зависимости извне и их подмешиванию, ми можем легко
// заменить наш модуль запросов на другой, который использует веб сокеты
const inventoryTracker = new InventoryTracker(['apples', 'bananas'], new InventoryRequesterV2());
inventoryTracker.requestItems();

Отдавайте предпочтение классам (ES2015 / ES6) над простыми функциями (ES5)


C помощью классических (ES5) классов тяжело реализовать читаемые наследование, конструкцию и определение методов. Если вам нужно наследование, не задумываясь используйте (ES2015 / ES6) классы. Тем не менее, отдавайте предпочтение маленьким функциям, а не классам, пока не будет необходимости в более крупных и сложных объектах.

Плохо:

const Animal = function(age) {
  if (!(this instanceof Animal)) {
    throw new Error('Instantiate Animal with `new`');
  }

  this.age = age;
};

Animal.prototype.move = function move() {};

const Mammal = function(age, furColor) {
  if (!(this instanceof Mammal)) {
    throw new Error('Instantiate Mammal with `new`');
  }

  Animal.call(this, age);
  this.furColor = furColor;
};

Mammal.prototype = Object.create(Animal.prototype);
Mammal.prototype.constructor = Mammal;
Mammal.prototype.liveBirth = function liveBirth() {};

const Human = function(age, furColor, languageSpoken) {
  if (!(this instanceof Human)) {
    throw new Error('Instantiate Human with `new`');
  }

  Mammal.call(this, age, furColor);
  this.languageSpoken = languageSpoken;
};

Human.prototype = Object.create(Mammal.prototype);
Human.prototype.constructor = Human;
Human.prototype.speak = function speak() {};

Хорошо:

class Animal {
  constructor(age) {
    this.age = age;
  }

  move() { /* ... */ }
}

class Mammal extends Animal {
  constructor(age, furColor) {
    super(age);
    this.furColor = furColor;
  }

  liveBirth() { /* ... */ }
}

class Human extends Mammal {
  constructor(age, furColor, languageSpoken) {
    super(age, furColor);
    this.languageSpoken = languageSpoken;
  }

  speak() { /* ... */ }
}

Используйте метод цепочки


Этот паттерн очень полезнен в JavaScript. Его используют многие библиотеки, такие как JQuery и Lodash. Это делает ваш код выразительным и не многословным. Используя этот паттерн, вы увидите насколько ваш код станет чище. Просто возвращайте this, в конце ваших методов и вы сможете вызывать их по цепочке.

Плохо:

class Car {
  constructor() {
    this.make = 'Honda';
    this.model = 'Accord';
    this.color = 'white';
  }

  setMake(make) {
    this.make = make;
  }

  setModel(model) {
    this.model = model;
  }

  setColor(color) {
    this.color = color;
  }

  save() {
    console.log(this.make, this.model, this.color);
  }
}

const car = new Car();
car.setColor('pink');
car.setMake('Ford');
car.setModel('F-150');
car.save();

Хорошо:

class Car {
  constructor() {
    this.make = 'Honda';
    this.model = 'Accord';
    this.color = 'white';
  }

  setMake(make) {
    this.make = make;
    // возвращаем this для вызова по цепочке
    return this;
  }

  setModel(model) {
    this.model = model;
     // возвращаем this для вызова по цепочке
    return this;
  }

  setColor(color) {
    this.color = color;
     // возвращаем this для вызова по цепочке
    return this;
  }

  save() {
    console.log(this.make, this.model, this.color);
    // возвращаем this для вызова по цепочке
    return this;
  }
}

const car = new Car()
  .setColor('pink')
  .setMake('Ford')
  .setModel('F-150')
  .save();

Отдавайте предочтение композиции над наследованием


Как было сказано в книге Design Patterns от Банды четырех, следует отдавать предпочтение композиции над наследованием, где вы только можете. Есть много причин, чтобы использовать наследование и много причин использовать композицию. Если ваш мозг инстиктивно видит наследование, попробуйте представить решение вашей проблемы с помощью композиции.

Когда же использовать наследование? Это зависит от конкретной проблемы. Вот список случаев, когда наследование имеет больше смысла, чем композиция:

  1. Когда наследование представляет собой зависимость «есть», а не «имеет» (Human->Animal vs. User->UserDetails)
  2. Вы можете повторно использовать класс (Люди могут двигаться как и все животные).
  3. Вы хотите, сделав изменения родительского класса, изменить дочерние классы
    (Изменение расхода калорий всех животных, когда они двигаются).

Плохо:

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

  // ...
}

// У сотрудников есть налоговые данные. Налоговые данные не могут быть сотрудником.
class EmployeeTaxData extends Employee {
  constructor(ssn, salary) {
    super();
    this.ssn = ssn;
    this.salary = salary;
  }

  // ...
}

Хорошо:

class EmployeeTaxData {
  constructor(ssn, salary) {
    this.ssn = ssn;
    this.salary = salary;
  }

  // ...
}

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

  setTaxData(ssn, salary) {
    this.taxData = new EmployeeTaxData(ssn, salary);
  }
  // ...
}
Tags:
Hubs:
+23
Comments 7
Comments Comments 7

Articles