状态归属比组件封装更重要:一次 Profile 外层错误边界重构

Profile 页这种业务页面最容易把状态写成一团。

它有主资料请求,有补偿刷新,有鉴权失败,有普通网络失败,也有拉黑、注销、内容不可见这类业务限制。每个状态都能影响页面是否展示内容,每个状态又都看起来像“挡住页面”。一开始很自然会抽一个外层组件,叫 ProfilePageBlocker 或类似名字,让它统一判断页面现在该显示什么。

这条路短期很好走,后面会越来越难解释:loading 是它管,error 是它管,内容不可用也是它管;组件名字像一个视觉遮罩,实际却变成了页面状态总线。

这轮重构最后留下的经验很简单:先判断状态属于谁,再判断要不要抽组件。组件封装只是结果,状态归属才是起点。

Profile 外层状态改造前后的职责变化

问题不是少一个组件

最早的外层 blocker 解决了真实问题。Profile 主资料没有回来时,页面不能露出旧内容;鉴权失败时,用户应该回首页;普通加载失败时,用户应该留在当前页重试。这些都需要一个统一出口。

真正变坏的是后续扩展。因为 blocker 已经站在页面最外层,任何“看起来会挡住页面”的状态都容易被继续塞进去:

  • 主资料第一次加载。
  • 主资料补偿刷新。
  • render 阶段抛出的异常。
  • 登录态失效。
  • 普通请求失败。
  • 被拉黑、用户注销、内容受限。

这些状态的视觉可能相似,owner 却完全不同。

loading 是一个短暂状态,应该由页面根节点或数据 source 驱动。error 是最终失败状态,应该由错误边界或重试机制处理。内容受限是业务内容状态,应该留在 Profile 内容层,因为它仍然依赖已经拿到的用户资料和关系信息。

把它们合在一个组件里,问题会从“代码行数多”变成“读者不知道这个状态为什么出现在这里”。后面再讨论命名时,BlockerErrorBlockerBoundaryStateView 都会别扭,因为它们都只描述了其中一部分职责。

四态可以统一口径

前端页面状态经常会被总结成四种:emptyloadingsuccesserror。这个分类有用,但它不等于要写一个万能组件把四种状态都包进去。

更稳的做法是先给四种状态找 owner:

empty、loading、success、error 四种状态的 owner 对照

empty 是“请求成功,但没有可展示数据”。它通常应该由 CommonEmptyState 这种视觉组件处理,因为插图、说明、按钮、底部链接都属于空态视觉规则。

loading 是“当前数据正在变化”。它不应该天然抹掉旧数据,也不应该被当作最终错误。它更适合由 v-loading 这类指令挂在当前容器上,按场景选择透明、半透明或不透明遮罩。

success 就是默认业务内容。最好的 success 组件通常是没有组件:默认 slot 或正常模板直接渲染即可。为了统一状态而把主内容塞进一层 StateView,常常只会让模板更远。

error 才是错误边界的职责。它需要捕获渲染异常、接收外部错误、响应 resetKey、清空旧错误、触发 retry。这些是机制,不是插图、按钮和空态布局。

这四个状态可以在文档里统一解释,但代码里不一定要统一成一个实体。统一的是判断口径,不是组件形状

Profile 外层只管主资料能不能展示

Profile 外层最终保留了一个很窄的判断:主资料有没有可展示基础。

如果没有 userProfile 且请求正在进行,根节点展示 v-loading。这时不生成 blocking error,因为请求还没结束,页面只是处在 transient loading。

如果请求结束后仍然没有 userProfile,并且有错误,才生成最终的 pageBlockingError。鉴权类错误引导回首页,普通加载错误留在当前页面重试。

如果已经拿到 userProfile,外层就放行。即使内容层判断用户被拉黑、注销或内容受限,也不再回到外层 error boundary。因为这类状态需要展示特定的业务空态、关系信息和可能的操作入口,它属于内容层。

Profile 外层加载和错误流转

改造后的关键逻辑大致长这样:

// app/pages/profile/[uid]/composables/useProfilePage/useProfilePageMainSource.ts
const profileLoading = computed(() =>
  (userProfileIsLoading.value || profileClientRetrying.value) &&
  (!userProfile.value || !profileMatchesRouteUid.value)
)

const profileBlockingError = computed(() => {
  if (userProfile.value) return null

  // 补偿刷新中的 loading 由根节点 v-loading 负责;
  // 这里如果生成 blocker,就会让加载态和最终错误态争同一个 owner。
  return createProfileErrorBlocker(
    userProfileError.value,
    profileClientRetrying.value,
    createProfileErrorBlockerCopy(t),
  )
})

这段逻辑最关键的是注释里的边界:补偿刷新期间不生成 blocker。loading 还没结束时,用户看到的是加载态;loading 结束以后,如果仍然没有数据,才进入最终错误态。

页面模板也随之变薄:

<!-- app/pages/profile/[uid]/index.vue -->
<div v-scrollbar>
  <CommonStateHost
    :loading="profileLoading && !pageBlockingError"
    :error="pageBlockingError"
    :error-message="pageBlockingError?.message"
    :error-retry-label="pageBlockingError?.actionLabel"
    error-type="refresh"
    @retry="handleProfileBoundaryRetry"
  >
    <main v-if="showProfileMain">
      <CommonProfileIdentity />
      <ProfilePageContent />
    </main>
  </CommonStateHost>
</div>

showProfileMain 只回答“有没有最终 blocker,并且是否已经有主资料”。它不再关心 runtime error 怎么捕获,也不再关心内容层为什么不可用。

ErrorBoundary 不再自己画空态

重构前还有一个小重复:CommonRenderErrorBoundary 自己引入错误插图,自己写 message,自己画 retry 按钮。项目里又有 CommonEmptyState,同样负责插图、文案和按钮。

这会造成两个坏处:

  • 错误态按钮、空态按钮、插图尺寸、垂直偏移很容易各写一份。
  • 后续 Figma 调整公共空态时,要同时改 CommonEmptyStateCommonRenderErrorBoundary

改造后,CommonRenderErrorBoundary 只负责错误机制,视觉壳交给 CommonEmptyState

CommonRenderErrorBoundary 与 CommonEmptyState 的契约

对应代码变成:

<!-- app/components/common/RenderError/Boundary/index.vue -->
<section v-if="currentError" v-bind="$attrs" class="common-render-error-boundary">
  <CommonEmptyState
    action-class="common-render-error-boundary__retry"
    class="common-render-error-boundary__empty-state"
    :action-text="visibleRetryLabel"
    :image-key="errorImageKey"
    :message="visibleMessage"
    message-class="common-render-error-boundary__message"
    @action="handleRetry"
  />
</section>

这里给 CommonEmptyState 加了两个很小的透传能力:actionClassmessageClass。它们服务于组合组件的稳定选择器,让测试和局部样式仍然能定位到“错误重试按钮”“错误文案”,公共按钮尺寸和空态布局仍然由 CommonEmptyState 统一维护。

这个边界要拿捏好。共享组件可以给组合组件留稳定 class 入口,但不能让每个调用方重新定义按钮宽度、插图大小和空态布局。公共视觉规则继续属于 CommonEmptyState

wrapper 能删就删

中间曾经有一个问题:既然 ProfilePageBlocker 现在只是在里面包了一层 CommonRenderErrorBoundary,它还有没有存在价值?

答案取决于它是否还有自己的业务语义。如果这个 wrapper 只是把 props 改个名、把 slot 透传进去、再把 retry 事件透传出来,它会增加跳转成本。读者看到 ProfilePageBoundary,还要点进去确认它是不是做了额外事情;如果点进去发现只是 CommonRenderErrorBoundary 的薄壳,这一跳就没有必要。

这轮没有保留 Profile 私有 wrapper。Profile 私有逻辑留在 useProfilePageMainSourcecreateProfileErrorBlocker() 里;通用错误机制留在 CommonRenderErrorBoundary,公共状态 UI 组合留在 CommonStateHost

这个判断也适合其他组件:

  • 有业务语义、能封住一组领域判断,可以保留 wrapper。
  • 只是转发 props、slot 和事件,优先删掉。
  • 如果只为了一个样式 class 保留 wrapper,先看能不能让底层公共组件暴露稳定 class 入口。

删掉薄 wrapper 的收益是降低阅读跳转成本。

可以抽 Host,但不能抢 owner

删掉 Profile 私有 wrapper 以后,页面里还会看到一个稳定组合:外层挂 v-loading,里面包 CommonRenderErrorBoundary,有些面板还会再接 CommonEmptyState。这类重复可以抽,但抽象的边界要非常窄。

最终新增的 CommonStateHost 只做宿主:

<CommonStateHost
  :loading="loading"
  :error="error"
  :empty="empty"
  :empty-state="{ message: '暂无内容' }"
>
  <FeatureContent />
</CommonStateHost>

它不读取接口,不判断谁被拉黑,不把分页失败升级成整页错误,也不接管滚动容器。调用方必须先把 loadingerrorempty 分类好,再把分类结果交给它。换句话说,CommonStateHost 统一的是 UI 组合,不统一业务状态归属。

这个边界让它和前面被删掉的 wrapper 有本质区别。Profile 私有 wrapper 只把 ProfilePageBlockingError 改名透传给错误边界,读者必须跳进去确认它没有额外逻辑;CommonStateHost 则把三个公共原语的组合方式固定下来,调用方一眼能看出自己只是在提供状态。

接入 Profile 时也故意留下几个不动的点:

  • v-scrollbar 留在 Profile 根节点。滚动条、吸顶、虚拟列表测量都属于布局和交互 owner,不属于状态 UI owner。
  • showProfileMain 留在 Profile 页。主资料是否可展示是页面自己的判断,Host 不替它推导。
  • 内容不可见继续留在 Content 层。封禁、注销、拉黑不是外层请求失败。
  • Tab 级接口失败继续由 Tab 自己处理。局部刷新失败不能挡住整个 Profile。

