Часть 1

Эта статья будет разделена на несколько частей; введение в концепции функционального программирования, создание и тестирование «Redux», построение логики состояния, а также объединение всего этого и рендеринг в DOM. Первая часть в основном теоретическая.

Введение

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

Когда мы закончим сегодня (?), это будет результатом наших усилий:

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

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

— Я уже устал!

Если уже заскучали, вот исходный код: https://github.com/perjo927/todoapp. Вы также можете ссылаться на него во время остальной части процесса.

Цель

Цель

Цель состоит в том, чтобы лучше понять, как использовать концепции функционального программирования в JavaScript, применяя их к практическому примеру — реальному приложению. Мы достигаем этого путем реализации облегченной версии Redux, создания приложения Todo поверх него, его модульного тестирования и, наконец, добавления слоя пользовательского интерфейса, чтобы пользователь мог с ним взаимодействовать. Добавляя пользовательский интерфейс в качестве последнего шага, легче увидеть преимущества использования такого решения, как Redux, и подхода к разработке, основанного на тестировании.

Почему ФП

Функциональное программирование (также известное как «FP») интересно тем, что возникло в разделе математики, называемом лямбда-исчисление, где вычисления основаны на функциях. Была разработана концепция чистой функции, согласно которой функция должна зависеть только от своих аргументов и всегда будет выводить одно и то же значение при одних и тех же входных данных.

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

Это представление может показаться идеалистичным, но определенно возможно достичь хотя бы высокого уровня чистоты (или тестируемости/доказуемости) даже в веб-приложении, подобном тому, которое мы создаем здесь.

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

JavaScript не является чисто функциональным языком, как Lisp, Erlang, Haskell или F#, но он обладает некоторыми важными функциями, которые позволят нам заимствовать некоторые концепции функционального программирования и использовать их парадигматически.

Концепции функционального программирования

Первоклассные функции

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

Функции высшего порядка

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

Чистые функции

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

Пример:

const f = x => x+1;
const result = f(1);
console.log(result === f(1)); //logs true (referential transparency)

Запоминаемые функции

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

Частичное применение

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

Пример:

// Partial application
const getMultiplier = x => y => x * y;
const double = getMultiplier(2);
const result1 = double(2); // 4
// Without partial application
const multiply = (x,y) => x * y;
const result2 = multiply(2,2); // 4
console.log(result1 === result2) // logs true 

карри

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

Пример:

// Not curried
const f = (x,y) => x+y;
// Curried
const f = x => y => x+y;

Закрытие

Замыкание позволяет функции помнить свое лексическое окружение даже после ее выполнения. Как мы видели в приведенном выше примере, функции getMultipler было присвоено значение 2 ее параметру x, чтобы создать служебную функцию double. Благодаря замыканию значение x сохранялось постоянно, и его можно было повторно использовать для умножения аргумента y, переданного функции double.

Рекурсия

Рекурсивная функция возвращает окончательный результат, вызывая себя n количество раз, пока не будет достигнут базовый случай. JavaScript не оптимизирован для рекурсии, и мы не будем его здесь использовать.

Функциональная композиция

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

Он основан на следующем принципе: если у вас есть две функции: f и g, и g применяется к выходу f(x), составная функция h может заменить их, так что h(x) = f(g(x)) и поведение программы не изменится. .

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

h(x) = f(g(x))

Пример:

import { compose } from "random-library";
const convertEuroToGBP = x => x * 0.9;
const formatCurrencyGBP = x => `£${x}`;
const gbp = convertEuroToGBP(1000); // 900
const formattedCurrency = formatCurrencyGBP(gbp); 
console.log(formattedCurrency); // "£900"
// You can replace the above with below, like so:
const convertEuroAndFormatGBP = compose(
   formatCurrencyGBP, convertEuroToGBP
);
console.log(convertEuroAndFormatGBP(1000) === formattedCurrency); 
// logs true

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

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

