Если вы знаете React, вы уже в значительной степени знакомы с Effect. Давайте рассмотрим, как ментальная модель Effect соответствует концепции, знакомой вам из React.
История
Когда я начал программировать примерно 20 лет назад, мир был совершенно другим. Всемирная паутина только начинала набирать обороты, возможности веб-платформы были очень ограничены, мы находились на заре эпохи Ajax, и большинство наших веб-страниц фактически представляли собой документы, отрисовываемые с сервера, с небольшими элементами интерактивности.
В значительной степени это был более простой мир — TypeScript не существовал, jQuery не было, браузеры делали всё, что им вздумается, а Java апплеты казались отличной идеей!
Если мы переместимся в наше время, можно легко заметить, что многое изменилось — веб-платформа предоставляет невероятные возможности, и большая часть программ, с которыми мы привыкли взаимодействовать, полностью построена на веб-технологиях.
Было бы возможно построить то, что у нас есть сегодня, используя технологии, которыми мы пользовались более 20 лет назад? Конечно, но это было бы не оптимально. С ростом сложности нам нужны более надёжные решения. Мы не смогли бы так легко создавать такие мощные пользовательские интерфейсы, рассыпая по коду прямые вызовы JS для манипуляции DOM, без безопасности типов и без прочной модели, гарантирующей корректность.
Многое из того, что мы делаем сегодня, возможно благодаря идеям, выдвинутым такими фреймворками, как Angular и React. Здесь я хочу исследовать, почему React доминировал на рынке в течение десятилетия и почему он до сих пор остаётся предпочтительным выбором для многих.
То, что мы будем рассматривать, одинаково применимо и к другим фреймворкам; на самом деле, эти идеи не специфичны для React, а гораздо более универсальны.
Сила React
Начнём с вопроса: «Почему React настолько мощный?». Когда мы создаём пользовательские интерфейсы (UI) в React, мы мыслим в терминах маленьких компонентов, которые можно комбинировать вместе. Такая ментальная модель позволяет нам справляться с самой сложной частью — мы создаём компоненты, которые инкапсулируют сложность, а затем объединяем их для построения мощных UI, которые не дают сбоев и достаточно просты в обслуживании.
Но что такое компонент? Возможно, вы уже встречались с кодом, который выглядит примерно так:
const App = () => { return <div>Hello World</div> }
Если убрать JSX, указанный выше код становится:
const App = () => { return React.createElement("div", { children: "Hello World" }) }
Таким образом, можно сказать, что компонент — это функция, которая возвращает React-элементы, или, что более правильно, компонент является описанием или чертежом пользовательского интерфейса.
Лишь когда мы монтируем компонент в конкретный DOM-элемент (в нашем примере ниже это элемент с id «root»), наш код выполняется, и полученное описание порождает побочные эффекты, которые в конечном итоге создают итоговый UI.
import { StrictMode } from "react" import { createRoot } from "react-dom/client" import App from "./App.tsx" createRoot(document.getElementById("root")!).render( <StrictMode> <App /> </StrictMode> )
Давайте проверим, что мы только что объяснили:
const MyComponent = () => { console.log("MyComponent Invoked") return <div>MyComponent</div> } const App = () => { <MyComponent /> return <div>Hello World</div> }
Если мы запустим этот код, который преобразуется в:
const MyComponent = () => { console.log("MyComponent Invoked") return React.createElement("div", { children: "MyComponent" }) } const App = () => { React.createElement(MyComponent) return React.createElement("div", { children: "Hello World" }) }
мы не увидим сообщений "MyComponent Invoked"
в консоли браузера.
Это происходит потому, что компонент был создан, но не отрендерен, так как он не является частью возвращённого описания UI.
Это доказывает, что простое создание компонента не выполняет каких-либо побочных эффектов — операция чистая, даже если сам компонент содержит побочные эффекты.
Изменив код на:
const MyComponent = () => { console.log("MyComponent Invoked") return <div>MyComponent</div> } const App = () => { return <MyComponent /> }
в консоли будет выведено сообщение "MyComponent Invoked"
, что означает, что выполняются побочные эффекты.
Программирование с чертежами (blueprints)
Ключевая идея React может быть кратко сформулирована так: «Моделируйте пользовательский интерфейс с помощью композиционных чертежей, которые могут быть отрисованы в DOM». Это упрощённое описание выбранной ментальной модели — конечно, детали гораздо сложнее, но при этом они скрыты от пользователя. Именно эта идея делает React гибким, простым в использовании и лёгким в сопровождении. Вы можете в любой момент разбить компоненты на более мелкие, произвести рефакторинг кода, и быть уверенными, что работавший ранее интерфейс продолжит работать.
Давайте взглянем на некоторые суперспособности, которые React получает благодаря этой модели. Прежде всего, компонент может быть отрисован несколько раз:
const MyComponent = (props: { message: string }) => { return <div>MyComponent: {props.message}</div> } const App = () => { return ( <div> <MyComponent message="Foo" /> <MyComponent message="Bar" /> <MyComponent message="Baz" /> </div> ) }
Этот пример несколько искусственный, но если ваш компонент выполняет что-то более интересное (например, моделирует кнопку), это может оказаться весьма мощным. Вы можете повторно использовать компонент Button
в разных местах, не переписывая его логику.
React-компонент также может выйти из строя и выбросить ошибку, и React предоставляет механизмы, позволяющие восстановиться после таких ошибок в родительских компонентах. Как только ошибка будет поймана в родительском компоненте, могут быть выполнены альтернативные действия, такие как отрисовка альтернативного интерфейса.
export declare namespace ErrorBoundary { interface Props { fallback: React.ReactNode children: React.ReactNode } } export class ErrorBoundary extends React.Component<ErrorBoundary.Props> { state: { hasError: boolean } constructor(props: React.PropsWithChildren<ErrorBoundary.Props>) { super(props) this.state = { hasError: false } } static getDerivedStateFromError() { return { hasError: true } } render() { if (this.state.hasError) { return this.props.fallback } return this.props.children } } const MyComponent = () => { throw new Error("Something went deeply wrong") return <div>MyComponent</div> } const App = () => { return ( <ErrorBoundary fallback={<div>Fallback Component!!!</div>}> <MyComponent /> </ErrorBoundary> ) }
Хотя предоставленный API для отлова ошибок в компонентах может показаться не очень удобным, на самом деле в компонентах React редко выбрасывают ошибки. Единственный реальный случай, когда в компоненте выбрасывают ошибку — это когда выбрасывают Promise
, который затем может быть await-нут ближайшей границей Suspense
, что позволяет компонентам выполнять асинхронную работу.
Давайте посмотрим:
let resolved = false const promiseToAwait = new Promise((resolve) => { setTimeout(() => { resolved = true resolve(resolved) }, 1000) }) const MyComponent = () => { if (!resolved) { throw promiseToAwait } return <div>MyComponent</div> } const App = () => { return ( <Suspense fallback={<div>Waiting...</div>}> <MyComponent /> </Suspense> ) }
Этот API довольно низкоуровневый, но существуют библиотеки, которые используют его внутренне для обеспечения таких функций, как плавное получение данных (обязательно отдаём должное React Query) и потоковая передача данных с серверного рендеринга с использованием серверных компонентов (новая модная тема).
Кроме того, поскольку компоненты React являются описанием интерфейса для отрисовки, компонент React может получать контекстные данные, предоставляемые родительскими компонентами. Давайте посмотрим:
const ContextualData = React.createContext(0) const MyComponent = () => { const context = React.useContext(ContextualData) return <div>MyComponent: {context}</div> } const App = () => { return ( <ContextualData.Provider value={100}> <MyComponent /> </ContextualData.Provider> ) }
В приведённом выше коде мы определили некоторую контекстную переменную — число, и предоставили её на верхнем уровне компонента App
. Таким образом, когда React отрисовывает MyComponent
, компонент получает свежие данные, предоставленные сверху.
Почему Effect
Вы можете спросить: «Почему мы так много говорим о React? Как это связано с Effect?» Так же, как React был и остаётся важным для разработки мощных пользовательских интерфейсов, Effect имеет равное значение для написания кода общего назначения. За последние два десятилетия JS и TS значительно эволюционировали, и благодаря идеям, предложенным Node.js, сегодня мы разрабатываем full stack приложения на основе того, что некоторые считали игрушечным языком.
По мере того, как растёт сложность наших программ на JS/TS, мы вновь сталкиваемся с ситуацией, когда требования, которые мы предъявляем к платформе, превосходят возможности, предоставляемые языком. Точно так же, как построить сложный пользовательский интерфейс на базе jQuery оказалось бы довольно непростой задачей, разработка приложений производственного уровня на чистом JS/TS становится всё более болезненной.
Код приложений производственного уровня имеет такие требования, как:
-
тестируемость
-
корректное прерывание
-
управление ошибками
-
логирование
-
телеметрия
-
метрики
-
гибкость
-
…и многое другое.
На протяжении многих лет мы наблюдали добавление множества функций в веб-платформу, таких как AbortController, OpenTelemetry и прочее. Хотя, казалось бы, все эти решения хорошо работают по отдельности, они не справляются с задачей композиции. Написание кода на JS/TS, который удовлетворяет всем требованиям программного обеспечения производственного уровня, превращается в настоящий кошмар из-за зависимостей NPM, вложенных операторов try/catch и попыток управлять конкурентностью, что в конечном итоге приводит к созданию хрупкого, сложно рефакторируемого и, в итоге, неустойчивого ПО.
Модель Effect
Если подвести короткий итог тому, что мы уже обсудили, то мы понимаем, что компонент React — это описание или чертёж пользовательского интерфейса, так же, как можно сказать, что Effect — это описание или чертёж общей вычислительной задачи.
Давайте посмотрим, как это работает на практике, начиная с примера, очень похожего на то, что мы видели в React:
import { Effect } from "effect" const print = (message: string) => Effect.sync(() => { console.log(message) }) const printHelloWorld = print("Hello World")
Отрыть в Playground
Как и в случае с React, простое создание Effect не приводит к выполнению каких-либо побочных эффектов. Фактически, как и компонент в React, Effect — это не что иное, как чертёж того, что мы хотим, чтобы наша программа делала. Только при выполнении этого чертежа запускаются побочные эффекты. Посмотрим, как это происходит:
import { Effect } from "effect" const print = (message: string) => Effect.sync(() => { console.log(message) }) const printHelloWorld = print("Hello World") Effect.runPromise(printHelloWorld)
Отрыть в Playground
Теперь сообщение "Hello World"
выводится в консоль.
Кроме того, подобно тому как в React мы можем комбинировать несколько компонентов, мы можем объединять различные Effects в более сложные программы. Для этого мы будем использовать generator-функцию:
import { Effect } from "effect" const print = (message: string) => Effect.sync(() => { console.log(message) }) const printMessages = Effect.gen(function* () { yield* print("Hello World") yield* print("We're getting messages") }) Effect.runPromise(printMessages)
Отрыть в Playground
Можно мысленно сопоставить yield*
с await
, а Effect.gen(function*() { })
с async function() {}
. Единственное отличие состоит в том, что если вы хотите передать аргументы, потребуется определить новую лямбда-функцию. Например:
import { Effect } from "effect" const print = (message: string) => Effect.sync(() => { console.log(message) }) const printMessages = (messages: number) => Effect.gen(function* () { for (let i = 0; i < messages; i++) { yield* print(`message: ${i}`) } }) Effect.runPromise(printMessages(10))
Отрыть в Playground
Как мы можем выбрасывать ошибки в компонентах React и обрабатывать их в родительских компонентах, так же мы можем выбрасывать ошибки в Effect и обрабатывать их в родительских эффектах:
import { Effect } from "effect" const print = (message: string) => Effect.sync(() => { console.log(message) }) class InvalidRandom extends Error { message = "Invalid Random Number" } const printOrFail = Effect.gen(function* () { if (Math.random() > 0.5) { yield* print("Hello World") } else { yield* Effect.fail(new InvalidRandom()) } }) const program = printOrFail.pipe( Effect.catchAll((e) => print(`Error: ${e.message}`)), Effect.repeatN(10) ) Effect.runPromise(program)
Отрыть в Playground
Приведённый выше код случайным образом завершается с ошибкой InvalidRandom
, от которой мы затем восстанавливаемся в родительском Effect, используя Effect.catchAll
. В данном случае логика восстановления состоит в простом выводе сообщения об ошибке в консоль.
Однако, что отличает Effect от React
, так это 100% типобезопасное обращение с ошибками — внутри нашего Effect.catchAll
мы знаем, что e
имеет тип InvalidRandom
. Это возможно благодаря тому, что Effect использует вывод типов для определения тех случаев ошибок, с которыми может столкнуться ваша программа, и представляет эти случаи в своём типе. Если вы проверите тип printOrFail
, то увидите:
Effect<void, InvalidRandom, never>
что означает, что этот Effect вернёт void
в случае успеха, но может завершиться ошибкой InvalidRandom
.
При комбинировании Effects, которые могут завершаться неудачей по разным причинам, итоговый Effect в своём типе будет содержать объединение всех возможных ошибок, например:
Effect<number, InvalidRandom | NetworkError | ..., never>
Effect может представлять любой фрагмент кода — будь то вызов console.log
, fetch-запрос, запрос к базе данных или вычисление. Кроме того, Effect способен выполнять как синхронный, так и асинхронный код в единой модели, что позволяет избежать проблемы «раскраски функций» (то есть наличия разных типов для async и sync).
Как компоненты React могут получать контекст, предоставляемый родительским компонентом, так и Effects могут получать контекст, предоставляемый родительским Effect. Давайте посмотрим, как это работает:
import { Context, Effect } from "effect" const print = (message: string) => Effect.sync(() => { console.log(message) }) class ContextualData extends Context.Tag("ContextualData")< ContextualData, number >() {} const printFromContext = Effect.gen(function* () { const n = yield* ContextualData yield* print(`Contextual Data is: ${n}`) }) const program = printFromContext.pipe( Effect.provideService(ContextualData, 100) ) Effect.runPromise(program)
Отрыть в Playground
Отличие Effect от React здесь в том, что нам не нужно предоставлять реализацию по умолчанию для контекста. Effect отслеживает все требования нашей программы в третьем параметре типа и запретит выполнение Effect, если не выполнены все его требования.
Если вы проверите тип printFromContext
, то увидите:
Effect<void, never, ContextualData>
что означает, что этот Effect вернёт void
в случае успеха, не завершится ожидаемыми ошибками и требует наличия ContextualData
для своего выполнения.
Заключение
Можно увидеть, что Effect и React по существу разделяют одну и ту же базовую модель – обе библиотеки предназначены для создания композиционных описаний программы, которые затем могут быть исполнены рантаймом. Единственное различие заключается в области применения: React ориентирован на построение пользовательских интерфейсов, а Effect – на создание программ общего назначения.
Это всего лишь введение, а Effect предлагает гораздо больше возможностей, чем показано здесь, включая такие функции, как:
-
Параллелизм
-
Планирование повторных попыток (Retry scheduling)
-
Телеметрия
-
Метрики
-
Логирование
-
И многое другое
Если вам интересно узнать больше о Effect, пожалуйста, ознакомьтесь с нашей документацией, а также с мастер-классом для начинающих.
Если вы дочитали до конца – спасибо за внимание.
ссылка на оригинал статьи https://habr.com/ru/articles/892440/
Добавить комментарий