Skip to content

Принципы и шаблоны проектирования программного обеспечения

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

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

В этой главе мы рассмотрим неполный и не исчерпывающий список принципов и шаблонов, которые часто встречаются при разработке приложений на Vue 3.

Принципы разработки ПО

  • Separation of concerns (Разделение ответственности)
  • Composition over inheritance (Композиция вместо наследования)
  • Single responsibility (Единая ответственность)
  • Encapsulation (Инкапсуляция)
  • KIC - keep it clean (держи это чистым)
  • DRY - don’t repeat yourself (не повторяться)
  • KISS - keep it simple stupid (держи это максимально простым)
  • Code for the next (пиши код для следующего программиста)

Шаблоны проектирования

  • Singleton (Синглтон)
  • Dependency injection (Инъекция зависимостей)
  • Observer (Обсервер)
  • Command (Команда)
  • Proxy (Прокси)
  • Decorator (Декоратор)
  • Façade (Фасад)
  • Callbacks (Обратные вызовы)
  • Promises (Промисы)

Понимание этих принципов и шаблонов поможет вам использовать фреймворк более эффективно и чаще всего не позволит вам "изобретать велосипед". Вместе с первой главой эта глава завершает фундаментальную часть данной книги и дает основу для изучения практической части и реализации примеров приложений в остальных частях книги.

Каковы принципы проектирования программного обеспечения?

При разработке программного обеспечения принципы проектирования - это концептуальные рекомендации высокого уровня, которые должны применяться ко всему процессу. Не в каждом проекте будут использоваться одни и те же принципы, и это не обязательные правила, которые должны соблюдаться. Они могут проявляться в проекте, начиная с архитектуры и заканчивая пользовательским интерфейсом (UI) и последним фрагментом кода. На практике некоторые из этих принципов могут также влиять на такие атрибуты программного обеспечения, как сопровождаемость (maintainability) и возможность повторного использования (re-usability).

Неполный список принципов проектирования

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

Separation of concerns (Разделение ответственности)

Это, пожалуй, самый важный принцип в программной инженерии. Разделение ответственности подразумевает, что система должна быть разделена на подсистемы элементов, сгруппированных по их функциям или услугам (ответственности). Например, мы можем рассматривать человеческий организм как систему, состоящую из множества подсистем (дыхательной, кровеносной, пищеварительной и т.д.). Они, в свою очередь, объединены различными органами, которые состоят из тканей, и так далее, вплоть до мельчайшей клетки. Следуя той же идее в программном обеспечении, приложение можно разделить на элементы, сгруппированные по проблемам, начиная с большой архитектуры и заканчивая последней функцией. Без такого разделения сложности на управляемые части создать функциональную систему было бы гораздо сложнее, а то и вовсе невозможно.

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

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

image

Рисунок 2.1 - Простой архитектурный вид веб-приложения, демонстрирующий разделение ответственности

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

Composition over inheritance (Композиция над наследованием)

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

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

В Vue 3 не существует расширения или наследования компонентов. Когда нам нужна общая или наследуемая функциональность, у нас есть хороший набор инструментов для замены парадигмы наследования. Как соблюсти этот принцип, используя композитные компоненты, мы увидим позже в главе 4, Композиция пользовательского интерфейса с помощью компонентов.

Single responsibility principle (Принцип единой ответственности)

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

Если вы работали в других дисциплинах и языках, то это естественно. Многоцелевые функции трудно поддерживать и они имеют тенденцию выходить из-под контроля, особенно в таких языках, как JavaScript, который является слабо типизированным и очень динамичным. Эта же концепция напрямую относится и к компонентам Vue 3. Каждый компонент должен выполнять одну конкретную операцию и не пытаться делать слишком много самостоятельно.

На практике, когда компонент выходит за определенные рамки, лучше разделить его на несколько компонентов или вынести поведение во внешние модули. Бывают случаи, когда в итоге получается компонент длиной в несколько тысяч строк, но, по моему опыту, это редко бывает необходимо, и этого можно и нужно избегать. Однако следует предупредить, что излишняя конкретика также может привести к ненужному усложнению.

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

  • Родительский компонент, который управляет логикой пользовательского интерфейса. Этот компонент решает, когда показывать/скрывать компоненты входа и регистрации.
  • Дочерний компонент, выполняющий функцию входа.
  • Дочерний компонент, выполняющий функцию регистрации.

Здесь представлено графическое представление данной конфигурации:

image

Рисунок 2.2 - Композиция интерфейса входа/регистрации с использованием нескольких компонентов.

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

Совет из Лучших практик

Наделите компоненты единой ответственностью и функциональностью. По возможности избегайте монолитных компонентов.

Encapsulation (Инкапсуляция)

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

Часто это делается в виде классов, модулей или библиотек. JavaScript не является исключением, и настоятельно рекомендуется следовать этому принципу. В Vue 3 эта концепция распространяется не только на компоненты, но и на стили CSS и HTML. Введение однофайловых компонентов - яркий пример того, как фреймворк продвигает этот принцип в действии и насколько он важен для современной разработки.

За исключением некоторых редких случаев мы должны рассматривать компоненты (UI) как "черные ящики", которые принимают входящие параметры и предоставляют исходящие данные. Другие компоненты не должны быть знакомы с их внутренним устройством, только с API. По мере создания примеров приложений, описанных в этой книге, вы увидите этот принцип в действии.

KIC - keep it clean

Этот принцип относится главным образом к тому, как вы пишете код. Здесь я должен подчеркнуть, что KIC непосредственно относится к двум категориям, которые сильно влияют на веб-приложения и приложения Vue 3:

  • Как вы форматируете свой код
  • Как вы упорядочиваете события и переменные

Первый пункт включает в себя использование кодовых соглашений, комментариев и отступов для организации кода и логической группировки функций. Например, если у вас есть методы, которые работают с операциями создания, чтения, обновления и удаления (CRUD), то лучше всего разместить их в коде рядом друг с другом, а не разбрасывать по всему исходному файлу.

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

Вторая часть этого принципа связана с обработкой памяти и ссылок. JavaScript имеет очень хороший сборщик мусора, функция которого заключается в отбрасывании неиспользуемых данных для освобождения памяти. Однако бывают случаи, когда алгоритм не может освободить ресурсы из-за наличия оставшихся ссылок. Если вы работали с другими языками, такими как C/C++, то эта проблема может показаться вам знакомой, поскольку вам необходимо вручную резервировать и освобождать память, когда она не используется.

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