const compose = (...functions) => initialArg =>
   functions.reduceRight(
      (accumulatedValue, func) =>
      func(accumulatedValue),
      initialArg
   );

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

Звучит сложно? Нам нужно только доказать, что это работает. Увидеть ниже:

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

неизменность

Неизменяемость означает, что после создания объекта его нельзя изменить.

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

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

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

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

Пример:

const a = [1,2];
const b = a;
a.pop();
b.pop();
console.log(a,b); // logs [],[]
// Versus
const a = [1,2];
const b = [...a]; // Spread syntax: creates a new array
a.pop();
b.pop();
console.log(a,b); // logs [1],[1]

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

Константы

Мы будем использовать const перед любым назначением переменных в нашем коде, чтобы обеспечить хорошее поведение. Хотя константа в JavaScript на самом деле не является изменяемой в строгом смысле этого слова, она, по крайней мере, предотвращает повторное переназначение переменной. Еще одним преимуществом является то, что вы получаете переменные с областью видимости блока, используя const.

Функторы

Функтор — это строго то, что подчиняется условиям, называемым законами функторов. Иногда его в общих чертах называют что-то, что вы можете отобразить, что не является на 100% точным, но в JavaScript вы можете рассматривать функцию .map() в прототипе массива как интерфейс, концептуально вдохновленный шаблоном функтора.

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

Сравните два разных стиля программирования:

// Square all numbers in the array (imperative paradigm)
const numbers = [1,2,3];
const squares = [];
for (let i=0; i < numbers.length; i++) {
   const square = numbers[i] * numbers[i];
   squares.push(square);
}
console.log(squares); // logs [1,4,9];
// Versus FP paradigm
const numbers = [1,2,3];
const square = x => x * x;
const squares = numbers.map(square);
console.log(squares); // logs [1,4,9];

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

const numbers = [1,2,3];
const inc = x => x + 1;
const double = x => x * 2;
const square = x => x * x;
const incDoubleSquares = numbers
   .map(inc)
   .map(double)
   .map(square);
console.log(incDoubleSquares); // logs [16, 36, 64];

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

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

Монады

Мы не будем здесь вдаваться в подробности, но вы, вероятно, однажды услышите слово монада, если вас интересует ФП, а это абстракция, которая, как и функтор, подчиняется определенным законам. Было заявлено, что монада — это то, что вы можете отобразить, и что функция .then() в Promise ведет себя аналогично, но это не совсем точно, и мы все равно не будем использовать промисы в приложении Todo.

Другие концепции

Состав объекта

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

Пример:

const nameObj = { name: "Per" };
const surnameObj = { surname: "Jonsson" };
const fullnameObj = { ...nameObj, ...surnameObj };
console.log(fullnameObj); // { name: "Per", surname: "Jonsson" }

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

Пример:

// Achieves the same outcome as above
const makePerson = (name, surname) => ({ ...name, ...surname});

Композиция объектов/примеси вместо наследования классов

Глядя на приведенные выше примеры, вы думали, что код был чистым и простым? Попытка добиться того же с синтаксисом конструктора класса или функции не так чиста и проста.

Поскольку классы в JavaScript в любом случае являются просто функциями и поскольку они ведут себя не так, как классы в C#, Java или C++, почему бы не пропустить их вообще?

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

Также примите во внимание тот факт, насколько жесткой и тесно связанной является иерархия классов. Изменение наверху сразу влияет на всех предков. В контексте работы с ПО в бизнесе с постоянно меняющимися требованиями, как только появляется новое требование, иерархия классов может моментально устаревать.

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

Redux и управление состоянием

Redux — это контейнер с предсказуемым состоянием для приложений JavaScript.

Изначально я заявил, что мы создадим «собственный Redux». Давайте просто проясним, что это будет наша собственная облегченная версия, вдохновленная API и концепциями Redux. Он не предназначен для производственного использования 🙂.

Так что же такое Redux, кроме того, что уже процитировано?

