[vue]一个省心省力的骨架屏实现方案

3,971 阅读3分钟

ClamView介绍

背景

在我的前前公司,接口经常出问题,基本每个接口都会出问题,这就要求我们前端对每个接口都要做错误信息的提醒,非常麻烦,当然这也是必须的,所以就想着做一个组件能够帮我们自动处理和展示这些错误信息,当时开发用的是Flutter,所以应该叫widget。后来又做vue和小程序了,想着能不能把那个解决方法带到vue上来,于是就有了这个

功能

  • 以骨架屏的形式展示加载中,而且可以丝滑过渡到加载完成
  • 加载失败展示错误信息

先看效果

test3.gif

原理

一般情况下,我们请求接口然后渲染数据时会先判断是否有数据来来渲染不同的视图,比如加载的展位图,空数据的占位图,有数据时就展示数据。

ClamView的思路则是 数据加载未完成时也给渲染的模板一个数据,然后通过给负责显示数据span、img等标签设置背景色和字体颜色来达到“骨架”的效果,待数据请求完成后,再使用动画将骨架隐去,完成过渡。

代码

  • ClamView.tsx

    ClamView 需要传入一个 ResponseBean点此了解 对象 res 来判断当前数据的状态,还需要传入一个加载过程中用到的假数据 emptyData 用来撑起你的 span 标签

import { defineComponent,computed } from 'vue';
import {ResponseBean} from "bdjf_http";
import './clam_view.css'
import './skeleton.css'

/**
 * 定义 ClamView 的四种状态
 * 1. LOADING res 为空或者res.code === -100 时状态为LOADING,此时显示骨架屏
 * 2. EMPTY res.code  === 0 且 res.data 为空时
 * 3. SHOW res.code  === 0 且 res.data 不为空时
 * 4. ERROR res.code  !== 0 时
 **/
type ViewStatusType = 'LOADING'|'EMPTY'|'SHOW'|'ERROR';

export default defineComponent({
    name:'ClamView',
    props:{
        res: {
            type:ResponseBean,
            default:()=>{
                // 默认显示一个loading
                return new ResponseBean().loading();
            }
        },
        showLoading :{
            type: Boolean,
            default:()=>{
                return false;
            }
        },
        emptyText:{
            type: String,
            default:()=>{
                return '暂无数据';
            }
        },
        emptyData:{
            type:Object,
            default:()=>{
                return {}
            }
        },
        noPackage:{
            type: Boolean,
            default:()=>{
                return false;
            }
        }
    },
    setup(props,{  slots }) {

        // 根据 res 的状态来判断如何显示
        const viewStatusAdapter = (response: ResponseBean): ViewStatusType => {
            // console.log('----viewStatusAdapter----',response)
            if (props.showLoading) {
                return "LOADING";
            }
            if (!response) {
                return "LOADING";
            }
            switch (response.code) {
                case 0:
                    if (!response.data || response.data.length === 0) {
                        return "EMPTY";
                    } else {
                        return "SHOW";
                    }
                case -100:
                    return "LOADING";
                default:
                    return "ERROR";
            }
        }

        // 用 computed 包一下
        const viewStatus = computed<ViewStatusType>(()=>{
            return viewStatusAdapter(props.res)
        })

        const noDataView = (text:string)=>{
            return (
                <div class="empty_view col-center item-center">
                    {text}
                </div>
            )
        }

        const emptyView = ()=>{
            if(viewStatus.value === 'EMPTY'){
                return slots.empty?slots.empty():noDataView(props.emptyText);
            }
        }

        const errorView = ()=>{
            if(viewStatus.value === 'ERROR'){
                 return slots.error?slots.error():noDataView(props.res.msg);
            }
        }



        return () => {
            if(viewStatus.value === 'EMPTY'){
                return emptyView();
            }else if(viewStatus.value === 'ERROR'){
                return errorView();
            }else {
                // noPackage 为 false 时,ClamView将会在 slots 外面包一层 div ,通过给div更换样式来实现状态切换;
                // 为 true 时,将不会包裹div,会通过 vClass 属性传替给 需要使用的地方绑定样式进行切换
                if(props.noPackage){
                    return slots.default({
                        data:viewStatus.value==='LOADING'?props.emptyData:props.res.data,
                        vClass:viewStatus.value==='LOADING'?'skeleton-view-empty-view':'skeleton-view-default-view'
                    })
                }else {
                    return (
                        <div  class={viewStatus.value==='LOADING'?'skeleton-view-empty-view':'skeleton-view-default-view'}>
                            {slots.default({
                                data:viewStatus.value==='LOADING'?props.emptyData:props.res.data
                            })}
                        </div>
                    )
                }
            }
        }
    }
})

  • skeleton.css

/**
正常状态下的 样式,
设置 transition 来让过渡平滑
*/
.skeleton-view-default-view span,
.skeleton-view-default-view a,
.skeleton-view-default-view img
{
    transition: all .7s ease;
    background-color: rgba(0, 0, 0, 0);
}


/**
加载时的样式,首先设置不监听任何事件,省的用户乱点
然后给 span、a、img标签设置可以动的背景,字体颜色设成透明,
就形成 “骨架” 了
*/
.skeleton-view-empty-view {
    pointer-events: none;
}
.skeleton-view-empty-view span,
.skeleton-view-empty-view a {
    color: rgba(0, 0, 0, 0) !important;
    border-radius: 2px;
    background: linear-gradient(
            -45deg,
            #F5F5F5 0%,
            #DCDCDC 25%,
            #F5F5F5 50%,
            #DCDCDC 75%,
            #F5F5F5 100%
    );
    animation: gradientBG 4s ease infinite;
    background-size: 400% 400%;
    background-color:#DCDCDC;
    transition: all 1s ease;
}

.skeleton-view-empty-view img {
    /* 这里是一个透明的小图片 */
    content: url(../../assets/img/no_url.png);
    border-radius: 2px;
    background: linear-gradient(
            -45deg,
            #F5F5F5 0%,
            #DCDCDC 25%,
            #F5F5F5 50%,
            #DCDCDC 75%,
            #F5F5F5 100%
    );
    animation: gradientBG 4s ease infinite;
    background-size: 400% 400%;
    background-color:#DCDCDC;
    transition: all 1s ease;
}
@keyframes gradientBG {
    0% {
        background-position: 100% 100%;
    }
    50% {
        background-position: 0% 0%;
    }
    100% {
        background-position: 100% 100%;
    }

}
  • clam_view.css
.clam-box{
    width: 100%;
    height: 100%;
}
.empty_view{
    padding-top: 100px;
    width: 100%;
    height: 100%;
    padding-bottom: 100px;
}
.empty_img{
    width: 310px;
    height: 218px;
}
.trip_text{
    font-size: 28px;
    color: #999999;
}

使用

<template>
  <div class="home col">
    <clam-view :res="response" v-slot="{data}" :empty-data="emptyData">
      <p><span>{{data.name}}</span></p>
      <p>Home</p>
      <router-link to="/about" >{{data.route}}</router-link>
    </clam-view>
  </div>
</template>
<script lang="ts">
import { defineComponent,reactive,toRefs,onMounted } from 'vue';
import {ResponseBean} from 'bdjf_http'

export default defineComponent({
  name: 'Home',
  setup(){

    const state = reactive({
      response:new ResponseBean().loading()
    })

    onMounted(()=>{
      setTimeout(()=>{
        state.response = new ResponseBean(0,'',{
          name:'Home',
          route:'About'
        })
      },2500)
    })

    const emptyData = {
      name:'站位文字',
      route:'站位文字'
    }

    return {
      ...toRefs(state),
      emptyData
    }
  }
});
</script>

<style scoped>
</style>

配合bdjf_http

如果你配合 bdjf_http 使用,就能用极少的代码完成所需功能 点此了解bdjf_http

post(API.getData())
.then(res => state.response = res;)