React 错误处理的陷阱

阅读 0 分钟

分享

分享

2025年3月,我们发布了代码边界(Code Boundaries)——一种围绕自定义代码的防弹封装。这最初只是一个简单的React错误边界,后来却演变为为期一个月的深入探究错误处理复杂性的工作。以下是其内部机制的一瞥。

Framer 是一个用于无代码站点的工具。但 Framer 确实支持代码!每个 Framer 站点都是一个(经过高度优化的)React 应用程序,站点创建者可以在任何他们想要的地方添加自定义 React 组件和覆盖

然而,自定义代码是有代价的。你知道如果在 React 应用程序中某个组件抛出错误会发生什么吗?没错——整个应用程序崩溃,你看到的是一个白屏:

从历史上看,Framer 站点就是这样工作的。如果一个代码组件崩溃,那么整个站点也会崩溃。

今年冬天,我们着手解决这个问题。为了解决这个问题,我们需要让自定义代码独立:如果一个自定义代码组件崩溃,它应该隐藏起来,但站点的其余部分应该继续工作。

欢迎来到这个兔子洞。

第一级:错误边界

从表面上看,如果它损坏了就隐藏它正是React 错误边界的用途。用一个错误边界包装每个自定义代码组件 → 捕获错误 → 渲染null → 完成:

class ErrorBoundary extends React.Component {
static getDerivedStateFromError(error) {
return { hasError: true }
}

render() {
return this.state.hasError ? null : this.props.children
}
}

除了:React 错误边界只在客户端工作!它们在你浏览网站时会防止崩溃,但不会在我们将其呈现在服务器上时。如果我们无法在服务器上渲染页面,我们就无法优化它,它将保持缓慢:

第二级:Suspense

在服务器上,React 错误边界被完全忽略。相反,当发生服务器端错误时,React 会找到最近的边界并渲染其候补

这意味着要隐藏出错的组件,我们不需要一个错误边界,而是需要两个——一个用于客户端,一个用于服务器:

class ClientErrorBoundary extends React.Component {
// (同上)
}

function ServerErrorBoundary({ children }) {
return {children}
}

function ErrorBoundary({ children }) {
return

{children}


}

不幸的是,用包装每一段代码会带来一些挑战。那是因为,除了在服务器上捕获错误之外,它还会做其他几件事。

第三级:Suspense 的诸多行为

除了错误处理,还有很多其他的行为!而我们不想要所有这些!

是一个非常重载的基元。截止到 2025 年,它大致做了以下所有事情:

在服务器端

在客户端

1. 如果有内容挂起,则渲染备用内容(可通过await stream.allReady跳过)

2. 如果有内容挂起,则渲染备用内容(可通过startTransition()跳过)

3. 如果发生错误,则渲染备用内容

4. 使水合具有选择性和并发性

我们对行为 3(我们的目标!)和 4(这是一个很好的性能提升!)感到满意。但如果某些用户代码在渲染时挂起怎么办?

export function MyCodeComponent() {
const weather = use(weatherPromise)
return

It’s {weather.temperature}° outside!
}


在服务器上,我们可以通过等待stream.allReady来等待代码解除挂起。这使得行为 1 也变得可以接受。

但在客户端,像那样挂起会导致中的渲染其候补 — null(行为 2)。(当然,如果渲染该组件的代码没有用startTransition包装,就会这样 — 但如果它来自用户,我们无法控制该代码。)结果是,组件在获取数据时会短暂闪烁。这远非优化。

我们如何解决这个问题?

  • 也许根本不在客户端渲染? 不行,那会导致水合不匹配。 渲染了一个;尽管不发出任何实际的 DOM 节点,但它输出特殊的 Suspense 注释(等等)。如果 React 在水合过程中无法匹配这些,它就会报错。


  • 也许在水合开始前删除 Suspense 注释(等等)? 不,当用户代码在服务器上崩溃(并渲染null)但在客户端成功(并渲染正确的 JSX)时,这仍然会导致水合不匹配。如果没有 Suspense,这些水合不匹配将导致整个根组件重新挂载


  • 也许在 Suspense 的候补中挂起?现在,这可能奏效。事实证明:

    • 如果一个 Suspense 边界渲染了一个候补,

    • 但是候补本身又挂起了,

    • 那么 React 将简单地忽略这个 Suspense 边界!

function SuspenseThatIsIgnored({ children }) {
return }>{children}
}

function Suspend() {
use(someInfinitePromise)
}

React 文档提到,当这种情况发生时,父级 Suspense 边界会被激活。但这即使在没有父级 Suspense 边界的情况下也有效:应用程序的行为就好像 Suspense 边界根本不存在一样!在组件挂起的情况下,这意味着在不激活任何边界的情况下挂起。

这听起来(而且确实)很危险——我们依赖于没有真正记录的 React 行为。从长远来看,我们将实施更好的解决方案。但作为短期解决方案,它出人意料地有效:在大多数情况下,如果用户代码挂起,根本不会闪烁。

这使我们能够保留所有我们喜欢的行为(#3,#4),但禁用我们不喜欢的行为(#2)。万岁!问题解决了。

第四级:外部组件

让我们放大到产品层面。

在 Framer 中,你不仅可以编写自己的代码,还可以重用别人编写的代码。这些代码可以嵌套在别人制作的无代码 UI 中:

这为实现增加了另一个复杂性。通常,自定义代码边界只会隐藏崩溃的组件。但上面这种情况,如果Input崩溃了,我们不应该只隐藏它,而是整个Form

为什么?因为对于网站作者来说,Form是完全不透明的。它不是他们的组件!他们无法窥视其中!对他们来说,Form是一个原子控件:

所以崩溃也应该原子化发生——要么完全不崩溃,要么整个组件一次性崩溃。

级别 5…N

我们还必须解决其他产品复杂性。

  • Framer 不仅支持代码组件,还支持代码覆盖(或者用 React 行话来说,高阶组件)——所以代码边界也必须支持这一点

  • 许多代码错误默认情况下都相当神秘,尤其是在代码被缩小的情况下。因此,我们不得不开发一种方法,使其易于找到哪个确切的组件出了问题:

我们终于完成了,我们为即将发布的产品感到自豪。

Create a free website with Framer, the website builder loved by startups, designers and agencies.