建立一个令人敬畏的图像加载体验(附代码)

238 阅读11分钟

你可能已经注意到,当打开我网站上的各种页面(尤其是博客文章)时,图像开始时是模糊的,一旦加载,完整的图像就会渐渐出现。这里有一个视频来演示这种体验。

我想让你了解一下我为实现这一目标所做的一些事情,并将我所做的一些事情与终极图片应用(unsplash.com)的做法进行比较。

布局转变

首先,你会注意到,图片不会突然出现在原地并导致回流/布局转移。事实上,我在"累计布局偏移 "的网络生命力评分中得到了100/100分😊。我通过tailwind的长宽比插件(我的网站使用tailwind,😅)指定容纳图片的区域的大小来做到这一点。

TL;DR:

<div class="aspect-h-4 aspect-w-3 md:aspect-w-3 md:aspect-h-2">
  <img src="..." alt="..." class="..." />
</div>

这就是我所需要的,以确保我不会在图片加载时出现一堆布局偏移(在《在图片上设置高度和宽度又很重要》中阅读更多关于这一点)。

sizes,srcset, 和Cloudinary

使图像快速加载的另一个重要方面是确保你只加载你需要的图像大小。如果你有一张3000x3000的图片,在视网膜屏幕上渲染成600x600的正方形,那么你就提供了1800x1800的太多像素!(视网膜意味着双倍的像素)。(视网膜意味着双倍的像素)。

这就是img 标签的sizessrcset 属性的作用。这些属性的大意是,它允许你告诉浏览器你的图像在不同的屏幕宽度上有不同的版本(srcset ),以及图像在一组给定的媒体查询中应该是什么尺寸(sizes )。下面是来自MDN的例子。

<img
  src="/files/16870/new-york-skyline-wide.jpg"
  srcset="
    /files/16870/new-york-skyline-wide.jpg 3724w,
    /files/16869/new-york-skyline-4by3.jpg 1961w,
    /files/16871/new-york-skyline-tall.jpg 1060w
  "
  sizes="((min-width: 50em) and (max-width: 60em)) 50em,
              ((min-width: 30em) and (max-width: 50em)) 30em,
              (max-width: 30em) 20em"
/>

这说明,当屏幕宽度在50em60em 之间时,图像将是50em 。因此,浏览器可以根据你给它的srcset ,确定该图像尺寸的最佳加载图像。看看这个渐进式增强功能吧!如果浏览器不支持这些属性,它就会像平常一样使用src 属性。

Unsplash大量使用这一功能,我也是如此。但创建所有这些尺寸的图片将是一个巨大的痛苦,这就是为什么我使用cloudinary的原因

下面是我的img标签在博客文章中的样子。

<img
  title="Photo by Kari Shea"
  class="z-10 rounded-lg object-cover object-center transition-opacity"
  alt="MacBook Pro on top of brown table"
  src="https://res.cloudinary.com/kentcdodds-com/image/upload/w_1517,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop"
  srcset="
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_280,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   280w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_560,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   560w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_840,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   840w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_1100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 1100w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_1650,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 1650w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_2500,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 2500w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_2100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 2100w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_3100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 3100w
  "
  sizes="(max-width:1023px) 80vw, (min-width:1024px) and (max-width:1620px) 67vw, 1100px"
/>

当然,我并没有手动写出这些。我有一个工具来为我生成这些道具。

function getImgProps(
  imageBuilder: ImageBuilder,
  {
    widths,
    sizes,
    transformations,
  }: {
    widths: Array<number>
    sizes: Array<string>
    transformations?: TransformerOption
  },
) {
  const averageSize = Math.ceil(widths.reduce((a, s) => a + s) / widths.length)

  return {
    alt: imageBuilder.alt,
    src: imageBuilder({
      quality: 'auto',
      format: 'auto',
      ...transformations,
      resize: {width: averageSize, ...transformations?.resize},
    }),
    srcSet: widths
      .map(width =>
        [
          imageBuilder({
            quality: 'auto',
            format: 'auto',
            ...transformations,
            resize: {width, ...transformations?.resize},
          }),
          `${width}w`,
        ].join(' '),
      )
      .join(', '),
    sizes: sizes.join(', '),
  }
}

