【超实用,包教会】Vue3 hook + directive实现全图素预加载解决方案

图片一直是页面中重要的视觉部分,尤其以图片作为主要视觉素材的C端页面。如果用户打开页面后,看到成片成片的图片正在缓慢呈现,脑壳难免一痛,轻则吐槽,重则直接退出,影响留存。

所以,我们有必要将页面中的关键图片进行预加载处理。保证开屏后,用户看到的图片是完整无加载的。

完整阅读本文,你会了解到:

  • 我对图片预加载概念的理解;
  • 浏览器对图片加载的时机,及借此实现的预加载思路;
  • 我的 Vue3 hook + directive 预加载策略的实现详解。

一. 什么是图素的预加载

我对预加载的定义是------在"页面主要视觉呈现前",而非"页面出现前"将物料图素加载完成

这也就意味着,在SPA应用中, 你不用纠结图片必须在当前组件挂载前就做好所有的图片加载。实际上,你完全可以在组件挂载后进行图片的预加载操作,加载期间,可以使用loading遮盖图素区域,当加载完成后,loading消失,展现给用户的就是完整无卡顿的图片。

在我的实践中,我会为每一个页面级别的组件都增加一个整页loading。当页面组件挂载后,开始预加载所有关键图素,当全部加载完成后,关闭整页loading。这一做法尤其适合物料图素多的页面。

本文介绍的 Vue3 hook + directive 解决方案,更是有利于这种整页loading的实现。

二. 实现效果

先来看看,不使用任何图片预加载策略的页面是什么体验。

TIP:已清除浏览器缓存,并关闭控制台的 Disable cache。

从上图可以看出,页面中的 图素多,有大有小;有<img />标签的、background-image的、js动态创建的。但无论大小和种类,刷新后都有肉眼可见的加载过程,体验非常不好,这也正是我们预加载需要解决的问题。

使用本文的策略,在各种图素加载完成前呈现整页loading,之后图片的展示就会无比丝滑。开发者使用起来也非常方便:

三. 到底有哪些图需要预加载?

预加载的处理范围并不仅仅只是<img />标签对应的图片,在这里我总结了下面几种需要处理的图素类型:

  1. <img /> 标签对应的链接图片;
  2. 容器节点的CSS background-image;
  3. display:none 隐藏且未来可能展示的上述节点;
  4. 通过 javascript 动态挂载的上述节点。

当然,图片也并非一定要做预加载,过长的整页loading也很劝退用户,因此图素是否需要预加载可考虑下面几点:

  1. 图片很小,可以瞬间加载的,考虑不用;
  2. 图片不醒目,信息承载不重要,考虑不用;
  3. 图片首屏看不到,看到时大概率已加载完成,考虑不用; 比如:页面需要滚动很久才能看到的底端图片(此时应该考虑使用滚动懒加载,而非预加载)。

TIP:不要滥用预加载。预加载 + 懒加载 才是最佳的呈现方式。一次性预加载过多图片,会占用网络带宽,抬高整页loading的覆盖时间,劝退用户,阻塞接口。

四. 预加载的思路?

图片预加载技术并不是神奇的魔法,本质是也是借助了浏览器特性和缓存策略 。下面列举了一些浏览器对图片的加载特性,你需要深知这些特性,才能找到各种图片预加载的场景和解决方案:

  1. DOM树中只要<img />存在,尽管是被display: none掉,或者他所在的容器被display:none掉,浏览器都会进行图片加载。 (意味着css中不能第一时间加载的图片可以借助创建 <img /> 加载)

  2. new Image()<img />一样,只要image.src被赋值,无论是否被append到页面上,都会进行图片加载。 (意味着可以不用将 <img /> 创建到dom上)

  3. CSS中的任何图片,只有在用到他们的时候才会临时加载。这就意味着,如果div没有被渲染,那么div的background-image也不会被加载。 (这是预加载需要解决的一个问题)

  4. 更多相同的src<img />,浏览器都只会请求第一次的img加载链接,不会有多个<img />就加载多少次相同的链接。

  5. 如果<img />已经出现过一次,后面通过js动态插入了更多img标签,那么要根据disable-cache分情况讨论:

    1. 开启了disable-cache:每次创建<img />,都请求一次,无论src是否此前已经请求过。 (这种情况下无解,毕竟用户直接关闭了浏览器缓存,但通常情况下不会这样)
    2. 没开启disable-cache:只有创建的<img />的 src 是从未出现过的,才会重新请求,否则不会发送请求。 (network 中没有from-cache,连这条图片的请求记录都没有)(可以用作处理动态创建 <img /> 的预加载思路)

