使用 medium-zoom 实现响应式图片的放大展示

2022-08-14codingblog

风景图片

风景图片 风景图片

引言

近期在写博客时,有一些都是文字的图片。

因为 Gatsby 默认把图片都限制到一定的大小,所以图片看起来有点糊。尽管它提供了把源图片地址链接到压缩后图片上的方法,但感觉用户体验不是很好。而且有些图片是 4k 的,有些图片是用手机拍的,源文件也比较大,甚至可能超过 10M,感觉也没必要直接展示源文件。

因此想要找到一个工具,可以点击图片在当前页面放大并展示图片。

medium-zoom?

其实这样的工具是有的,在 gatsby 的插件里搜索,不难发现相关插件,gatsby-remark-images-zoomgatsby-remark-images-medium-zoom-plugin
简单看了一些,这些插件都是以 medium-zoom 为基础的。

看起来足够了,但实际使用下来发现它存在着一些问题。

在 Gatsby 中,生成的图片结构是类似于这样的:

<img 
  class="gatsby-resp-image-image medium-zoom-image"
  alt="风景图片"
  title="风景图片"
  src="/static/fff71aec42a9eed56e41170296bf2dd8/6af66/clip-20220814141904.png"
  srcset="/static/fff71aec42a9eed56e41170296bf2dd8/72799/clip-20220814141904.png 320w,
/static/fff71aec42a9eed56e41170296bf2dd8/6af66/clip-20220814141904.png 640w,
/static/fff71aec42a9eed56e41170296bf2dd8/d9199/clip-20220814141904.png 960w,
/static/fff71aec42a9eed56e41170296bf2dd8/07a9c/clip-20220814141904.png 1440w,
/static/fff71aec42a9eed56e41170296bf2dd8/29114/clip-20220814141904.png 1920w,
/static/fff71aec42a9eed56e41170296bf2dd8/c2d13/clip-20220814141904.png 2560w,
/static/fff71aec42a9eed56e41170296bf2dd8/9b29b/clip-20220814141904.png 3840w" 
  sizes="(max-width: 640px) 100vw, 640px"
  style="width: 100%; height: 100%; margin: 0px; vertical-align: middle; position: absolute; top: 0px; left: 0px; opacity: 1; transition: opacity 0.5s ease 0s; color: inherit; box-shadow: white 0px 0px 0px 400px inset;"
  loading="lazy"
  decoding="async"
>

有着 srcsetsizes 属性,我们需要的是这样的效果,在没有点开时,图片根据视图宽度和屏幕分辨率自动加载对应尺寸的图片。而如果点开了大图,则自动加载更大尺寸的图片。

事实上 medium-zoom 已经做到了这一点,它会克隆图片节点,去除图片的 sizes 属性,这时图片会按照默认 100vw 的尺寸来加载图片。

但是遇到的第一个问题就是它会等加载完成后再进行图片的缩放,期间会阻塞所有操作,同时没有 fallback。比如你先打开了一个图片,你断网了,再点开大图展示,那么你的页面会白屏,其他内容不能正常展示,只有靠刷新才能解决。 如果你网速慢呢,恰巧图片也比较大,那会阻塞页面十几秒。

这点是完全接受不了的,此外还有一些别的原因,在下文一一介绍。

srcset 和 sizes

我们先来讲一下 srcsetsizes 属性是干嘛的吧。 它们是响应式图片的一些属性,可以在不同的屏幕尺寸和分辨率设备上展示不同的图片。

更多细节见:MDN 响应式图片

以上面的 img 标签为例,srcset 中可以分成多个部分,每一个部分先是一个 url,在空格后面跟着尺寸描述符,如 320w 表示图片宽度的像素是 320 ,这里指的是图片原有尺寸,而非展示的尺寸。当然尺寸描述符不止 w,你也可以用 "url1 2x" 来标明不同分辨率加载的图片。

sizes 则可以定义一组媒体查询条件。可以根据不同的条件去展示不同的尺寸。比如 sizes="(max-width: 640px) 100vw, 640px" 这个就表示视窗宽度小于 640px 时,以 100vw 展示图片,如果大于 640px,则以 640px 去展示图片。

首先要说明, sizes 这个属性不能拿到放图片的容器的尺寸,比如说如果图片放到一个最大宽度为 300px 的 div 里,它并不会按照这个 300px 去加载对应的图片,而是默认以你最大将图片铺满整个页面——即 100vw 来加载。

