Паттерн

Если вы прежде работали с nodejs, то знаете, что пакеты  —  важнейшая часть данной платформы. Ежедневно и ежесекундно в реестре npm происходит обновление или публикация нового пакета. Большинство из них можно переиспользовать и расширять. Для этого достаточно лишь воспользоваться одним из множества предлагаемых способов. Но все пакеты объединяет одна общая черта: их можно рассматривать как шаблоны, подлежащие выполнению.  

В статье мы подробно разберем паттерн проектирования JavaScript “Шаблонный метод” (англ. Template): изучим суть этого подхода, рассмотрим с ним один сценарий и схематично отобразим его структуру. А под конец реализуем паттерн в коде, чтобы обеспечить вам практический опыт работы с ним в JavaScript.

Как работает паттерн “Шаблонный метод”

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

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

Аналогичным образом работает “Шаблонный метод”. 

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

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

Когда применяется “Шаблонный метод”

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

В этом случае выручает паттерн “Шаблонный метод”. Он инкапсулирует эти сходства и делегирует обязанности одних частей другим, которые их наследуют и реализуют.  

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

Как “Шаблонный метод” выглядит в коде

В этом разделе реализуем паттерн “Шаблонный метод”. 

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

Допустим, мы создаем функцию, которая запускает серию “преобразующих” функций для набора дат любого формата. Выглядит это следующим образом: 

const dates = [
357289200000,
989910000000,
'Tue Jan 18 2005 00:00:00 GMT-0800 (Pacific Standard Time)',
new Date(2001, 1, 03),
new Date(2000, 8, 21),
'1998-02-08T08:00:00.000Z',
new Date(1985, 1, 11),
'12/24/1985, 12:00:00 AM',
new Date(2020, 6, 26),
'Tue May 15 2001 00:00:00 GMT-0700 (Pacific Daylight Time)',
1652252400000,
'2005-01-18T08:00:00.000Z',
new Date(2022, 7, 14),
'1999-02-01T08:00:00.000Z',
1520668800000,
504259200000,
'4/28/1981, 12:00:00 AM',
'2015-08-08T07:00:00.000Z',
]

Эта функция будет реализовывать паттерн “Шаблонный метод”. Задача состоит в том, чтобы определить базовый каркас, содержащий вот такие “пустые” плейсхолдеры: 

  1. reducer;
  2. transformer;
  3. finalizer;
  4. sorter.

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

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

transformer  —  функция, которая преобразует и возвращает значение любого типа данных. 

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

sorter  —  функция, которая принимает один элемент в качестве первого аргумента и другой элемент  —  в качестве второго. Она аналогична реализации функции в нативном методе .Array.sort.

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

function createPipeline(...objs) {
let transformer
let reducer
let finalizer
let sorter

objs.forEach((o) => {
const id = Symbol.keyFor(_id_)
if (o[id] === _t) transformer = o
else if (o[id] === _r) reducer = o
else if (o[id] === _f) finalizer = o
else if (o[id] === _s) sorter = o
})

if (!transformer) transformer = { transform: identity }
if (!reducer) reducer = { reduce: identity }
if (!finalizer) finalizer = { finalize: identity }
if (!sorter) sorter = { sort: (item1, item2) => item1 - item2 }

return {
into(initialValue, ...items) {
return items
.reduce((acc, item) => {
return reducer.reduce(
acc,
finalizer.finalize(transformer.transform(item)),
)
}, initialValue)
.sort((item1, item2) => sorter.sort(item1, item2))
},
}
}

Эта простая функция является шаблонным методом, который вызывающие программы могут передавать в свои алгоритмы. Теперь они могут не передавать никакой реализации или передать одну или все 4 функции, задействованные в конвейере. 

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

Как правило, библиотеки, предлагающие пользователям ту или иную форму интерфейса для шаблонного метода, стараются максимально упростить с ним работу. 

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

