Vue3骨架屏(Skeleton)

简介: 该文章介绍了一个名为Skeleton的Vue组件,用于创建加载时的占位符界面,包含多种可配置项如按钮、输入框、图像等,并支持动画效果。

效果如下图:在线预览

在这里插入图片描述
在这里插入图片描述

APIs

Skeleton

参数 说明 类型 默认值
animated 是否展示动画效果 boolean true
button 是否使用按钮占位图 boolean | SkeletonButtonProps false
avatar 是否显示头像占位图 boolean | SkeletonAvatarProps false
input 是否使用输入框占位图 boolean | SkeletonInputProps false
image 是否使用图像占位图 boolean false
title 是否显示标题占位图 boolean | SkeletonTitleProps true
paragraph 是否显示段落占位图 boolean | SkeletonParagraphProps true
loading true 时,显示占位图,反之则直接展示子组件 boolean true

SkeletonButtonProps Type

名称 说明 类型 默认值
shape? 指定按钮的形状 ‘default’ | ‘round’ | ‘circle’ ‘default’
size? 设置按钮的大小 ‘small’ | ‘middle’ | ‘large’ ‘middle’
block? 将按钮宽度调整为其父宽度的选项 boolean false

SkeletonAvatarProps Type

名称 说明 类型 默认值
shape 指定头像的形状 ‘circle’ | ‘square’ ‘circle’
size 设置头像占位图的大小 number | ‘small’ | ‘middle’ | ‘large’ ‘middle’

SkeletonInputProps Type

名称 说明 类型 默认值
size 设置输入框的大小 ‘small’ | ‘middle’ | ‘large’ ‘middle’

SkeletonTitleProps Type

名称 说明 类型 默认值
width 设置标题占位图的宽度 number | string ‘38%’

SkeletonParagraphProps Type

名称 说明 类型 默认值
rows 设置段落占位图的行数 number | string avatar ? 2 : 3
width 设置段落占位图的宽度,若为数组时则为对应的每行宽度,反之则是最后一行的宽度 number | string | Array ‘61%’

创建骨架屏组件Skeleton.vue

<script setup lang="ts">
import { computed } from 'vue'

interface SkeletonButtonProps {
  shape?: 'default' | 'round' | 'circle' // 指定按钮的形状,默认 'default'
  size?: 'small' | 'middle' | 'large' // 设置按钮的大小,默认 'middle'
  block?: boolean // 将按钮宽度调整为其父宽度的选项,默认 false
}
interface SkeletonAvatarProps {
  shape?: 'circle' | 'square' // 指定头像的形状,默认 'circle'
  size?: number | 'small' | 'middle' | 'large' // 设置头像占位图的大小,默认 'middle'
}
interface SkeletonInputProps {
  size: 'small' | 'middle' | 'large' // 设置输入框的大小,默认 'middle'
}
interface SkeletonTitleProps {
  width?: number | string // 设置标题占位图的宽度,默认 '38%'
}
interface SkeletonParagraphProps {
  rows?: number | string // 设置段落占位图的行数,默认 avatar ? 2 : 3
  width?: number | string | Array<number | string> // 设置段落占位图的宽度,若为数组时则为对应的每行宽度,反之则是最后一行的宽度,默认 '61%'
}
interface Props {
  animated?: boolean // 是否展示动画效果
  button?: boolean | SkeletonButtonProps // 是否使用按钮占位图
  avatar?: boolean | SkeletonAvatarProps // 是否显示头像占位图
  input?: boolean | SkeletonInputProps // 是否使用输入框占位图
  image?: boolean // 是否使用图像占位图
  title?: boolean | SkeletonTitleProps // 是否显示标题占位图
  paragraph?: boolean | SkeletonParagraphProps // 是否显示段落占位图
  loading?: boolean // 为 true 时,显示占位图,反之则直接展示子组件
}
const props = withDefaults(defineProps<Props>(), {
  animated: true,
  button: false,
  image: false,
  avatar: false,
  input: false,
  title: true,
  paragraph: true,
  loading: true
})
const buttonSize = computed(() => {
  if (typeof props.button === 'object') {
    if (props.button.size === 'large') {
      return 40
    }
    if (props.button.size === 'small') {
      return 24
    }
    return 32
  }
})
const titleTop = computed(() => {
  if (typeof props.avatar === 'boolean') {
    return 8
  } else {
    if (typeof props.avatar.size === 'number') {
      return (props.avatar.size - 16) / 2
    } else {
      const topMap = {
        small: 4,
        middle: 8,
        large: 12
      }
      return topMap[props.avatar.size || 'middle']
    }
  }
})
const titleWidth = computed(() => {
  if (typeof props.title === 'boolean') {
    return '38%'
  } else {
    if (typeof props.title.width === 'number') {
      return props.title.width + 'px'
    }
    return props.title.width || '38%'
  }
})
const paragraphRows = computed(() => {
  if (typeof props.paragraph === 'boolean') {
    if (props.avatar) {
      return 2
    } else {
      return 3
    }
  } else {
    if (props.avatar) {
      return props.paragraph.rows || 2
    } else {
      return props.paragraph.rows || 3
    }
  }
})
const paragraphWidth = computed(() => {
  if (typeof props.paragraph === 'object') {
    if (Array.isArray(props.paragraph.width)) {
      return props.paragraph.width.map((width: number | string) => {
        if (typeof width === 'number') {
          return width + 'px'
        } else {
          return width
        }
      })
    } else if (typeof props.paragraph.width === 'number') {
      return Array(paragraphRows.value).fill(props.paragraph.width + 'px')
    } else if (typeof props.paragraph.width === 'string') {
      return Array(paragraphRows.value).fill(props.paragraph.width)
    }
  }
  return Array(paragraphRows.value)
})
</script>
<template>
  <div
    v-if="loading"
    class="m-skeleton"
    :class="{ 'skeleton-avatar': avatar, 'skeleton-animated': animated }"
    :style="`--button-size: ${buttonSize}px; --title-top: ${titleTop}px;`"
  >
    <span
      v-if="button"
      class="skeleton-button"
      :class="{
        'button-round': typeof button !== 'boolean' && button.shape === 'round',
        'button-circle': typeof button !== 'boolean' && button.shape === 'circle',
        'button-sm': typeof button !== 'boolean' && button.size === 'small',
        'button-lg': typeof button !== 'boolean' && button.size === 'large',
        'button-block': typeof button !== 'boolean' && button.shape !== 'circle' && button.block
      }"
    ></span>
    <span
      v-if="input"
      class="skeleton-input"
      :class="{
        'input-sm': typeof input !== 'boolean' && input.size === 'small',
        'input-lg': typeof input !== 'boolean' && input.size === 'large'
      }"
    ></span>
    <div v-if="image" class="skeleton-image">
      <svg class="image-svg" viewBox="0 0 1098 1024" xmlns="http://www.w3.org/2000/svg">
        <path
          class="svg-path"
          d="M365.714286 329.142857q0 45.714286-32.036571 77.677714t-77.677714 32.036571-77.677714-32.036571-32.036571-77.677714 32.036571-77.677714 77.677714-32.036571 77.677714 32.036571 32.036571 77.677714zM950.857143 548.571429l0 256-804.571429 0 0-109.714286 182.857143-182.857143 91.428571 91.428571 292.571429-292.571429zM1005.714286 146.285714l-914.285714 0q-7.460571 0-12.873143 5.412571t-5.412571 12.873143l0 694.857143q0 7.460571 5.412571 12.873143t12.873143 5.412571l914.285714 0q7.460571 0 12.873143-5.412571t5.412571-12.873143l0-694.857143q0-7.460571-5.412571-12.873143t-12.873143-5.412571zM1097.142857 164.571429l0 694.857143q0 37.741714-26.843429 64.585143t-64.585143 26.843429l-914.285714 0q-37.741714 0-64.585143-26.843429t-26.843429-64.585143l0-694.857143q0-37.741714 26.843429-64.585143t64.585143-26.843429l914.285714 0q37.741714 0 64.585143 26.843429t26.843429 64.585143z"
        ></path>
      </svg>
    </div>
    <div v-if="avatar" class="skeleton-header">
      <span
        class="skeleton-avatar"
        :class="{
          'avatar-sm': typeof avatar !== 'boolean' && avatar.size === 'small',
          'avatar-lg': typeof avatar !== 'boolean' && avatar.size === 'large',
          'avatar-square': typeof avatar !== 'boolean' && avatar.shape === 'square'
        }"
      ></span>
    </div>
    <template v-if="!button && !image && !input">
      <div v-if="title || paragraph" class="skeleton-content">
        <h3 v-if="title" class="skeleton-title" :style="{ width: titleWidth }"></h3>
        <ul v-if="paragraph" class="skeleton-paragraph" :class="{ mt24: title, mt28: title && avatar }">
          <li v-for="n in paragraphRows" :key="n" :style="`width: ${paragraphWidth[(n as number) - 1]};`"></li>
        </ul>
      </div>
    </template>
  </div>
  <slot v-else></slot>
</template>
<style lang="less" scoped>
.m-skeleton {
  display: table;
  width: 100%;
  .skeleton-button {
    display: inline-block;
    vertical-align: top;
    background: rgba(0, 0, 0, 0.06);
    border-radius: 4px;
    width: 64px;
    min-width: 64px;
    height: 32px;
    line-height: 32px;
  }
  .button-sm {
    width: 48px;
    min-width: 48px;
    height: 24px;
    line-height: 24px;
  }
  .button-lg {
    width: 80px;
    min-width: 80px;
    height: 40px;
    line-height: 40px;
  }
  .button-round {
    border-radius: var(--button-size);
  }
  .button-circle {
    width: var(--button-size);
    min-width: var(--button-size);
    border-radius: 50%;
  }
  .button-block {
    width: 100%;
  }
  .skeleton-input {
    display: inline-block;
    vertical-align: top;
    background: rgba(0, 0, 0, 0.06);
    border-radius: 4px;
    width: 160px;
    min-width: 160px;
    height: 32px;
    line-height: 32px;
  }
  .input-sm {
    width: 120px;
    min-width: 120px;
    height: 24px;
    line-height: 24px;
  }
  .input-lg {
    width: 200px;
    min-width: 200px;
    height: 40px;
    line-height: 40px;
  }
  .skeleton-image {
    display: flex;
    align-items: center;
    justify-content: center;
    vertical-align: top;
    background: rgba(0, 0, 0, 0.06);
    border-radius: 4px;
    width: 96px;
    height: 96px;
    line-height: 96px;
    .image-svg {
      width: 48px;
      height: 48px;
      line-height: 48px;
      max-width: 192px;
      max-height: 192px;
      .svg-path {
        fill: #bfbfbf;
      }
    }
  }
  .skeleton-header {
    display: table-cell;
    padding-right: 16px;
    vertical-align: top;
    .skeleton-avatar {
      display: inline-block;
      vertical-align: top;
      background: rgba(0, 0, 0, 0.06);
      width: 32px;
      height: 32px;
      line-height: 32px;
      border-radius: 50%;
    }
    .avatar-sm {
      width: 24px;
      height: 24px;
      line-height: 24px;
    }
    .avatar-lg {
      width: 40px;
      height: 40px;
      line-height: 40px;
    }
    .avatar-square {
      border-radius: 6px;
    }
  }
  .skeleton-content {
    display: table-cell;
    width: 100%;
    vertical-align: top;
    .skeleton-title {
      margin: 0;
      height: 16px;
      background: rgba(0, 0, 0, 0.06);
      border-radius: 4px;
    }
    .skeleton-paragraph {
      margin: 0;
      padding: 0;
      li {
        height: 16px;
        list-style: none;
        background: rgba(0, 0, 0, 0.06);
        border-radius: 4px;
        &:not(:first-child) {
          margin-top: 16px;
        }
        &:last-child {
          width: 61%;
        }
      }
    }
    .mt24 {
      margin-top: 24px;
    }
    .mt28 {
      margin-top: 28px;
    }
  }
}
.skeleton-avatar {
  .skeleton-content {
    .skeleton-title {
      margin-top: var(--title-top);
    }
  }
}
.skeleton-animated {
  .skeleton-button,
  .skeleton-input,
  .skeleton-image,
  .skeleton-header .skeleton-avatar,
  .skeleton-content .skeleton-title,
  .skeleton-content .skeleton-paragraph li {
    position: relative;
    z-index: 0;
    overflow: hidden;
    background: transparent;
    &::after {
      position: absolute;
      top: 0;
      left: -150%;
      bottom: 0;
      right: -150%;
      background: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, 0, 0.15) 37%, rgba(0, 0, 0, 0.06) 63%);
      animation-name: skeleton-loading;
      animation-duration: 1.4s;
      animation-timing-function: ease;
      animation-iteration-count: infinite;
      content: '';
    }
    @keyframes skeleton-loading {
      0% {
        transform: translateX(-37.5%);
      }
      100% {
        transform: translateX(37.5%);
      }
    }
  }
}
</style>

