[Перевод] От React к Effect

Если вы знаете React, вы уже в значительной степени знакомы с Effect. Давайте рассмотрим, как ментальная модель Effect соответствует концепции, знакомой вам из React.
История
Когда я начал программировать примерно 20 лет назад, мир был совершенно другим. Всемирная паутина только начинала набирать обороты, возможности веб-платформы были очень ограничены, мы находились на заре эпохи Ajax, и большинство наших веб-страниц фактически представляли собой документы, отрисовываемые с сервера, с небольшими элементами интерактивности.
В значительной степени это был более простой мир — TypeScript не существовал, jQuery не было, браузеры делали всё, что им вздумается, а Java апплеты казались отличной идеей!
Если мы переместимся в наше время, можно легко заметить, что многое изменилось — веб-платформа предоставляет невероятные возможности, и большая часть программ, с которыми мы привыкли взаимодействовать, полностью построена на веб-технологиях.
Было бы возможно построить то, что у нас есть сегодня, используя технологии, которыми мы пользовались более 20 лет назад? Конечно, но это было бы не оптимально. С ростом сложности нам нужны более надёжные решения. Мы не смогли бы так легко создавать такие мощные пользовательские интерфейсы, рассыпая по коду прямые вызовы JS для манипуляции DOM, без безопасности типов и без прочной модели, гарантирующей корректность.
Многое из того, что мы делаем сегодня, возможно благодаря идеям, выдвинутым такими фреймворками, как Angular и React. Здесь я хочу исследовать, почему React доминировал на рынке в течение десятилетия и почему он до сих пор остаётся предпочтительным выбором для многих.
То, что мы будем рассматривать, одинаково применимо и к другим фреймворкам; на самом деле, эти идеи не специфичны для React, а гораздо более универсальны.
Сила React
Начнём с вопроса: «Почему React настолько мощный?». Когда мы создаём пользовательские интерфейсы (UI) в React, мы мыслим в терминах маленьких компонентов, которые можно комбинировать вместе. Такая ментальная модель позволяет нам справляться с самой сложной частью — мы создаём компоненты, которые инкапсулируют сложность, а затем объединяем их для построения мощных UI, которые не дают сбоев и достаточно просты в обслуживании.
Но что такое компонент? Возможно, вы уже встречались с кодом, который выглядит примерно так:
const App = () => {
return Hello World
}
Если убрать 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(
)
Давайте проверим, что мы только что объяснили:
const MyComponent = () => {
console.log("MyComponent Invoked")
return MyComponent
}
const App = () => {
return Hello World
}
Если мы запустим этот код, который преобразуется в:
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 MyComponent
}
const App = () => {
return
}
в консоли будет выведено сообщение "MyComponent Invoked"
, что означает, что выполняются побочные эффекты.
Программирование с чертежами (blueprints)
Ключевая идея React может быть кратко сформулирована так: «Моделируйте пользовательский интерфейс с помощью композиционных чертежей, которые могут быть отрисованы в DOM». Это упрощённое описание выбранной ментальной модели — конечно, детали гораздо сложнее, но при этом они скрыты от пользователя. Именно эта идея делает React гибким, простым в использовании и лёгким в сопровождении. Вы можете в любой момент разбить компоненты на более мелкие, произвести рефакторинг кода, и быть уверенными, что работавший ранее интерфейс продолжит работать.
Давайте взглянем на некоторые суперспособности, которые React получает благодаря этой модели. Прежде всего, компонент может быть отрисован несколько раз:
const MyComponent = (props: { message: string }) => {
return MyComponent: {props.message}
}
const App = () => {
return (
)
}
Этот пример несколько искусственный, но если ваш компонент выполняет что-то более интересное (например, моделирует кнопку), это может оказаться весьма мощным. Вы можете повторно использовать компонент Button
в разных местах, не переписывая его логику.
React-компонент также может выйти из строя и выбросить ошибку, и React предоставляет механизмы, позволяющие восстановиться после таких ошибок в родительских компонентах. Как только ошибка будет поймана в родительском компоненте, могут быть выполнены альтернативные действия, такие как отрисовка альтернативного интерфейса.
export declare namespace ErrorBoundary {
interface Props {
fallback: React.ReactNode
children: React.ReactNode
}
}
export class ErrorBoundary extends React.Component {
state: {
hasError: boolean
}
constructor(props: React.PropsWithChildren) {
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 MyComponent
}
const App = () => {
return (
Fallback Component!!!
}>
)
}
Хотя предоставленный 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 MyComponent
}
const App = () => {
return (
Waiting...
}>
)
}
Этот API довольно низкоуровневый, но существуют библиотеки, которые используют его внутренне для обеспечения таких функций, как плавное получение данных (обязательно отдаём должное React Query) и потоковая передача данных с серверного рендеринга с использованием серверных компонентов (новая модная тема).
Кроме того, поскольку компоненты React являются описанием интерфейса для отрисовки, компонент React может получать контекстные данные, предоставляемые родительскими компонентами. Давайте посмотрим:
const ContextualData = React.createContext(0)
const MyComponent = () => {
const context = React.useContext(ContextualData)
return MyComponent: {context}
}
const App = () => {
return (
)
}
В приведённом выше коде мы определили некоторую контекстную переменную — число, и предоставили её на верхнем уровне компонента 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
что означает, что этот Effect вернёт void
в случае успеха, но может завершиться ошибкой InvalidRandom
.
При комбинировании Effects, которые могут завершаться неудачей по разным причинам, итоговый Effect в своём типе будет содержать объединение всех возможных ошибок, например:
Effect
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
что означает, что этот Effect вернёт void
в случае успеха, не завершится ожидаемыми ошибками и требует наличия ContextualData
для своего выполнения.
Заключение
Можно увидеть, что Effect и React по существу разделяют одну и ту же базовую модель — обе библиотеки предназначены для создания композиционных описаний программы, которые затем могут быть исполнены рантаймом. Единственное различие заключается в области применения: React ориентирован на построение пользовательских интерфейсов, а Effect — на создание программ общего назначения.
Это всего лишь введение, а Effect предлагает гораздо больше возможностей, чем показано здесь, включая такие функции, как:
Параллелизм
Планирование повторных попыток (Retry scheduling)
Телеметрия
Метрики
Логирование
И многое другое
Если вам интересно узнать больше о Effect, пожалуйста, ознакомьтесь с нашей документацией, а также с мастер-классом для начинающих.
Если вы дочитали до конца — спасибо за внимание.