空状态设计的前端实现技巧与实战经验

迷人的常青 优化 阅读 2,957
赞 20 收藏
二维码
手机扫码查看
反馈

又翻车了,空状态组件差点被产品退回重做

昨天上线前最后一刻,测试群里突然甩来一张截图:列表页数据为空的时候,页面直接白屏了。我第一反应是接口挂了,结果一查发现接口返回的是 [],结构没问题。问题出在——我们的空状态组件压根没生效。

空状态设计的前端实现技巧与实战经验

这里我踩了个坑:之前为了省事,直接在列表渲染时写了个三元表达式:

{list.length > 0 ? list.map(...) : <EmptyState />}

看着没问题对吧?但问题是,这个 list 是从一个复杂的状态对象里取的:

const { data, loading, error } = useFetchList();
const list = data?.items || [];

你以为 data.items 没数据就是空数组?too young。某些异常情况下,datanull,而我们组件里判断 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:原始数据,可以是数组、对象或 null
  • loading:是否正在加载
  • 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,在空状态下也能放操作按钮
  • 兼容数组和对象类型的数据源

当然也不是完美无缺。比如现在如果 dataundefined 但其实是合法初始状态,也会被当成空数据。不过我们在项目里约定所有接口初始值必须显式为 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 ? 未找到 &quot;${searchKeyword}&quot; 的结果 : '暂无数据'}
  ...
/>

看起来 OK,但有个小瑕疵:如果用户输完关键词删掉,重新变回空字符串,这个提示会闪一下“未找到 ‘’ 的结果”,有点怪。

目前的 workaround 是加个防抖或者判断 trim 后长度:

emptyText={
  searchKeyword && searchKeyword.trim().length > 0
    ? 未找到 &quot;${searchKeyword}&quot; 的结果
    : '暂无数据'
}

改完后观察了一天,没再收到反馈。虽然不算优雅,但线上稳定就行。

总结一下

这次空状态的问题本质不是技术多难,而是开发习惯太随意:总想着“先这么写着,后面再抽离”,结果越拖坑越多。

现在我把 DataView 放进了公共组件库,要求所有新页面必须用这个模式。老页面逐步迁移,至少保证新增功能不会继续埋雷。

另外提醒自己一句:永远不要相信任何外部数据结构,哪怕文档写着“一定返回数组”。运行时校验该加还得加。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。特别是那种能自动识别搜索场景的空状态管理器,我一直没想好怎么做得更智能。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论