在要使用的页面引入

其中引入使用了以下组件

<script setup lang="ts">
import Skeleton from './Skeleton.vue'
import { ref } from 'vue'

const loading = ref<boolean>(false)

const showSkeleton = () => {
  loading.value = true
  setTimeout(() => {
    loading.value = false
  }, 2000)
}
const animated = ref(false)
const block = ref(false)
const size = ref('middle')
const buttonShape = ref('default')
const avatarShape = ref('circle')
const sizeOptions = ref([
  {
    label: 'small',
    value: 'small'
  },
  {
    label: 'middle',
    value: 'middle'
  },
  {
    label: 'large',
    value: 'large'
  }
])
const buttonShapeOptions = ref([
  {
    label: 'default',
    value: 'default'
  },
  {
    label: 'round',
    value: 'round'
  },
  {
    label: 'circle',
    value: 'circle'
  }
])
const avatarShapeOptions = ref([
  {
    label: 'square',
    value: 'square'
  },
  {
    label: 'circle',
    value: 'circle'
  }
])
</script>
<template>
  <div>
    <h1>{
  { $route.name }} {
  { $route.meta.title }}</h1>
    <h2 class="mt30 mb10">基本使用</h2>
    <Skeleton />
    <h2 class="mt30 mb10">复杂的组合</h2>
    <Skeleton avatar :paragraph="{ rows: 4 }" />
    <h2 class="mt30 mb10">包含子组件</h2>
    <Button :loading="loading" @click="showSkeleton">Show Skeleton</Button>
    <br />
    <br />
    <Skeleton :loading="loading">
      <div>
        <h4>Vue Amazing UI, a design language</h4>
        <br />
        <p>
          We supply a series of design principles, practical patterns and high quality design resources, to help people
          create their product prototypes beautifully and efficiently.
        </p>
      </div>
    </Skeleton>
    <h2 class="mt30 mb10">自定义标题和段落</h2>
    <Skeleton avatar :title="{ width: '24%' }" :paragraph="{ rows: 4, width: ['48%', '72%', '96%', '60%'] }" />
    <h2 class="mt30 mb10">按钮 / 输入框 / 图像 / 头像</h2>
    <Flex :gap="32">
      <Flex vertical :gap="12" width="50%">
        <Skeleton :animated="animated" :button="{ shape: buttonShape, size: size, block: block }" />
        <Skeleton style="width: 200px" :animated="animated" :input="{ size: size }" />
        <Skeleton :animated="animated" image />
        <Skeleton :avatar="{ shape: avatarShape, size: size }" :paragraph="{ rows: 2 }" />
      </Flex>
      <Flex vertical gap="large" width="50%">
        <Space gap="large">
          <Space align="center">
            animated:
            <Switch v-model="animated" />
          </Space>
          <Space align="center">
            Button Block:
            <Switch v-model="block" />
          </Space>
        </Space>
        <Space align="center">
          Size:
          <Radio :options="sizeOptions" v-model:value="size" button />
        </Space>
        <Space align="center">
          Button Shape:
          <Radio :options="buttonShapeOptions" v-model:value="buttonShape" button />
        </Space>
        <Space align="center">
          Avatar Shape:
          <Radio :options="avatarShapeOptions" v-model:value="avatarShape" button />
        </Space>
      </Flex>
    </Flex>
  </div>