Когда алгоритм требует строгого порядка выполнения внутри реализации паттерна “Шаблонный метод”, его обычно скрывают в реализации, как createStore в Redux.

Вернемся к предыдущему примеру и обратим внимание на эти строки: 

objs.forEach((o) => {
const id = Symbol.keyFor(_id_)
if (o[id] === _t) transformer = o
else if (o[id] === _r) reducer = o
else if (o[id] === _f) finalizer = o
else if (o[id] === _s) sorter = o
})

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

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

console.log(getResult(reducer, transformer, finalizer, sorter))
console.log(getResult(transformer, reducer, finalizer, sorter))
console.log(getResult(finalizer, sorter, transformer, reducer))
console.log(getResult(sorter, finalizer, transformer, reducer))

Во внутренней реализации при условии вызова функций в разном порядке это решение не сработает должным образом. Дело в том, что sorter должна быть заключительной операцией. Перед ней обязательно выполняется finalizer, которая предваряется transformer

Рассмотрим реализацию более высокого уровня: 

function createFactories() {
const _id_ = Symbol.for('__pipeline__')
const identity = (value) => value

const factory = (key) => {
return (fn) => {
const o = {
[key](...args) {
return fn?.(...args)
},
}

Object.defineProperty(o, Symbol.keyFor(_id_), {
configurable: false,
enumerable: false,
get() {
return key
},
})

return o
}
}

const _t = 'transform'
const _r = 'reduce'
const _f = 'finalize'
const _s = 'sort'

return {
createTransformer: factory(_t),
createReducer: factory(_r),
createFinalizer: factory(_f),
createSorter: factory(_s),
createPipeline(...objs) {
let transformer
let reducer
let finalizer
let sorter

objs.forEach((o) => {
const id = Symbol.keyFor(_id_)
if (o[id] === _t) transformer = o
else if (o[id] === _r) reducer = o
else if (o[id] === _f) finalizer = o
else if (o[id] === _s) sorter = o
})

if (!transformer) transformer = { transform: identity }
if (!reducer) reducer = { reduce: identity }
if (!finalizer) finalizer = { finalize: identity }
if (!sorter) sorter = { sort: (item1, item2) => item1 - item2 }

return {
into(initialValue, ...items) {
return items
.reduce((acc, item) => {
return reducer.reduce(
acc,
finalizer.finalize(transformer.transform(item)),
)
}, initialValue)
.sort((item1, item2) => sorter.sort(item1, item2))
},
}
},
}
}

Одной из ключевых частей внутренней реализации являются следующие строки: 

Object.defineProperty(o, Symbol.keyFor(_id_), {
configurable: false,
enumerable: false,
get() {
return key
},
})

Этот фрагмент кода придает “официальности” паттерну, поскольку он скрывает идентификатор от внешнего взгляда и только демонстрирует пользователю createTransformer, createReducer, createFinalizer, createSorter и createPipeline

Еще одной вспомогательной частью “Шаблонного метода” является объект поверх него: 

const o = {
[key](...args) {
return fn?.(...args)
},
}

Он позволяет структурировать текучий API, который читается как английский: 

into(initialValue, ...items) {
return items
.reduce((acc, item) => {
return reducer.reduce(
acc,
finalizer.finalize(transformer.transform(item)),
)
}, initialValue)
.sort((item1, item2) => sorter.sort(item1, item2))
}

Предположим, что пользователь намерен применить “Шаблонный метод” для набора дат: 

const dates = [
357289200000,
989910000000,
'Tue Jan 18 2005 00:00:00 GMT-0800 (Pacific Standard Time)',
new Date(2001, 1, 03),
new Date(2000, 8, 21),
'1998-02-08T08:00:00.000Z',
new Date(1985, 1, 11),
'12/24/1985, 12:00:00 AM',
new Date(2020, 6, 26),
'Tue May 15 2001 00:00:00 GMT-0700 (Pacific Daylight Time)',
1652252400000,
'2005-01-18T08:00:00.000Z',
new Date(2022, 7, 14),
'1999-02-01T08:00:00.000Z',
1520668800000,
504259200000,
'4/28/1981, 12:00:00 AM',
'2015-08-08T07:00:00.000Z',
]

