Skeleton动画实现的那些坑我都帮你踩过了

夏侯志鸣 组件 阅读 598
赞 6 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

说实话,Skeleton动画这个东西现在各大厂都在用,我也在好几个项目里实现了。最直观的感受就是用户体验确实提升了,至少用户不会看到一片空白在那里傻等。今天就把我用过的几种方案都分享出来,毕竟这个技术我已经用了一年多了。

Skeleton动画实现的那些坑我都帮你踩过了

先上一个最简单的基础版本:

<!DOCTYPE html>
<html>
<head>
    <style>
        .skeleton {
            background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
            background-size: 200% 100%;
            animation: loading 1.5s infinite;
        }
        
        @keyframes loading {
            0% {
                background-position: 200% 0;
            }
            100% {
                background-position: -200% 0;
            }
        }
    </style>
</head>
<body>
    <div class="skeleton" style="width: 200px; height: 20px; margin-bottom: 10px;"></div>
    <div class="skeleton" style="width: 150px; height: 20px; margin-bottom: 10px;"></div>
    <div class="skeleton" style="width: 180px; height: 60px;"></div>
</body>
</html>

这段代码的核心就是那个渐变背景加关键帧动画。background-position从200%移动到-200%,就形成了那种扫描的效果。我第一次做的时候就在这里纠结了好久,后来发现其实很简单,就是不断地改变背景位置。

不同场景下的应用

实际项目中,Skeleton不是只有一种形态。我在做新闻列表的时候,需要模拟出标题、摘要、图片的占位效果:

.skeleton-news-title {
    width: 100%;
    height: 24px;
    border-radius: 4px;
}

.skeleton-news-summary {
    width: 100%;
    height: 16px;
    margin-top: 8px;
    border-radius: 3px;
}

.skeleton-news-image {
    width: 80px;
    height: 60px;
    border-radius: 4px;
}

.skeleton-container {
    display: flex;
    padding: 16px;
    gap: 12px;
}

.skeleton-text-container {
    flex: 1;
}
<div class="skeleton-container">
    <div class="skeleton skeleton-news-image"></div>
    <div class="skeleton-text-container">
        <div class="skeleton skeleton-news-title"></div>
        <div class="skeleton skeleton-news-summary"></div>
        <div class="skeleton skeleton-news-summary" style="width: 80%;"></div>
    </div>
</div>

电商项目的商品卡片稍微复杂一点,要考虑价格、销量这些信息的展示:

.skeleton-product-card {
    width: 150px;
    padding: 12px;
    box-sizing: border-box;
}

.skeleton-product-image {
    width: 100%;
    height: 150px;
    border-radius: 8px;
}

.skeleton-product-title {
    width: 100%;
    height: 14px;
    margin-top: 12px;
    border-radius: 2px;
}

.skeleton-product-price {
    width: 60%;
    height: 16px;
    margin-top: 8px;
    border-radius: 2px;
}

.skeleton-product-sales {
    width: 40%;
    height: 12px;
    margin-top: 6px;
    border-radius: 2px;
}
<div class="skeleton-product-card">
    <div class="skeleton skeleton-product-image"></div>
    <div class="skeleton skeleton-product-title"></div>
    <div class="skeleton skeleton-product-title" style="width: 80%;"></div>
    <div class="skeleton skeleton-product-price"></div>
    <div class="skeleton skeleton-product-sales"></div>
</div>

踩坑提醒:这三点一定注意

说几个我踩过坑的地方。

第一个坑是性能问题。一开始我把所有的动画都放在DOM元素上,结果页面一多就开始卡顿。后来改为CSS变量控制动画速度,性能提升了不少:

.skeleton {
    --loading-speed: 1.5s;
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: loading var(--loading-speed) infinite;
}

@keyframes loading {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}

第二个坑是在移动端的表现。iOS Safari有个奇怪的问题,background-position的动画有时候会卡住。我后来加上了transform3d强制开启硬件加速才解决:

.skeleton {
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
    transform: translateZ(0); /* 强制硬件加速 */
}

@keyframes loading {
    0% {
        background-position: 200% 0;
        transform: translateZ(0);
    }
    100% {
        background-position: -200% 0;
        transform: translateZ(0);
    }
}

第三个坑是颜色选择。很多人随便选个灰色就用了,但实际上要考虑整体设计风格。我一般会取主色调的浅色版本,这样看起来不会突兀。比如主题色是#1890ff,我就用#e6f7ff作为骨架屏的背景色之一。

React组件封装实践

实际项目中肯定不能每次都写一堆div。我封装了一个React组件,用起来比较方便:

import React from 'react';

const Skeleton = ({ 
    rows = 1, 
    width = '100%', 
    height = '16px', 
    circle = false,
    className = '',
    ...props 
}) => {
    const renderRows = () => {
        return Array.from({ length: rows }, (_, index) => (
            <div
                key={index}
                className={skeleton ${className}}
                style={{
                    width: circle ? height : width,
                    height,
                    borderRadius: circle ? '50%' : undefined,
                    marginBottom: index < rows - 1 ? '12px' : undefined
                }}
            />
        ));
    };

    return <div {...props}>{renderRows()}</div>;
};

// 使用示例
const MyComponent = () => {
    const [loading, setLoading] = useState(true);

    return (
        <div>
            {loading ? (
                <div>
                    <Skeleton rows={3} height="20px" />
                    <Skeleton width="60%" height="16px" style={{ marginTop: '10px' }} />
                </div>
            ) : (
                // 真实内容
            )}
        </div>
    );
};

Vue版本也很简单

Vue的实现大同小异,主要是利用props传递参数:

<template>
    <div v-if="loading" :class="wrapperClass">
        <div
            v-for="n in rows"
            :key="n"
            :class="['skeleton', className]"
            :style="getRowStyle(n)"
        />
    </div>
    <slot v-else />
</template>

<script>
export default {
    name: 'Skeleton',
    props: {
        loading: {
            type: Boolean,
            default: true
        },
        rows: {
            type: Number,
            default: 1
        },
        width: {
            type: String,
            default: '100%'
        },
        height: {
            type: String,
            default: '16px'
        },
        circle: {
            type: Boolean,
            default: false
        },
        className: String,
        wrapperClass: String
    },
    methods: {
        getRowStyle(index) {
            const isLastRow = index === this.rows;
            return {
                width: this.circle ? this.height : this.width,
                height: this.height,
                borderRadius: this.circle ? '50%' : '4px',
                marginBottom: !isLastRow ? '12px' : '0'
            };
        }
    }
};
</script>

动态控制显隐的最佳时机

什么时候隐藏Skeleton是最合适的?我在实践中发现,不能等到所有数据都加载完成才切换,这样反而会让用户觉得卡顿。我的做法是分批显示,先显示主要的内容区域,再逐步显示细节。

// 模拟接口请求
const fetchData = async () => {
    const response = await fetch('https://jztheme.com/api/content');
    const data = await response.json();
    
    // 设置一个延迟,让Skeleton多显示一会儿
    setTimeout(() => {
        setContent(data);
        setLoading(false);
    }, 300); // 至少显示300ms,避免闪烁
};

另外还要考虑网络特别快的情况,如果数据瞬间返回,Skeleton可能只闪了一下,用户体验反而不好。所以我会设置最小显示时间,保证动画能够完整播放一次。

这个技术的拓展用法还有很多,比如结合Intersection Observer实现懒加载的Skeleton、配合服务端渲染的首屏优化等等。后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。

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

暂无评论