Он основан на трех принципах:

  • Единый источник достоверной информации. Глобальное состояние приложения хранится в объекте JavaScript (также известном как «хранилище»).
  • Состояние доступно только для чтения: изменения состояния инициируются диспетчерскими действиями (также объектами JavaScript).
  • Изменения вносятся с помощью чистых функций: редукторы  — «чистые функции» — «описывают, как будет выглядеть следующее состояние в зависимости от типа действия и полезной нагрузки».

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

Вот пример из документации:

import { createStore } from 'redux'
// Reducer function
function counter(state = 0, action) {
   switch (action.type) {
      case 'INCREMENT':   
         return state + 1
      case 'DECREMENT':
         return state - 1
      default:
         return state
   }
}
// Create a Redux store holding the state of your app.
// Its API is { subscribe, dispatch, getState }.
let store = createStore(counter)
// Subscribe to store updates
store.subscribe(() => console.log(store.getState()))
// Transform state by dispatching an action.
store.dispatch({ type: 'INCREMENT' }) // 1
store.dispatch({ type: 'INCREMENT' }) // 2
store.dispatch({ type: 'DECREMENT' }) // 1

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

Модульное тестирование

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

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

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

Часть 2

Пока у вас установлена ​​относительно новая версия Node.js, приведенные здесь примеры кода должны работать нормально.

Получение практичности

Следующие файлы кода должны быть всем, что вам нужно для начала работы. Все находится в корневой папке, кроме main.js, который находится в папке src. Все тесты должны находиться в папке test, файлы с именами *.test.js.

Просто возьмите package.json и запустите npm install. После установки npm start вызовет parcel-bundler для сборки кода и запуска сервера. Изначально пользовательского интерфейса не будет, поэтому мы будем использовать журналы консоли и модульные тесты, чтобы увидеть, что происходит. npm test выполнит юнит-тесты (используя Mocha), если они есть.

Создание создателя магазина

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

С чего начать? Вернемся к предыдущему примеру кода из Redux:

// Create a Redux store holding the state of your app.
// Its API is { subscribe, dispatch, getState }.
let store = createStore(counter)

Нам нужно предоставить функцию createStore, которая принимает редюсер (а также необязательное начальное состояние) в качестве аргумента и возвращает объект с функциями subscribe, dispatch и getState.

Обработка состояний

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

Потребитель API магазина получит следующее состояние:

const store = createStore(reducer);
const state = store.getState();

… следуя дизайну API Redux.

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

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

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

Пример:

const makeStateHandlers = (stateContainer) => ({
   getState() {
      const [lastState] = stateContainer.slice(-1);
      return lastState;
   },
});
// Getting and using the state handler
const stateContainer = [1];
const { getState } = makeStateHandlers(stateContainer);
console.log(getState()); // 1

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

Внутри мы также можем захотеть установить состояние. Давайте создадим и эту функцию.

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

Отправка

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

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

Вы бы создали диспетчер следующим образом:

const stateContainer = [];
const reducer = () => { /* Code here */ };
const onDispatch = () => { /* Code here */ };
const stateHandlers = { ...makeStateHandlers(stateContainer)};
const { dispatch } = makeDispatcher(stateHandlers, reducer, onDispatch);

И вы можете проверить это следующим образом:

Подписка

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

Пример:

// In its simplest form
store.subscribe(() => console.log(store.getState()));

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

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

Тесты:

Составление магазина

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

Это просто набор всего того, над чем мы ранее работали, протестировано и готово к использованию!

Полный набор тестов вы можете найти здесь: https://github.com/perjo927/todoapp/blob/master/test/store.test.js

Время праздновать

Теперь у нас есть полностью работающая (по крайней мере, для наших целей) домашняя версия Redux 🥂! Но чтобы начать его использовать, мы должны предоставить ему действия и редукторы, чтобы мы могли создать приложение Todo.

Это будет объяснено в следующей части, нажмите ниже, чтобы продолжить:

Части 3 и 4

Дальнейшее чтение

Для более глубокого изучения тем, затронутых в статье: