状态归属比组件封装更重要:一次 Profile 外层错误边界重构
Profile 页这种业务页面最容易把状态写成一团。
它有主资料请求,有补偿刷新,有鉴权失败,有普通网络失败,也有拉黑、注销、内容不可见这类业务限制。每个状态都能影响页面是否展示内容,每个状态又都看起来像“挡住页面”。一开始很自然会抽一个外层组件,叫 ProfilePageBlocker 或类似名字,让它统一判断页面现在该显示什么。
这条路短期很好走,后面会越来越难解释:loading 是它管,error 是它管,内容不可用也是它管;组件名字像一个视觉遮罩,实际却变成了页面状态总线。
这轮重构最后留下的经验很简单:先判断状态属于谁,再判断要不要抽组件。组件封装只是结果,状态归属才是起点。
问题不是少一个组件
最早的外层 blocker 解决了真实问题。Profile 主资料没有回来时,页面不能露出旧内容;鉴权失败时,用户应该回首页;普通加载失败时,用户应该留在当前页重试。这些都需要一个统一出口。
真正变坏的是后续扩展。因为 blocker 已经站在页面最外层,任何“看起来会挡住页面”的状态都容易被继续塞进去:
- 主资料第一次加载。
- 主资料补偿刷新。
render阶段抛出的异常。- 登录态失效。
- 普通请求失败。
- 被拉黑、用户注销、内容受限。
这些状态的视觉可能相似,owner 却完全不同。
loading 是一个短暂状态,应该由页面根节点或数据 source 驱动。error 是最终失败状态,应该由错误边界或重试机制处理。内容受限是业务内容状态,应该留在 Profile 内容层,因为它仍然依赖已经拿到的用户资料和关系信息。
把它们合在一个组件里,问题会从“代码行数多”变成“读者不知道这个状态为什么出现在这里”。后面再讨论命名时,Blocker、ErrorBlocker、Boundary、StateView 都会别扭,因为它们都只描述了其中一部分职责。
四态可以统一口径
前端页面状态经常会被总结成四种: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。因为这类状态需要展示特定的业务空态、关系信息和可能的操作入口,它属于内容层。
改造后的关键逻辑大致长这样:
// 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:
对应代码变成:
<!-- 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 组合,不统一业务状态归属。
这个边界让它和前面被删掉的 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 的测试覆盖了 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
讨论到这里,很容易走向另一个极端:既然状态能分成 empty / loading / success / error,是不是应该做一个统一 StateView?
我会谨慎。
统一 StateView 有价值的场景是列表卡片、简单面板、同构数据块。它们的状态关系很稳定:请求中、空结果、失败、成功。调用方只要传 loading、error、empty 和几个 slot 就够了。
Profile 这种页面外层不一样。它有主资料、内容层、身份区、Tab 数据、写后同步和业务限制。把所有状态塞进一个 StateView,会把不同 owner 重新揉在一起。
更可迁移的规则是:
- 简单同构块 可以用统一状态组件。
- 整页外层 先按 owner 拆状态,再看是否需要状态组件。
- error boundary 处理错误机制,empty state 处理错误视觉。
- 短暂 loading 用 loading 指令或局部 loading 组件,不直接变成最终 blocker。
- 业务不可见 留在业务内容层,除非它真的阻止整页主资料展示。
统一抽象应该来自稳定重复,不应该来自“我想把四个状态都塞进一个地方”。
这次沉淀下来的判断
- 状态命名要回答 owner。
profileLoading、pageBlockingError、contentUnavailable比一个泛泛的pageState更容易读。 - loading 和 error 不要抢同一个 owner。loading 结束以后,才进入最终错误分类。
- ErrorBoundary 负责捕获、同步、清空和 retry,不负责重复实现公共空态视觉。
- CommonEmptyState 负责公共空态插图、按钮、文案和 footer,组合组件只传内容和稳定 class。
- wrapper 如果只透传 props、slot 和事件,就优先删掉。
- 内容不可用不等于页面错误。只要主资料已经存在,拉黑、注销、权限限制这类状态应该留在内容层。
- 注释优先写反常逻辑和删除条件。读者最需要知道的是“为什么这里故意不做某件事”。
- 测试要守住状态归属,而不只是守住某个 DOM 节点存在。
我越来越觉得,前端重构里真正困难的是把“这件事到底归谁管”说清楚。
一旦 owner 清楚,组件大小、目录位置、props 命名、测试断言都会顺很多。反过来,如果 owner 没说清楚,哪怕拆出再多组件,也只是把同一团状态搬到更多文件里。
