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

5c4260a9cce8f9ab56aa9ab9e3e87c31.jpg

Если вы знаете 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, пожалуйста, ознакомьтесь с нашей документацией, а также с мастер-классом для начинающих.

Если вы дочитали до конца — спасибо за внимание.

© Habrahabr.ru