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

后端

下面代码以 NestJS, Typegoose 为例
提取 markdown 中的图片链接
export const pickImagesFromMarkdown = (text: string) => {
  const reg = /(?<=\!\[.*\]\()(.+)(?=\))/g
  const images = [] as string[]
  for (const r of text.matchAll(reg)) {
    images.push(r[0])
  }
  return images
}
获取图片并分析, 用到了 NestJS 的 http 模块和 image-size
import { imageSize } from 'image-size'
import { HttpService } from '@nestjs/common'
export const getOnlineImageSize = async (http: HttpService, image: string) => {
  const { data } = await http
    .get(image, {
      responseType: 'arraybuffer',
    })
    .toPromise()
  const buffer = Buffer.from(data)
  const size = imageSize(buffer)
  return size
}
存到数据库
// base.service.ts
// class WriteBaseService

async RecordImageDimensions(id: string, socket?: SocketIO.Socket) {
    const text = (await this.__model.findById(id).lean()).text
    const images = pickImagesFromMarkdown(text)
    const result = [] as ISizeCalculationResult[]
    for await (const image of images) {
      try {
        this.logger.log('Get --> ' + image)
        const size = await getOnlineImageSize(this.__http, image)
        if (socket) {
          socket.send(
            gatewayMessageFormat(
              EventTypes.IMAGE_FETCH,
              'Get --> ' + image + JSON.stringify(size),
            ),
          )
        }
        result.push(size)
      } catch (e) {
        this.logger.error(e.message)
        if (socket) {
          socket.send(gatewayMessageFormat(EventTypes.IMAGE_FETCH, e.message))
        }
        result.push({
          width: undefined,
          height: undefined,
          type: undefined,
        })
      }
    }

    await this.__model.updateOne(
      { _id: id as any },
      // @ts-ignore
      { $set: { images: result } },
    )
  }
因为有些图片可能 404 了,或者网不好(国内直连 https://raw.githubusercontent.com/),所以如果报错也要 push 一下,全为空就行了,前端到时候在处理一下。
;[this.postService, this.noteService, this.pageService].forEach(
      async (s) => {
        s.refreshImageSize(socket)
      },
    )
 }
最后把所有 model 都执行一下这个方法。

前端

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

    setCal(cal)
  }, [images])
  if (typeof document === 'undefined') {
    return null
  }

  return (
    <ImageLazyWithPopup
      src={src}
      alt={alt}
      height={cal.height}
      width={cal.width}
    />
  )
}
完整的 Image 组件可到 mx-web 查看。包含了图片的过度动画。