Игра Яндекс Практикума
Игра Яндекс Практикума
Игра Яндекс Практикума

Декларативный JavaScript

На примере JavaScript объяснили, как пересекается декларативное программирование с функциональным, и как кодить на JS декларативно.

1К открытий8К показов
Декларативный JavaScript

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

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

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

Писать код на 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 или SQL. В JavaScript мы можем представить синтаксис этих языков с помощью функций. Если нас учили, что функции должны представлять собой действия и их названия должны начинаться с глагола, например: `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')

// Output: 'SELECT name, email, country FROM users WHERE age < 21 GROUP BY country'
		

Таким образом я хочу протянуть нить между декларативным и функциональным программированием.

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

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

Предлагаю еще раз взглянуть на конструкции `switch/case` и представить их в виде обычной функции:

			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
}

// scalar value
const selectComponent = select(
  when('ADMIN', AdminView),
  when('EDITOR', EditorView),
  when('USER', UserView),
  () => GuestView,
)

const Component = selectComponent('EDITOR') // -> EditorView
		

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

Для ясности, предлагаю развернуть функцию `selectComponent`, после вызова функций `when` мы получим следующий код:

			const selectComponent = input => [
  value => 'ADMIN' === value ? AdminView : false,
  value => 'EDITOR' === value ? EditorView : false,
  value => 'USER' === value ? UserView : false,
  () => GuestView,
].reduce(
  (found, fn) => found || fn(input), null
)

const Component = selectComponent('ADMIN') // -> AdminView
		

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

			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 > 88, 'A'),
  when(between(76, 88), 'B'),
  when(between(66, 87), 'C'),
  when(between(55, 65), 'D'),
  () => 'F',
)

const grade = toGrade(78) // -> 'B'
		

В следующей статье мы подробно рассмотрим данную технику на примере создания редьюсеров Redux. Узнаем, как заменить `try/catch` и продолжим обсуждение функционального программирования.

			const authReducer = createReducer(
  initialState,
  on('SIGN_IN', signIn),
  on('SIGN_OUT', signOut),
  on('SIGN_OUT', clearCookies),
)
		
Следите за новыми постами
Следите за новыми постами по любимым темам
1К открытий8К показов