Мы рассмотрим жизненный цикл компонента в главе 4, Композиция пользовательского интерфейса с помощью компонентов, а пока возьмем следующий пример в качестве хорошего применения этого принципа и сохраним его в качестве лучшей практики. В этом примере мы создадим компонуемый компонент для обнаружения изменения размера окна, поэтому в разделе script setup мы найдем что-то подобное этому:

  1. Регистрируем функцию на событие изменения размера объекта окна в состоянии монтирования.
  2. Снимаем регистрацию события перед размонтированием компонента.

Здесь приведен фрагмент кода:

vue
<script setup>
import { onBeforeUnmount, onMounted } from "vue";
onMounted(() => {
  window.addEventListener("resize", myFunction);
});
onBeforeUnmount(() => {
  window.removeEventListener("resize", myFunction);
});
function myFunction() {
  // Делаем что-то с событием здесь
}
</script>

Функции onMounted и onBeforeUnmount являются частью фреймворка Vue 3 и вызываются соответствующим событием жизненного цикла компонента. Здесь мы прикрепляем нашу функцию к событию resize, когда компонент монтируется в Document Object Model (DOM), и освобождаем ее непосредственно перед удалением. Важно помнить, что необходимо убирать за собой и keep it clean.

DRY – don’t repeat yourself (не повторяйся)

Этот принцип достаточно известен, почти превратился в клише. К сожалению, его легко забыть. Его авторство принадлежит Эндрю Ханту и Дэвиду Томасу, которые использовали его в книге Прагматичный программист. Чаще всего его понимают как не пиши одно и то же дважды, и это не так уж и далеко от истины, но он выходит за рамки этого понятия.

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

Например, большинство веб-приложений имеют асинхронную связь с сервером через API. В приложении может быть несколько элементов, которые будут использовать или должны использовать эту связь между удаленным компьютером и сервером. Если бы вы собирались написать весь код/логику взаимодействия с сервером в каждом компоненте, то в итоге мы бы получили не только дублирование кода, но и логики приложения. Такая система открыла бы двери для огромного количества негативных побочных эффектов, проблем с безопасностью, плохой работы пользователей и многого другого.

В соответствии с этим принципом более правильным подходом является абстрагирование всего коммуникационного кода, связанного с API сервера, в единый модуль или класс. На практике в JavaScript это можно даже делегировать веб-рабочему в отдельном потоке. Мы рассмотрим эту реализацию позже в главе 8, Многопоточность с Web Workers.

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

KISS - keep it simple and short (держи это простым и коротким)

