class
и о так называемых фабричных функциях (Factory Function). Автор материала, перевод которого мы публикуем сегодня, исследует и сравнивает эти две концепции в поисках ответа на вопрос о плюсах и минусах каждой из них.
Обзор
Ключевое слово
class
появилось в ECMAScript 2015 (ES6), в результате теперь у нас есть два конкурирующих паттерна создания объектов. Для того чтобы их сравнить, я опишу один и тот же объект (TodoModel
), пользуясь синтаксисом классов, и применив фабричную функцию.Вот как выглядит описание
TodoModel
с использованием ключевого слова class
:class TodoModel {
constructor(){
this.todos = [];
this.lastChange = null;
}
addToPrivateList(){
console.log("addToPrivateList");
}
add() { console.log("add"); }
reload(){}
}
Вот — описание того же самого объекта, выполненное средствами фабричной функции:
function TodoModel(){
var todos = [];
var lastChange = null;
function addToPrivateList(){
console.log("addToPrivateList");
}
function add() { console.log("add"); }
function reload(){}
return Object.freeze({
add,
reload
});
}
Рассмотрим особенности этих двух подходов к созданию классов.
Инкапсуляция
Первая особенность, которую можно заметить, сравнивая классы и фабричные функции, заключается в том, что все члены, поля и методы объектов, создаваемых с помощью ключевого слова
class
, общедоступны.var todoModel = new TodoModel();
console.log(todoModel.todos); //[]
console.log(todoModel.lastChange) //null
todoModel.addToPrivateList(); //addToPrivateList
При использовании фабричных функций общедоступно только то, что мы сознательно открываем, всё остальное скрыто внутри полученного объекта.
var todoModel = TodoModel();
console.log(todoModel.todos); //undefined
console.log(todoModel.lastChange) //undefined
todoModel.addToPrivateList(); //taskModel.addToPrivateList
is not a function
Иммутабельность API
После того, как объект создан, я ожидаю, что его API не будет меняться, то есть, жду от него иммутабельности. Однако мы можем легко изменить реализацию общедоступных методов объектов, созданных с помощью ключевого слова
class
.todoModel.reload = function() { console.log("a new reload"); }
todoModel.reload(); //a new reload
Эту проблему можно решить, вызывая
Object.freeze(TodoModel.prototype)
после объявления класса, или используя декоратор для «заморозки» классов, когда он будет поддерживаться.С другой стороны, API объекта, созданного с помощью фабричной функции, иммутабельно. Обратите внимание на использование команды
Object.freeze()
для обработки возвращаемого объекта, который содержит лишь общедоступные методы нового объекта. Закрытые данные этого объекта могут быть модифицированы, но сделать это можно только посредством этих общедоступных методов.todoModel.reload = function() { console.log("a new reload"); }
todoModel.reload(); //reload
Ключевое слово this
Объекты, создаваемые с помощью ключевого слова
class
, подвержены давней проблеме потери контекста this
. Например, this
теряет контекст во вложенных функциях. Это не только усложняет процесс программирования, подобное поведение ещё и является постоянным источником ошибок.class TodoModel {
constructor(){
this.todos = [];
}
reload(){
setTimeout(function log() {
console.log(this.todos); //undefined
}, 0);
}
}
todoModel.reload(); //undefined
А вот как
this
теряет контекст при использовании соответствующего метода в событии DOM:$("#btn").click(todoModel.reload); //undefined
Объекты, созданные с помощью фабричных функций, от подобной проблемы не страдают, так как тут ключевое слово
this
не используется.function TodoModel(){
var todos = [];
function reload(){
setTimeout(function log() {
console.log(todos); //[]
}, 0);
}
}
todoModel.reload(); //[]
$("#btn").click(todoModel.reload); //[]
Ключевое слово this и стрелочные функции
Стрелочные функции частично решают проблемы, связанные с потерей контекста
this
при использовании классов, но, в то же время, они создают новую проблему. А именно, при использовании стрелочных функций в классах ключевое слово this
больше не теряет контекст во вложенных функциях. Однако this
теряет контекст при работе с событиями DOM.Я переработал класс
TodoModel
с использованием стрелочных функций. Стоит отметить, что в процессе рефакторинга, при замене обычных функций на стрелочные, мы теряем кое-что важное для читаемости кода: имена функций. Взгляните на следующий пример.//имя указывает на цель использования функции
setTimeout(function renderTodosForReview() {
/* code */
}, 0);
//код менее понятен при использовании стрелочной функции
setTimeout(() => {
/* code */
}, 0);
При использовании стрелочных функций мне приходится читать текст функции для того, чтобы понять, что именно она делает. Мне же хотелось бы прочесть имя функции и понять её суть, а не читать весь её код. Конечно, можно обеспечить хорошую читабельность кода и при использовании стрелочных функций. Например, можно завести привычку использовать стрелочные функции так:
var renderTodosForReview = () => {
/* code */
};
setTimeout(renderTodosForReview, 0);
Оператор new
При создании объектов на основе классов нужно использовать оператор
new
. А при создании объектов с помощью фабричных функций new
не требуется. Однако если использование new
улучшит читаемость кода, данный оператор можно использовать и с фабричными функциями, вреда от этого не будет.var todoModel= new TodoModel();
При использовании
new
с фабричной функцией функция просто вернёт созданный ей объект.Безопасность
Предположим, что приложение использует объект
User
для работы с механизмами авторизации. Я создал пару таких объектов, используя оба описываемых здесь подхода.Вот описание объекта
User
с использованием класса:class User {
constructor(){
this.authorized = false;
}
isAuthorized(){
return this.authorized;
}
}
const user = new User();
Вот как выглядит тот же объект, описанный средствами фабричной функции:
function User() {
var authorized = false;
function isAuthorized(){
return authorized;
}
return Object.freeze({
isAuthorized
});
}
const user = User();
Объекты, создаваемые с использованием ключевого слова
class
, уязвимы к атакам в том случае, если у злоумышленника имеется ссылка на объект. Так как все свойства всех объектов общедоступны, атакующий может использовать другие объекты для получения доступа к тому объекту, в котором он заинтересован.Например, получить соответствующие права можно прямо из консоли разработчика, если переменная
user
является глобальной. Для того чтобы в этом убедиться, откройте код примера и модифицируйте переменную user
из консоли.Этот пример подготовлен с помощью ресурса Plunker. Для того, чтобы получить доступ к глобальным переменным, измените контекст в закладке консоли с
top
на plunkerPreviewTarget(run.plnkr.co/)
.user.authorized = true; //доступ к закрытому свойству
user.isAuthorized = function() { return true; } //переопределение API
console.log(user.isAuthorized()); //true

