本文由 AI 辅助写作,作记录用
一开始想在博客中,实现类似 Minimal CSS-only blurry image placeholders 的 CSS-only LQIP(低质量图片占位符),使用单个 CSS 自定义属性 --lqip 编码图片的模糊预览。
这篇文章的技术原理是使用 20 位整数编码图片信息(8 位 Oklab 基础色 + 12 位亮度分量),在 CSS 中通过位运算解码( mod() , round(down) , pow() 等),使用径向渐变叠加渲染模糊效果,配合二次缓动实现平滑过渡。
我选择简化一些的实现,不追求 CSS Only 了,因为打算先做博客内部的图片,文章内部的外部图片等后续优化的时候再一起做,放上最终效果在这里:


需要运行一下 nr generate:lqips 就会生成一个 lqips.json 的 json 文件在 assets 下,若没有这个文件则不提供占位符~
以下为 AI 生成的记录,仅做小改。
# 什么是 LQIP
LQIP(Low Quality Image Placeholder)是一种图片加载优化技术,在高清图片加载完成前,先显示一个低质量的占位符,避免页面出现空白或布局抖动。
常见的 LQIP 实现方式包括:
- 缩略图方案:生成一张极小的图片(如 20x20),加载时放大并模糊显示
- BlurHash:将图片编码为一个短字符串,在客户端解码渲染
- 主色调方案:提取图片的主色调作为纯色背景
- 渐变方案:提取多个色彩点生成 CSS 渐变
本文介绍的是渐变方案,通过构建时提取图片的四象限主色,生成 CSS 线性渐变作为占位符。这种方案的优势在于:
- 零运行时开销:纯 CSS 实现,无需 JavaScript 解码
- 极小的数据体积:每张图片仅需 18 个字符存储
- 视觉效果自然:渐变色比纯色更贴近原图的色彩分布
# 方案设计
整体架构分为三个部分:
flowchart LR | |
A[构建时生成脚本<br/>generateLqips.ts] --> B[JSON 数据文件<br/>lqips.json] | |
B --> C[运行时工具函数<br/>lqip.ts] | |
A -.-> D[sharp 处理图片<br/>提取四象限颜色] | |
C -.-> E[Astro 组件调用<br/>生成 CSS 渐变] |
# 数据格式设计
为了最小化 JSON 体积,我们采用紧凑的存储格式:
{ | |
"cover/1.webp": "87a3c4c2dfefbddae9", | |
"cover/2.webp": "6e3b38ae7472af7574" | |
} |
每个值是 18 个十六进制字符,由 3 个颜色组成(去掉 # 前缀):
- 字符 0-5:左上角颜色
- 字符 6-11:右上角颜色
- 字符 12-17:右下角颜色
运行时将其解码为 CSS 渐变:
linear-gradient(135deg, #87a3c4 0%, #c2dfef 50%, #bddae9 100%) |
(PS:这个其实是权衡了牺牲了一点可读性,但是还想保留一点可编辑性,别喷我喵我知道肯定会有人问为什么不用二进制格式)
# 构建时生成脚本
src/scripts/generateLqips.ts 负责在构建时处理所有图片:
import sharp from "sharp"; | |
import { glob } from "glob"; | |
import fs from "fs/promises"; | |
import path from "path"; | |
import chalk from "chalk"; | |
//--------- 配置 --------- | |
const IMAGE_GLOB = "public/img/**/*.{webp,jpg,jpeg,png}"; | |
const OUTPUT_FILE = "src/assets/lqips.json"; | |
//--------- 类型定义 --------- | |
interface RgbColor { | |
r: number; | |
g: number; | |
b: number; | |
} | |
type LqipMap = Record<string, string>; | |
//--------- 颜色工具函数 --------- | |
/** | |
* RGB 转十六进制字符串 | |
*/ | |
function rgbToHex(rgb: RgbColor): string { | |
const toHex = (n: number) => | |
Math.round(Math.max(0, Math.min(255, n))) | |
.toString(16) | |
.padStart(2, "0"); | |
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`; | |
} | |
//--------- 图片处理 --------- | |
/** | |
* 处理单张图片,生成紧凑的颜色字符串 | |
*/ | |
async function processImage(imagePath: string): Promise<string | null> { | |
try { | |
// Resize to 2x2 to get 4 quadrant colors | |
const resized = await sharp(imagePath) | |
.resize(2, 2, { fit: "fill" }) | |
.raw() | |
.toBuffer({ resolveWithObject: true }); | |
const channels = resized.info.channels; | |
const data = resized.data; | |
// Extract 4 colors (top-left, top-right, bottom-left, bottom-right) | |
const colors: string[] = []; | |
for (let i = 0; i < 4; i++) { | |
const offset = i * channels; | |
const rgb: RgbColor = { | |
r: data[offset], | |
g: data[offset + 1], | |
b: data[offset + 2], | |
}; | |
colors.push(rgbToHex(rgb)); | |
} | |
// 紧凑存储:3 个颜色(左上、右上、右下),去掉 # 前缀 | |
// 用于生成 135deg 斜向渐变 | |
const compact = `${colors[0].slice(1)}${colors[1].slice( | |
1 | |
)}${colors[3].slice(1)}`; | |
return compact; | |
} catch (error) { | |
console.error(chalk.red(` Error processing ${imagePath}:`), error); | |
return null; | |
} | |
} | |
/** | |
* 文件路径转短键名 | |
*/ | |
function filePathToKey(filePath: string): string { | |
// public/img/cover/1.webp → cover/1.webp | |
return filePath.replace(/^public\/img\//, ""); | |
} | |
//--------- 主函数 --------- | |
async function main() { | |
const startTime = Date.now(); | |
console.log(chalk.cyan("=== LQIP Generator ===\n")); | |
const files = await glob(IMAGE_GLOB); | |
if (!files.length) { | |
console.log(chalk.yellow("No image files found.")); | |
return; | |
} | |
console.log(chalk.blue(`Found ${files.length} images\n`)); | |
const lqips: LqipMap = {}; | |
let processed = 0; | |
for (const file of files) { | |
process.stdout.write(`\r Processing ${processed + 1}/${files.length}...`); | |
const compact = await processImage(file); | |
if (compact !== null) { | |
const key = filePathToKey(file); | |
lqips[key] = compact; | |
processed++; | |
} | |
} | |
const dir = path.dirname(OUTPUT_FILE); | |
await fs.mkdir(dir, { recursive: true }); | |
await fs.writeFile(OUTPUT_FILE, JSON.stringify(lqips, null, 2) + "\n"); | |
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); | |
console.log( | |
chalk.green( | |
`\n\nDone! Generated LQIP for ${processed} images in ${elapsed}s` | |
) | |
); | |
console.log(chalk.cyan(`Output saved to: ${OUTPUT_FILE}`)); | |
} | |
main(); |
# 核心原理:2x2 缩放
sharp 的 resize(2, 2, { fit: 'fill' }) 将图片缩放到 2x2 像素,每个像素代表原图四分之一区域的平均色:
flowchart LR | |
subgraph orig[原图] | |
direction TB | |
subgraph row1[" "] | |
A1["区域 A"] | |
B1["区域 B"] | |
end | |
subgraph row2[" "] | |
C1["区域 C"] | |
D1["区域 D"] | |
end | |
end | |
orig -.->|2x2 缩放| scaled | |
subgraph scaled[2x2 缩放结果] | |
direction TB | |
subgraph row3[" "] | |
A2["A"] | |
B2["B"] | |
end | |
subgraph row4[" "] | |
C2["C"] | |
D2["D"] | |
end | |
end |
我们选取 A(左上)、B(右上)、D(右下)三个颜色,生成 135 度斜向渐变,这样既能覆盖图片的主要色彩分布,又避免存储冗余数据。
# 运行时工具函数
src/lib/lqip.ts 提供运行时 API:
// 导入构建时生成的 LQIP 数据 | |
let lqips: Record<string, string> = {}; | |
try { | |
const lqipData = await import("@assets/lqips.json"); | |
lqips = lqipData.default as Record<string, string>; | |
} catch { | |
// 文件不存在时静默失败(首次构建前) | |
} | |
/** | |
* 图片路径转 LQIP 键名 | |
*/ | |
function imagePathToKey(imagePath: string): string { | |
return imagePath.replace(/^\/img\//, ""); | |
} | |
/** | |
* 获取图片的 LQIP 渐变 CSS | |
*/ | |
export function getLqipGradient(imagePath: string): string | undefined { | |
const key = imagePathToKey(imagePath); | |
const compact = lqips[key]; | |
if (compact?.length !== 18) return undefined; | |
// 解码紧凑格式:18 字符 → 3 个十六进制颜色 | |
const c1 = `#${compact.slice(0, 6)}`; | |
const c2 = `#${compact.slice(6, 12)}`; | |
const c3 = `#${compact.slice(12, 18)}`; | |
return `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`; | |
} | |
/** | |
* 判断是否为外部图片 | |
*/ | |
export function isExternalImage(imagePath: string): boolean { | |
return imagePath.startsWith("http://") || imagePath.startsWith("https://"); | |
} | |
/** | |
* 获取 LQIP 内联样式 | |
*/ | |
export function getLqipStyle(imagePath: string): string | undefined { | |
if (isExternalImage(imagePath)) { | |
return undefined; | |
} | |
const gradient = getLqipGradient(imagePath); | |
return gradient ? `background-image:${gradient}` : undefined; | |
} | |
/** | |
* 获取 LQIP props(用于组件) | |
*/ | |
export function getLqipProps(imagePath: string): { | |
style?: string; | |
class?: string; | |
} { | |
if (isExternalImage(imagePath)) { | |
return { class: "lqip-fallback" }; | |
} | |
const style = getLqipStyle(imagePath); | |
return style ? { style } : {}; | |
} |
# 外部图片降级处理
对于外部图片(用户自定义的封面 URL),我们无法在构建时获取,因此提供 CSS 降级方案:
/* src/styles/components/lqip.css */ | |
.lqip-fallback { | |
background-color: hsl(var(--muted)); | |
} |
# 在 Astro 组件中使用
# 文章卡片封面
PostItemCard.astro 中应用 LQIP:
---
import { getLqipProps } from '@lib/lqip';
const finalCover = cover ?? randomCover ?? defaultCoverList[0];
const lqipProps = getLqipProps(finalCover);
---
<a href={href} style={lqipProps.style} class={cn('relative overflow-hidden', lqipProps.class)}>
<Image src={finalCover} width={600} height={186} loading="lazy" alt="post cover" class="h-full w-full object-cover" />
</a>
# 页面横幅
Cover.astro 中应用 LQIP:
---
import { getLqipStyle } from '@lib/lqip';
const bannerLqipStyle = getLqipStyle('/img/site_header_1920.webp');
---
<div class="relative h-full w-full" style={bannerLqipStyle} id="banner-box">
<Image src="/img/site_header_1920.webp" width={1920} height={1080} alt="cover" class="h-full w-full object-cover" />
</div>
# 存储优化
在迭代过程中,我们进行了多轮体积优化:
| 版本 | 格式示例 | 每条约 |
|---|---|---|
| v1 完整 CSS | linear-gradient(135deg, #87a3c4 0%, ...) | 80 字节 |
| v2 紧凑颜色 | 87a3c4c2dfefbddae9 | 47 字节 |
| v3 短键名 | cover/1.webp → 同上 | 42 字节 |
最终版本相比 v1 减少约 50% 体积。按 1000 张图片估算:
- v1:~80KB
- v3:~42KB
对于更激进的优化,还可以考虑:
- Base64 编码:3 色 = 9 字节 → 12 字符 Base64
- 二进制格式:完全避免 JSON 开销
但考虑到可读性和调试便利性,当前的 JSON 格式已经是较好的平衡点。
# 构建集成
在 package.json 中添加脚本:
{ | |
"scripts": { | |
"generate:lqips": "npx tsx src/scripts/generateLqips.ts" | |
} | |
} |
可以在 CI/CD 中配置为构建前置步骤,或在添加新图片后手动执行。
# 效果展示
以红叶图片为例:
- 原图路径:
/img/cover/2.webp - LQIP 数据:
6e3b38ae7472af7574 - 解码渐变:
linear-gradient(135deg, #6e3b38 0%, #ae7472 50%, #af7574 100%)
渐变色准确反映了原图的暖红色调,在图片加载前提供了良好的视觉预期。
# 总结
本文介绍了一种轻量级的 LQIP 实现方案:
- 构建时:使用 sharp 提取图片四象限主色,生成紧凑的颜色字符串
- 运行时:解码为 CSS 渐变,作为图片容器的背景
- 降级策略:外部图片使用纯色占位符
这种方案的核心优势在于零运行时开销和极小的数据体积,非常适合静态站点生成器如 Astro、Next.js 等场景。
# Refs
- CSS-only LQIP - 原始灵感来源,使用 20 位整数编码的高级方案
- sharp 文档 - Node.js 高性能图片处理库
- BlurHash - 另一种 LQIP 方案
本文随时修订中,有错漏可直接评论