本文由 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 实现方式包括:

  1. 缩略图方案:生成一张极小的图片(如 20x20),加载时放大并模糊显示
  2. BlurHash:将图片编码为一个短字符串,在客户端解码渲染
  3. 主色调方案:提取图片的主色调作为纯色背景
  4. 渐变方案:提取多个色彩点生成 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 完整 CSSlinear-gradient(135deg, #87a3c4 0%, ...)80 字节
v2 紧凑颜色87a3c4c2dfefbddae947 字节
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 实现方案:

  1. 构建时:使用 sharp 提取图片四象限主色,生成紧凑的颜色字符串
  2. 运行时:解码为 CSS 渐变,作为图片容器的背景
  3. 降级策略:外部图片使用纯色占位符

这种方案的核心优势在于零运行时开销极小的数据体积,非常适合静态站点生成器如 Astro、Next.js 等场景。

# Refs

  • CSS-only LQIP - 原始灵感来源,使用 20 位整数编码的高级方案
  • sharp 文档 - Node.js 高性能图片处理库
  • BlurHash - 另一种 LQIP 方案

本文随时修订中,有错漏可直接评论

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

cos 微信支付

微信支付

cos 支付宝

支付宝