Модификация объекта с помощью консоли разработчика
Объект, созданный с помощью фабричной функции, нельзя изменить извне.
Композиция и наследование
Классы поддерживают и наследование, и композицию объектов.
Я создал пример наследования, в котором класс
SpecialService
является наследником класса Service
.class Service {
log(){}
}
class SpecialService extends Service {
logSomething(){ console.log("logSomething"); }
}
var specialService = new SpecialService();
specialService.log();
specialService.logSomething();
При использовании фабричных функций наследование не поддерживается, тут можно пользоваться лишь композицией. Как вариант, можно использовать команду
Object.assign()
для копирования всех свойств из существующих объектов. Например, предположим, что нам надо повторно использовать все члены объекта Service
в объекте SpecialService
.function Service() {
function log(){}
return Object.freeze({
log
});
}
function SpecialService(args){
var standardService = args.standardService;
function logSomething(){
console.log("logSomething");
}
return Object.freeze(Object.assign({}, standardService, {
logSomething
}));
}
var specialService = SpecialService({
standardService : Service()
});
specialService.log();
specialService.logSomething();
Фабричные функции содействуют использованию композиции вместо наследования, что даёт разработчику более высокий уровень гибкости в плане проектирования приложений.
При использовании классов тоже можно предпочесть композицию наследованию, на самом деле, это всего лишь архитектурные решения, касающиеся повторного использования существующего поведения.
Память
Использование классов способствует экономии памяти, так как они реализованы на базе системы прототипов. Все методы создаются лишь один раз, в прототипе, ими пользуются все экземпляры класса.
Дополнительные затраты памяти, которая потребляется объектами, создаваемыми с помощью фабричных функций, заметны лишь при создании тысяч схожих объектов.
Вот страница, использованная для выяснения затрат памяти, характерных для использования фабричных функций. Вот результаты, полученные в Chrome для различного количества объектов с 10 и 20 методами.

