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

Profile 页有主资料请求、补偿刷新、鉴权失败、普通网络失败,也有拉黑、注销、内容不可见这类业务限制。这些状态都会影响内容展示,很容易被归到同一个外层组件里,由 ProfilePageBlocker 或类似组件统一判断页面该显示什么。

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

这轮重构先重新划分状态归属,再决定组件边界:先判断状态属于谁,再判断要不要抽组件。

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

外层 blocker 混入了不同职责

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

后续扩展时,因为 blocker 已在页面最外层,各种影响展示的状态都容易继续放进来:

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

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

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

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

四态可以统一口径

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

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

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

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

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

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 调整公共空态时,要同时改 CommonEmptyState 和 CommonRenderErrorBoundary。

改造后,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 加了两个很小的透传能力:actionClass 和 messageClass。它们服务于组合组件的稳定选择器,让测试和局部样式仍然能定位到「错误重试按钮」「错误文案」,公共按钮尺寸和空态布局仍然由 CommonEmptyState 统一维护。

稳定 class 入口用于定位和局部样式,按钮宽度、插图大小和空态布局仍由 CommonEmptyState 维护,不能让每个调用方重新定义。

wrapper 能删就删

重构过程中,ProfilePageBlocker 只剩下对 CommonRenderErrorBoundary 的包装,需要重新判断它是否还有业务职责。

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

这轮没有保留 Profile 私有 wrapper。Profile 私有逻辑留在 useProfilePageMainSource 和 createProfileErrorBlocker() 里;通用错误机制留在 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>

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

Profile 私有 wrapper 只把 ProfilePageBlockingError 改名透传给错误边界,增加了一次确认职责的跳转;CommonStateHost 则固定三个公共原语的组合方式,调用方负责提供已分类的状态。

接入 Profile 时,布局、展示判断和局部失败仍由各自的 owner 处理:

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

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

可复制源码

下面这个源码包是脱敏后的完整版本,包含 CommonEmptyState、CommonRenderErrorBoundary、CommonStateHost、主资源边界 composable、页面接入示例和状态 owner 回归测试。

请接入页面状态边界工具包。工具包根路径:https://shengsheng.fun/files/profile-state-error-boundary-ownership/kits/state-boundary-host/
先读取 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md;再按 FILES.json 读取 copy/app/components/common/、copy/app/composables/、copy/app/utils/、copy/tests/ 和 examples/user-profile-page.vue,把公共空态、错误边界、状态宿主和主资源边界迁移到当前 Vue/Nuxt 项目。
页面状态边界源码正在加载代码工作区...

接入时重点看四个位置:

  • copy/app/components/common/EmptyState/index.vue:公共空态视觉,只管插图、文案、按钮和 footer。
  • copy/app/components/common/RenderError/Boundary/index.vue:错误边界机制,负责渲染异常、外部错误、resetKey 和 retry。
  • copy/app/components/common/StateHost/index.vue:公共状态宿主,只组合 loading、error、empty 和默认 slot。
  • copy/app/composables/usePrimaryResourceBoundary/index.ts:页面主资源 owner,判断什么时候显示 loading,什么时候生成最终 blocking error。

源码包里没有直接复刻项目私有的 v-loading、图片资源和 i18n。CommonStateHost 用内置 overlay 保证复制后能跑;真实项目如果已经有 loading 指令,可以把 overlay 替换成自己的指令绑定。

注释要写在反常逻辑旁边

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

比如 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 就能看出重试态应由 loading 承接,避免又把它改回 blocker。

测试守住 owner,不只守住 DOM

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

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

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

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

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

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

StateView 适合状态关系稳定的区域

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

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

更可迁移的规则是:

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

先确认状态关系存在稳定重复,再抽统一组件,四态分类本身不足以决定组件边界。

后续改动按 owner 检查

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

这次重构里,最难的是确定状态由谁负责。owner 清楚后,组件大小、目录位置、props 命名和测试断言才有共同依据;只增加组件数量,仍会把同一团状态分散到更多文件里。