从 React 到 Effect

2024-08-31  本文已影响0人  涅槃快乐是金

如果你熟悉 React,那么你已经在很大程度上了解了 Effect。让我们来探讨一下 Effect 的思维模型如何与 React 中你已经熟知的概念相对应。

历史背景

大约 20 年前我刚开始编程时,世界与现在完全不同。Web 刚刚开始爆发,Web 平台的功能非常有限,我们正处于 Ajax 的起步阶段,大多数网页实际上是从服务器渲染的文档,只有少量的交互性。

在很大程度上,那是一个更简单的世界——当时 TypeScript 还不存在,jQuery 也不存在,浏览器各行其是,而 Java Applets 看起来像是个好主意!

如果我们快进到今天,很容易看到事物已经发生了巨大变化——Web 平台提供了令人难以置信的功能,我们习惯与之交互的大多数程序都完全建立在 Web 上。

如果我们用 20 多年前的技术来构建今天的内容,可能吗?当然,但这不会是最优的。随着复杂性的增加,我们需要更健壮的解决方案。通过零散的 JS 调用来操作 DOM,而没有类型安全性,没有一个强有力的模型来保证正确性,我们将无法轻松地构建如此强大的用户界面。

我们今天所做的许多事情都得益于诸如 Angular 和 React 等框架提出的想法,在这里,我想探讨一下为什么 React 主导了市场十年之久,并且为什么它仍然是许多人的首选。

我们将探讨的内容同样适用于其他框架,事实上,这些想法并非 React 所特有,而是更普遍的。

React 的能力

我们应该从问自己“为什么 React 如此强大?”开始。当我们在 React 中编写 UI 时,我们以小组件的形式思考,这些组件可以组合在一起。这种思维模型使我们能够从根本上应对复杂性,我们构建的组件封装了复杂性,并将它们组合起来,以构建强大的 UI,这些 UI 不会频繁崩溃,并且足够易于维护。

但是,什么是组件呢?

你可能熟悉如下的代码:

const App = () => {
  return <div>Hello World</div>;
};

去掉 JSX 后,上述代码变成了:

const App = () => {
  return React.createElement("div", { children: "Hello World" });
};

所以我们可以说组件是一个返回 React 元素的函数,或者更好地说,组件是一个 UI 的描述或模板。

只有当我们将组件挂载到特定的 DOM 节点(在我们的示例中称为 "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” 消息,这意味着副作用正在执行。

用模板编程

React 的核心思想可以简要概括为:“使用可组合的模板对 UI 进行建模,然后将其渲染到 DOM 中。” 这是为了展示思维模型而故意简化的,当然,细节要复杂得多,但这些细节对用户来说是隐藏的。正是这一思想使 React 变得灵活、易于使用且易于维护。你可以随时将组件拆分成更小的部分,重构代码,并且可以确保之前正常工作的 UI 仍然能够正常运行。

让我们来看看 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 提供了允许在父组件中恢复这些错误的机制。一旦错误在父组件中被捕获,就可以执行替代操作,例如渲染替代的 UI。

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,然后可以在最近的 Suspense 边界内等待这个 Promise,从而允许组件执行异步工作。

让我们来看看:

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)和来自 SSR 的数据流(最新热点)的功能。

此外,由于 React 组件是要渲染的 UI 的描述,因此 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 提出的想法,我们现在可以在最初被认为是玩具语言的基础上开发全栈应用程序。

随着 JS / TS 程序的复杂性增加,我们再次发现自己处于一种情况,即我们对平台的需求超过了语言提供的能力。就像在 jQuery 之上构建复杂的 UI 是一项相当困难的任务一样,在纯 JS / TS 上开发生产级应用程序也变得越来越痛苦。

生产级应用程序代码有如下需求:

多年来,我们已经看到许多功能被添加到 Web 平台上,例如 AbortControllerOpenTelemetry 等。虽然所有这些解决方案在单独使用时似乎效果很好,但它们最终未能通过组合测试。编写满足生产级软件所有要求的 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")

就像我们在 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)

现在我们的“Hello World”消息已经被打印到控制台。

此外,类似于在 React 中将多个组件组合在一起,我们还可以将不同的 Effect 组合成更复杂的程序。为此,我们将使用生成器函数:

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)

你可以将 yield* 心理映射为 await,并将 Effect.gen(function*() { }) 映射为 async function() {},唯一的区别是如果你想传递参数,你需要定义一个新的 lambda。例如:

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))

就像我们可以在 React 组件中引发错误并在父组件中处理它们一样,我们也可以在 Effect 中引发错误,并在父 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)

上述代码会随机触发 InvalidRandom 错误,然后我们通过父级 Effect 使用 Effect.catchAll 进行恢复。在这种情况下,恢复逻辑只是将错误信息记录到控制台。

然而,Effect 与 React 的区别在于,Effect 中的错误是 100% 类型安全的——在我们的 Effect.catchAll 中,我们知道 eInvalidRandom 类型的。这之所以可能,是因为 Effect 使用类型推断来理解程序可能遇到的错误情况,并在其类型中表示这些情况。如果你查看 printOrFail 的类型,你会看到:

Effect<void, InvalidRandom, never>

这意味着该 Effect 成功时将返回 void,但也可能因 InvalidRandom 错误而失败。

当你组合可能因不同原因失败的 Effects 时,最终的 Effect 将在一个联合类型中列出所有可能的错误,因此你会在类型中看到如下内容:

Effect<number, InvalidRandom | NetworkError | ..., never>

一个 Effect 可以表示任何一段代码,无论是 console.log 语句、fetch 调用、数据库查询或是计算。Effect 还完全能够在统一的模型中执行同步和异步代码,从而避免了函数着色问题(即为异步或同步代码提供不同类型的问题)。

就像 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)

Effect 与 React 在这里的区别在于,我们不必为上下文提供默认实现。Effect 会在其第三个类型参数中跟踪我们程序的所有需求,并且将禁止执行那些没有满足所有需求的 Effect。

如果你查看 printFromContext 的类型,你会看到:

Effect<void, never, ContextualData>

这意味着该 Effect 成功时将返回 void,不会因任何预期的错误而失败,并且需要 ContextualData 才能变为可执行。

结论

我们可以看到,Effect 和 React 本质上共享相同的基础模型——两个库都是关于创建可组合的程序描述,然后由运行时执行。唯一的区别是领域不同——React 专注于构建用户界面,而 Effect 专注于创建通用程序。

这只是一个入门,Effect 提供的功能远不止这里展示的内容,还包括以下功能:

上一篇 下一篇

猜你喜欢

热点阅读