Затраты памяти (в Chrome)
ООП-объекты и структуры данных
Прежде чем продолжать анализ затрат памяти, следует разграничить два вида объектов:
- ООП-объекты
- Объекты с данными (структуры данных).
Объекты предоставляют поведение и скрывают данные.
Структуры данных предоставляют данные, но не обладают сколько-нибудь значительным поведением.
Роберт Мартин, «Чистый код».
Взглянем на уже знакомый вам пример объекта
TodoModel
для того, чтобы разъяснить разницу между объектами и структурами данных.function TodoModel(){
var todos = [];
function add() { }
function reload(){ }
return Object.freeze({
add,
reload
});
}
Объект
TodoModel
ответственен за хранение списка объектов todo
и за управление ими. TodoModel
— это ООП-объект, тот самый, который предоставляет поведение и скрывает данные. В приложении будет лишь один его экземпляр, поэтому при его создании с использованием фабричной функции дополнительных затрат памяти не потребуется.Объекты, хранящиеся в массиве
todos
— это структуры данных. В программе может быть множество таких объектов, но это — обычные JavaScript-объекты. Мы не заинтересованы в том, чтобы делать их методы закрытыми. Скорее мы стремимся к тому, чтобы все их свойства и методы были бы общедоступными. В результате все эти объекты будут построены с использованием прототипной системы, благодаря чему нам удастся сэкономить память. Их можно создавать с помощью обычного объектного литерала или командой Object.create()
.Компоненты пользовательского интерфейса
В приложениях могут быть сотни или тысячи экземпляров компонентов пользовательского интерфейса. Это — та ситуация, в которой нужно найти компромисс между инкапсуляцией и экономией памяти.
Компоненты будут создаваться в соответствии с методами, принятыми в используемом фреймворке. Например, в Vue используются объектные литералы, в React — классы. Каждый член объекта-компонента будет общедоступным, но, благодаря использованию прототипной системы, применение таких объектов позволит экономить память.
Две противоположные парадигмы ООП
В более широком смысле, классы и фабричные функции демонстрируют битву двух противоположных парадигм объектно-ориентированного программирования.
ООП, основанное на классах, в применении к JavaScript, означает следующее:
- Все объекты в приложении описывают, используя синтаксис классов, применяя типы, задаваемые классами.
- Для написания программ ищут язык со статической типизацией, код на котором затем транспилируют в JavaScript.
- В ходе разработки используют интерфейсы.
- Применяют композицию и наследование.
- Функциональное программирование используют совсем мало, или почти не проявляют к нему интереса.
ООП без использования классов сводится к следующему:
- Типы, определяемые разработчиком, не используются. В этой парадигме нет места чему-то вроде
instanceof
. Все объекты создают с помощью объектных литералов, некоторые из них — с общедоступными методами (ООП-объекты), некоторые — с общедоступными свойствами (структуры данных). - В ходе разработки применяется динамическая типизация.
- Интерфейсы не используются. Разработчика интересует лишь то, имеет ли объект необходимое ему свойство. Такой объект можно создать с помощью фабричной функции.
- Применяется композиция, но не наследование. При необходимости все члены одного объекта копируют в другой, используя
Object.assign()
. - Используется функциональное программирование.
Итоги
Сильная сторона классов заключается в том, что они хорошо знакомы программистам, пришедшим в JS из языков, разработка на которых основана на классах. Классы в JS представляют собой «синтаксический сахар» для прототипной системы. Однако, проблемы с безопасностью и использование
this
, ведущее к постоянным ошибкам из-за потери контекста, ставят классы на второе место в сравнении с фабричными функциями. В порядке исключения к классам прибегают в тех случаях, когда они применяются в используемом фреймворке, например — в React.Фабричные функции — это не только инструмент для создания защищённых, инкапсулированных и гибких ООП-объектов. Этот подход к созданию классов, кроме того, открывает дорогу для новой, уникальной для JavaScript, парадигмы программирования.
Позволю себе в заключение этого материала процитировать Дугласа Крокфорда: «Я думаю, что ООП без классов — это подарок человечеству от JavaScript».
Уважаемые читатели! Что и почему вам ближе: классы или фабричные функции?