五. 我为什么用hook + directives组合式解决方案

结合上面的浏览器特性,我们的脑中很容易蹦出一个常见的解决思路:

  • SPA实际上是一个页面,那就在上一个页面级组件中,通过new Image()的方式加载下个页面的所有图片

我认为,这样的方式很不耐用,也经不起考验,因为他们有3个重大缺陷:

  1. 太麻烦: 需要收集下个页面的需要预加载的图素链接,人肉遍历HTML和CSS以及JS中动态创建的图素。
  2. 无法保证全部图素预加载完成: 在前一个页面预加载期间切换到下一个页面,就会出现部分图片未预加载生效的问题。
  3. 作为入口页面时,无法进行预加载: 假设你分享了一个图素丰富的H5活动页,用户直接打开的是活动页面路由,此时只会挂载活动页面组件,不会先挂载承担预加载逻辑的页面,你的预加载逻辑也就得不到执行。

所以,不必想办法在前一个页面中预加载,这样的方法不通用、不可靠。所以不如就在当前页面实现预加载,在图片加载前开启整页 loading,完成后关闭整页loading。

如果搭配使用 Vue3 hook + directive 实现方式,那么图片预加载将变得简单且有效,这样做有3个好处:

  1. 使用简单;
  2. 一眼能看出图片哪些图片预加载了,哪些没有;
  3. Hook和directive本身就具备很强的复用性;

六. 实现预加载hook------useAllImageLoaded()

核心就是去监听图片们是否已经全部加载完成,然后触发hook回调。在我的实践中,hook中的回调通常负责关闭整页loading。

使用方式如下:

typescript 复制代码
const loading = ref(true) // 整页loading是否开启
const container = ref(null) // container是DOM类型,表示container下的<img />全都需要预加载

useAllImageLoaded(container,  () => {
  // 当container下的<img />都加载完成后,关闭整页loading
  loading.value = false
})

1. useAllImageLoaded() 支持收集和监听

我们先来看useAllImageLoaded需要处理的第一个具体需求:

  • 在组件mounted时,和updated时,对container 下的<img /> 进行收集,并监听load事件。

想要实现这个需求并不困难,但你需要注意以下几个细节,我们才能做的更好

  • 做参数转换兼容: 如果用户提供的container不存在,则抛出警告,不做任何处理;如果container存在但类型不是DOM,则用body作为container;
  • 防止内存泄露: 当图片已经加载过,立刻删除load事件监听回调;
  • 考虑到页面更新: 某些图片在组件更新后复用,他还是已经加载过了,此时不应该重复设置load监听。
  • 对用户的逻辑做容错: 用户的逻辑就是hook的回调参数,发生错误应该catch捕获,保证不终止程序运行,不让预加载成为页面的杀手。
typescript 复制代码
import { onMounted, onUpdated, Ref } from "vue";

type AnyFunc = () => any

function noop () { /* do nothing... */ }