然后我像这样使用它。

<img
  key={frontmatter.bannerCloudinaryId}
  title={frontmatter.bannerCredit}
  className="rounded-lg object-cover object-center"
  {...getImgProps(
    getImageBuilder(
      frontmatter.bannerCloudinaryId,
      getBannerAltProp(frontmatter),
    ),
    {
      widths: [280, 560, 840, 1100, 1650, 2500, 2100, 3100],
      sizes: [
        '(max-width:1023px) 80vw',
        '(min-width:1024px) and (max-width:1620px) 67vw',
        '1100px',
      ],
      transformations: {
        background: 'rgb:e6e9ee',
      },
    },
  )}
/>

我们没有太多的时间去研究imageBuilder 。这只是我在MDN之上的一个小抽象cloudinary-build-url上的一个小抽象,用于以一种类型安全的方式构建云端的URL。我的观点是,Cloudinary使我很容易为你的设备和屏幕尺寸提供正确大小的图像,所以它加载迅速,我为你节省了数据

Unsplash的占位符

如果我在这一点上停下来,那么用户在图片加载之前就会得到一个空白的空间。显示一些占位符要好得多。你肯定在网络上见过这些东西。Medium是我第一次看到这样的东西的地方。我在这个网站的旧版本中使用了gatsby-plugin-sharp ,它支持一种内联的SVG,是一种图像的追踪(结果有好有坏,但大多是积极的)。而unsplash也有这方面的支持。为了使其运作良好,你需要占位符很小,由服务器渲染,并且是内联的。如果你必须加载你的占位符,那么你就需要为你的占位符提供一个占位符虽然这听起来很荒谬,但这实际上是Unsplash的工作。

当你登陆unsplash图片时,根据你的网络速度,有三件事会相继发生。

  1. 显示图片的主色调。这是服务器渲染的。
  2. 显示图片的模糊版本。我不确定他们是否使用blurhash来做这个,但他们做的是完全一样的事情。这是个画布画。
  3. 最终的图像被加载*。

*这第3步还有一点,我们后面会讲到。

这些实际上都会发生,但它们是分层的,先是图像在上面,然后是模糊画布,然后是有背景颜色的div。这样unsplash就能尽快地展示出最好的东西。由于这个原因,你很可能在最初的页面加载中看不到模糊的图像,但如果你在页面加载后浏览,你会看到所有其他的图像,你不会再看到原色的背景。这是因为显示模糊的图像画布需要JavaScript来工作。

因此,如果图像在JavaScript之前加载,那么JavaScript就没有机会在你看实际图像之前为你设置模糊图像的画布。而如果JavaScript已经加载了(比如你在做客户端导航),你就不会看到背景色,而只会看到模糊的图像。

这是一个很好的设置,当我在做我自己的图片加载体验时,我看了这个,以获得灵感。blurhash/canvas方法真正酷的地方在于,图片所需的数据大小非常小。 比如,说真的,你只需要将这些数据传递给blurhash客户端库,就可以获得一张漂亮的完整图片的模糊效果:LGFFaXYk^6#M@-5c,1J5@[or[Q6.

blurhash website showing an image being
converted into a hash and the canvas representation of
that

说实话,这太酷了。神奇的🧙

最终,这里的目标是尽量减少所需的数据量,以便在加载全分辨率图片时给用户一个良好的体验。这是一个速度和良好用户体验的平衡。

当我对unsplash的图片加载技术进行逆向工程时,我评估了是否应该采用他们的方法或者尝试一些不同的方法。 我真的不喜欢他们在渲染模糊的图片之前必须先渲染一个带有纯色背景的div 。为什么不直接在服务器上通过一个base64编码的数据URL来渲染模糊的图像?

所以我决定试一试。首先,我需要找到一种方法来自动生成base64数据的URL。首先,我知道如果我只是尝试使用普通尺寸的图片,URL会非常大(这基本上会使我的页面加载缓慢,从而否定所有的用户体验收益)。

