空状态设计的前端实现技巧与实战经验
又翻车了,空状态组件差点被产品退回重做
昨天上线前最后一刻,测试群里突然甩来一张截图:列表页数据为空的时候,页面直接白屏了。我第一反应是接口挂了,结果一查发现接口返回的是 [],结构没问题。问题出在——我们的空状态组件压根没生效。
这里我踩了个坑:之前为了省事,直接在列表渲染时写了个三元表达式:
{list.length > 0 ? list.map(...) : <EmptyState />}
看着没问题对吧?但问题是,这个 list 是从一个复杂的状态对象里取的:
const { data, loading, error } = useFetchList();
const list = data?.items || [];
你以为 data.items 没数据就是空数组?too young。某些异常情况下,data 是 null,而我们组件里判断 length 的时候已经炸了:Cannot read property 'length' of null。
更骚的是,这个错误发生在 JSX 内部,React 直接整个组件崩溃,连 fallback 的 EmptyState 都没机会渲染。这就尴尬了。
折腾半天才发现,判断时机太早了
我一开始以为是 EmptyState 组件自己有问题,于是各种 console.log 打日志,甚至加了边界包裹组件,结果发现根本进不到那个分支。
后来试了下把判断提到最外层:
if (!data) {
return <EmptyState type="loading" />;
}
if (error) {
return <EmptyState type="error" message={error.message} />;
}
if (data.items.length === 0) {
return <EmptyState type="empty" message="暂无数据" />;
}
这样写虽然能避开报错,但代码变得特别啰嗦,而且每个列表页都得复制一遍逻辑。我可不想以后改个空状态要改十个文件。
于是开始想怎么抽象成通用组件。最开始尝试搞个高阶组件(HOC),结果发现 React 官方现在都不推荐用 HOC 了,还容易产生嵌套地狱。后来改用自定义 Hook + Children 渲染模式,终于找到一个还算顺手的方案。
最终方案:用状态机思路重构空状态处理
核心思路是:不要让业务组件去判断“有没有数据”,而是交给一个统一的容器组件来管理加载、错误、空数据等状态。
我搞了个叫 DataView 的组件,它接收几个关键 props:
data:原始数据,可以是数组、对象或 nullloading:是否正在加载error:是否有错误renderItem:渲染每一项的函数emptyText:空数据时的提示文案
然后它内部自动判断该显示什么内容,业务层只需要关心“我要展示哪些数据”就行了。
下面是最终的核心代码:
function DataView({
data,
loading,
error,
renderItem,
emptyText = '暂无数据',
children
}) {
// 优先处理错误状态
if (error) {
return (
<div className="empty-state">
<div className="icon">⚠️</div>
<p>加载失败:{error.message}</p>
<button onClick={() => window.location.reload()}>
重试
</button>
</div>
);
}
// 加载中
if (loading) {
return (
<div className="loading-state">
<div className="spinner"></div>
<p>加载中...</p>
</div>
);
}
// 数据为空
const isEmpty = !data ||
(Array.isArray(data) && data.length === 0) ||
(typeof data === 'object' && data !== null && Object.keys(data).length === 0);
if (isEmpty) {
return (
<div className="empty-state">
<div className="icon">📦</div>
<p>{emptyText}</p>
{children && <div className="actions">{children}</div>}
</div>
);
}
// 正常渲染数据
if (Array.isArray(data)) {
return <ul className="data-list">{data.map(renderItem)}</ul>;
}
return children ? <>{children}</> : null;
}
使用方式非常简洁:
function ProductList() {
const { data, loading, error } = useFetch('https://jztheme.com/api/products');
return (
<DataView
data={data}
loading={loading}
error={error}
emptyText="还没有上架商品哦~"
renderItem={(item) => (
<li key={item.id}>
<img src={item.thumbnail} alt={item.name} />
<h3>{item.name}</h3>
<span>¥{item.price}</span>
</li>
)}
>
<button onClick={() => navigate('/create')}>
新增商品
</button>
</DataView>
);
}
这个设计有几个好处:
- 业务组件不再需要手动写一堆 if-else 判断
- 空状态样式统一,产品没法挑刺
- 支持透传 children,在空状态下也能放操作按钮
- 兼容数组和对象类型的数据源
当然也不是完美无缺。比如现在如果 data 是 undefined 但其实是合法初始状态,也会被当成空数据。不过我们在项目里约定所有接口初始值必须显式为 null 或 [],所以问题不大。
CSS这块也踩了个小坑
本来空状态的样式很简单:
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-state .actions {
margin-top: 20px;
}
但后来发现移动端上,图标太大了,显得很突兀。试了下用 rem 单位配合媒体查询调整,结果忘了项目里没配根字体大小,默认是 16px,导致计算混乱。
最后干脆改成用 vw 单位:
@media (max-width: 768px) {
.empty-state .icon {
font-size: 8vw;
}
}
虽然不是最优解,但在我们这种轻量级项目里够用了。真要追求完美应该上 SVG + 动态缩放,但现在工期紧,先这么着吧。
还有个边界情况没完全解决
今天 QA 提了个新问题:当用户搜索关键词但没结果时,应该显示“没有找到相关商品”,而不是默认的“暂无数据”。
我临时加了个逻辑:
<DataView
emptyText={searchKeyword ? 未找到 "${searchKeyword}" 的结果 : '暂无数据'}
...
/>
看起来 OK,但有个小瑕疵:如果用户输完关键词删掉,重新变回空字符串,这个提示会闪一下“未找到 ‘’ 的结果”,有点怪。
目前的 workaround 是加个防抖或者判断 trim 后长度:
emptyText={
searchKeyword && searchKeyword.trim().length > 0
? 未找到 "${searchKeyword}" 的结果
: '暂无数据'
}
改完后观察了一天,没再收到反馈。虽然不算优雅,但线上稳定就行。
总结一下
这次空状态的问题本质不是技术多难,而是开发习惯太随意:总想着“先这么写着,后面再抽离”,结果越拖坑越多。
现在我把 DataView 放进了公共组件库,要求所有新页面必须用这个模式。老页面逐步迁移,至少保证新增功能不会继续埋雷。
另外提醒自己一句:永远不要相信任何外部数据结构,哪怕文档写着“一定返回数组”。运行时校验该加还得加。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。特别是那种能自动识别搜索场景的空状态管理器,我一直没想好怎么做得更智能。

暂无评论