export default function useAllImageLoaded(container: Ref<HTMLElement> | string[] | null, dynamicSrcs?: string[] | AnyFunc, callback?: AnyFunc) {
  if (arguments.length < 1) {
    // 不带入参数是没有意义的
    console.warn('At least 1 parameter are required! ------from useAllImageLoaded Hook.')
    return false
  }

  function collectImageLoaded() {
    if ([null, undefined].includes((container as Ref<HTMLElement>).value)) {
      console.warn('useAllImageLoaded hook warning: no container, no static images preloaded!')
      container = null
    }
    
    if (container !== null && !((container as Ref<HTMLElement>).value as any instanceof HTMLElement)) {
      console.warn('useAllImageLoaded hook warning: container is not a HTMLElement instance, use body')
      container = { value: document.body } as Ref<HTMLElement>
    }

    let images = [] // 所有<img /> DOM对象集合

    // 获取container下的所有<img />标签。
    // 注意querySelectorAll获取的是伪数组,这里应该转换为数组
    const imgDomsUnderContainer = 
      Array.prototype.slice.call(
        (
          (container as Ref<HTMLElement>)?.value && (container as Ref<HTMLElement>)?.value.querySelectorAll('img')
        ) || []
      )


    images = imgDomsUnderContainer

    const promises = images.map(node => {
      // 遍历images中的每一个图片节点
      return new Promise((resolve) => {
        // 图片loaded之后的回调
        function onceLoaded() {
          // 图片加载过一次之后,移除load event listener,防止内存泄露
          node.removeEventListener('load', onceLoaded)
          resolve(true)
        }
        // 如果 images 已经加载过一次了,不必在增加额外的load
        if (node.complete) {
          // 考虑到页面更新,某些图片src未发生改变,diff后复用的情况下,load事件不会被触发
          // 因此对于已经load又被复用的图片,应该根据node.complete属性来判断图片是否已经加载完成。
          resolve(true)
        } else {
          // 设置对图片的load监听
          node.addEventListener('load', loadedCallback)
        }
      })
    })

    Promise.all(promises).then(() => {
      // 当收集的所有需要预加载的图素都loaded,那么触发参数回调
      callback && callback();
    }).catch(e => {
      // 因为callback是用户提供的,随时可能发生错误。
      // 但预加载发生错误不致命,此时应该catch捕获,通过不终止程序的手段抛出错误
      console.error('网络异常或其他程序异常', e);
    })
  }

  onMounted(() => {
    // 当组件挂载后,子DOM挂载全部完成,开始收集img
    collectImageLoaded()
  })

  onUpdated(() => {
    // 组件更新完成后,根据最新的DOM,重新收集img
    collectImageLoaded()
  })
}

现在,通过hook,我们已经可以实现对container下,所有的<img />进行收集,并设置监听,在全部<img />预加载完成后,触发hook回调,用户可以通过hook回调关闭loading蒙层。

接下来,我们可以进一步扩展useAllImageLoaded的功能,支持动态图素预加载。

2. useAllImageLoaded() 对动态图素的预加载

什么是动态图素?

  • 通过 javascript 创建的图素就是动态图素。 其中也包括了动态创建的<img />、带有background-image的DOM容器等,还包括在其他页面中出现的图素。

对于动态图素,如果通过Vue数据驱动的形式去创建的就还好,因为有updated生命周期,useAllImageLoaded() 可以实现自动收集和监听。但通过自己写的js创建的图素,目前没有太好的办法进行自动收集和监听。

为此,我特别为 useAllImageLoaded() 追加了一个动态图片的加载入口。 可以让用户手动将那些动态图素链接代入到useAllImageLoaded()中,useAllImageLoaded()就会根据这些动态图素的链接构造出 <img /> 对象,并和静态图素一起实现load监听。这样对动态图素的预加载能力也就实现了。

使用方式如下:

typescript 复制代码
const loading = ref(true)
const container = ref(null)

useAllImageLoaded(container, 
    // 数组中包含着你想要预加载的动态图素链接
    [
        'https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg',
        "https://img0.baidu.com/it/u=3363994336,562770620&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400"
    ], 
    () => {
      loading.value = false
    }
)

另外,参数container未必总有意义,用户可能在当前页面没有想要预加载的图素,比如:只想在当前页面为未来某个页面预加载的图素。此场景下,用户可能希望这样使用:

typescript 复制代码
const loading = ref(true)

// 无需需要回调
useAllImageLoaded(['https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg'])

// 需要回调
useAllImageLoaded(['https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg'], () => {
  loading.value = false
})

所以,现在我们可以整理出当前useAllImageLoaded()需要补充的能力:

  • 支持函数重载;
  • 将动态图素链接通过new Image()的方式构造成<img / >,掺入预加载图素集合中,并设置load监听。

