从 React 到 Effect


如果你熟悉 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 不会频繁崩溃,并且足够易于维护。

但是,什么是组件呢?

你可能熟悉如下的代码:

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

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

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

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

只有当我们将组件挂载到特定的 DOM 节点(在我们的示例中称为 “root”)时,我们的代码才会被执行,并且生成的描述会产生副作用,最终创建出最终的 UI。

1
2
3
4
5
6
7
8
9
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

让我们验证一下我们刚刚解释的内容:

1
2
3
4
5
6
7
8
const MyComponent = () => {
console.log("MyComponent Invoked");
return <div>MyComponent</div>;
};
const App = () => {
<MyComponent />;
return <div>Hello World</div>;
};

如果我们运行这段代码,它会被转换为:

1
2
3
4
5
6
7
8
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 描述的一部分。

这证明了简单地创建一个组件并不会产生任何副作用——它是一个纯粹的操作,即使组件本身包含副作用。

将代码更改为:

1
2
3
4
5
6
7
const MyComponent = () => {
console.log("MyComponent Invoked");
return <div>MyComponent</div>;
};
const App = () => {
return <MyComponent />;
};

将会在控制台中记录 “MyComponent Invoked” 消息,这意味着副作用正在执行。

用模板编程

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

让我们来看看 React 从这种模型中获得的一些超能力,首先,组件可以多次渲染:

1
2
3
4
5
6
7
8
9
10
11
12
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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,从而允许组件执行异步工作。

让我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 组件可以访问父组件提供的上下文数据。让我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
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 中最初看到的非常相似的示例:

1
2
3
4
5
6
import { Effect } from "effect"
const print = (message: string) =>
Effect.sync(() => {
console.log(message)
})
const printHelloWorld = print("Hello World")

就像我们在 React 中看到的一样,简单地创建一个 Effect 不会导致任何副作用的执行。事实上,就像 React 中的组件一样,Effect 只不过是我们希望程序执行的模板。只有当我们执行这个模板时,副作用才会启动。让我们看看如何做到这一点:

1
2
3
4
5
6
7
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 组合成更复杂的程序。为此,我们将使用生成器函数:

1
2
3
4
5
6
7
8
9
10
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。例如:

1
2
3
4
5
6
7
8
9
10
11
12
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 中处理它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 的类型,你会看到:

1
Effect<void, InvalidRandom, never>

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

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

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

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

就像 React 组件可以访问由父组件提供的上下文一样,Effects 也可以访问由父 Effect 提供的上下文。让我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 的类型,你会看到:

1
Effect<void, never, ContextualData>

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

结论

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

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

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