在 Astro 博客中实现 LQIP(低质量图片占位符)

发表于 2025-12-20 2146 字 11 min read

文章目录
cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

暂无目录
本文提出了一种轻量级的CSS-only低质量图片占位符(LQIP)方案,通过构建时使用Sharp提取图片四象点主色,生成紧凑的18字符十六进制字符串,运行时解码为CSS线性渐变,实现零运行时开销和极小体积(约42KB/1000张)。方案支持内部图片自动处理、外部图片降级为纯色占位,并通过优化体积和可读性平衡了性能与可维护性。

本文由 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

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

先使用 Remark42 作为临时评论系统,样式等有待优化