Декларативный JavaScript
Погружение в мир функционального программирования иногда может показаться ошеломляющим. Переход от понимания основных концепций к их фактическому применению в реальных сценариях может оказаться непростым. Если вы знакомы с некоторыми методами функционального программирования, но не знаете, когда и как их использовать, вы попали по адресу. В этой статье мы обсудим некоторые ключевые концепции и их практическое использование, а также исследуем, как декларативное программирование связано с функциональным программированием.
Представьте себе, что вы пишете код JavaScript без использования переменных, циклов или логических конструкций.
Это может показаться смелым, не так ли? Но как часто вам приходится писать подобный код для переключения видимости компонента в JSX
?
<div>
{hasComments ? <Comments/> : null}
</div>
Мы можем переписать его более декларативным образом:
<div>
<Comments visible={hasComments}/>
</div>
Как отмечается, эта версия носит более декларативный характер. Хотя мы по-прежнему используем условный оператор if
внутри, цель состоит в том, чтобы абстрагировать его от основного кода.
const Comments = ({visible}) => {
if (!visible) return null
...
}
Свойство visible
побуждает нас писать код в декларативной манере, даже несмотря на то, что сам компонент частично реализован в императивном стиле.
Давайте взглянем на логический оператор switch
, который не только тесно связан с if
, но и является любимым среди многих разработчиков:
const App = () => {
const role = useUserRole()
let Component
switch(role) {
case 'ADMIN': {
Component = AdminView
break
}
case 'EDITOR': {
Component = EditorView
break
}
case 'USER': {
Component = UserView
break
}
default: {
Component = GuestView
break
}
}
return (
<main>
<NavBar/>
<Component/>
</main>
)
}
Возможно, вы уже догадались, к чему я клоню, тем более что аналогичный подход реализован в React Router.
const App = () => (
<main>
<NavBar/>
<Switch test={useUserRole()}>
<Case when='ADMIN' use={AdminView}/>
<Case when='EDITOR' use={EditorView}/>
<Case when='USER' use={UserView}/>
<Otherwise use={GuestView}/>
</Switch>
</main>
)
Давайте рассмотрим этот код с точки зрения функционального программирования, где теги HTML/JSX
рассматриваются как функции, генерирующие выходные данные HTML, а атрибуты тегов действуют как входные параметры функции.
// it will return HTML: <main id="app">Hello World<main/>
main({id: 'app', children: 'Hello world!'})
// The second parameter can be used as a children attribute
main({id: 'app'}, 'Hello world!')
Опираясь на приведенную выше концепцию, давайте представим код JSX через наборы функций. Уместно отметить, что switch/case
— это зарезервированные ключевые слова JavaScript, поэтому мы добавим подчеркивание:
const app = () => (
main(
navbar(),
switch_({test: useUserRole()},
case_({when: 'ADMIN', use: AdminView}),
case_({when: 'EDITOR', use: EditorView}),
case_({when: 'USER', use: UserView}),
otherwise({use: GuestView}),
)
)
)
Как видите, декларативный код не всегда означает HTML или JSX. В JavaScript мы можем использовать функции для представления синтаксиса этих языков. Согласно соглашениям JS, функции должны представлять действия и обычно начинаются с глагола, например find
или setTitle
. Однако в этом случае функции также могут представлять сущность. Например, SQL-запрос можно записать так:
// a function composition
query(
select('name', 'email', 'country'),
from('users'),
where({age: less(21)}),
groupBy('country'),
)
// or as a chaining function like Promise
select('name', 'email', 'country')
.from('users')
.where({age: less(21)})
.groupBy('country')
// SELECT name, email, country FROM users WHERE age < 21 GROUP BY country
Таким образом, я хочу провести связь между декларативным и функциональным программированием. Я считаю, что функциональное программирование не стремится намеренно к декларативности; скорее, это результат интенсивного использования функций. Стоит отметить, что функциональное программирование — это не только принцип DRY, который предполагает выделение повторяющегося кода в отдельные функции. По своей сути функциональное программирование фокусируется на создании универсальных функций, которые легко компонуются и универсальны в применении.
Теперь мы можем еще раз взглянуть на переключатель/кейс и представить их как простую функцию:
const selectComponent= ({test, cases, defaultValue}) => {
const found = cases.find([value] => test === value)
return found?.at(1) || defaultValue
}
const App = () => {
const role = useUserRole()
const Component = selectComponent({
test: role,
cases: [
['ADMIN', AdminView],
['EDITOR', EditorView],
['USER', UserView],
],
defaultValue: GuestView,
})
return (
<main>
<NavBar/>
<Component/>
</main>
)
}
Текущая реализация не обеспечивает гибкости и совместимости с другими функциями. Чтобы повысить ее полезность, мы могли бы преобразовать функцию selectComponent
в функцию более высокого порядка и разбить ее на более детальные функции.
const select = (...fns) => value => fns.reduce(
(found, fn) => found || fn(value), null
)
const when = (test, wanted) => value => {
const matched = typeof test === 'function' ? test(value) : test === value
return matched && wanted
}
const selectComponent = select(
when('ADMIN', AdminView),
when('EDITOR', EditorView),
when('USER', UserView),
() => GuestView,
)
const Component = selectComponent('EDITOR') // -> EditorView
Если вы лишь немного знакомы с концепцией каррированных функций, это служит убедительной иллюстрацией их практического использования. Хотя каррированная функция напоминает стандартную функцию, ее выполнение можно отложить до тех пор, пока не будут переданы все ее параметры. Эта возможность не только облегчает композицию функций, но и делает код более декларативным и понятным.
У нас есть возможность создавать различные итерации функции When
, при этом гарантируя, что основная функция выбора останется неизменной и не потребует никаких изменений. Ниже приведен пример, демонстрирующий поддержку отложенной загрузки компонентов:
import {Suspense, lazy} from 'react'
const when = (test, path) => value => (
test === value && lazy(() => import(path))
)
const selectComponent = select(
when('ADMIN', './admin-view'),
when('EDITOR', './editor-view'),
when('USER', './user-view'),
() => GuestView,
)
const App = () => {
const role = useUserRole()
const Component = selectComponent(role)
return (
<main>
<NavBar/>
<Suspense fallback={<div>Loading...</div>}>
<Component/>
</Suspense>
</main>
)
}
Если мы внимательно посмотрим на первоначальную версию функции selectComponent
, то станет ясно, что расширить ее функциональность без внесения изменений невозможно. При таком подходе мы можем разбить код на более мелкие функции, каждая из которых решает конкретную задачу. Это улучшает качество тестового покрытия и сводит к минимуму будущие изменения, влияющие на несколько разделов кода. Если нам когда-нибудь понадобится расширить функциональность, мы можем просто добавить новую функцию и интегрировать ее с существующими функциями, а не переписывать каждый раз основную функцию.
const between = (min, max) => n => (
min >= n && n <= max
)
// range of values
const toGrade = select(
when(val => val > 90, 'A'),
when(between(80, 89), 'B'),
when(between(70, 79), 'C'),
when(between(50, 69), 'D'),
() => 'F',
)
const grade = toGrade(81) // -> 'B'
В следующем посте мы подробно рассмотрим эту технику на примере создания редюсеров Redux. Мы узнаем, как заменить подход try/catch
, и продолжим обсуждение функционального программирования.
const authReducer = createReducer(
initialState,
on('SIGN_IN', signIn),
on('SIGN_OUT', signOut),
on('SIGN_OUT', clearCookies),
)