Этот принцип не является исключительным для сферы разработки программного обеспечения. Он был введен в обиход военно-морскими силами США еще в 60-х годах (согласно Википедии, https://en.wikipedia.org/wiki/KISS_principle).

Идея основана на здравом смысле: лучше создавать простые, небольшие функциональные части, которые работают вместе, чем пытаться создать большую и сложную программу за один раз. Кроме того, алгоритмы должны быть реализованы наиболее простым и эффективным способом. В веб-разработке этот принцип очень важен.

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

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

Code for the next (Пиши код для следующего)

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

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

В этом случае стоит упомянуть о принципе бойскаута, который аналогичен, но применяется в группах. Он гласит, что если вы обнаружили трудночитаемый или "спагетти"-код, то рефакторите его, чтобы сделать чистым.

Совет из Лучших практик

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

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

Что такое шаблон проектирования программного обеспечения?

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

На практике применение шаблона дает большой простор для творчества. Этой теме посвящены целые книги, в которых приводится больше подробностей, чем позволяет объем данной книги. На следующих страницах мы рассмотрим наиболее часто встречающиеся, на мой взгляд, шаблоны, которые следует иметь в виду при работе с приложениями Vue 3.

Несмотря на то, что для целей изучения мы видим их по отдельности, в реальности часто реализация накладывает, смешивает и заключает в себе несколько шаблонов в одном фрагменте кода. Например, вы можете использовать синглтон для выполнения функций декоратора и прокси для упрощения или изменения взаимодействия между сервисами в вашем приложении (на самом деле мы будем делать это довольно часто, и полный код можно увидеть в Глава 8, Многопоточность с Web Workers).

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

Давайте рассмотрим список шаблонов, которые должны стать частью вашего инструментария для проектов на Vue 3.

Краткий справочный список шаблонов

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

Порождающие шаблоны

Они касаются подхода к созданию классов, объектов и структур данных:

  • Шаблон Singleton
  • Шаблон Dependency injection
  • Шаблон Factory

Поведенческие шаблоны

Они касаются взаимодействия между объектами, компонентами и другими элементами приложения:

  • Шаблон Observer
  • Шаблон Command

Структурные шаблоны

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

  • Шаблон Proxy
  • Шаблон Decorator
  • Шаблон Façade

Асинхронные шаблоны

Они работают с потоком данных и процессов с асинхронными запросами и событиями в однопоточных приложениях (широко используются в веб-приложениях):

  • Шаблон Callbacks
  • Шаблон Promises

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

После такого знакомства с шаблонами проектирования давайте рассмотрим их подробнее на примерах.

Шаблон Singleton

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

Когда его использовать

Вот краткое эмпирическое правило, позволяющее понять, когда следует применять этот шаблон:

  • Когда необходимо убедиться, что доступ к ресурсу осуществляется только через один шлюз, например, к глобальному состоянию приложения
  • Когда необходимо инкапсулировать или упростить поведение, или взаимодействие (используется в сочетании с другими шаблонами). Например, объект доступа к API.
  • Когда затраты на многочисленные инстанцирования являются большими. Например, создание web workers.

Реализация

Существует множество способов применения этого шаблона в JavaScript. В некоторых случаях реализация из других языков переносится в JavaScript, часто следуя примерам Java с использованием метода getInstance() для получения синглтона. Однако существуют более эффективные способы реализации этого шаблона в JavaScript. Рассмотрим их далее.

Метод 1

Самый простой способ - через модуль, экспортирующий обычный объектный литерал или JavaScript Object Notation (JSON), который является статическим объектом:

js
const my_singleton = {
  // Код реализации здесь...
};
export default my_singleton;

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

Метод 2

Этот метод создает класс, а затем, при первом инстанцировании, сохраняет ссылку на него для последующих обращений. Для того чтобы это работало, мы используем переменную (традиционно называемую _instance) из класса и сохраняем ссылку на экземпляр в конструкторе. В последующих вызовах мы проверяем, существует ли значение _instance, и если да, то возвращаем его. Вот код:

js
class myClass {
  constructor() {
    if (myClass._instance) {
      return myClass._instance;
    }

    myClass._instance = this;
    return this;
  }
}
export default new myClass();

Второй метод может быть более привычным для разработчиков других языков. Обратите внимание, что мы также экспортируем новый экземпляр класса, а не непосредственно сам класс. Таким образом, инвокеру не придется каждый раз вспоминать об инстанцировании класса, а код будет таким же, как и в методе 1. Этот вариант использования необходимо согласовать с вашей командой, чтобы избежать различных реализаций.

Затем инвокер может вызывать методы каждого из них напрямую (при условии, что синглтон имеет функцию/метод с названием myFunction()):

js
import my_method1_singleton from "./singleton-json";
import my_method2_singleton from "./singleton-class";
console.log("В обоих случаях инстанцирования нет!");
my_method1_singleton.myFunction();
my_method2_singleton.myFunction();

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

Шаблон Dependency injection (инъекция зависимостей)

Этот шаблон просто утверждает, что зависимости для класса или функции предоставляются в качестве входных данных, например, в виде параметров, свойств или других типов реализаций. Это простое утверждение открывает очень широкий спектр возможностей. Возьмем, к примеру, класс, работающий с IndexedDB API браузера через класс абстракции. Подробнее об API IndexedDB мы узнаем в главе 7, Управление потоками данных, а пока остановимся на зависимой части.

Предположим, что файл dbManager.js экспортирует объект, который обрабатывает операции с базой данных, а объект projects обрабатывает CRUD-операции для таблицы (или коллекции) projects. Без использования инъекции зависимостей получится что-то подобное этому:

./chapter 2/dependency-injection-1.js

js
import dbManager from "dbManager";
const projects = {
  getAllProjects() {
    return dbManager.getAll("projects");
  }
};
export default projects;

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

./chapter 2/dependency-injection-2.js

js
const projects = {
  getAllProjects(dbManager) {
    return dbManager.getAll("projects");
  }
};
export default projects;

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

Приведенный пример не является единственным способом инъекции зависимости. Можно, например, присвоить ее внутреннему свойству объекта. Например, если бы файл projects.js был реализован с использованием подхода, основанного на свойствах, то он выглядел бы следующим образом:

./chapter 2/dependency-injection-3.js

js
const projects = {
  dbManager,
  getAllProjects() {
    return this.dbManager.getAll("projects");
  }
};
export default projects;

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

./chapter 2/dependency-injection-4.js

js
import projects from "projects.js";
import dbManager from "dbManager.js";
projects.dbManager = dbManager;
projects.getAllProjects();

Но такой подход не рекомендуется. Вы можете видеть, что он нарушает принцип инкапсуляции, поскольку мы напрямую присваиваем свойство объекту. Кроме того, это не похоже на чистый код, даже если это корректный код.

Передача зависимостей по одной функции за раз также не рекомендуется. Итак, какой же подход лучше? Это зависит от реализации:

  • В классе удобно требовать наличие зависимостей в конструкторе (а если они не найдены, то выдавать ошибку)
  • В обычном JSON-объекте удобно предоставить функцию для явного задания зависимости и позволить объекту самому решать, как ее использовать

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

Приведем пример кода для первого пункта, упомянутого в предыдущем списке:

./chapter 2/dependency-injection-5.js

js
class Projects {
  constructor(dbManager = null) {
    if (!dbManager) {
      throw "Dependency missing";
    } else {
      this.dbManager = dbManager;
    }
  }
}

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

js
// Projects - это класс
import Projects from "projects.js";
import dbManager from "dbManager.js";
try {
  const projects = new Projects(dbManager);
} catch {
  // Обработчик ошибок здесь
}

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

js
import projects from "projects.js";
import dbManager from "dbManager.js";
projects.setDBManager(dbManager);

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

Совет из Лучших практик

Какой бы подход вы ни использовали для инъекции зависимостей, он должен оставаться неизменным во всей вашей кодовой базе.

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

В данном примере ответственность за реализацию зависимости просто перенесена на другой класс в иерархии. Но что если реализовать шаблон singleton для обработки всех или большинства зависимостей в нашем приложении? Таким образом, мы могли бы просто делегировать загрузку зависимостей одному классу или объекту в определенный момент жизненного цикла нашего приложения. Но как это реализовать? Нам понадобится следующее:

  • Метод для регистрации зависимости
  • Метод для получения зависимости по имени
  • Структура для хранения ссылок на каждую зависимость

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

./chapter 2/dependency-injection-6.js

js
const dependencyService = {                         // 1
  dependencies: {}, // 2
  provide(name, dependency) {                     // 3
    this.dependencies[name] = dependency;       // 4
    return this;                                // 5
  },
  inject(name) {                                  // 6
    return this.dependencies[name] ?? null;     // 7
  }
};
export default dependencyService;

После этой минимальной реализации рассмотрим каждую строку по строковому комментарию:

  1. Мы создаем простой объектный литерал JavaScript как синглтон.
  2. Объявляем пустой объект для использования в качестве словаря для хранения зависимостей по именам.
  3. Функция provide позволяет нам зарегистрировать зависимость по имени.
  4. Здесь мы просто используем имя в качестве имени поля и присваиваем зависимость, переданную в качестве аргумента (заметьте, мы не проверяем уже существующие имена и т.д.).
  5. Здесь мы возвращаем исходный объект, в основном для удобства, чтобы можно было выстроить цепочку вызовов.
  6. Функция inject принимает имя, зарегистрированное в provide функции.
  7. Мы возвращаем зависимость или null, если она не найдена.

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

./chapter 2/dependency-injection-7.js

js
import dependencyService from "./dependency-injection-6";
import myDependency1 from "myFile1";
import myDependency2 from "myFile2";
import dbManager from "dbManager";
dependencyService
     .provide("dependency1", myDependency1);
     .provide("dependency2", myDependency2);
     .provide("dbManager", dbManager);

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

js
import dependencyService from "./dependency-injection-6";
const dbManager = dependencyService.inject("dbManager");

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

Выбор имен для методов объекта dependencyService также не случаен: они совпадают с теми, что используются в Vue 3 внутри иерархии компонента. Это очень удобно для реализации некоторых шаблонов проектирования пользовательского интерфейса. Более подробно мы рассмотрим это в главе 4, Композиция пользовательского интерфейса с компонентами и Главе 7, Управление потоком данных.

Как видите, этот шаблон очень важен и реализован в Vue 3 с помощью функций provide/inject. Это отличное дополнение к нашему набору инструментов, но это еще не все. Давайте перейдем к следующему.

Шаблон factory (фабрика)

Шаблон фабрики предоставляет нам способ создания объектов без создания прямых зависимостей. Он работает через функцию, которая, основываясь на входных данных, возвращает инстанцированный объект. Использование такой реализации будет осуществляться через общий или стандартный интерфейс.

Например, рассмотрим два класса: Circle и Square. Оба реализуют один и тот же метод draw(), который рисует фигуру на холсте. Тогда функция фабрики будет работать примерно так:

js
function createShape(type) {
  switch (type) {
    case "circle": return new Circle();
    case "square": return new Square();
  }
}
const
  shape1 = createShape("circle");
const shape2 = createShape("square");
shape1.draw();
shape2.draw();

Этот метод довольно популярен, особенно в сочетании с другими шаблонами, что мы неоднократно увидим в этой книге.

Шаблон Observer (наблюдатель)

Шаблон "наблюдатель" очень полезен и является одной из основ реактивного фреймворка. Он определяет отношения между объектами, в которых один из них наблюдает (субъект) за изменениями или событиями, а другой (другие) уведомляется об этих изменениях (наблюдатели). Наблюдатели также называются слушателями. Вот графическое представление:

image

Рисунок 2.3 - Субъект выдает событие и оповещает наблюдателей.

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

При рассмотрении реализации этого шаблона важно обратить внимание на кардинальность публикации: 1 событие на 0..N наблюдателей (функций). Это означает, что субъект должен реализовать, помимо своего основного назначения, функциональность по публикации событий и отслеживанию подписчиков. Поскольку это нарушает один или два принципа проектирования (разделение забот, единая ответственность и т.д.), принято выносить эту функциональность в средний объект. В этом случае предыдущий проект изменяется, добавляя средний слой:

image

Рисунок 2.4 - Реализация наблюдателя со средним объектом диспетчера.

Этот средний объект, иногда называемый "диспетчером событий", включает в себя базовую функциональность по регистрации наблюдателей, получению событий от субъекта и их отправке наблюдателям. Он также выполняет некоторые действия по очистке, когда наблюдатель перестает наблюдать. Давайте рассмотрим эти понятия на примере простой и наивной реализации диспетчера событий на обычном JavaScript:

./chapter 2/Observer-1.js

js
class ObserverPattern {
  constructor() {
    this.events = {};                                       // 1
  }

  on(event_name, fn = () => {}) {                             // 2
    if (!this.events[event_name]) {
      this.events[event_name] = [];
    }

    this.events[event_name].push(fn);                       // 3
  }

  emit(event_name, data) {                                    // 4
    if (!this.events[event_name]) {
      return;
    }
    for (let i = 0, l = this.events[event_name].length; i < l; i++) {
      this.events[event_name][i](data);
    }
  }

  off(event_name, fn) {                                       // 5
    const i = this.events[event_name].indexOf(fn);

    if (i > -1) {
      this.events[event_name].splice(i, 1);
    }
  }
}

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

  1. В конструкторе мы объявляем объект для использования в качестве внутреннего словаря для событий.
  2. Метод on позволяет наблюдателям зарегистрировать свои функции. В этой строке, если событие не инициализировано, мы создаем пустой массив.
  3. В этой строке мы просто кладем функцию в массив (как я уже говорил, это наивная реализация, так как мы не проверяем, например, дубликаты).
  4. Метод emit позволяет субъекту опубликовать событие по его имени и передать ему некоторые данные. Здесь мы пробегаемся по массиву и выполняем каждую функцию, передавая в качестве параметра полученные данные.
  5. Метод off необходим для того, чтобы снять с регистрации функцию, если она не используется (см. принцип keep it clean, приведенный ранее в этой главе).

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

js
import dispatcher from "ObserverClass.js";   // a singleton
dispatcher.on("event_name", myFunction);

После этого субъект испускает событие и передает данные следующими строками:

js
import dispatcher from "ObserverClass.js";   // a singleton
dispatcher.emit("event_name", data);

И наконец, когда наблюдателю больше не нужно следить за объектом, необходимо очистить ссылку с помощью метода off:

js
dispatcher.off("имя_события", myFunction);

Существует большое количество крайних случаев и элементов управления, которые мы здесь не рассмотрели, и вместо того, чтобы изобретать велосипед, я предлагаю использовать готовые решения для этих случаев. В нашей книге мы будем использовать одно из них под названием mitt (https://www.npmjs.com/package/mitt). Оно имеет те же методы, что и в нашем примере. Как устанавливать упакованные зависимости, мы рассмотрим в главе 3, Установка рабочего проекта.

Шаблон Command (команда)

Этот шаблон очень полезен и прост для понимания и реализации. Вместо того чтобы сразу выполнять функцию, основная концепция заключается в создании объекта или структуры с информацией, необходимой для выполнения. Затем этот пакет данных (команда) делегируется другому объекту, который будет выполнять исполнение в соответствии с некоторой логикой его обработки. Например, команды могут быть сериализованы и поставлены в очередь, запланированы, отменены, сгруппированы и преобразованы. Вот графическое представление этого шаблона с необходимыми частями:

image

Рисунок 2.5 - Графическая реализация командного шаблона

На схеме показано, как клиенты передают свои команды инвокеру. Обычно инвокер реализует некую очередь или массив задач для обработки команд, а затем направляет их выполнение соответствующему приемнику. Если есть какие-либо данные для возврата, он также возвращает их соответствующему клиенту.

Также часто инвокер присоединяет к команде дополнительные данные для отслеживания клиентов и получателей, особенно в случае асинхронного выполнения. Это также обеспечивает единую точку "входа" в приемники и отделяет от них "клиентов".

Снова поработаем над наивной реализацией класса Invoker:

./chapter 2/Command-1.js

js
class CommandInvoker {
  addCommand(command_data) {                              // 1
    // ... реализация очереди здесь
  }

  runCommand(command_data) {                              // 2
    switch (command_data.action) { // 3
      case "eat":
        // ... вызываем получателя здесь
        break;
      case "code":
        // ... вызываем получателя здесь
        break;
      case "repeat":
        // ... вызываем получателя здесь
        break;
    }
  }
}

В предыдущем коде мы построчно реализовали пример того, каким должен быть инвокер:

  1. В Invoker раскрывается метод для добавления команд в объект. Это необходимо только в том случае, если команды будут каким-то образом ставиться в очередь, сериализовываться или обрабатываться в соответствии с некоторой логикой.
  2. Эта строка выполняет команду в соответствии с полем action, содержащимся в параметре command_data.
  3. На основании поля действие invoker направляет выполнение в соответствующий приемник.

Реализовать логику маршрутизации выполнения можно разными способами. Важно отметить, что данный шаблон может быть реализован в более широком масштабе в зависимости от контекста. Например, инвокер может находиться даже не в клиентском веб-приложении, а на сервере или на другой машине. Реализацию этого шаблона мы увидим в главе 8, Многопоточность с Web Workers, где мы используем этот шаблон для обработки задач между различными потоками и разгрузки главного потока (в котором работает Vue 3).

Шаблон Proxy

Определение этого шаблона вытекает непосредственно из его названия, поскольку слово "прокси" означает что-то или кого-то, кто действует от имени другого, как если бы это было одно и то же. Довольно многословно, но это поможет вам запомнить его.

Давайте рассмотрим пример, чтобы прояснить, как это работает. Нам понадобятся как минимум три сущности (компонента, объекта и т.д.):

  • Сущность клиент, которой необходимо получить доступ к API целевой сущности
  • А целевая сущность, которая предоставляет некий API
  • Объект прокси, который занимает промежуточное положение и предоставляет тот же API, что и целевой объект, одновременно перехватывая каждое сообщение от клиента и передавая его целевому объекту

Взаимосвязь между этими объектами можно представить графически следующим образом:

image

Рисунок 2.6 - Прокси-объект раскрывает тот же API, что и целевой.

Ключевым фактором данного шаблона является то, что прокси ведет себя и предоставляет тот же API, что и целевой объект, причем таким образом, что клиент не знает или не должен знать, что он имеет дело с прокси, а не с целевым объектом напрямую. Итак, зачем нам это нужно? Есть много веских причин, например, следующие:

  • Вам необходимо сохранить оригинальный немодифицированный API, но в то же время:
    • Необходимо обрабатывать входные или выходные данные для клиента
    • Необходимо перехватывать каждый вызов API для добавления внутренней функциональности, например, операций обслуживания, повышения производительности, проверка ошибок и валидация
    • Цель является дорогим ресурсом, поэтому прокси может реализовать логику для использования его операций (например, кэш)
  • Вам необходимо изменить клиента или цель, но вы не можете изменить API
  • Вам необходимо поддерживать обратную совместимость

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

На более низком уровне JavaScript даже содержит встроенный конструктор для проксирования объектов, который Vue 3 использует для создания реактивности.

В главе 1, The Vue 3 Framework, мы рассмотрели варианты реактивности с помощью ref(), но в новой версии Vue также появилась другая альтернатива для сложных структур, называемая reactive(). В первом случае используются методы pub/sub(шаблон наблюдателя!), а во втором - нативные обработчики прокси (этот шаблон!). Рассмотрим на примере наивной частичной реализации, как может работать эта нативная реализация.

В этом простом примере мы заставим объект с реактивными свойствами автоматически преобразовывать градусы Цельсия в градусы Фаренгейта и обратно с помощью Proxy объекта:

./chapter 2/proxy-1.js

js
const temperature = { celsius: 0, fahrenheit: 32 };                     // 1
const handler = { // 2
  set(target, key, value) {                                   // 3
    target[key] = value;                                    // 4
    switch (key) {
      case "celsius":
        target.fahrenheit = calculateFahrenheit(value); // 5
        break;
      case "fahrenheit":
        target.celsius = calculateCelsius(value);
        break;
    }
  },
  get(target, key) {
    return target[key]; // 6
  }
};
degrees = new Proxy(temperature, handler);                      // 7

// Вспомогательные функции
function calculateCelsius(fahrenheit) {
  return (fahrenheit - 32) / 1.8;
}
function calculateFahrenheit(celsius) {
  return (celsius * 1.8) + 32;
}
degrees.celsius = 25; // 8
console.log(degrees);
// Выводится в консоль:
// {celsius: 25, fahrenheit: 77}                                    //9

Давайте рассмотрим код построчно, чтобы увидеть, как это работает:

  1. В этой строке мы объявляем объект temperature, который будет нашей целью для проксирования. Мы инициализируем два его свойства одинаковым преобразованным значением.
  2. Объявляем объект handler, который будет нашим прокси для объекта temperature.
  3. Функция set в прокси-обработчике получает три аргумента: целевой объект, ключ, на который ссылаются, и значение, которое пытаются присвоить. Обратите внимание, что я говорю "попытка", поскольку операция была перехвачена прокси-сервером.
  4. В этой строке мы выполняем присваивание, как и предполагалось, свойству объекта. Здесь мы могли бы выполнить другие преобразования или логику, например, проверку или вызвать событие (снова шаблон Observer!).
  5. Обратите внимание, как мы используем переключатель для фильтрации интересующих нас имен свойств. Когда ключом является celsius, мы вычисляем и присваиваем значение в градусах Фаренгейта. Обратное происходит, когда мы получаем задание для градусов Фаренгейта. Вот здесь-то и проявляется реактивность.
  6. Для функции get, по крайней мере в этом примере, мы просто возвращаем запрошенное значение. В том виде, в котором это реализовано, это было бы то же самое, как если бы мы пропустили функцию getter. Однако именно здесь в качестве примера мы можем оперировать и преобразовывать возвращаемое значение, поскольку эта операция также перехватывается.
  7. И наконец, в строке 7 мы объявляем объект degrees в качестве прокси для temperature с обработчиком.
  8. В этой строке мы проверяем реактивность, присваивая члену объекта degrees значение в Celsius , как это обычно делается для любого другого объекта.
  9. Когда мы выводим объект degrees на консоль, мы замечаем, что свойство fahrenheit автоматически обновилось.

Это довольно ограниченный и простой пример того, как работает и применяется конструктор Proxy(), реализующий данный шаблон. Vue 3 имеет более сложный подход к реактивности и отслеживанию зависимостей, используя шаблоны proxy и observer. Тем не менее, это дает нам хорошее представление о том, какой подход происходит за кулисами, когда мы видим обновление HTML в реальном времени на наших глазах.

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

Шаблон Decorator (декоратор)

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

В нем есть те же подвижные части, что и в прокси, то есть клиент, цель и декоратор между ними, который реализует тот же интерфейс, что и цель (да, точно так же, как и в прокси). Однако если в шаблоне Proxy перехваченные вызовы API в основном работают с данными и внутренним обслуживанием, то декоратор расширяет функциональность исходного объекта, делая его более функциональным. Это и есть фактор, который их разделяет.

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

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

image

Рисунок 2.7 - Пример декоратора, дополняющего цель функцией протоколирования.

Здесь то, что сначала было простым прокси, теперь, в результате выполнения скромного вызова логирования, превратилось в декоратор. В коде нам достаточно добавить эту строку перед концом метода set() (при условии, что существует также функция с именем getTimeStamp()):

js
console.log(getTimeStamp());

Конечно, это простой пример, просто чтобы пояснить суть. В реальном мире декораторы очень полезны для добавления функциональности в приложение без необходимости переписывать логику или значительные части кода. Кроме того, декораторы могут быть штабелируемыми (stackable) или цепляемыми (chainable), что означает, что при необходимости можно создавать "декораторы для декораторов", каждый из которых будет представлять собой один шаг добавления функциональности, сохраняющий тот же API целевого объекта.

И вот так мы начинаем входить в границы шаблона middleware, но в этой книге мы его рассматривать не будем. В любом случае, идея этого шаблона заключается в создании слоев промежуточных функций с определенным API, каждая из которых выполняет одно действие, но с той разницей, что на любом шаге можно принять решение о прерывании операции, и тогда целевой объект может быть вызван, а может и нет. Но это уже другая история... Вернемся к декораторам.

Ранее в этой книге мы уже упоминали, что компоненты Vue 3 не имеют наследования, как обычные классы JavaScript, реализуемые путем расширения друг от друга. Вместо этого мы можем использовать шаблон декораторов в компонентах для добавления функциональности или изменения внешнего вида. Сейчас рассмотрим краткий пример, поскольку более подробно компоненты и проектирование пользовательского интерфейса будут рассмотрены в главе 4, Композиция пользовательского интерфейса с компонентами.

Рассмотрим простейший компонент, отображающий скромный тег h1 с заголовком, который получает на вход следующие данные:

./chapter 2/decorator-1.vue

vue
<script setup>
const $props = defineProps(["label"]);          // 1
</script>

<template>
      <h1>{{ $props.label }}</h1>                     //2
</template>

<style scoped></style>

В этом простом компоненте в строке //1 мы объявляем один входной элемент с именем label. Не стоит пока беспокоиться о синтаксисе, поскольку мы подробно рассмотрим это в главе 4, Композиция пользовательского интерфейса с компонентами. В строке //2 мы интерполируем значение непосредственно внутри тегов h1, как и ожидалось.

Таким образом, чтобы создать декоратор для этого компонента, нам необходимо применить следующие простые правила:

  • Он должен действовать от имени компонента (объекта)
  • Он должен использовать тот же API (входы, выходы, вызовы функций и т.д.)
  • Он должен дополнять функциональность или визуальное представление до, после или во время выполнения целевого API

В связи с этим мы можем создать компонент-декоратор, который перехватывает атрибут label, немного изменяет его, а также модифицирует визуальный вид целевого компонента:

./chapter 2/decorator-2.vue

vue
<script setup>
import HeaderH1 from "./decorator-1.vue";
const $props = defineProps(["label"]);               // 1
</script>

<template>
      <div style="color: purple !important;">
                  //2
             <HeaderH1 :title="`${$props.label}!!!`" />        //3
  </div>
</template>

В этом коде в строке //1 видно, что мы сохраняем тот же интерфейс, что и у целевого компонента (который мы импортировали в предыдущей строке), затем в строке //2 мы изменяем (дополняем) атрибут color , а в строке //3 мы также изменяем данные, передаваемые целевому компоненту, добавляя три восклицательных знака. Выполнив эти простые задачи, мы сохранили условия для построения шаблона декоратора, экстраполированного на компоненты Vue 3. Совсем неплохо.

Декораторы очень полезны, но есть еще один прокси-подобный шаблон, который также очень распространен и удобен: шаблон фасада.

Шаблон Façade (фасад)

К настоящему времени вы, возможно, уже видели прогрессивный шаблон в этих, ну, шаблонах. Мы начали с прокси, действующего от имени другого объекта или сущности, дополнили его декораторами, сохранив при этом тот же API, и вот теперь настал черед шаблона façade.

Его задача, помимо функций прокси и декоратора, состоит в том, чтобы упростить API и скрыть за ним большую сложность. Итак, фасад располагается между клиентом и объектом, но теперь объект сложен - это система или даже несколько подсистем. Этот шаблон также используется для изменения API объекта или для ограничения его воздействия на клиента. Взаимодействие можно представить следующим образом:

image

Рисунок 2.8 - Фасадный объект, упрощающий взаимодействие со сложным API или системой.

Как видите, основное назначение фасада - предложить более простой подход к сложному взаимодействию или API. Мы неоднократно будем использовать этот шаблон в наших примерах для упрощения нативных реализаций в браузере с помощью более удобных для разработчика подходов. Мы будем использовать библиотеки для инкапсуляции использования IndexedDB и создадим собственное упрощенное взаимодействие с web-workers в главе 8, Многопоточность с Web Workers.

Необходимо отметить, что вы уже видели этот шаблон в действии, поскольку он является одной из основополагающих концепций современных технологий. Скрытие сложности за простым интерфейсом (API) встречается повсеместно и является неотъемлемой частью веб-разработки. В конце концов, весь Интернет чрезвычайно сложен, в нем тысячи движущихся частей, а технология, с помощью которой создаются веб-страницы, близка к волшебству. Без этого шаблона мы бы до сих пор программировали с помощью нулей и единиц.

На практике вы будете добавлять уровни упрощения в свои собственные приложения, чтобы снизить сложность. Одним из способов сделать это является использование сторонних библиотек, предоставляющих упрощенный интерфейс. В следующих главах мы будем использовать некоторые из них, например, такие:

  • Axios: для обработки всех асинхронных JavaScript и XML (AJAX) взаимодействий с сервером
  • DexieDB: Для работы с API к IndexedDB (локальной базе данных браузера)
  • Mitt: Для создания конвейеров событий (мы упоминали об этом в шаблоне Observer)
  • Vue 3: Для создания удивительных пользовательских интерфейсов

В целом, для большинства нативных реализаций веб-технологий существуют библиотеки фасадов, которые хорошо протестированы. Разработчики очень хорошо умеют упрощать их и делиться кодом с другими, благодаря движению open source. Тем не менее, используя чужие модули, убедитесь, что они "безопасны". Не изобретайте велосипед и не повторяйтесь. Но теперь пора перейти к следующему шаблону в нашем списке.

Шаблон Callback (обратный вызов)

Шаблон обратного вызова прост для понимания. Он применяется в тех случаях, когда необходимо выполнить операцию после завершения синхронной или асинхронной операции. Для этого в вызов функции в качестве одного из параметров включается функция, которая должна быть выполнена по завершении операций. При этом необходимо различать следующие два типа потока кода:

  • Синхронные операции выполняются последовательно друг за другом. Это основной поток кода, сверху вниз.
  • Асинхронные операции выполняются вне обычного потока после их вызова. Их продолжительность неопределенна, равно как и успех или неудача.

Именно для асинхронных случаев особенно полезен шаблон callback. Вспомним, например, сетевой вызов. После вызова мы не знаем, сколько времени потребуется для получения ответа от сервера и будет ли он успешным, неудачным или приведет к ошибке. Если бы у нас не было асинхронных операций, наше приложение было бы заморожено, ожидая, пока произойдет разрешение. Это было бы не очень удобно для пользователя, хотя с вычислительной точки зрения это было бы правильно.

Одной из важных особенностей JavaScript является то, что, будучи однопоточными, асинхронные функции не блокируют основной поток, позволяя продолжить выполнение. Это важно, поскольку функции рендеринга браузера работают в одном потоке. Правда, это не бесплатно, так как они потребляют ресурсы, но зато не приводят к зависанию пользовательского интерфейса, по крайней мере, в теории. На практике это будет зависеть от ряда факторов, в значительной степени зависящих от среды браузера и аппаратного обеспечения. Тем не менее, давайте придерживаться теории.

Рассмотрим пример синхронной функции обратного вызова и превратим ее в асинхронную. Пример функции очень прост: мы будем вычислять значение Фибоначчи для заданного числа, используя шаблон обратного вызова. Но сначала напомним формулу вычисления:

js
F(0) = 0
F(1) = 1
F(n) = F(n-1)+F(n-2), причем n>=2

Здесь представлена функция JavaScript, которая применяет формулу и получает обратный вызов для возврата значения. Обратите внимание, что эта функция является синхронной:

./chapter 2/callback-1.js - Синхронный Фибоначчи

js
function FibonacciSync(n, callback) {
  if (n < 2) {
    callback(n);
  } else {
    let pre_1 = 0; let pre_2 = 1; let value;
    for (let i = 1; i < n; i++) {
      value = pre_1 + pre_2;
      pre_1 = pre_2;
      pre_2 = value;
    }
    callback(value);
  }
}

Заметьте, что вместо того, чтобы вернуть значение с помощью return, мы передаем его в качестве параметра функции callback. Когда полезно использовать такое? Рассмотрим эти простые примеры:

js
FibonacciSync(8, console.log);
// В консоль будет выведено 21

FibonacciSync(8, alert);
// Будет показано окошко с числом 21

Заменив функцию обратного вызова, мы можем существенно изменить представление результата. Однако у функции из примера есть фундаментальный недостаток, влияющий на пользовательский опыт. Будучи синхронной, время вычисления пропорционально переданному параметру: чем больше n, тем больше времени это займет. При достаточно большом числе мы легко можем "подвесить" браузер, но также, гораздо раньше, можем "заморозить" интерфейс. Проверить синхронность выполнения можно с помощью следующего фрагмента:

js
console.log("Before");
FibonacciSync(9, console.log);
console.log("After");

// Будет выведено:
/*
    Before
    34
    After
*/

Чтобы превратить эту простую функцию в асинхронную, можно просто обернуть логику внутри вызова в setImmediate. Это выведет выполнение из обычного рабочего процесса. Теперь новая функция выглядит следующим образом:

Совет

Функция setImmediate() помечена как deprecated в JavaScript Web API, пользоваться ею не стоит.

js
function FibonacciAsync(n, callback) {
  setImmediate(() => {
    if (n < 2) {
      callback(n);
    } else {
      let pre_1 = 0; let pre_2 = 1; let value;
      for (let i = 1; i < n; i++) {
        value = pre_1 + pre_2;
        pre_1 = pre_2;
        pre_2 = value;
      }
      callback(value);
    }
  });
}

Как видите, мы используем стрелочную функцию, чтобы завернуть код без каких-либо модификаций. Теперь посмотрите на разницу, когда мы выполним тот же сниппет, что и раньше, с этой функцией:

js
console.log("Before");
FibonacciAsync(9, console.log);
console.log("After");

// Будет выведено:
/*
    Before
    After
    34
*/

Как видно из вывода, сниппет выводит After раньше, чем 34. Это происходит потому, что наша асинхронная операция, как и ожидалось, была выведена из обычного потока. При вызове асинхронной функции выполнение не ожидает результата и продолжает выполнять следующую инструкцию. Это может иногда сбивать с толку, но является очень мощным и полезным.

Однако шаблон не предписывает, как обрабатывать ошибки или неудачные операции, как выстраивать цепочку или последовательно выполнять несколько вызовов. Существуют различные способы решения этих проблем, но они не являются частью шаблона. Существует другой способ обработки асинхронных операций, который обеспечивает большую гибкость и контроль: promises (обещания). Мы рассмотрим его далее, и в большинстве случаев вы можете использовать оба шаблона как взаимозаменяемые. Я говорю "в большинстве случаев", а не во всех!

Шаблон Promise (обещаниe)

Шаблон promise предназначен в первую очередь для работы с асинхронными операциями. Как и в случае с обратными вызовами, вызов обещанной функции выводит выполнение из обычного потока, но возвращает специальный объект Promise. Этот объект предоставляет простой API с тремя методами: then, catch и finally:

  • Метод then получает две функции обратного вызова, традиционно называемые resolve и reject. Они используются в асинхронном коде для возврата успешного значения (resolve) или неудачного или отрицательного значения (reject).
  • Метод catch получает параметр error и срабатывает, когда процесс выбрасывает ошибку и выполнение прерывается.
  • Метод finally выполняется в любом случае и получает функцию обратного вызова.

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

Посмотрим, как реализовать предыдущий пример с рядом Фибоначчи с помощью обещаний:

js
function FibonacciPromise(n) {
  return new Promise((resolve, reject) => {           // 1
    if (n < 0) {
      reject();                                 // 2
    } else {
      if (n < 2) {
        resolve(n); // 3
      } else {
        let pre_1 = 1; let pre_2 = 1; let value;
        for (let i = 2; i < n; i++) {
          value = pre_1 + pre_2;
          pre_1 = pre_2;
          pre_2 = value;
        }
        resolve(value);
      }
    }
  });
}

На первый взгляд легко заметить, что реализация немного изменилась. В строке //1 мы начинаем с того, что сразу возвращаем объект new Promise(). Этот конструктор получает функцию обратного вызова, которая, в свою очередь, получит два обратных вызова с именами resolve() и reject(). Их мы должны использовать в нашей логике для возврата значения в случае успеха (resolve) или неудачи (reject).

Также обратите внимание, что нам не нужно оборачивать наш код в функцию setImmediate, поскольку обещание по своей природе является асинхронным. Теперь мы проверяем наличие отрицательных чисел и в этом случае отклоняем операцию (строка //2). Другое изменение, которое мы делаем - это замена callback() на resolve() (//3).

Вызов теперь также изменяется:

js
console.log("Before");
FibonacciPromise(9).then(
  value => console.log(value),
  () => { console.log("Неопределено для отрицательных чисел!"); }
);
console.log("After");

// Будет выведено:
/*
    Before
    After
    34
*/

Как видите, мы выстраиваем цепочку к вызову, методу then, и передаем ему две функции для успеха и неудачи (resolve и reject в нашем коде). Как и раньше, мы получаем тот же самый результат. Теперь это может показаться более многословным (так оно и есть), но преимущества значительно перевешивают дополнительный набор текста. Обещания можно передавать по цепочке, то есть, при успешном выполнении операции можно вернуть новое обещание и таким образом получить последовательную операцию. Вот пример:

js
MyFunction()
    .then(() => { return new Promise(...)}, () => {...} )
    .then(() => { return new Promise(...)}, () => {...} )
    .then(() => { return new Promise(...)}, () => {...} )
    .then(() => { return new Promise(...)}, () => {...} )
    .catch(err => {...});

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

Тем не менее, получается довольно многословно. К счастью для нас, JavaScript предоставляет нам упрощенный синтаксис для работы с обещаниями, async/await, считая их способом кодирования более "традиционным" способом. Это относится только к вызову обещанных функций и может быть использовано только в функциях.

Для примера представим, что у нас есть три функции, возвращающие обещания, с именами MyFuncA, MyFuncB и MyFuncC (да, я знаю, не самые удачные имена). Каждая из них в случае успеха возвращает одно единственное значение (это условие). Затем они используются внутри MyProcessFunction с новым синтаксисом. Вот объявление:

js
async function myProcessFunction() { // 1
  try { // 2
    const
      a = await MyFuncA();                    // 3
    const b = await MyFuncB();
    const c = await MyFuncC();

    console.log(a + b + c); // 4
  } catch {
    console.log("Ошибка");
  }
}
// Вызов функции в обычном режиме
MyProcessFunction();                                // 5

Начнем с объявления нашей функции с ключевым словом async (строка //1). Это сигнализирует интерпретатору, что мы будем использовать синтаксис await внутри нашей функции. Одно из условий - обернуть код в блок try...catch (начиная со строки //2). Тогда мы сможем использовать ключевое слово await перед вызовом каждой обещанной функции, как в строке //3. К строке //4 мы уверены, что каждая переменная получила свое значение. Безусловно, такой подход более удобен для восприятия.

Исследуем эквивалентности для строки:

js
const a = await MyFuncA();

Это будет соответствовать синтаксису thenable (с использованием .then):

js
let a;
MyFuncA()
  .then((result) => { a = result; });

Однако проблема с последним синтаксисом заключается в том, что нам необходимо убедиться, что все переменные a, b и c имеют значения, прежде чем мы сможет выполнить строку //4, console.log(a + b + c), что позволяет выстроить цепочку вызовов следующим образом:

js
let a, b, c;
MyFuncA()
  .then((result) => { a = result; return MyFuncB(); })
  .then((result) => { b = result; return MyFuncC(); })
  .then((result) => { c = result; console.log(a + b + c); });

Этот формат сложнее для понимания и, конечно, более многословен. Для таких случаев предпочтительнее использовать синтаксис async/await.

Использование обещаний отлично подходит для обертывания длинных или неопределенных операций и интеграции с другими рассмотренными нами шаблонами (façade, decorator и т.д.). Это важный шаблон, который мы будем широко использовать в наших приложениях.

Подведение итогов

В этой главе мы рассмотрели принципы разработки программного обеспечения и важные шаблоны проектирования, привели примеры на обычном JavaScript и, когда это было уместно, намекнули на реализацию с помощью Vue 3. Эти шаблоны могут быть сложны для восприятия при первом знакомстве с ними, но мы будем использовать их и возвращаться к ним в остальных частях книги, так что эта глава будет работать как справочник. Это позволит вам лучше понять, когда и как применять те или иные шаблоны в зависимости от потребностей вашего приложения.

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

Вопросы для проверки

  • В чем разница между принципом и шаблоном проектирования?
  • Почему шаблон singleton так важен?
  • Как можно управлять зависимостями?
  • Какие шаблоны делают возможной реактивность?
  • Взаимосвязаны ли шаблоны между собой? Почему? Можете ли вы привести пример?
  • Что такое асинхронное программирование и почему оно так важно?
  • Можете ли вы придумать примеры использования promised функций?