深入 Next.js 源码:Image 组件 Color Placeholder 实现原理

86 阅读3分钟

深入 Next.js 源码:Image 组件 Color Placeholder 实现原理

本文将深入 Next.js 源码,解析 Image 组件是如何用一个仅 35 字节的 1x1 像素 GIF,生成全尺寸模糊占位符的。

一、引言

在现代 Web 应用中,图片加载体验直接影响用户的第一印象。当用户打开页面时,如果看到的是空白区域或者布局跳动,体验会大打折扣。

Next.js 的 Image 组件提供了 placeholder="blur" 功能,可以在图片加载完成前显示一个模糊的占位符。对于静态导入的图片,Next.js 会在构建时自动生成 blurDataURL;而对于动态图片,我们可以手动提供一个纯色占位符。这张gif是3G网络状态下录屏效果。

Adobe Express - 4c9a1e5b574e99f432a22cfd68db2fcd.gif

二、使用示例

让我们先看官方examples的 Color Placeholder 源码位置examples/image-component/app/color/page.tsx

// app/color/page.tsx
import Image from "next/image";

// 生成 1x1 像素 GIF 的 base64 编码
const keyStr =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

const triplet = (e1: number, e2: number, e3: number) =>
  keyStr.charAt(e1 >> 2) +
  keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
  keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
  keyStr.charAt(e3 & 63);

const rgbDataURL = (r: number, g: number, b: number) =>
  `data:image/gif;base64,R0lGODlhAQABAPAA${
    triplet(0, r, g) + triplet(b, 255, 255)
  }/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`;

export default function Color() {
  return (
    <Image
      alt="Dog"
      src="/dog.jpg"
      placeholder="blur"
      blurDataURL={rgbDataURL(237, 181, 6)}  // 橙黄色
      width={750}
      height={1000}
    />
  );
}

这段代码做了两件事:

  1. rgbDataURL 函数:根据 RGB 值生成一个 1x1 像素的 GIF Data URL
  2. Image 组件:通过 placeholder="blur"blurDataURL 属性,在图片加载前显示该颜色的模糊占位符

运行后,你会看到图片加载前先显示一个橙黄色的模糊背景,加载完成后平滑过渡到真实图片, 就像上面的gif一样。

三、1x1 像素 GIF 原理

3.1 为什么选择 GIF?

在所有图片格式中,GIF 是生成单像素图片最小的选择:

格式1x1 像素大小特点
GIF~35 bytes最小,支持透明
PNG~67 bytes较大,无损压缩
JPEG~134 bytes最大,有损压缩

3.2 GIF 文件结构

一个最小的 GIF 文件结构如下:

GIF89a           // 文件头 (6 bytes)
[宽度][高度]      // 逻辑屏幕描述符 (7 bytes)
[全局颜色表]      // 包含我们的颜色 (3 bytes)
[图像描述符]      // 图像块 (10 bytes)
[图像数据]        // LZW 压缩数据 (若干 bytes)
GIF 结束符        // (1 byte)

四、源码分析

Next.js Image 组件的源码位于 vercel/next.js 仓库。我们按照调用链自顶向下分析。

4.1 Image 组件入口

源码位置packages/next/src/client/image-component.tsx

'use client'

import { useState, useCallback, forwardRef } from 'react'
import { getImgProps } from '../shared/lib/get-img-props'

const Image = forwardRef((props, forwardedRef) => {
  // 1. 状态管理:占位符是否完成
  const [blurComplete, setBlurComplete] = useState(false)
  
  // 2. 调用 getImgProps 生成 <img> 的所有属性
  const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
    defaultLoader,
    imgConf: config,
    blurComplete,  // 传入当前状态
    showAltText
  })

  // 3. 渲染 img 元素
  return (
    <img
      {...imgAttributes}
      onLoad={(event) => {
        // 4. 图片加载完成后,移除占位符
        handleLoading(event.currentTarget, placeholder, setBlurComplete)
      }}
      onError={() => {
        setBlurComplete(true)  // 加载失败也移除占位符
      }}
    />
  )
})

关键点

  • 'use client' 标记:这是一个客户端组件,但支持 SSR(getImgProps 在服务端也能执行)
  • blurComplete 状态:控制占位符的显示/隐藏
  • 图片加载完成后调用 setBlurComplete(true),触发重渲染移除占位符

4.2 图片加载处理

function handleLoading(
  img: HTMLImageElement,
  placeholder: string,
  setBlurComplete: (v: boolean) => void,
) {
  // 等待图片解码完成,避免闪烁
  const p = 'decode' in img ? img.decode() : Promise.resolve()
  
  p.catch(() => {}).then(() => {
    if (placeholder !== 'empty') {
      setBlurComplete(true)  // 触发重渲染
    }
  })
}

为什么要 img.decode()

直接在 onLoad 中移除占位符可能导致闪烁——图片数据虽然下载完成,但浏览器还没解码成像素。img.decode() 确保解码完成后再切换,过渡更平滑。

4.3 属性生成逻辑

源码位置packages/next/src/shared/lib/get-img-props.ts

import { getImageBlurSvg } from './image-blur-svg'

export function getImgProps(props, _state) {
  const { blurComplete } = _state
  
  // 核心:根据状态决定是否生成占位符背景
  const backgroundImage = !blurComplete && placeholder !== 'empty' 
    ? placeholder === 'blur' 
      // 调用 getImageBlurSvg 生成 SVG 模糊滤镜
      ? `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg({
          widthInt,
          heightInt,
          blurWidth,
          blurHeight,
          blurDataURL: blurDataURL || '',
          objectFit: imgStyle.objectFit
        })}")`
      : `url("${placeholder}")`
    : null;

  // 生成占位符样式
  let placeholderStyle = backgroundImage ? {
    backgroundSize,
    backgroundPosition: imgStyle.objectPosition || '50% 50%',
    backgroundRepeat: 'no-repeat',
    backgroundImage
  } : {};

  return {
    props: {
      style: {
        ...imgStyle,
        ...placeholderStyle,
        color: 'transparent'  // 隐藏 alt 文字
      },
      src: imgAttributes.src,
      srcSet: imgAttributes.srcSet,
      // ...
    }
  }
}

逻辑流程

  1. blurComplete === falseplaceholder === 'blur' → 生成占位符
  2. 调用 getImageBlurSvg() 生成 SVG Data URL
  3. 将 SVG 作为 background-image 设置到 <img> 元素
  4. 图片加载完成后 blurComplete 变为 true,重渲染时 backgroundImagenull

4.4 SVG 模糊滤镜生成

源码位置packages/next/src/shared/lib/image-blur-svg.ts

export function getImageBlurSvg({
  widthInt,
  heightInt,
  blurWidth,
  blurHeight,
  blurDataURL,
  objectFit,
}: {
  widthInt?: number
  heightInt?: number
  blurWidth?: number
  blurHeight?: number
  blurDataURL: string
  objectFit?: string
}): string {
  const std = 20  // 高斯模糊标准差
  
  // 关键:放大 40 倍,为模糊提供足够空间
  const svgWidth = blurWidth ? blurWidth * 40 : widthInt
  const svgHeight = blurHeight ? blurHeight * 40 : heightInt

  const viewBox = svgWidth && svgHeight 
    ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` 
    : ''

  // SVG 滤镜管道
  return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E
    %3Cfilter id='b' color-interpolation-filters='sRGB'%3E
      %3CfeGaussianBlur stdDeviation='${std}'/%3E
      %3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E
      %3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E
      %3CfeComposite operator='out' in='s'/%3E
      %3CfeComposite in2='SourceGraphic'/%3E
      %3CfeGaussianBlur stdDeviation='${std}'/%3E
    %3C/filter%3E
    %3Cimage width='100%25' height='100%25' x='0' y='0' 
      preserveAspectRatio='none' 
      style='filter: url(%23b);' 
      href='${blurDataURL}'/%3E
  %3C/svg%3E`
}

SVG 滤镜管道说明

滤镜作用
feGaussianBlur (第一次)模糊扩散颜色,但边缘会变成半透明
feColorMatrix处理 Alpha 通道,消除半透明边缘
feFlood + feComposite用纯色填充被裁剪的边缘区域
feGaussianBlur (第二次)再次模糊,让填充后的边缘更自然

为什么不能直接放大?

你可能会问:SVG 本身就支持无损缩放,为什么还要加这么多滤镜?

答案是:这套机制不只是为纯色 GIF 设计的,而是要兼容真实图片缩略图。 用以下对比即可看出区别 场景一:1×1 纯色 GIF

场景二:8×8 真实缩略图

五、设计决策

5.1 为什么放大 40 倍?

const svgWidth = blurWidth ? blurWidth * 40 : widthInt

高斯模糊的有效范围约为 3σ(3 倍标准差)。当 stdDeviation = 20 时,模糊会影响周围约 60 像素。如果 viewBox 太小,模糊会"溢出"边界导致边缘被截断。放大 40 倍确保 1 像素的图片也有足够空间让模糊效果自然过渡。

5.2 为什么用 SVG 而非 CSS blur?

对比项CSS blurSVG 滤镜
边缘处理模糊会溢出容器完美控制
额外 DOM需要包裹元素无需额外元素
定制性有限可组合多个滤镜原语

5.3 性能考量

  • Data URL 大小:完整 SVG 约 500-600 bytes,内联在 HTML 中
  • 零网络请求:占位符随 HTML 一起到达,首屏即可渲染
  • GPU 加速:SVG 滤镜由浏览器 GPU 渲染
  • 自动清理:图片加载后背景样式被移除,不占用额外内存

参考资料