同时如果 sizes 中的判断条件为 640px,那实际上想去加载的图片尺寸是 640 * window.devicePixelRatio, window.devicePixelRatio 是当前显示设备的物理像素分辨率与CSS 像素分辨率之比,如果你调整你的屏幕缩放和浏览器缩放,都会改变这个值。

比如你的 devicePixelRatio 为 1.5, 那 sizes 判断条件为 640px 时,视图会加载 srcset 中最接近 960 像素的图片。比如 srcset="url1 650w, url2 1440w" 时,1440/960=1.5, 960/650=1.477,会加载 url1;如果 srcset="url1 640w, url2 1440w",两边的尺寸比例都为 1.5,那会加载更大的图片,会加载 url2。

同时响应式图片还有一个问题,即不能直接通过属性拿到它当前加载的图片的尺寸,反正我查了挺久,没有找到方法。

比如你在图片加载完成后,去拿 image.naturalWidth,拿到的实际数据是 sizes 返回的尺寸,而不是图片原有的尺寸,如果没有 sizes 则为 100vw

这也是 medium-zoom 的另一个问题,不管原图尺寸多大,只要点击打开大图,就一定会占整个屏幕。如果是一个比较小的图片,那把它放大到屏幕大小就显得很不美观。

响应式图片也有一些别的神奇的点,当你 sizes 里的判断条件更新时,比如视图宽度增大或缩放倍数增加,响应式图片都会自动重新加载更新图片。
同时,如果响应式图片存在高清的缓存,那么它就会直接使用高清图片。这个是指如果一张响应式图片你点开看了高清大图,那么在刷新页面后,就算它还是小图,但实际加载的图片资源已经是高清大图了。

picture 和 source

响应式图片不止上面那一种写法,还可以通过 picture 和 source 标签来实现。

<picture>
  <source media="(max-width: 799px)" srcset="elva-480w-close-portrait.jpg">
  <source media="(min-width: 800px)" srcset="elva-800w.jpg">
  <img src="elva-800w.jpg" alt="Chris standing up holding his daughter Elva">
</picture>

比如可以有多个 source,每个 source 都可以有对应的查询条件,如果满足那就展示对应的文件。

如果你在 gatsby-remark-images 的图片配置里,配置了 withWebp,那么图片生成的结构是下面这样的

<picture>
  <source 
    srcset="/static/fff71aec42a9eed56e41170296bf2dd8/cb523/clip-20220814141904.webp 320w,
/static/fff71aec42a9eed56e41170296bf2dd8/797b9/clip-20220814141904.webp 640w,
/static/fff71aec42a9eed56e41170296bf2dd8/6c7d1/clip-20220814141904.webp 960w,
/static/fff71aec42a9eed56e41170296bf2dd8/ff8d7/clip-20220814141904.webp 1440w,
/static/fff71aec42a9eed56e41170296bf2dd8/f3ff0/clip-20220814141904.webp 1920w,
/static/fff71aec42a9eed56e41170296bf2dd8/a662b/clip-20220814141904.webp 2560w,
/static/fff71aec42a9eed56e41170296bf2dd8/e3ad8/clip-20220814141904.webp 3840w"  
    sizes="(max-width: 640px) 100vw, 640px"
    type="image/webp"
  >
  <source 
    srcset="/static/fff71aec42a9eed56e41170296bf2dd8/72799/clip-20220814141904.png 320w,
/static/fff71aec42a9eed56e41170296bf2dd8/6af66/clip-20220814141904.png 640w,
/static/fff71aec42a9eed56e41170296bf2dd8/d9199/clip-20220814141904.png 960w,
/static/fff71aec42a9eed56e41170296bf2dd8/07a9c/clip-20220814141904.png 1440w,
/static/fff71aec42a9eed56e41170296bf2dd8/29114/clip-20220814141904.png 1920w,
/static/fff71aec42a9eed56e41170296bf2dd8/c2d13/clip-20220814141904.png 2560w,
/static/fff71aec42a9eed56e41170296bf2dd8/9b29b/clip-20220814141904.png 3840w" 
    sizes="(max-width: 640px) 100vw, 640px"
    type="image/png"
  >
  <img
    class="gatsby-resp-image-image medium-zoom-image"
    src="/static/fff71aec42a9eed56e41170296bf2dd8/6af66/clip-20220814141904.png"
    alt="风景图片"
    title="风景图片"
    loading="lazy"
    decoding="async"
    style="width: 100%; height: 100%; margin: 0px; vertical-align: middle; position: absolute; top: 0px; left: 0px; opacity: 1; transition: opacity 0.5s ease 0s; color: inherit; box-shadow: white 0px 0px 0px 400px inset;"
  >
