Компоненты
Использование компонентов подразумевает независимость их реализации: одни компоненты могут быть классами, другие — объектами, третьи — функциями и т. д.
Одни компоненты могут состоять из единственного JS-файла и просто экспортировать несколько переменных. Другие могут быть набором файлов в директории, где через главный файл index.js
мы получаем все, что нам нужно для использования компонента на странице. В этой директории могут быть LESS-файлы, JS-файлы, картинки и все, что нужно компоненту для работы. Webpack все это соберет до кучи.
Нужно стараться делать компоненты как можно проще.
Разметка для компонента
Отображение (HTML) по возможности нужно выносить в темплейты.
Методы компонента
Не нужно стремиться запихать в this
как можно больше всего. Возможно что-то лучше вынести в отдельный файл, что-то в функцию, что-то объявить непосредственно перед использованием в методе. Мы контролируем то, что наш компонент экспортирует во внешний мир, остальная реализация будет скрыта.
Важно то, что компонент не должен сам себя инициализировать. Так мы теряем переносимость. Инициализация компонента должна выполняться либо в точке входа, либо в другом компоненте, который его реквайрит (импортирует).
Метод компонента должен выполнять только одну задачу: инициализировать, обрабатывать событие, посылать запросы. Иначе метод возьмет на себя слишком много и мы потеряем в читабельности и переносимости. Поддерживать будет сложнее.
Например, есть такой метод renderRegions
:
class CitySelector() {
constructor(el) {
this.$container = $(el);
}
renderRegions(url) { // Плохо, много всего напихано
this.$container.on('click', '.js-btn-load', () => {
$.ajax({
url: url,
beforeSend() { $.fancybox.showLoading(); }
}).done((regions) => {
let $regionWrapper = $('#regions');
$regionWrapper.html(regionsTmpl({regions}))
}).error((err, jqXhr) => {
console.log(err);
}).always(() => {
$.fancybox.hideLoading();
});
});
}
}
Метод называется renderRegion
, однако здесь происходит много чего:
- навешивается обработчик события на клик;
- тут же в анонимной функции происходит обработка;
- шлется аяксовый запрос;
- обрабатываются данные, пришедшие с сервера;
- происходит обновление DOM.
Почему это плохо:
- Например, при добавлении метода
renderLocation
для отрисовки конкретного города нужно будет опять писать весь аякс целиком, опять обрабатывать ошибки и показывать/скрывать лоадер (опять писатьajax
,done
,error
,always
). Логично вынести аяксовые реквесты в отдельный метод. - Метод называется
renderRegions
. Это подразумевает рендеринг — вставку HTML в DOM. То есть смысл метода с таким названием — просто вставить в DOM кусок HTML. Значит в нашем случае метод получается крайне избыточным и не отвечающим своему названию. - Если мы захотим, чтобы то же самое происходило не только по клику на кнопку
.js-btn-load
, а и при загрузке страницы и при клике на какую-то другую кнопку, то у нас ничего не выйдет. Вся логика засунута в анонимную функцию. Нужно будет писать заново.
Как можно улучшить?
Навешивать обработчики событий логично где-то при инициализации, а в методы выносить непосредственно код обработчиков:
class CitySelector() {
constructor() { // или метод init(), если работаем с объектом
constructor(el) {
this.$container = $(el);
}
// Навешиваем обработчики при инициализации через делегирование
this.$container
.on('click', '.js-btn-load', this.loadRegions.bind(this))
.on('click', '.js-other-btn', this.renderOtherStuff.bind(this));
}
sendRequest(url) {
// Возвращаем из метода промис,
// который сможем обработать через метод .done
return $.ajax({
url: url,
beforeSend() {
$.fancybox.showLoading();
}
// обрабатываем ошибки и показ/скрытие лоадера в одном месте
}).error((err, jqXhr) => {
console.log(err);
}).always(() => {
$.fancybox.hideLoading();
});
}
// А вот тут мы уже загружаем регионы
loadRegions(url) {
this.sendRequest(url).done((regions) => { // this.sendRequest(url) вернет промис, а у него есть метод .done
const $regionWrapper = $('#regions');
$regionWrapper.html(regionsTmpl({regions}))
});
}
}
Теперь вместо одного метода с избыточным функционалом мы имеем два: один для загрузки и обработки данных и другой, для вставки данных в DOM. Обработчик события навешивается при инициализации компонента. Каждый метод делает что-то одно и это делает код более читабельным, понятным и менее ломким. Его проще поддерживать, проще искать баги.
Перфекционисты могут предложить пойти дальше и отвязать в loadRegions
запрос от рендеринга, но мы помним, что «лучшее — враг хорошего» и, пожалуй, на этом остановимся.
Говоря более формализовано, существует принцип разделения ответственности, который стоит учитывать в любых масштабах: методы, классы, компоненты. Один метод/класс/компонент должен стараться делать ровно столько, сколько от него требуется. Не стоит всеми правдами и неправдами добиваться этого на 100%, но стремиться к этому нужно.
Общение компонентов между собой
Часто бывает нужно связать два компонента. Например, при выборе региона подгружать с сервера товары, имеющиеся в наличии.
Вызывать метод одного компонента из другого — плохая практика. Так мы их жестко сцепим и не сможем использовать отдельно друг-от-друга без дополнительных манипуляций.
Так плохо:
<div id="citySelector">...</div>
<div id="productList">...</div>
<script>
class ProductList {
// ...
}
const productList = new ProductList('#productList');
class CitySelector {
constructor() {
// ...
this.$container.on('click', '.js-city', (ev) => {
// Плохо: обращаемся к экземпляру ProductList напрямую
productList.update($(this).data('region-id'));
});
}
}
</script>
Теперь CitySelector
не может обойтись без ProductList
. Ему нужен его экземпляр.
Однако, выбиралка городов нужна на всех страницах, а список товаров — только на категории. Нет productList
— получаем ошибку. Ну... мы можем написать if (typeof productList !=== undefined)
, но это совсем плохо. От выбиралки городов может много чего зависеть на сайте и мы запаримся писать условия.
Что делать? Использовать кастомные события. Вместо того, чтобы напрямую вызывать методы других компонентов, мы можем подписаться на события, которые эти компоненты генерируют. В события можно передавать данные.
jQuery позволяет генерировать события на любой коллекции:
const obj = {};
$(obj).triggerHandler('myCustomEvent', { name: 'Vasya' });
class MyComponent {
constructor() {
$(obj).on('myCustomEvent', (event, data) => {
console.log(data); // { name: 'Vasya'}
});
}
}
obj
тут — обычный объект. После оборачивания в $
у него появляются методы и свойства jQuery, которые позволяют работать с кастомными событиями.
При работе с компонентами мы можем использовать $(document)
: генерировать на нем события и навешивать обработчики. Он будет присутствовать на любой странице и будет доступен любым компонентам. Таким образом мы получим простую и удобную реализацию механизма Pub/Sub.
Общение выбиралки городов и списка товаров теперь можно реализовать так:
<div id="citySelector">...</div>
<div id="productList">...</div>
<script>
class ProductList {
// ...
constructor() {
$(document).on('citySelector:change', (event, data) => {
this.update(data.regionId);
})
}
}
class CitySelector {
constructor() {
// ...
this.$container.on('click', '.js-city', (ev) => {
$(document).triggerHandler('citySelector:change', {
regionId: $(ev.currentTarget).data('region-id');
})
});
}
}
</script>
Теперь каждый компонент сам по себе.
Хорошее, развернутое объяснение слабой связанности и принципа единственной ответственности.
Делегирование
При создании компонента нужно навешивать события на основной элемент и делегировать их на дочерние. Так мы можем безбоязненно менять любой HTML внутри компонента и не заморачиваться с переинициализацией:
class CitySelector {
constructor(elementId) {
this.$container = $('#' + elementId);
this.$container
.on('click', '.js-btn', this.handleBtnClick)
.on('click', '.js-region', this.handleRegionClick)
// ...
}
}
Можно использовать неймспейсы событий, чтобы, например, при дестрое компонента снять все обработчики:
class CitySelector {
constructor(elementId) {
this.$container = $('#' + elementId);
this.$container
.on('click.citySelector', '.js-btn', this.handleBtnClick)
.on('click.citySelector', '.js-region', this.handleRegionClick)
// ...
},
destroy() {
// снимаем все навешенные в этом конструкторе обработчики
// но оставляем те, которые могли навесить другие компоненты
this.$container.off('.citySelector');
}
}