Предстоит решить пару задач: 

  1. Даты представлены разными типами данных. Мы должны преобразовать их все в формат ISO
  2. Даты не отсортированы. Необходимо распределить их в порядке возрастания. 

Для решения этих задач воспользуемся кодом, который реализует паттерн “Шаблонный метод”. В результате мы получим отсортированный набор дат в формате ISO

const isDate = (v) => v instanceof Date
const toDate = (v) => (isDate(v) ? v : new Date(v))
const subtract = (v1, v2) => v1 - v2
const concat = (v1, v2) => v1.concat(v2)

const reducer = factory.createReducer(concat)
const transformer = factory.createTransformer(toDate)
const finalizer = factory.createFinalizer(toDate)
const sorter = factory.createSorter(subtract)

const getResult = (...fns) => {
const pipe = factory.createPipeline(...fns)
return pipe.into([], ...dates)
}

console.log(getResult(reducer, transformer, finalizer, sorter))
console.log(getResult(transformer, reducer, finalizer, sorter))
console.log(getResult(finalizer, sorter, transformer, reducer))
console.log(getResult(sorter, finalizer, transformer, reducer))

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

[
"1981-04-28T07:00:00.000Z",
"1981-04-28T07:00:00.000Z",
"1985-02-11T08:00:00.000Z",
"1985-12-24T08:00:00.000Z",
"1985-12-24T08:00:00.000Z",
"1998-02-08T08:00:00.000Z",
"1999-02-01T08:00:00.000Z",
"2000-09-21T07:00:00.000Z",
"2001-02-03T08:00:00.000Z",
"2001-05-15T07:00:00.000Z",
"2001-05-15T07:00:00.000Z",
"2005-01-18T08:00:00.000Z",
"2005-01-18T08:00:00.000Z",
"2015-08-08T07:00:00.000Z",
"2018-03-10T08:00:00.000Z",
"2020-07-26T07:00:00.000Z",
"2022-05-11T07:00:00.000Z",
"2022-08-14T07:00:00.000Z"
]

Схематичное представление паттерна “Шаблонный метод”: 

Цель достигнута! 

Дополнительный пример 

Я часто обращаюсь к библиотеке snabbdom для демонстрации концепций программирования, поскольку она небольшая, простая, эффективная и имеет в своем арсенале ряд полезных техник. Snabbdom  —  это frontend-библиотека JavaScript, позволяющая работать с виртуальным DOM для создания надежных веб-приложений. Она ориентирована на простоту, модульную архитектуру и производительность. 

Библиотека предлагает API модуля, где разработчики могут создавать собственные модули за счет предоставления пользователю паттерна “Шаблонный метод”. Он обеспечивает хуки, которые подключаются к жизненному циклу этапа “применения изменений” (англ. patching), в котором элементы DOM передаются в жизненные циклы. Такой подход представляет собой простой, но эффективный способ работы с виртуальным DOM, а также один из отличных примеров вариации паттерна “Шаблонный метод”. 

Паттерн “Шаблонный метод” в действии: 

const myModule = {
// Запуск процесса применения изменений
pre() {
//
},
// Создание узла DOM
create(_, vnode) {
//
},
// Обновление узла DOM
update(oldVNode, vnode: VNode) {
//
},
// Завершение процесса внесения изменений
post() {
//
},
// Удаление узла DOM непосредственно из DOM с помощью .remove()
remove(vnode, cb) {
//
},
// Удаление узла DOM любым методом, включая removeChild
destroy(vnode) {
//
},
}

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи jsmanifest: The Power of Template Design Pattern in JavaScript

Предыдущая статья10 ошибок, которые выдают новичков в Python
Следующая статьяСделайте свой первый вклад в открытый исходный код!