</picture>

如果支持 webp 则加载 webp 的响应式图片,不支持则回退到 png 的响应式图片。 同一个图片,如果以 png 加载需要 774KB, 以 webp 加载则只需 103KB,所以还是更推荐使用 webp 格式来加载图片。

很遗憾,picture 和 source 标签,medium-zoom 也不支持。

改进

综上,主要的痛点有 3 点吧。

  1. 图片加载过程阻塞页面行为,如果不能加载成功,白屏不能退出,只能刷新页面。
  2. 有着 srcset 的图片不能正常拿到图片尺寸
  3. 不支持 picture、source 标签

所以我 fork 了 medium-zoom,做了一些修改,具体见:https://github.com/voderl/medium-zoom

srcset 加载过程更新

在首先展示原有图片,并动画到对应的尺寸。

比如原有的 image.naturalWidth 为 600,而实际宽度只有 300,会先动画到宽度 600。动画持续时间 0.3s,在动画完成后即可操作,点击或滚动均会退出放大模式。

在展示原有图片的同时,会再复制一遍图片节点,并去除 sizes 属性,以 100vw 的尺寸来加载图片。

在加载图片过程中,会尽可能快地拿到 currentSrc 和 naturalWidth,一般很快就能拿到不需要请求完整个图像就可以拿到,一个 demo: http://jsfiddle.net/aUk9P/

// https://stackoverflow.com/questions/6575159/get-image-dimensions-with-javascript-before-image-has-fully-loaded
const image = new Image();
image.src = 'someSrc';
const checkImageInfo = setInterval(() => {
  if (image.currentSrc && image.naturalWidth) {
    clearInterval(checkImageInfo)
    console.log(performance.now(), image.naturalWidth, image.naturalHeight);
  }
}, 10)

img.onload = function () { 
  console.log(
    'Fully loaded',
    performance.now(),
    image.naturalWidth,
    image.naturalHeight
  ); 
}

响应式图像不能拿到图像大小的问题其实也是采用的同样的解决方案:

  1. 使用上面的方法,尽可能快地拿到高清图像的 currentSrc

  2. 拿到 currentSrc 后,再次使用上面的方法,尽可能快地拿到对应图像的 naturalWidth,在获取完成后可以直接把 src 设置为空字符串,取消加载。(正常情况因为浏览器同时请求一个 url 或浏览器存在缓存,并不会实际加载两遍,实际这个过程比想象中要快很多)

在拿到尺寸后,我们就可以把原有图片先动画到需要展示的尺寸。等到高清图形完全加载后,再去显示加载高清图形。

整个过程中,不管高清图片加载完成时,原有图片在动画中还是已经动画完成了,都可以无缝替换为新的高清图片。因为高清图片和原有图片的动画过程是同步的,在高清图片加载中,它的 visibility 为 hidden,在加载完成后才置为 visible。

其余更改

支持了 picture 和 source 标签的响应式图形,source 的兼容其实就和 srcset 一样,只不过需要在复制时需要把其父元素一起复制。它的加载行为和展示逻辑都与 srcset 一致。

同时也更新了部分样式,避免和 gatsby 的原有样式产生冲突。

使用方法

正常使用,用法和原有 package 一致。

在 Gatsby 中使用:

  1. install this package
yarn add @voderl/medium-zoom
  1. install gatsby-plugin-images
[
  {
    resolve: `gatsby-remark-images`,
    options: {
      linkImagesToOriginal: false, // important
    },
  },
]
  1. Copy the following code into gatsby-browser.js
import mediumZoom from '@voderl/medium-zoom'

const options = {
  margin: 24,
  background: '#fff',
  scrollOffset: 40,
  container: undefined,
  template: undefined,
  zIndex: 999,
  excludedSelector: undefined,
  respectSrcsetImageSize: true,
}

export const onClientEntry = () => {
  const { zIndex } = options

  const styles = `
    .medium-zoom-overlay, .medium-zoom-image--opened {
      z-index: ${zIndex};
    }
  `

  const node = document.createElement(`style`)
  node.id = `medium-zoom-styles`
  node.innerHTML = styles
  document.head.appendChild(node)
}

export const onRouteUpdate = () => {
  mediumZoom('.gatsby-resp-image-image', options)
}

更多

更多关于本博客的信息,见 使用Gatsby搭建个人博客

感谢你看到这里,如有困惑,请联系底部邮箱

点赞 0