所以我需要为图片的缩小版本生成一个base64数据的URL。 这真的很容易,因为我的所有图片都是用Cloudinary。此外,Cloudinary有能力在图像上应用模糊等变换。这意味着,我可以很容易地减少在我的base64字符串中的数据量。因此,我生成了一个像这样的cloudinary URL。

https://res.cloudinary.com/kentcdodds-com/image/upload/w_100,q_auto,f_webp,e_blur:1000/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop

当我获取该图像并对其进行base64编码时,我的结果是这样的。

data:image/webp;base64,UklGRhQBAABXRUJQVlA4IAgBAAAQDQCdASpkAEMAPrFGmko7qyWhsls9U3AWCWkGcA01nlwbK5buwWRoA3koD7+5vLBXAtOMrneG2GT90JyrLz+2XeotIAEq5PL4F0N1qTRIJ7LnMa5Zcre8UaDTMRtFt14eXNoGYkhNSt0REMN2PN4FwAD+7s4jHeyE9BXykzZMxIuwC4FSp408GYxRjoczsMvwZlqrnzr4cuA6X6MspvaoVHUro1XNU1SNxrLKLjhZrJ3GmlyoorlW1L532OP9tbhOeQgFiDwE81g+CH4d16xfOjEGrpus0wYxdunoI7Nokc5fnyoAw8pKJEq6cW3Yp4rqZw9fosV61qnAN+ViAH+WOzoqC6R90AA=

现在,这比代表blurhash的30个左右的字符要多得多,但请记住,blurhash也需要一个比这更大的客户端库。但这其实并不是我不选择blurhash的原因。一旦你把这个应用到几个图片上,blurhash就会超过它本身的重量。因此,像unsplash这样的网站肯定会达到这样的程度,它有很大的意义。

在我的网站上,blurhash可能也会支付它的费用。我在每个页面的底部都有推荐,这些推荐也都是模糊加载的。

那么,为什么我没有在这个时候使用blurhash呢?这是因为我真的不想渲染主色调。我只是觉得它在我的网站上看起来不怎么样。而且这个数据URL并没有大到我认为值得用服务器渲染纯色块的程度。真可惜,我们不能用服务器渲染画布。将是两全其美的事情。

所以我在实际图像后面渲染base64。因此,当图像正在加载时,我显示一个服务器渲染的模糊和放大的图像版本。

不幸的是,像这样放大它使它看起来非常有像素感。

Pixelated blog post placeholder

平台拯救了我!我只是把这个放在我的base64图像之后的DOM中,我们就开始工作了。

<div class="backdrop-blur-xl"></div>

最终有效地应用了这个css。

backdrop-filter: blur(24px);

我们得到了漂亮的模糊效果。

Blurred blog post placeholder

很好!

渐入佳境的图像onload

我有一个很好的占位符,但有一点困扰我,那就是当图片加载时,它只是出现在占位符的位置,而我希望感觉占位符有点变成了实际的图片。为了达到这个效果,我需要写一些JavaScript。我想现在是时候向你展示我的BlurrableImage 组件了...首先,这是我如何在我的博客文章页面上使用它。

function BlogScreen() {
  // ...
  return (
    // ...
    <div className="col-span-full mt-10 lg:col-span-10 lg:col-start-2 lg:mt-16">
      {frontmatter.bannerCloudinaryId ? (
        <BlurrableImage
          key={frontmatter.bannerCloudinaryId}
          blurDataUrl={frontmatter.bannerBlurDataUrl}
          className="aspect-h-4 aspect-w-3 md:aspect-w-3 md:aspect-h-2"
          img={
            <img
              key={frontmatter.bannerCloudinaryId}
              title={frontmatter.bannerCredit}
              className="rounded-lg object-cover object-center"
              {...getImgProps(
                getImageBuilder(
                  frontmatter.bannerCloudinaryId,
                  frontmatter.bannerAlt ??
                    frontmatter.bannerCredit ??
                    frontmatter.title ??
                    'Post banner',
                ),
                {
                  widths: [280, 560, 840, 1100, 1650, 2500, 2100, 3100],
                  sizes: [
                    '(max-width:1023px) 80vw',
                    '(min-width:1024px) and (max-width:1620px) 67vw',
                    '1100px',
                  ],
                  transformations: {
                    background: 'rgb:e6e9ee',
                  },
                },
              )}
            />
          }
        />
      ) : null}
    </div>
    // ...
  )
  // ...
}

