状态归属比组件封装更重要:一次 Profile 外层错误边界重构
Profile 页有主资料请求、补偿刷新、鉴权失败、普通网络失败,也有拉黑、注销、内容不可见这类业务限制。这些状态都会影响内容展示,很容易被归到同一个外层组件里,由 ProfilePageBlocker 或类似组件统一判断页面该显示什么。
这条路短期很好走,后面会越来越难解释:loading 是它管,error 是它管,内容不可用也是它管;组件名字像一个视觉遮罩,实际却变成了页面状态总线。
这轮重构先重新划分状态归属,再决定组件边界:先判断状态属于谁,再判断要不要抽组件。
外层 blocker 混入了不同职责
最早的外层 blocker 解决了真实问题。Profile 主资料没有回来时,页面不能露出旧内容;鉴权失败时,用户应该回首页;普通加载失败时,用户应该留在当前页重试。这些都需要一个统一出口。
后续扩展时,因为 blocker 已在页面最外层,各种影响展示的状态都容易继续放进来:
- 主资料第一次加载。
- 主资料补偿刷新。
render阶段抛出的异常。- 登录态失效。
- 普通请求失败。
- 被拉黑、用户注销、内容受限。
这些状态的视觉可能相似,owner 却完全不同。
loading 是一个短暂状态,应该由页面根节点或数据 source 驱动。error 是最终失败状态,应该由错误边界或重试机制处理。内容受限是业务内容状态,应该留在 Profile 内容层,因为它仍然依赖已经拿到的用户资料和关系信息。
把它们合在一个组件里,问题会从「代码行数多」变成「读者不知道这个状态为什么出现在这里」。后面再讨论命名时,Blocker、ErrorBlocker、Boundary、StateView 都会别扭,因为它们都只描述了其中一部分职责。
四态可以统一口径
前端页面状态经常会被总结成四种: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。鉴权类错误引导回首页,普通加载错误留在当前页面重试。
如果已经拿到与当前路由 uid 匹配的 userProfile,外层才放行。即使内容层判断用户被拉黑、注销或内容受限,也不再回到外层 error boundary。因为这类状态需要展示特定的业务空态、关系信息和可能的操作入口,它属于内容层。
这里保留已有主资料,针对的是普通刷新或局部请求失败。认证失效、权限撤销仍要由统一认证链路和数据 source 按业务契约处理,必要时清理受保护资料、结束会话或跳转;不能因为当前 uid 的旧资料还在,就继续把它当作有效授权下的内容。下面的资源分支图以这些校验已经完成为前提。
改造后的关键逻辑大致长这样:
// 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 && profileMatchesRouteUid.value) return null
if (userProfileIsLoading.value || profileClientRetrying.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,并且是否已经有与当前路由 uid 匹配的主资料」。它不再关心 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 用于透传稳定 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 入口。
可以抽 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 继续拆清楚。
配套工程提供状态视图、Host、主资源边界与 owner 回归;私有 loading、图片和 i18n 接口按接入项目替换。完整内容见状态边界的完整源码弹窗。
注释要写在反常逻辑旁边
这类状态重构里,注释最应该解释「为什么这里不做某件看起来合理的事」。
比如 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 重新揉在一起。
总结
Profile 外层的混乱来自不同状态共用一个 blocker。重构后,页面主资源 source 判断资料是否属于当前路由、是否仍在加载,以及何时生成最终阻断错误;补偿刷新由 loading 承接,拉黑、注销等业务限制留在内容层,Tab 的局部失败也不升级成整页错误。已有资料可用于普通刷新失败后的展示,认证失效或权限撤销仍须按认证与数据契约处理。
组件边界跟随这些归属:CommonEmptyState 维护视觉,CommonRenderErrorBoundary 维护错误机制,CommonStateHost 组合调用方已分类的状态,页面继续持有滚动布局和主资料展示判断。没有业务职责的 Profile 薄 wrapper 因此可以删除,稳定的公共 UI 组合则值得复用。
测试也要守住这份分工,尤其是重试时不生成最终 blocker、错误边界复用公共空态。先明确状态由谁负责,再决定封装什么,才能避免把同一团状态分散到更多文件里。
状态边界的完整源码
可复制源码
下面这个源码包是脱敏后的完整版本,包含 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 替换成自己的指令绑定。