博客实现图片懒加载以及优化加载图片页面抖动问题

970 阅读6分钟

思考: 如何在 Markdown 博客中实现图片懒加载 ?

通常我们可以使用 Vue-Lazyload自定义指令来实现,但是通常这些库都需要template有真实的 img 标签,例如:

<template>
  <img v-lazy="imgUrl" />
</template>

而我的博客是,基于 Markdown 的博客,后台管理系统通过提交Markdown文本提交到数据库,前端博客展示时用marked库解析为 html,用v-html直接把解析好的html插入到页面中

image.png

这种情况,template中没有真实的img标签,没办法使用上面的自定义指令.

不过这种情况,实现图片懒加载也非常简单

实现图片懒加载一般有两种方案:

  1. 监听 scroll 事件配合getBoundingClientRect等方法判断图片是否出现在页面可视区域

  2. 采用Intersection Observer api

IntersectionObserver 兼容性

image.png

由于项目是vue3的,vue3本身就不支持IE11,所以项目对于兼容性要求没那么高了,再加上这里我个人的项目,不需要太考虑兼容性,所以我选择用IntersectionObserver的解决方案

实现图片懒加载

自定义 marked 的 renderer

为了防止img直接加载,我们还需要把它的src的属性改为data-src,如何实现呢?

我们可以自定义markedrenderer

import marked from "marked";
const renderer = new marked.Renderer();
// 自定义渲染器,renderer.image的意思是,在渲染img时,采用自定义的渲染方式
renderer.image = function (href, title, alt) {
  return `<img data-src="${href}"  alt="${alt}">`;
};
export default renderer;

使用时只需要把我们实现好的renderer通过配置项传入即可,它内部会和默认的渲染器进行一个合并

通过 nextTick 获取到渲染完的真实 dom

解析完markdownhtml , 把html插入页面后,调用 nextTick把文章内容的根节点传入 lazyImage函数中,最后实现lazyImage 函数

marked(markdown, {
  renderer,
});
<div v-html="contentHTML" class="markdown-body" ref="contentEl"></div>
// 1.通过api 获取到markdown内容通过marked解析
// 2.解析完后赋值给 contentHTML
// 之后调用 nextTick 可以获取到渲染后的dom节点
nextTick(() => {
  if (contentEl.value) {
    // 懒加载图片
    lazyImage(contentEl.value);
  }
});

实现 lazyImage 函数

lazyImage 通过接收一个 dom 节点来获取它所有的子img节点,然后对 img 进行懒加载

const callback: IntersectionObserverCallback = (entries) => {
  entries.forEach((item) => {
    // isIntersecting是一个Boolean值,判断目标元素当前是否可见
    if (item.isIntersecting) {
      // 到了可见区域,需要img节点的data-src改为src,并且停止监听这个img节点
      const src = item.target.getAttribute("data-src") || "";
      item.target.setAttribute("src", src);
      item.target.removeAttribute("data-src");
      //停止监听特定目标元素
      intersectionObserver.unobserve(item.target);
    }
  });
};

const intersectionObserver = new IntersectionObserver(callback);

export default function lazyImage(parentEl: HTMLElement): void {
  // 获取到父节点下的所有带data-src的img标签
  const imgs = parentEl.querySelectorAll("img[data-src]");
  imgs.forEach((item) => {
    //开始监听一个目标元素
    intersectionObserver.observe(item);
  });
}

这样就实现好了图片懒加载

解决图片抖动问题

虽然上面已经实现好了图片懒加载,但是还不够完美,这也是我在ipad上刷掘金的经常遇到的问题.

因为学校图书馆 WiFi 不稳定,有时候网速特别慢.当网络慢的时候,在掘金打开一篇文章,往下滚动页面,阅读文章的时候,有可能因为网络原因,上面的一张图片刚开始没有加载出来,等我看了一会文章后,上面的图片又突然加载好了,造成页面突然的抖动.

为了更加直观的理解上面的内容,我特意的录了 gif

1.gif

在控制台把图片

分析原因

造成这样的原因就是图片刚开始没有高度,等图片加载出来后又有了高度,造成页面的抖动.

我们通常的解决方案是在img标签外包裹一个img-wrap,给img-wrap设置宽高,img的宽高都设置为100%,但是问题是 markdown 中我们不知道图片的高度,或者说图片的比例.如果说我们有了图片的大小信息,就可以先给图片一个高度.这样页面就不会出现抖动的问题了.

经过查阅资料,我了解到css中有一个aspect-ratio的属性

aspect-ratio

aspect ratio 翻译为中文就是宽高比(也称:纵横比)即 x:y。