这里是BlurrableImage组件本身。

import * as React from 'react'
import clsx from 'clsx'
import {useSSRLayoutEffect} from '~/utils/misc'

function BlurrableImage({
  img,
  blurDataUrl,
  ...rest
}: {
  img: React.ReactElement<React.ImgHTMLAttributes<HTMLImageElement>>
  blurDataUrl?: string
} & React.HTMLAttributes<HTMLDivElement>) {
  const [visible, setVisible] = React.useState(false)
  const jsImgElRef = React.useRef<HTMLImageElement>(null)

  React.useEffect(() => {
    if (!jsImgElRef.current) return
    if (jsImgElRef.current.complete) return

    let current = true
    jsImgElRef.current.addEventListener('load', () => {
      if (!jsImgElRef.current || !current) return
      setTimeout(() => {
        setVisible(true)
      }, 0)
    })

    return () => {
      current = false
    }
  }, [])

  const jsImgEl = React.cloneElement(img, {
    // @ts-expect-error no idea 🤷‍♂️
    ref: jsImgElRef,
    className: clsx(img.props.className, 'transition-opacity', {
      'opacity-0': !visible,
    }),
  })

  return (
    <div {...rest}>
      {blurDataUrl ? (
        <>
          <img
            src={blurDataUrl}
            className={img.props.className}
            alt={img.props.alt}
          />
          <div className={clsx(img.props.className, 'backdrop-blur-xl')} />
        </>
      ) : null}
      {jsImgEl}
      <noscript>{img}</noscript>
    </div>
  )
}

export {BlurrableImage}

好吧,这有点难接受...。让我带你看一下...

首先,道具是非常简单的。我们接受一个img 元素,这是我们想要加载的最终图像。我们接受一个blurDataUrl ,在我们等待图像加载时,渲染一个模糊的版本。然后,其余的道具只是应用于div ,它是所有东西的容器。我几乎只把它用于className ,用于长宽比的东西。

让我们跳过中间的所有东西,直接进入我们正在渲染的内容。

我们渲染一个包裹性的div来保持所有的东西在一起(特别是为了使长宽比的东西正常工作)。

然后,如果有一个blurDataUrl ,我们就渲染一个imgblurDataUrl 的元素。我们继承className ,以确保我们得到正确的边框半径等东西。

然后,在这之下,我们渲染背景,以平滑数据URL图像的模糊性,因为这将被放大,如前面所述。

然后,我们渲染我称之为jsImgEl 。这是img 的副本。jsImgEl 是主要的图像,当所有的事情都完成后,将显示给用户。我复制了它,这样我就可以为淡入动作添加一些css。 稍后会有更多的内容。

最后,<noscript>{img}</noscript> 这些东西是为少数可能禁用JavaScript的用户准备的,因为否则他们将无法显示图片(因为显示图片需要JavaScript)。这样的用户可能不多,但这很容易,为什么不呢?

好吧,为了使淡出工作,我们需要让jsImgEl 开始时是不可见的。浏览器仍会为我们加载这个,而且它在加载过程中会触发事件,所以我们使用useEffect 来添加一个事件处理程序,以了解它何时被加载,当它完成加载时,我们将触发一个更新,使图像淡入。

就这样了。

结论

因此,回顾一下,为了创造一个优秀的图片加载体验,我正在做几件事:

  1. 通过使用Tailwind的长宽比插件来避免布局偏移
  2. 通过<img /> 属性sizessrcset + cloudinary 变换加载完美尺寸的图片。
  3. 生成图片的较小的模糊版本的base64编码(向cloudinary喊话)。我做了缓存以使其快速。
  4. 内联渲染模糊的图像,这样它就可以和一些JS一起被服务器渲染,以加载完整的图像并在加载完成后显示出来。

这就是这个网站上超棒的图片加载体验的动力。重要的是要记住,用户体验并不完全是性能问题。它也是关于体验的。我觉得我在这里所做的权衡对于我想在你浏览这个网站时提供给你的用户体验来说是可靠的。我希望你喜欢它 :)