Комментарии (19)
RidgeA
28.03.2018 14:53Вот как выглядит описание TodoModel с использованием ключевого слова class:
Вот — описание того же самого объекта, выполненное средствами фабричной функции
не совсем корректно, т.к. методы, которые объявлены в ES6 классе будут свойствами прототипа
chelovekkakvse
28.03.2018 16:10Мне кажется тогда проще использовать прототипирование. Иначе какой-то дуализм выходит.
Честно говоря не совсем понимаю Крокфорда. Если в мире сложились стандарты и общие представления ООП для всех языков, то зачем в JS городить свои велосипеды и все усложнять? Основное преимуществ ООП, на мой дилетантский взгляд, легкость обслуживания приложения в дальнейшем. И если в JS ООП будет, как и везде, то жить станет легче.
zede
28.03.2018 16:26Статья с слишком провокационными заявлениями. Тут всеми силами выпячиваются достоинства фабричных методов и принижаются все их недостатки, в то время как классы всячески осуждаются и все их недостатки раздуваются. Самый яркий пример:
Фабричные функции содействуют использованию композиции вместо наследования, что даёт разработчику более высокий уровень гибкости в плане проектирования приложений.
, при том что это абсолютно такой же не менее популярный метод и в случае классов, однако «более гибкий», хоть и ограничивает один из подходов. Пункт про безопасность тоже особо весомым не нахожу, ибо если кому-то действительно сильно надо влезть в контекст замыкания, то он может воспользоваться грязным хаком через Function.prototype.toString(). И самое удивительное неужели человек склоняющий к функциональному программированию высказывается против стрелочной нотации которую повсеместно для этого используют. Статья была бы хороша как сравнение подходов, но в итоге в конце она превратилась в сплошное осуждение классов.Cyber0x346
28.03.2018 16:48Классы медленнее инициализируются(у меня получилось, что иногда даже медленнее чем DOM ready с большим количеством нод. возможно я где-то не так реализовал архитектуру, но проявляется во всех браузерах)… Единственный, пожалуй, весомый недостаток.
jMas
28.03.2018 19:00Все верно подмечено про стрелочные функции, мы теряем название функции в стеке ошибок, что серьезно усложняет понимание в каком месте отвалилось. Их нужно использовать с умом.
iShatokhin
29.03.2018 13:26Все верно подмечено про стрелочные функции, мы теряем название функции в стеке ошибок, что серьезно усложняет понимание в каком месте отвалилось.
Если присваивать стрелочную функцию переменной, то в стеке отобразится имя этой переменной.
(() => { throw new Error('Some error'); })(); // Uncaught Error: Some error // at <anonymous>:2:12 const funcName = () => { throw new Error('Some error'); }; funcName (); // Uncaught Error: Some error // at funcName (<anonymous>:2:11) (() => { throw new Error('Some unnaned func error'); }).name; // "" funcName.name; // "funcName"
justboris
29.03.2018 14:372018 год на дворе, надо пользоваться асинхронными функциями:
async function doSomething() { await new Promise(resolve => setTimeout(resolve)); throw new Error('test'); }
В них стектрейс сохраняется:
> Error: test at doSomething (repl:4:9) at <anonymous>
iShatokhin
29.03.2018 14:481. Зачем в синхронном коде асинхронные функции?
2. Функция doSomething у вас не стрелочная.justboris
29.03.2018 14:52Изначально в статье был наброс на стрелочные функции в
setTimeout
и других асинхронных коллбеках:
setTimeout(() => {}, 0);
Я показал, как этого избежать, правильно используя фичи языка.
Функция doSomething у вас не стрелочная.
Я придерживаюсь правила: все функции верхнего уровня в модуле — обычные, именованные, вложенные функции — стрелочные.
Aries_ua
28.03.2018 19:49Проблема с потерей контекста высосана из пальца. С появлением стрелочных функций это ушло в небытие. У нас даже у джунов не возникает такой ошибки.
Что касаемо скрытия и иммутабельности, то в чем проблема? Так часто кто-то на проекте делаем манки патч? Не делаются тесты и код ревью? За 10 лет девелопинга не было ни разу такой проблемы. Теоретически может быть… Но на практике — не было.
И да, наследование. Зачем отказаться от наследования в пользу функционального подхода? Надо уметь пользоваться арсеналом, который предоставляет язык.
Да, статья, если честно, просто однобока.
JSmitty
28.03.2018 20:43А можно от последнего подхода сделать еще один шаг и перестать связывать функции и данные в объекты, и перейти к функциональному подходу, если уж так хочется. Тогда this вообще исчезнет как понятие.
justboris
28.03.2018 21:19На прошлой неделе уже публиковался перевод: Элегантные паттерны современного JavaScript: Ice Factory.
В той статье была хорошо рассказана идея, было интересно. А здесь — сплошные набросы на классический подход.
Вопрос к переводчику: а в чем был смысл переводить вторую статью почти с таким же контентом?
friday
29.03.2018 09:42Проблема с доступностью свойств абсолютно надумана. Всё равно весь код обычно в модулях/функциях, и получить к нему доступ нельзя. А если разработчик использует глобальные переменные для хранения чего-то хоть сколько-нибудь важного, у него проблемы посерьёзнее, чем доступ к свойствам.
iShatokhin
29.03.2018 13:08Первая особенность, которую можно заметить, сравнивая классы и фабричные функции, заключается в том, что все члены, поля и методы объектов, создаваемых с помощью ключевого слова class, общедоступны.
Тем временем в TC39 добавили private поля.
github.com/tc39/proposal-class-fields/blob/master/PRIVATE_SYNTAX_FAQ.md
пример использования в Canary c флагом `Experimental JavaScript`torbasow
29.03.2018 16:56Например, this теряет контекст во вложенных функциях. Это не только усложняет процесс программирования, подобное поведение ещё и является постоянным источником ошибок.
Можно подумать, вложенные функции сами по себе не усложняют процесс программирования и не являются постоянным источником ошибок.
Выносим коллбэки в отдельные методы и байндим, и если есть проблемы, то точно не с этим.
Cyber0x346
Я выбрал классы с примесью prototype.
alex6636
Вот потом и разберись в коде. Один выберет классы с примесью, второй без примеси, третий фабрики так далее