如今图片懒加载已是家常便饭,然而一般的图片懒加载的占位往往不跟随原图片的大小,导致图片加载完成后,总体的位置会改变,体验很是不好。相信大家都看过知乎或者 Medium 之类的图片加载方式,从开始加载到完成加载,占位始终在一个地方,再加上平滑的过度,视觉上很舒适。
于是我今天尝试将图片懒加载也做成这样,但是有一个问题,我所有文章中的图都不在本地,而是分布于各个图床,甚至有些还失效了。我开始尝试在前端处理,图片在开始加载的时候是能在加载完成之前获取到图片头部的相关信息的。但是有个问题,这样的话还是做不到平滑过度。
于是,放弃了一个下午的研究成果,开始重做后端。在后端模型中,我加入了一个新的字段,用于记录图片的各种信息。每篇文章可能不止一个图,所以应该是一个数组。
之后,把所有文章中的图片链接提取出来,然后去请求数据,在分析图片并记录到数据库,这样的话,只需要一次操作就行了,之后文章更新额时候在触发一下钩子。

后端

下面代码以 NestJS, Typegoose 为例
提取 markdown 中的图片链接
1export const pickImagesFromMarkdown = (text: string) => {
2  const reg = /(?<=\!\[.*\]\()(.+)(?=\))/g
3  const images = [] as string[]
4  for (const r of text.matchAll(reg)) {
5    images.push(r[0])
6  }
7  return images
8}
Copy
获取图片并分析, 用到了 NestJS 的 http 模块和 image-size
1import { imageSize } from 'image-size'
2import { HttpService } from '@nestjs/common'
3export const getOnlineImageSize = async (http: HttpService, image: string) => {
4  const { data } = await http
5    .get(image, {
6      responseType: 'arraybuffer',
7    })
8    .toPromise()
9  const buffer = Buffer.from(data)
10  const size = imageSize(buffer)
11  return size
12}
Copy
存到数据库
1// base.service.ts
2// class WriteBaseService
3
4async RecordImageDimensions(id: string, socket?: SocketIO.Socket) {
5    const text = (await this.__model.findById(id).lean()).text
6    const images = pickImagesFromMarkdown(text)
7    const result = [] as ISizeCalculationResult[]
8    for await (const image of images) {
9      try {
10        this.logger.log('Get --> ' + image)
11        const size = await getOnlineImageSize(this.__http, image)
12        if (socket) {
13          socket.send(
14            gatewayMessageFormat(
15              EventTypes.IMAGE_FETCH,
16              'Get --> ' + image + JSON.stringify(size),
17            ),
18          )
19        }
20        result.push(size)
21      } catch (e) {
22        this.logger.error(e.message)
23        if (socket) {
24          socket.send(gatewayMessageFormat(EventTypes.IMAGE_FETCH, e.message))
25        }
26        result.push({
27          width: undefined,
28          height: undefined,
29          type: undefined,
30        })
31      }
32    }
33
34    await this.__model.updateOne(
35      { _id: id as any },
36      // @ts-ignore
37      { $set: { images: result } },
38    )
39  }
Copy
因为有些图片可能 404 了,或者网不好(国内直连 https://raw.githubusercontent.com/),所以如果报错也要 push 一下,全为空就行了,前端到时候在处理一下。
1;[this.postService, this.noteService, this.pageService].forEach(
2      async (s) => {
3        s.refreshImageSize(socket)
4      },
5    )
6 }
Copy
最后把所有 model 都执行一下这个方法。

前端

前端部分以 React 为例。
处理完之后,后端返回的数据中多了一个 images 字段,如。
前端在渲染图片之前先要根据实际大小计算出渲染到页面中的尺寸,然后定死 placeholder 的大小,在图片加载完成之后移除或者隐藏 placeholder。
计算尺寸可以参考,如下
1const calculateDimensions = (width?: number, height?: number) => {
2 if (!width || !height) {
3   return { height: 300, width: undefined }
4 }
5 const MAX = {
6   width: document.getElementById('write')?.offsetWidth || 500, // 容器的宽度
7   height: Infinity, // 可选最大高度
8 }
9 const dimensions = { width, height }
10 if (width > height && width > MAX.width) {
11   dimensions.width = MAX.width
12   dimensions.height = (MAX.width / width) * height
13 } else if (height === width) {
14   if (width <= MAX.width) {
15     dimensions.height = dimensions.width = height
16   } else {
17     dimensions.height = MAX.width
18     dimensions.width = dimensions.height
19   }
20 }
21 return dimensions
22}
Copy
因为 Markdown 渲染的结构比较复杂,我所以我使用了 Context 进行传值,我使用的渲染库是 react-markdown,可以对每个 tag 进行自定义渲染。
1const RenderImage: FC<{ src: string; alt?: string }> = ({ src, alt }) => {
2  const images = useContext(imageSizesContext)
3  const [cal, setCal] = useState({} as { height?: number; width?: number })
4  useEffect(() => {
5    const size = images.shift()
6    const cal = calculateDimensions(size?.width, size?.height)
7
8    setCal(cal)
9  }, [images])
10  if (typeof document === 'undefined') {
11    return null
12  }
13
14  return (
15    <ImageLazyWithPopup
16      src={src}
17      alt={alt}
18      height={cal.height}
19      width={cal.width}
20    />
21  )
22}
Copy
完整的 Image 组件可到 查看。包含了图片的过度动画。

亲亲留个评论再走呗

正在加载评论区...