美文网首页
从 React 到 Effect

从 React 到 Effect

作者: 涅槃快乐是金 | 来源:发表于2024-08-31 21:54 被阅读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 提供的功能远不止这里展示的内容,还包括以下功能:

    • 并发
    • 重试调度
    • 遥测
    • 指标
    • 日志记录
    • 以及更多。

    相关文章

      网友评论

          本文标题:从 React 到 Effect

          本文链接:https://www.haomeiwen.com/subject/sfgqljtx.html