首先定宽高比,在网页中开发中时很常见的,而且面试也偶有出现。我之前使用的 padding-bottom 来实现的。不过,今天的 CSS 提供了更加方便的解决方案:aspect-ratioaspect-ratio 翻译为中文就是纵横比的意思。

因为博客的图片宽度我都是设置的85%,所以理论上只需要得到图片的纵横比,就可以配合aspect ratio属性,给img初始的高度,例如图片的大小是1887 * 2831,则需要设置style="aspect-ratio:1887 / 2831;"图片就有了个默认高度.

但是问题有来了我们怎么能得到图片的真实宽高呢?

我想到两个方案:

  1. 后端给图片的url添加图片宽高信息

    但是这需要图片链接本身必须是自己的服务器才行,如果是外链的图片就不行了,解决方案就是在提交 markdown 的时候把外链的图片都换成的自己服务器的

  2. 我们可以在图片加载完后获取图片的naturalWidthnaturalHeight属性

    naturalWidth 和 naturalHeight 是 html5 新增的属性,它们可以直接获取图片的原始宽高。

    图片加载完后 ? 图片加载完成后还有屁用啊!

    别慌,我们可以在后台管理系统中获取到图片的naturalWidthnaturalHeight属性,把这些信息添加到图片的alt

    例如这样子:

image.png

如果给图片添加大小信息 ? 我采用的是正则的方案,在提交到服务器前,先判断图片有没有大小信息,如果没有给图片添加图片的大小信息.

// 获取所有图片的正则
const reg = /!\[(.*?)\]\((.*?)\)/g;
// 判断有没有图片大小信息的正则
const hasSizeReg = /\d+\*\d+$/;
interface ImagesInfo {
  imageText: string;
  title: string;
  src: string;
  naturalWidth?: number;
  naturalHeight?: number;
}

//
export default async function markdownImageAddSize(markdown: string) {
  const images: ImagesInfo[] = [];
  markdown.replace(reg, replaceHelper);

  return await handleImageAll(images, markdown);

  function replaceHelper(imageText: string, title: string, src: string): string {
    images.push({ imageText, title, src });
    return imageText;
  }
}

async function handleImageAll(images: ImagesInfo[], markdown: string) {
  let res: string = markdown;
  for (const img of images) {
    const { title, src, imageText } = img;
    try {
      const { naturalWidth, naturalHeight } = await getImageNaturalInfo(src);

      let sizeInfo = "";
      //如果没有sizeInfo,则加sizeInfo
      if (!hasSizeReg.test(title)) {
        sizeInfo = `-${naturalWidth}*${naturalHeight}`;
      }

      const newImageText = `![${title}${sizeInfo}](${src})`;
      res = res.replaceAll(imageText, newImageText);
    } catch (e) {
      console.warn(`image ${img.src} error`);
    }
  }
  return res;
}

// 通过图片的src,获取所有图片的大小信息

function getImageNaturalInfo(src: string) {
  return new Promise<{
    naturalWidth: number;
    naturalHeight: number;
  }>((resolve, reject) => {
    // 通过 new Image 创建一个 Image , 并设置src
    const img = new Image();
    img.src = src;
    // 因为在markdown有预览,所以这些src的图片都加载好了,onload会很快
    img.onload = function () {
      //
      const naturalWidth = img.naturalWidth;
      const naturalHeight = img.naturalHeight;
      resolve({
        naturalWidth,
        naturalHeight,
      });
    };
    img.onerror = function (e) {
      reject(e);
    };
  });
}

这样处理后,markdown 文本在传入数据库前都会给图片添加图片大小信息

博客中就可以用这个信息来设置aspect-ratio 属性

改造 markedRenderer

import marked from "marked";
// 获取图片的size
const getSizeReg = /(\d+)\*(\d+)$/;
const renderer = new marked.Renderer();
renderer.image = function (href, title, alt) {
  const res = getSizeReg.exec(alt);
  // 如果有size信息,则添加style aspect-ratio
  if (res) {
    const [, naturalWidth, naturalHeight] = res;
    return `<img data-src="${href}" style="aspect-ratio:${naturalWidth} / ${naturalHeight};" alt="${alt}">`;
  } else {
    return `<img data-src="${href}"  alt="${alt}">`;
  }
};
export default renderer;

添加 loading 效果

@keyframes skeleton-loading {
  0% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}
.markdown-body :deep(img) {
  width: 85%;
  background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
  background-size: 400% 100%;
  padding: 0;
  animation: skeleton-loading 1s ease infinite;
}

最终效果图

2.gif

对比

优化前

1.gif

优化后

2.gif

想去尝试的小伙伴可以访问我的个人博客: coderly的个人博客