后续其他面板要接 CommonStateHost,先看三件事:loading 是否只是遮罩式过渡,error 是否代表整个区域不可用,empty 是否已经由调用方明确判断。三个答案都清楚,再接;只要有一个答案含糊,就先把状态 owner 继续拆清楚。

注释要写在反常逻辑旁边

这类状态重构里,注释最应该解释“为什么这里不做某件看起来合理的事”。

比如 createProfileErrorBlocker() 里有一段非常关键的逻辑:

// app/pages/profile/[uid]/composables/utils/profilePage.ts
export function createProfileErrorBlocker(error, retrying, copy) {
  // 补偿刷新中的 loading 由 Profile 根节点的 `profileLoading` 统一承接。
  // 这里如果同时生成 blocker,会让加载态和最终错误态争同一个遮罩 owner。
  if (retrying || !error) return null

  // ...按 auth / load 分类
}

从代码看,retrying 时返回 null 可能有点反直觉:正在重试,为什么不返回一个 recovering 状态?

注释要回答的正是这个问题。因为 recovering 不是最终错误,它应该由 loading owner 承接。如果这里返回 blocker,页面会同时存在“正在加载”和“最终错误”两个 owner,后续又会出现谁覆盖谁、谁响应 retry 的问题。

这类注释能保护后来的维护者,也能保护后来的 Agent。读代码的人不必重新经历一次讨论,就知道这个分支是故意设计的。

测试守住 owner,不只守住 DOM

这组测试不只断言“页面上有错误按钮”。它还要守住状态 owner。

CommonEmptyState 的测试覆盖了 actionClassmessageClass,确保组合组件可以透传稳定定位 class,而不需要复制空态结构。

CommonRenderErrorBoundary 的测试覆盖了它会复用 CommonEmptyState,不会再自己渲染两套插图和按钮。

Profile page 的测试重点是 createProfileErrorBlocker():补偿刷新中返回 null,请求最终失败才生成 blocking error;auth 错误和普通加载错误落到不同 action。

架构测试也跟着调整。之前某个全局 composable import 了组件层的类型,后面类型被移到页面 composable 自己的 types 目录,测试里的反例也改成真正的 UI layer deep import,避免因为删除旧文件而让规则失去样本。

这些测试背后的共同目标是:防止状态 owner 再次变宽。如果后面有人把 loading 又塞回 blocker,或者让 ErrorBoundary 再自己画一套空态,测试应该能第一时间提示。

不要急着统一成一个 StateView

讨论到这里,很容易走向另一个极端:既然状态能分成 empty / loading / success / error,是不是应该做一个统一 StateView

我会谨慎。

统一 StateView 有价值的场景是列表卡片、简单面板、同构数据块。它们的状态关系很稳定:请求中、空结果、失败、成功。调用方只要传 loadingerrorempty 和几个 slot 就够了。

Profile 这种页面外层不一样。它有主资料、内容层、身份区、Tab 数据、写后同步和业务限制。把所有状态塞进一个 StateView,会把不同 owner 重新揉在一起。

更可迁移的规则是:

  • 简单同构块 可以用统一状态组件。
  • 整页外层 先按 owner 拆状态,再看是否需要状态组件。
  • error boundary 处理错误机制,empty state 处理错误视觉
  • 短暂 loading 用 loading 指令或局部 loading 组件,不直接变成最终 blocker。
  • 业务不可见 留在业务内容层,除非它真的阻止整页主资料展示。

统一抽象应该来自稳定重复,不应该来自“我想把四个状态都塞进一个地方”。

这次沉淀下来的判断

  • 状态命名要回答 owner。profileLoadingpageBlockingErrorcontentUnavailable 比一个泛泛的 pageState 更容易读。
  • loading 和 error 不要抢同一个 owner。loading 结束以后,才进入最终错误分类。
  • ErrorBoundary 负责捕获、同步、清空和 retry,不负责重复实现公共空态视觉。
  • CommonEmptyState 负责公共空态插图、按钮、文案和 footer,组合组件只传内容和稳定 class。
  • wrapper 如果只透传 props、slot 和事件,就优先删掉。
  • 内容不可用不等于页面错误。只要主资料已经存在,拉黑、注销、权限限制这类状态应该留在内容层。
  • 注释优先写反常逻辑和删除条件。读者最需要知道的是“为什么这里故意不做某件事”。
  • 测试要守住状态归属,而不只是守住某个 DOM 节点存在。

我越来越觉得,前端重构里真正困难的是把“这件事到底归谁管”说清楚。

一旦 owner 清楚,组件大小、目录位置、props 命名、测试断言都会顺很多。反过来,如果 owner 没说清楚,哪怕拆出再多组件,也只是把同一团状态搬到更多文件里。