Сила паттерна проектирования стратегии в JavaScript

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

React уже является фактическим доказательством, подтверждающим это, поскольку после него были изобретены удивительные инструменты. Существует также Electron, на котором основаны такие бурно развивающиеся сегодня технологии, как Visual Studio Code и Figma.

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

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

Какие объекты задействованы?

В паттерне Strategy всегда задействованы эти два объекта:

  1. Контекст
  2. Стратегия

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

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

Фактическая стратегия реализует логику выполнения для себя, которая будет использоваться при выполнении.

Сильные стороны

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

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

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

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

Вот диаграмма, изображающая этот поток:

Реализация

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

Мы будем использовать библиотеку запросов axios, родной https-модуль node и библиотеку node-fetch для реализации по одной стратегии.

Всего у нас будет 3 стратегии:

const axios = require('axios').default
const https = require('https')
const fetch = require('node-fetch')

function createFetcher() {
  const _identifer = Symbol('_createFetcher_')
  let fetchStrategy

  const isFetcher = (fn) => _identifer in fn

  function createFetch(fn) {
    const fetchFn = async function _fetch(url, args) {
      return fn(url, args)
    }
    fetchFn[_identifer] = true
    return fetchFn
  }

  return {
    get fetch() {
      return fetchStrategy
    },
    create(fn) {
      return createFetch(fn)
    },
    use(fetcher) {
      if (!isFetcher(fetcher)) {
        throw new Error(`The fetcher provided is invalid`)
      }
      fetchStrategy = fetcher
      return this
    },
  }
}

const fetcher = createFetcher()

const axiosFetcher = fetcher.create(async (url, args) => {
  try {
    return axios.get(url, args)
  } catch (error) {
    throw error
  }
})

const httpsFetcher = fetcher.create((url, args) => {
  return new Promise((resolve, reject) => {
    const req = https.get(url, args)
    req.addListener('response', resolve)
    req.addListener('error', reject)
  })
})

const nodeFetchFetcher = fetcher.create(async (url, args) => {
  try {
    return fetch(url, args)
  } catch (error) {
    throw error
  }
})

fetcher.use(axiosFetcher)
Вход в полноэкранный режим Выход из полноэкранного режима

Внутри нашей функции createFetcher мы создали эту строку: const _identifer = Symbol('_createFetcher_').

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

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

Когда клиент вызывает use, он передает axiosFetcher для использования в качестве текущей стратегии и затем привязывается как ссылка, пока клиент не заменит другую стратегию через use.

Теперь у нас есть три стратегии для получения данных:

const url = 'https://google.com'

fetcher.use(axiosFetcher)

fetcher
  .fetch(url, { headers: { 'Content-Type': 'text/html' } })
  .then((response) => {
    console.log('response using axios', response)
    return fetcher.use(httpsFetcher).fetch(url)
  })
  .then((response) => {
    console.log('response using node https', response)
    return fetcher.use(nodeFetchFetcher).fetch(url)
  })
  .then((response) => {
    console.log('response using node-fetch', response)
  })
  .catch((error) => {
    throw error instanceof Error ? error : new Error(String(error))
  })
Войти в полноэкранный режим Выход из полноэкранного режима

Ура! Теперь мы увидели, как это можно реализовать в коде. Но можем ли мы придумать ситуацию в реальном мире, когда нам это понадобится? На самом деле, можно придумать множество! Однако если вы впервые читаете об этом паттерне, то я понимаю, что может быть трудно придумать сценарий заранее, пока мы не увидим его на практике.

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

В следующем примере мы рассмотрим сценарий, в котором паттерн проектирования стратегии определенно необходим.

Работа с различными типами данных

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

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

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

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

Рассмотрим эти коллекции:

const nums = [2, -13, 0, 42, 1999, 200, 1, 32]
const letters = ['z', 'b', 'm', 'o', 'hello', 'zebra', 'c', '0']
const dates = [
  new Date(2001, 1, 14),
  new Date(2000, 1, 14),
  new Date(1985, 1, 14),
  new Date(2020, 1, 14),
  new Date(2022, 1, 14),
]
// Need to be sorted by height
const elements = [
  document.getElementById('submitBtn'),
  document.getElementById('submit-form'),
  ...document.querySelectorAll('li'),
]
Войти в полноэкранный режим Выход из полноэкранного режима

Мы можем создать класс стратегии Sort и класс контекста Sorter.

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

const sorterId = Symbol('_sorter_')

class Sort {
  constructor(name) {
    this[sorterId] = name
  }

  execute(...args) {
    return this.fn(...args)
  }

  use(fn) {
    this.fn = fn
    return this
  }
}

class Sorter {
  sort(...args) {
    return this.sorter.execute.call(this.sorter, ...args)
  }

  use(sorter) {
    if (!(sorterId in sorter)) {
      throw new Error(`Please use Sort as a sorter`)
    }
    this.sorter = sorter
    return this
  }
}

const sorter = new Sorter()
Вход в полноэкранный режим Выход из полноэкранного режима

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

Sorter ничего не знает о стратегиях. Он не знает, что существует сортировщик дат, сортировщик чисел и т.д. Он просто вызывает метод execute сортировщика.

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

const sorter = new Sorter()

const numberSorter = new Sort('number')
const letterSorter = new Sort('letter')
const dateSorter = new Sort('date')
const domElementSizeSorter = new Sort('dom-element-sizes')

numberSorter.use((item1, item2) => item1 - item2)
letterSorter.use((item1, item2) => item1.localeCompare(item2))
dateSorter.use((item1, item2) => item1.getTime() - item2.getTime())
domElementSizeSorter.use(
  (item1, item2) => item1.scrollHeight - item2.scrollHeight,
)
Вход в полноэкранный режим Выход из полноэкранного режима

С учетом сказанного, мы (клиент) должны обработать это соответствующим образом:

function sort(items) {
  const type = typeof items[0]
  sorter.use(
    type === 'number'
      ? numberSorter
      : type === 'string'
      ? letterSorter
      : items[0] instanceof Date
      ? dateSorter
      : items[0] && type === 'object' && 'tagName' in items[0]
      ? domElementSizeSorter
      : Array.prototype.sort.bind(Array),
  )
  return [...items].sort(sorter.sort.bind(sorter))
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть надежная функция из 15 строк, которая может сортировать 4 различных варианта коллекций!

console.log('Sorted numbers', sort(nums))
console.log('Sorted letters', sort(letters))
console.log('Sorted dates', sort(dates))
Вход в полноэкранный режим Выход из полноэкранного режима

И в этом заключается сила паттерна проектирования «Стратегия» в JavaScript.

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

Заключение

На этом мы заканчиваем этот пост! Надеюсь, он был вам полезен, и следите за новыми полезными советами в будущем!

Найдите меня на Medium

Оставьте комментарий

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