深入 Next.js 源码:Image 组件 Color Placeholder 实现原理
本文将深入 Next.js 源码,解析 Image 组件是如何用一个仅 35 字节的 1x1 像素 GIF,生成全尺寸模糊占位符的。
一、引言
在现代 Web 应用中,图片加载体验直接影响用户的第一印象。当用户打开页面时,如果看到的是空白区域或者布局跳动,体验会大打折扣。
Next.js 的 Image 组件提供了 placeholder="blur" 功能,可以在图片加载完成前显示一个模糊的占位符。对于静态导入的图片,Next.js 会在构建时自动生成 blurDataURL;而对于动态图片,我们可以手动提供一个纯色占位符。这张gif是3G网络状态下录屏效果。
二、使用示例
让我们先看官方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}
/>
);
}
这段代码做了两件事:
rgbDataURL函数:根据 RGB 值生成一个 1x1 像素的 GIF Data URL- 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,
// ...
}
}
}
逻辑流程:
blurComplete === false且placeholder === 'blur'→ 生成占位符- 调用
getImageBlurSvg()生成 SVG Data URL - 将 SVG 作为
background-image设置到<img>元素 - 图片加载完成后
blurComplete变为true,重渲染时backgroundImage为null
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 blur | SVG 滤镜 |
|---|---|---|
| 边缘处理 | 模糊会溢出容器 | 完美控制 |
| 额外 DOM | 需要包裹元素 | 无需额外元素 |
| 定制性 | 有限 | 可组合多个滤镜原语 |
5.3 性能考量
- Data URL 大小:完整 SVG 约 500-600 bytes,内联在 HTML 中
- 零网络请求:占位符随 HTML 一起到达,首屏即可渲染
- GPU 加速:SVG 滤镜由浏览器 GPU 渲染
- 自动清理:图片加载后背景样式被移除,不占用额外内存
参考资料: