От 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 <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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *