#请添加图片描述

项目开源地址:https://atomgit.com/nutpi/rn_for_openharmony_element

打开一个 App,点进详情页,白屏,转圈,1秒、2秒、3秒……内容终于出来了。

这 3 秒钟里你在想什么?“卡了?”“网不好?”“要不要退出重进?”

现在换一种体验:点进详情页,页面立刻出现了灰色的轮廓——头像的位置是个圆,标题的位置是条横线,正文的位置是几行短横。虽然内容还没加载出来,但你已经知道页面长什么样了。

这就是骨架屏的价值。它不是让加载变快,而是让等待变得可以忍受。

从心理学角度理解骨架屏

人对"不确定"的等待特别焦虑。

想象两个场景:

  • 场景 A:电梯显示"正在运行",你不知道还要等多久
  • 场景 B:电梯显示"还有 3 层",你知道大概要等 10 秒

哪个让你更安心?显然是场景 B。

骨架屏做的就是类似的事情。它告诉用户:"内容马上就来,而且大概长这样。"用户的大脑有了预期,焦虑感就降低了。

研究表明,使用骨架屏可以让用户感知的等待时间缩短 10%-20%。实际加载时间没变,但体验变好了。

拆解源码:src/components/ui/Skeleton.tsx

这个文件里有两个组件:SkeletonSkeletonGroup。我们先看主角 Skeleton。

引入的东西

import React, { useEffect, useRef } from 'react';
import { View, Animated, StyleSheet, ViewStyle } from 'react-native';
import { UITheme } from './theme';

骨架屏需要动画,所以引入了 AnimateduseRef 用来存动画值,useEffect 用来启动动画循环。

没有引入 Text 组件——骨架屏不需要显示文字,它就是一块灰色的形状。

类型定义

interface SkeletonProps {
  width?: number | string;
  height?: number;
  variant?: 'text' | 'circular' | 'rectangular' | 'rounded';
  animation?: 'pulse' | 'wave' | 'none';
  style?: ViewStyle;
}

五个属性,每个都有默认值,所以都是可选的。

width 支持数字和字符串两种类型。数字是像素值,字符串可以是百分比(如 ‘60%’)。这个设计很实用——骨架屏经常需要"占父容器的百分之多少宽"。

variant 决定形状。四种选择对应四种常见的 UI 元素:

  • text:文本行,有小圆角
  • circular:圆形,用于头像
  • rectangular:矩形,无圆角,用于图片
  • rounded:圆角矩形,用于卡片或按钮

animation 目前实现了 pulse(脉冲闪烁)和 none(静态)。代码里预留了 wave 类型,但还没实现——这是一个常见的渐进式开发策略。

默认值设置

export const Skeleton: React.FC<SkeletonProps> = ({
  width = '100%',
  height = 20,
  variant = 'text',
  animation = 'pulse',
  style,
}) => {

默认宽度 100%,高度 20px,文本形状,带脉冲动画。

这组默认值的意思是:如果你什么都不传,就会得到一个"满宽的文本行骨架"。这是最常用的场景,所以作为默认值很合理。

动画的实现

  const pulseAnim = useRef(new Animated.Value(0.3)).current;

  useEffect(() => {
    if (animation === 'pulse') {
      Animated.loop(
        Animated.sequence([
          Animated.timing(pulseAnim, { toValue: 1, duration: 800, useNativeDriver: true }),
          Animated.timing(pulseAnim, { toValue: 0.3, duration: 800, useNativeDriver: true }),
        ])
      ).start();
    }
  }, [animation]);

脉冲动画的原理很简单:透明度在 0.3 和 1 之间来回变化。

初始值是 0.3(比较暗),然后 800ms 内变到 1(正常亮度),再 800ms 变回 0.3。一个完整周期是 1.6 秒,不快不慢,刚好有"呼吸"的感觉。

为什么用 0.3 而不是 0?因为完全透明会让骨架屏"消失",用户会困惑。0.3 的透明度让骨架屏变暗但仍然可见。

useNativeDriver: true 是性能优化。透明度动画可以完全在原生层面执行,不需要 JS 线程参与。

有个细节:useEffect 的依赖数组是 [animation]。如果 animation 属性变了(比如从 ‘pulse’ 变成 ‘none’),动画会重新处理。但当前实现有个小问题——从 ‘pulse’ 切换到 ‘none’ 时,已经启动的动画不会停止。实际使用中这不是大问题,因为很少有人会动态切换骨架屏的动画类型。

圆角计算

  const getBorderRadius = (): number => {
    switch (variant) {
      case 'circular':
        return typeof height === 'number' ? height / 2 : 50;
      case 'text':
        return UITheme.borderRadius.sm;
      case 'rounded':
        return UITheme.borderRadius.md;
      case 'rectangular':
        return 0;
      default:
        return UITheme.borderRadius.sm;
    }
  };

四种形状,四种圆角策略:

circular:圆角等于高度的一半,这样就是个正圆。如果 height 不是数字(理论上不会,因为类型定义是 number),就用 50 作为兜底。

text:小圆角(4px),让文本骨架看起来柔和一点。

rounded:中等圆角(8px),比 text 更圆润。

rectangular:零圆角,方方正正,适合图片占位。

尺寸计算

  const getSize = (): ViewStyle => {
    if (variant === 'circular') {
      return { width: height, height };
    }
    return { width, height };
  };

这个函数处理一个特殊情况:圆形骨架的宽高必须相等。

如果是 circular,宽度强制等于高度,确保是正圆。其他形状就用传入的 width 和 height。

为什么圆形用 height 而不是 width 作为直径?因为 height 的类型是 number,而 width 可能是字符串(如 ‘60%’)。用 height 可以确保得到一个数字,方便计算圆角。

渲染

  return (
    <Animated.View
      style={[
        styles.skeleton,
        getSize(),
        { borderRadius: getBorderRadius(), opacity: animation === 'pulse' ? pulseAnim : 1 },
        style,
      ]}
    />
  );
};

最终渲染就是一个带动画的 View。样式合并了四部分:

  1. styles.skeleton:基础样式,就是灰色背景
  2. getSize():宽高
  3. 动态样式:圆角和透明度
  4. style:外部传入的自定义样式

透明度的处理:如果是 pulse 动画,用动画值;否则用 1(完全不透明)。

基础样式

const styles = StyleSheet.create({
  skeleton: { backgroundColor: UITheme.colors.gray[200] },
});

就一行。骨架屏的样式极其简单——一个灰色的形状。

用的是 gray[200],一个很浅的灰色。太深会显得突兀,太浅会看不清。200 是个平衡点。

SkeletonGroup:批量生成的便捷工具

单个 Skeleton 用起来没问题,但如果要生成一组文本骨架,每次都写循环有点烦。SkeletonGroup 就是为这个场景设计的。

interface SkeletonGroupProps {
  count?: number;
  gap?: number;
  children?: React.ReactNode;
  style?: ViewStyle;
}

四个属性:

  • count:生成几个骨架,默认 3
  • gap:骨架之间的间距,默认 8px
  • children:如果传了 children,就渲染 children 而不是自动生成
  • style:容器样式
export const SkeletonGroup: React.FC<SkeletonGroupProps> = ({
  count = 3,
  gap = UITheme.spacing.sm,
  children,
  style,
}) => {
  if (children) {
    return <View style={style}>{children}</View>;
  }

  return (
    <View style={style}>
      {Array.from({ length: count }).map((_, index) => (
        <Skeleton key={index} style={{ marginBottom: index < count - 1 ? gap : 0 }} />
      ))}
    </View>
  );
};

这个组件有两种用法:

用法一:自动生成

<SkeletonGroup count={5} />

生成 5 个默认的文本骨架,自动带间距。

用法二:自定义内容

<SkeletonGroup>
  <Skeleton variant="circular" height={48} />
  <Skeleton width="60%" />
  <Skeleton width="80%" />
</SkeletonGroup>

把 SkeletonGroup 当作一个普通容器用,children 里放自定义的骨架组合。

Array.from({ length: count }) 是生成指定长度数组的技巧。比 new Array(count).fill(null) 更简洁。

间距的处理有个小细节:最后一个骨架不加 marginBottom。用 index < count - 1 判断是不是最后一个。

Demo 里的实战用法

看看 src/screens/demos/SkeletonDemo.tsx 怎么用这个组件。

最简单的用法

<ShowcaseSection title="基础用法" description="文本占位">
  <SkeletonGroup count={3} />
</ShowcaseSection>

一行代码,三行骨架。这是骨架屏最常见的用途——文章或列表加载时的占位。

组合出复杂布局

<ShowcaseSection title="形状" description="文本、圆形、矩形、圆角">
  <View style={styles.row}>
    <Skeleton variant="circular" height={48} />
    <View style={styles.flex}>
      <Skeleton variant="text" width="60%" style={styles.ml} />
      <Skeleton variant="text" width="80%" style={[styles.ml, styles.mt]} />
    </View>
  </View>
</ShowcaseSection>

这是一个"头像 + 两行文字"的布局,模拟用户信息卡片。

左边是圆形骨架(头像),右边是两行文本骨架(用户名和简介)。用 flex: 1 让文本区域占据剩余空间。

注意两行文本的宽度不一样(60% 和 80%)。这是个小技巧——真实的文本长度通常不一样,骨架屏也应该模拟这种参差感。如果每行都是 100%,看起来会很假。

模拟卡片

<ShowcaseSection title="卡片骨架" description="模拟卡片加载状态">
  <View style={styles.card}>
    <View style={styles.row}>
      <Skeleton variant="circular" height={56} />
      <View style={[styles.flex, styles.ml]}>
        <Skeleton variant="text" width="50%" height={16} />
        <Skeleton variant="text" width="30%" height={12} style={styles.mt} />
      </View>
    </View>
    <Skeleton variant="rectangular" height={120} style={styles.mtLg} />
    <Skeleton variant="text" width="100%" style={styles.mt} />
    <Skeleton variant="text" width="80%" style={styles.mt} />
  </View>
</ShowcaseSection>

这是一个完整的卡片骨架:

  • 顶部:头像 + 用户名 + 时间
  • 中间:一张图片(rectangular)
  • 底部:两行正文

styles.card 给了白色背景和圆角,让骨架屏看起来像一张真正的卡片。

图片骨架用 rectangular(无圆角),高度固定 120px。真实的图片加载出来后,会替换这个位置。

模拟列表

<ShowcaseSection title="列表骨架" description="模拟列表加载状态">
  {[1, 2, 3].map(i => (
    <View key={i} style={[styles.listItem, i > 1 && styles.mt]}>
      <Skeleton variant="circular" height={40} />
      <View style={[styles.flex, styles.ml]}>
        <Skeleton variant="text" width="70%" height={14} />
        <Skeleton variant="text" width="40%" height={12} style={styles.mt} />
      </View>
    </View>
  ))}
</ShowcaseSection>

三个列表项,每个都是"头像 + 两行文字"的结构。

[1, 2, 3].map 而不是 SkeletonGroup,因为每个列表项是一个复杂的组合,不是简单的文本行。

i > 1 && styles.mt 给第二、第三项加上顶部间距。第一项不需要。

静态骨架

<ShowcaseSection title="无动画" description="静态骨架屏">
  <SkeletonGroup count={2}>
    <Skeleton animation="none" />
    <Skeleton animation="none" width="60%" style={styles.mt} />
  </SkeletonGroup>
</ShowcaseSection>

animation="none" 关闭动画。什么时候需要静态骨架?

  • 性能敏感的场景,动画会消耗资源
  • 骨架屏显示时间很短,动画还没开始就结束了
  • 设计要求静态效果

实际项目中怎么用

骨架屏不是独立使用的,它要和数据加载配合。

模式一:条件渲染

const UserCard = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  if (loading) {
    return (
      <View style={styles.card}>
        <View style={styles.row}>
          <Skeleton variant="circular" height={48} />
          <View style={styles.flex}>
            <Skeleton width="50%" />
            <Skeleton width="30%" style={styles.mt} />
          </View>
        </View>
      </View>
    );
  }

  return (
    <View style={styles.card}>
      <View style={styles.row}>
        <Avatar source={user.avatar} size={48} />
        <View style={styles.flex}>
          <Text style={styles.name}>{user.name}</Text>
          <Text style={styles.bio}>{user.bio}</Text>
        </View>
      </View>
    </View>
  );
};

loading 为 true 时显示骨架,为 false 时显示真实内容。

这种模式的关键是:骨架屏的布局要和真实内容的布局一致。头像的位置、大小,文字的行数、宽度,都要对应上。否则内容加载出来时会有"跳动"的感觉。

模式二:封装成组件

如果骨架屏和真实内容的结构完全一样,可以封装成一个组件:

const UserCardSkeleton = () => (
  <View style={styles.card}>
    <View style={styles.row}>
      <Skeleton variant="circular" height={48} />
      <View style={styles.flex}>
        <Skeleton width="50%" />
        <Skeleton width="30%" style={styles.mt} />
      </View>
    </View>
  </View>
);

const UserCard = ({ user }) => (
  <View style={styles.card}>
    <View style={styles.row}>
      <Avatar source={user.avatar} size={48} />
      <View style={styles.flex}>
        <Text style={styles.name}>{user.name}</Text>
        <Text style={styles.bio}>{user.bio}</Text>
      </View>
    </View>
  </View>
);

// 使用
{loading ? <UserCardSkeleton /> : <UserCard user={user} />}

把骨架屏单独抽成组件,代码更清晰。

模式三:列表场景

列表加载时,通常显示固定数量的骨架项:

const UserList = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  if (loading) {
    return (
      <FlatList
        data={[1, 2, 3, 4, 5]}  // 假数据,只是为了渲染 5 个骨架
        renderItem={() => <UserCardSkeleton />}
        keyExtractor={item => String(item)}
      />
    );
  }

  return (
    <FlatList
      data={users}
      renderItem={({ item }) => <UserCard user={item} />}
      keyExtractor={item => item.id}
    />
  );
};

骨架屏的数量不需要和真实数据一样。通常显示 3-5 个就够了,让用户知道"这是个列表"。

模式四:渐进式加载

有时候数据是分批到达的。比如先拿到标题,再拿到正文:

const Article = ({ articleId }) => {
  const [title, setTitle] = useState(null);
  const [content, setContent] = useState(null);

  return (
    <View>
      {title ? (
        <Text style={styles.title}>{title}</Text>
      ) : (
        <Skeleton height={24} width="80%" />
      )}
      
      {content ? (
        <Text style={styles.content}>{content}</Text>
      ) : (
        <SkeletonGroup count={5} />
      )}
    </View>
  );
};

标题和正文分别有自己的骨架屏。哪个先加载出来,哪个就先显示真实内容。

一些设计上的思考

骨架屏应该多像真实内容?

越像越好,但不需要完美。

骨架屏的目的是给用户一个"预期",让他知道内容大概长什么样。只要形状、位置、大小差不多就行。

不需要:

  • 精确到像素的宽度
  • 完全一致的行数
  • 复杂的嵌套结构

需要:

  • 主要元素的位置对应(头像在左边、标题在上面)
  • 大致的比例关系
  • 视觉上的"像"

动画要不要?

大多数情况下要。

动画告诉用户"正在加载",静态的灰块可能被误认为是 bug 或者加载失败。

但如果骨架屏显示时间很短(比如数据已经缓存了,只是在做校验),动画可能还没开始就结束了,这时候可以用静态骨架。

骨架屏显示多久合适?

理想情况是 0.5-3 秒。

太短(< 0.5 秒):骨架屏一闪而过,用户还没看清就没了,不如不显示。可以加一个最小显示时间:

const [showSkeleton, setShowSkeleton] = useState(true);

useEffect(() => {
  const minTime = new Promise(resolve => setTimeout(resolve, 500));
  const dataFetch = fetchData();
  
  Promise.all([minTime, dataFetch]).then(([_, data]) => {
    setData(data);
    setShowSkeleton(false);
  });
}, []);

太长(> 3 秒):用户会失去耐心。如果加载真的需要这么久,考虑:

  • 优化接口性能
  • 分批加载
  • 显示进度条而不是骨架屏

和 Spinner 怎么选?

骨架屏:知道内容的结构,想让用户提前了解布局
Spinner:不知道内容的结构,或者内容结构不重要

举例:

  • 文章详情页 → 骨架屏(用户想知道文章有多长)
  • 登录验证 → Spinner(用户不关心验证过程的"结构")
  • 商品列表 → 骨架屏(用户想知道有几个商品)
  • 文件上传 → 进度条(用户想知道上传了多少)

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