本文由 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 方案
本文随时修订中,有错漏可直接评论
先使用 Remark42 作为临时评论系统,样式等有待优化