</template>
相关文章
|
2月前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
143 64
|
2月前
|
JavaScript 前端开发 API
Vue 3 中 v-model 与 Vue 2 中 v-model 的区别是什么?
总的来说,Vue 3 中的 `v-model` 在灵活性、与组合式 API 的结合、对自定义组件的支持等方面都有了明显的提升和改进,使其更适应现代前端开发的需求和趋势。但需要注意的是,在迁移过程中可能需要对一些代码进行调整和适配。
115 60
|
10天前
|
JavaScript API 数据处理
vue3使用pinia中的actions,需要调用接口的话
通过上述步骤,您可以在Vue 3中使用Pinia和actions来管理状态并调用API接口。Pinia的简洁设计使得状态管理和异步操作更加直观和易于维护。无论是安装配置、创建Store还是在组件中使用Store,都能轻松实现高效的状态管理和数据处理。
39 3
|
2月前
|
前端开发 JavaScript 测试技术
Vue3中v-model在处理自定义组件双向数据绑定时,如何避免循环引用?
Web 组件化是一种有效的开发方法,可以提高项目的质量、效率和可维护性。在实际项目中,要结合项目的具体情况,合理应用 Web 组件化的理念和技术,实现项目的成功实施和交付。通过不断地探索和实践,将 Web 组件化的优势充分发挥出来,为前端开发领域的发展做出贡献。
39 8
|
2月前
|
存储 JavaScript 数据管理
除了provide/inject,Vue3中还有哪些方式可以避免v-model的循环引用?
需要注意的是,在实际开发中,应根据具体的项目需求和组件结构来选择合适的方式来避免`v-model`的循环引用。同时,要综合考虑代码的可读性、可维护性和性能等因素,以确保系统的稳定和高效运行。
33 1
|
2月前
|
JavaScript
Vue3中使用provide/inject来避免v-model的循环引用
`provide`和`inject`是 Vue 3 中非常有用的特性,在处理一些复杂的组件间通信问题时,可以提供一种灵活的解决方案。通过合理使用它们,可以帮助我们更好地避免`v-model`的循环引用问题,提高代码的质量和可维护性。
42 1
|
2月前
|
JavaScript
在 Vue 3 中,如何使用 v-model 来处理自定义组件的双向数据绑定?
需要注意的是,在实际开发中,根据具体的业务需求和组件设计,可能需要对上述步骤进行适当的调整和优化,以确保双向数据绑定的正确性和稳定性。同时,深入理解 Vue 3 的响应式机制和组件通信原理,将有助于更好地运用 `v-model` 实现自定义组件的双向数据绑定。
|
2月前
|
JavaScript 索引
Vue 3.x 版本中双向数据绑定的底层实现有哪些变化
从Vue 2.x的`Object.defineProperty`到Vue 3.x的`Proxy`,实现了更高效的数据劫持与响应式处理。`Proxy`不仅能够代理整个对象,动态响应属性的增删,还优化了嵌套对象的处理和依赖追踪,减少了不必要的视图更新,提升了性能。同时,Vue 3.x对数组的响应式处理也更加灵活,简化了开发流程。
|
2月前
|
JavaScript 前端开发 API
从Vue 2到Vue 3的演进
从Vue 2到Vue 3的演进
44 0
|
2月前
|
JavaScript 前端开发 API
Vue.js响应式原理深度解析:从Vue 2到Vue 3的演进
Vue.js响应式原理深度解析:从Vue 2到Vue 3的演进
65 0

热门文章

最新文章