useAllImageLoaded() 完整版代码如下,内含注释详细讲解:

typescript 复制代码
import { onMounted, onUpdated, Ref } from "vue";

type AnyFunc = () => any

function noop () { /* do nothing... */ }

export default function useAllImageLoaded(container: Ref<HTMLElement> | string[] | null, dynamicSrcs?: string[] | AnyFunc, callback?: AnyFunc) {
  if (arguments.length < 1) {
    // 没参数是没有意义的
    console.warn('At least 1 parameter are required! ------from useAllImageLoaded Hook.')
    return false
  }

  if (arguments.length === 1) {
    // 如果带入1个参数,只能是 动态链接集合
    // 此时只有参数为需要动态加载的图片集合方面存在意义,如果参数为container或者callback函数都没有意义
    if (Array.isArray(container)) {
      dynamicSrcs = container
      container = null
      callback = noop
    } else {
      // 如果用户代入错误的参数情况,终止预加载,并抛出警告
      console.warn('When only 1 parameter is brought in, it is required that an image link array be required! ------from useAllImageLoaded Hook.')
      return false
    }
  }
    
  if (arguments.length === 2) {
    // 如果带入2个参数,就是 (动态链接集合 + 回调) 和 (container + 回调)
    // 如果是 (container + 动态链接集合)实际上没什么意义,但我们也进行container下的预加载处理
    if (typeof dynamicSrcs === 'function') {
      if (Array.isArray(container)) {
        // (动态链接集合 + 回调)
        callback = dynamicSrcs as unknown as AnyFunc
        dynamicSrcs = container
        container = null
      } else {
        // (container + 回调)
        callback = dynamicSrcs
        dynamicSrcs = []
      }
    } else if (Array.isArray(dynamicSrcs)) {
      // (container + 动态链接集合)
      callback = noop
    }
  }

  function collectImageLoaded() {
    // 如果container是null或undefined,说明当前用户无需做静态图片预加载
    if ([null, undefined].includes((container as Ref<HTMLElement>).value)) {
      console.warn('useAllImageLoaded hook warning: no container, no static images preloaded!')
      container = null
    }

    if (container !== null && !((container as Ref<HTMLElement>).value as any instanceof HTMLElement)) {
      // 如果container存在,但不是DOM类型,说明用户有心预加载,但参数错误,用body替代container
      console.warn('useAllImageLoaded hook warning: container is not a HTMLElement instance, use body')
      container = { value: document.body } as Ref<HTMLElement>
    }

    let images = []

    const imgDomsUnderContainer = 
      Array.prototype.slice.call(
        (
          (container as Ref<HTMLElement>)?.value && (container as Ref<HTMLElement>)?.value.querySelectorAll('img')
        ) || []
      )

    const dynamicImages = (dynamicSrcs && (dynamicSrcs as string[]).map(imgSrc => {
      // 将动态链接实例化为<img />实例
      const img = new Image()
      img.src = imgSrc
      return img
    })) || []

    images = [...imgDomsUnderContainer, ...dynamicImages]

    const promises = images.map(node => {
      return new Promise((resolve) => {
        function onceLoaded() {
          node.removeEventListener('load', onceLoaded)
          resolve(true)
        }

        if (node.complete) {
          // 考虑到页面更新,某些图片src未发生改变,diff后复用的情况下,load事件不会被触发
          // 因此对于已经load又被复用的图片,应该根据node.complete属性来进行判断。
          resolve(true)
        } else {
          node.addEventListener('load', onceLoaded)
        }
      })
    })

    Promise.all(promises).then(() => {
      callback && callback();
    }).catch(e => {
      console.error('网络异常或其他程序异常', e);
    })
  }

  onMounted(() => {
    console.log('useAllImageLoaded mounted')
    collectImageLoaded()
  })

  onUpdated(() => {
    collectImageLoaded()
  })
}

七. Hook 搭配 v-preImg 实现 background-image 预加载

useAllImageLoaded() Hook 实现了某容器下全部 <img />预加载,以及动态图素的加载。可一些图素是在CSS的background-image里的,useAllImageLoaded() 还不能做到对背景图的收集和加载监听。

为了解决CSS背景图的预加载问题,我们可以回想前文所提到的浏览器特性:

  1. DOM树中只要<img />存在,尽管是被display: none掉,或者他所在的容器被display:none掉,浏览器都会进行图片加载。

这意味着css中不能第一时间加载的图片可以借助创建<img />的方式加载。这也是v-preImage需要去协助useAllImageLoaded()完成的工作。

在实现 v-preImage 前,我们先看看将一个url创建为 <img />节点有什么讲究:

typescript 复制代码
const body = document.body

// 用map去记录指令append的url-div有哪些
const wrappers = new Map([])
let wrapperId = 0

function createImgSection(url: string) {
  // 如果没有url或者相同url已经创建过了,那就不用再创建div img了
  if (!url) return false
  if (wrappers.has(url)) return true
  // 创建div img
  const wrapper = document.createElement('div')
  const img = new Image()
  img.src = url

  wrapper.classList.add(`_preload_img_section_wrapper_${wrapperId++}`)
  wrapper.style.display = 'none' // 不渲染
  wrapper.appendChild(img)

  wrappers.set(url, wrapper)
  body.appendChild(wrapper)

  return true
}

我用一个Map结构去缓存所有已经挂载过的wrapper,在下次根据url创建wrapper前,先去判断当前Url是不是已经挂载过了。如果没有,就会在wrapper下创建一个<img />节点,然后通过 display:none 的方式挂载到页面上。

除此之外,我还给wrapper增加了一个独特的class + 唯一的id。这些是为了方便调试,在你的需求中,你可以不这么做。

接下来,将v-preImage的实现就简单了,只需要做好3件事情:

  • 拿到指令挂载的DOM节点,并拿到 background-image 链接;
  • 将连接通过 createImgSection() 创建为隐藏的 <img />
  • 在 background-image 所在节点销毁时,也同步删除<img />

实现代码如下:

typescript 复制代码
const body = document.body

// 用map去记录指令append的url-div有哪些
const wrappers = new Map([])
let wrapperId = 0

export default function createPreLoadImgDirectives(config: {unmount: boolean} = { unmount : true }) {
  return {
    mounted(el) {
      console.log('preload mounted')
      if (el.tagName === 'img') {
        // 如果当前是img标签,无需操作,因为Img无论display如何,那么都会进行加载
        return
      }
      // 如果不是img的其他容器,拿到css中的背景图
      const bgUrl = getComputedStyle(el).backgroundImage
      // 将url从css中解构出来
      const url = bgUrl.slice(5, bgUrl.length - 2) // 拿到background-image链接

      // 由于vue指令的this是undefined,所以指令不同生命周期之间不能传递数据,通过把数据挂载到el上
      el._vpreloadUrl = url
      // 创建div img标签在body上
      createImgSection(url)
    },
    unmounted(el) {
      if (config.unmount) {
        // 当指令元素被卸载时,如果开启了unmounted配置
        // 从div img集合中找到对应的div
        const wrapper = wrappers.get(el._vpreloadUrl)
        if (wrapper) {
          // 从body上删除掉wrapper
          body.removeChild(wrapper as HTMLElement)
          // 从div img集合中删除掉
          wrappers.delete(el._vpreloadUrl)
        }
      }
    }
  }
}

这样,对于在 background-image 中的图素,v-preImg 也可以将其创建为 <img />。搭配 useAllImageLoaded() Hook,可以将 v-preImg 创建的 <img /> 收集起来,并同其他预加载图素loaded后,执行 hook 的 callback,关闭整页 loading。

  • 你可能想问,useAllImageLoaded() 一定能保证在 v-preImg 创建完 <img / > 后收集么?

    • 答案:一定的!这和 Vue 的组件渲染原理有关。在 useAllImageLoaded() 中我们是在 mounted 生命周期中进行 <img / > 收集的。在 mounted 时,当前组件的下的全部子节点(不包括子组件的子节点)一定是挂载完成的。updated 生命周期也同理。
  • 子组件下的子节点即然不能保证?那该怎么办?

    • 答案很简单,useAllImageLoaded() 和 v-preImg 不推荐跨组件层级使用,应该在子组件下使用 useAllImageLoaded() 和 v-preImg。

八. 开始轻松的使用预加载

下面代码为在本文标题二中的demo示例。展示了如何通过本文的 useAllImageLoaded() Hook + v-preImg 来对不同类型的图素进行预加载。

html 复制代码
<template>
  <Loader v-if="loading"></Loader>
  <div ref="container" class="test-img-wrapper">
    <button @click="toggleShow" v-pre-img>显示or隐藏 含有background-image的div</button> <br />
    
    <button @click="showImg">通过js挂载曾加载过src的img标签</button>
    <br />
    <button @click="toggleHiddenBgShow">挂载or删除 动态渲染的background-image的div</button>
    <br />
    <div class="img-box">
      这是img标签渲染的图片1 ↓
      <img v-pre-img src="https://gips2.baidu.com/it/u=2128184746,1403673116&fm=3039&app=3039&f=PNG?w=1024&h=1024" alt="">
    </div>
    <div class="img-box">
      这是img标签渲染的图片2 ↓
      <img src="https://gips2.baidu.com/it/u=45931653,2379222580&fm=3039&app=3039&f=PNG?w=1024&h=1024" alt="">
    </div>
    
    <div></div>

    <div class="img-box" v-show="show" style="color: blue">
      这是div通过css background-image渲染的图片1 ↓
      <div v-pre-img class="img-wrapper-1"></div>
    </div>
    <div class="img-box" v-show="show" style="color: blue">
      这是div通过css background-image渲染的图片2 ↓
      <div v-pre-img class="img-wrapper-2"></div>
    </div>
    <div class="img-box" v-show="show" style="color: blue">
      这是div通过css background-image渲染的图片3 ↓
      <div v-pre-img class="img-wrapper-3"></div>
    </div>
    
    <div v-if="hiddenBgShow"  class="img-box" style="color: green">
      这是javascript新创建的图片 ↓
      <div class="hidden-bg-wrapper"></div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref } from 'vue'
import Loader from './Loader.vue'
import vPreImg from '@/directives/preload-img/v-preImg'
import useAllImageLoaded from '@/hooks/useAllImageLoaded'

const show = ref(false)
const loading = ref(true)
const container = ref(null)

useAllImageLoaded(
    // 预加载处理的DOM范围
    container,  
    // 需要预加载的动态图素的链接
    ['https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg'], 
    // 全部图素预加载完成的回调函数
    () => {
      loading.value = false  // 关闭整页loading
    }
)

// (container + 动态链接集合 + 回调)
// useAllImageLoaded(container, ['https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg'], () => {
//   loading.value = false
// })

// (动态链接集合 + 回调)
// useAllImageLoaded(['https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg'], () => {
//   loading.value = false
// })

// (container+ 回调)
// useAllImageLoaded(container, () => {
//   loading.value = false
// })

// 带入错误的container类型
// useAllImageLoaded(show, ['https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg'],() => {
//   loading.value = false
// })

function scrollBottom() {
  nextTick(() => {
    document.body.scrollTop = document.body.scrollHeight - document.body.clientHeight
  })
}

function toggleShow() {
  show.value = !show.value
  scrollBottom()
}

function showImg() {
  scrollBottom()

  const div = document.createElement('div')
  div.classList.add('img-box')
  div.style.color = 'purple'
  
  const tipText = document.createTextNode('这是通过js挂载的,曾加载过src的img标签')
  div.appendChild(tipText)
  
  const img = new Image(200)
  img.src = 'https://gips2.baidu.com/it/u=45931653,2379222580&fm=3039&app=3039&f=PNG?w=1024&h=1024'

  div.appendChild(img)
  container.value.appendChild(div)
}

const hiddenBgShow = ref(false)

function toggleHiddenBgShow() {
  hiddenBgShow.value = !hiddenBgShow.value

  scrollBottom()
}

</script>

<style lang="scss">
img {
  height: 200px;
  width: 200px;
}
.img-wrapper {
  &-1, &-2, &-3 {
    border: 1px red solid;
    height: 200px;
    width: 200px;
    background-size: 100%;
  }
  &-1 {
    background-image: url("https://img0.baidu.com/it/u=3363994336,562770620&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400");
  }
  &-2 {
    background-image: url("https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fblog%2F202102%2F13%2F20210213025356_s52TL.jpeg&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1719381992&t=f2d3a454843d63042ed68fe4f25f5dd8");
  }
  &-3 {
    background-image: url("https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fcbu01.alicdn.com%2Fimg%2Fibank%2FO1CN01jhVYw71UFY7OUUTbW_%21%212208119382488-0-cib.jpg&refer=http%3A%2F%2Fcbu01.alicdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1719382157&t=b7f0a0119405e3cb0c1e6fa7e9849bb2");
  }
}
.loading {
  @include flex-center;
  height: 100px;
  width: 100px;
  font-size: 0.5rem;
  color: white;
  border-radius: 50px;
  background: red;
}

.test-img-wrapper {
  padding-left: 20px;
  div.img-box {
    display: inline-flex;
    align-items: center;
    flex-direction: column;
    // width : 500px ;
    font-size: 16px;
    color: red;
    margin-top: 10px;
    margin-right: 10px;
  }
}

.hidden-bg-wrapper {
  border: 3px black solid;
  height: 200px;
  width: 200px;
  background: url('https://p4.itc.cn/images01/20230830/1c8dadf3b4114379aff79de75badd1c9.jpeg');
  background-size: 100%;
}
</style>

Loader.vue

html 复制代码
<template>
  <div class="loader-container">
    <div class="loader-01">
    </div>
    <div style="font-size: 16px; margin-top: 10px">整页加载中</div>
  </div>
</template>
<style lang="scss" scoped>
body {
  background: -webkit-radial-gradient(ellipse farthest-corner at center bottom, #69d2fb 0%, #20438e 100%) center bottom/100% fixed;
  background: radial-gradient(ellipse farthest-corner at center bottom, #69d2fb 0%, #20438e 100%) center bottom/100% fixed;
  text-align: center;
  box-sizing: border-box;
  font-family: sans-serif;
  color: rgba(255, 255, 255, 0.8);
}

body *,
body *:before,
body *:after {
  box-sizing: inherit;
}

.loader-container {
  @include flex-center;
  flex-direction: column;
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  border: 3px #FF7E00 solid;
  border-radius: 10px;
  background: white;
}

[class*="loader-"] {
  color: inherit;
  vertical-align: middle;
  pointer-events: none;
}

.loader-01 {
  width: 1em;
  height: 1em;
  border: .2em dotted #FF7E00;
  border-radius: 50%;
  -webkit-animation: 1s loader-01 linear infinite;
  animation: 1s loader-01 linear infinite;
}

@-webkit-keyframes loader-01 {
  0% {
    -webkit-transform: rotate(0deg);
    transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
    transform: rotate(360deg);
  }
}

@keyframes loader-01 {
  0% {
    -webkit-transform: rotate(0deg);
    transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
    transform: rotate(360deg);
  }
}
</style>
相关推荐
酷酷的阿云5 分钟前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
微信:137971205878 分钟前
web端手机录音
前端
齐 飞13 分钟前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
神仙别闹30 分钟前
基于tensorflow和flask的本地图片库web图片搜索引擎
前端·flask·tensorflow
sszmvb12341 小时前
测试开发 | 电商业务性能测试: Jmeter 参数化功能实现注册登录的数据驱动
jmeter·面试·职场和发展
测试杂货铺1 小时前
外包干了2年,快要废了。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
王佑辉1 小时前
【redis】redis缓存和数据库保证一致性的方案
redis·面试
真忒修斯之船1 小时前
大模型分布式训练并行技术(三)流水线并行
面试·llm·aigc
GIS程序媛—椰子1 小时前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_0011 小时前
前端八股文(一)HTML 持续更新中。。。
前端·html