GinoGino

使用 Next.js 14 实现精美的 OpenGraph 和 Twitter Card 图片生成

16 分钟阅读构建之路

前言

在当今的社交媒体时代,链接预览图片已成为提升内容分享体验和点击率的关键因素。一个精心设计的预览图不仅能够吸引用户注意,还能传递更多的内容信息。本文将详细介绍如何使用 Next.js 14 实现专业的社交媒体预览图片生成功能。

基础概念

OpenGraph 协议

OpenGraph 协议(简称 OGP)是由 Facebook 推出的一项开放标准。它的主要目的是让网页内容在社交媒体上以更丰富的形式展示。

想象一下,当你在微信或 Discord 中分享一个链接时,不再只是显示一个简单的 URL,而是能看到一个精美的预览卡片。这个卡片包含了标题、描述、图片等信息。这就是 OpenGraph 协议的作用。

工作原理: 通过在网页的 <head> 标签中添加特定的元数据标签,告诉社交平台如何展示这个页面。就像是给网页添加一个"社交名片"。

协议介绍: The Open Graph protocol

关键元数据:

  • og:title: 对象标题。
  • og:type: 对象类型 (website, article, book 等)。
  • og:image: 对象图像 URL (影响视觉效果)。
  • og:url: 对象规范 URL (唯一 ID)。
  • og:description: 对象简述。
  • og:locale: 语言地区 (如 zh_CN)。
  • og:site_name: 网站名。

og:image 重要属性:

  • og:image:url: 同 og:image
  • og:image:type: MIME 类型 (如 image/jpeg)。
  • og:image:width: 宽度 (像素)。
  • og:image:height: 高度 (像素)。
  • og:image:alt: 图像描述 (无障碍)。

示例:

<meta property="og:title" content="Gemini 2.0 现已向所有人开放 | BestBlogs.dev" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://www.bestblogs.dev/article/c377bf" />
<meta property="og:image" content="https://www.bestblogs.dev/article/c377bf/opengraph-image" />
<meta property="og:description" content="谷歌深思宣布 Gemini 2.0 Flash 正式发布,并同时推出实验版本的 Gemini 2.0 Pro 和 Flash-Lite,具备增强的性能、多模态能力和强大的安全措施。" />
<meta property="og:site_name" content="BestBlogs.dev" />
<meta property="og:locale" content="en_US" />
<meta property="og:image:url" content="https://www.bestblogs.dev/article/c377bf/opengraph-image" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Gemini 2.0 现已向所有人开放 | BestBlogs.dev" />

OpenGraph 示例

X Cards (Twitter Cards)

X Cards (原 Twitter Cards) 是 X (Twitter) 平台的类似机制,用元标签控制推文中链接的预览效果。X Cards 将文本链接转为带图片、视频的预览,提升吸引力和点击率。

核心思想: 类似 OpenGraph,用元标签描述网页。

协议介绍: X Cards

关键元数据:

  • twitter:card (必需): 卡片类型:
    • summary: 标题、描述、小图。
    • summary_large_image: 大图、标题、描述。
    • app: 应用下载。
    • player: 嵌入视频/音频。
  • twitter:site: 网站 X 账号。
  • twitter:creator: 作者 X 账号。
  • twitter:title: 标题 (可回退到 og:title)。
  • twitter:description: 描述 (可回退到 og:description)。
  • twitter:image: 图片 URL (可回退到 og:image)。

示例:

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@hongming731" />
<meta name="twitter:title" content="Gemini 2.0 现已向所有人开放 | BestBlogs.dev" />
<meta name="twitter:description" content="谷歌深思宣布 Gemini 2.0 Flash 正式发布,并同时推出实验版本的 Gemini 2.0 Pro 和 Flash-Lite,具备增强的性能、多模态能力和强大的安全措施。" />
<meta name="twitter:image" content="https://www.bestblogs.dev/article/c377bf/twitter-image" />
<meta name="twitter:creator" content="@hongming731" />
<meta name="twitter:image:type" content="image/png" />
<meta name="twitter:image:width" content="1200" />
<meta name="twitter:image:height" content="675" />

X Card 示例

X Cards 和 OpenGraph 兼容: X Cards 优先使用 twitter: 前缀属性,找不到则回退到 Open Graph 属性。详细介绍可参考:Cards Markup Tag Reference

技术方案

Next.js 14 为我们提供了三种实现社交预览图的方式。每种方式都有其适用场景,让我们一一了解:

  1. 静态元数据: 适用于网站固定页面,如首页、关于页等
  2. 动态元数据: 适用于博客文章、产品详情等动态内容
  3. 动态图片生成: 适用于需要完全自定义预览图的场景

让我们详细探讨每种方式的实现方法。

静态元数据

静态元数据可以在 layout.tsxpage.tsx 中通过导出 metadata 对象来定义,具体的定义规范可以参考官方的 文档,以下是一个完整的示例:

// app/[lang]/layout.tsx
export const metadata = {
    metadataBase: new URL("https://www.bestblogs.dev"),
    title: 'bestblogs.dev - 汇集顶级编程、人工智能、产品、科技文章,大语言模型摘要评分辅助阅读,探索编程和技术未来',
    description: 'bestblogs.dev 为您提供独特的编程、人工智能、产品设计、商业科技和个人成长领域的价值导向内容,汇集自顶级技术公司和社区。我们利用先进语言模型,为您摘要、评分和翻译这些文章,节省您的阅读时间。我们了解数据筛选的痛点,致力于为您呈现精选内容。立即订阅,探索未来技术的无限可能!',
    keywords: 'bestblogs, 编程, 软件, 开发, 技术, 阅读, 技术博客, 技术社区, 人工智能, 产品管理, 网页设计, 商业, 科技, 个人成长, 优质文章, 大语言模型, AI 阅读',
    applicationName: "BestBlogs.dev",
    openGraph: {
      title: 'bestblogs.dev - 汇集顶级编程、人工智能、产品、科技文章,大语言模型摘要评分辅助阅读,探索编程和技术未来',
      description: 'bestblogs.dev 为您提供独特的编程、人工智能、产品设计、商业科技和个人成长领域的价值导向内容,汇集自顶级技术公司和社区。我们利用先进语言模型,为您摘要、评分和翻译这些文章,节省您的阅读时间。我们了解数据筛选的痛点,致力于为您呈现精选内容。立即订阅,探索未来技术的无限可能!',
      url: 'https://www.bestblogs.dev',
      siteName: 'BestBlogs.dev',
      images: [
        {
          url: 'https://www.bestblogs.dev/og.png',
          width: 1200,
          height: 630
        }
      ],
      locale: 'zh_CN',
      type: 'website'
    },
    twitter: {
      card: 'summary_large_image',
      title: 'bestblogs.dev - 汇集顶级编程、人工智能、产品、科技文章,大语言模型摘要评分辅助阅读,探索编程和技术未来',
      description: 'bestblogs.dev 为您提供独特的编程、人工智能、产品设计、商业科技和个人成长领域的价值导向内容,汇集自顶级技术公司和社区。我们利用先进语言模型,为您摘要、评分和翻译这些文章,节省您的阅读时间。我们了解数据筛选的痛点,致力于为您呈现精选内容。立即订阅,探索未来技术的无限可能!',
      site: '@hongming731',
      creator: '@hongming731',
      images: [{
        url: 'https://www.bestblogs.dev/og.png',
        width: 1200,
        height: 675,
        alt: "BestBlogs.dev"
      }]
    },
  // 其他元数据
}

需要注意的是:

  1. 元数据对象支持模板字符串,可以使用 ${...} 语法动态生成值
  2. 如果在 layout.tsx 中定义,会作为默认值被所有子页面继承
  3. 子页面可以通过定义自己的 metadata 对象来覆盖父级配置

BestBlogs.dev 首页及文章列表等页面使用的是静态元数据,OpenGraph 和 Twitter Card 的图片使用的是 Open Graph Image Generator 生成的。这个网站提供了一个模板工具,输入网站信息和截图可以快速生成 OpenGraph 和 Twitter Card 的图片。

Open Graph Image Generator

PS:如果你喜欢写代码来生成静态图片,建议使用 Vercel Open Graph Play Ground 这个在线工具,它还支持使用 Tailwind 来编写样式,非常方便。

动态元数据

动态元数据在 generateMetadata 函数 中生成,可以基于页面内容动态生成元数据,此时可以指定特定文章的 OpenGraph 或者 Twitter Card 的图片,之前的版本 BestBlogs.dev 使用的是文章的封面地址。以下是一个示例:

// app/[lang]/article/[id]/layout.tsx
export async function generateMetadata({ params }) {
  const { id } = params;
  const language = params.lang;
  const resourceMeta = await getDirect(`/api/resource/meta`, { id, language }) as QualifiedResource;
  if (!resourceMeta) {
    return {};
  }
 
  const showTitle = `${resourceMeta.title} | BestBlogs`;
  let url;
  if (language !== 'zh') {
    url = `https://www.bestblogs.dev/${language}/article/${id}`;
  } else {
    url = `https://www.bestblogs.dev/article/${id}`;
  }
 
  const image = resourceMeta.cover || 'https://www.bestblogs.dev/og.png';
  const summary = resourceMeta.summary || "在 BestBlogs 上阅读最新的深度分析文章,提供独到见解和详尽信息。";
 
  return {
    title: showTitle,
    description: summary,
    image: image,
    keywords: resourceMeta.tags ? resourceMeta.tags.join(', ') : '深度分析, 最新研究, 详尽信息',
    openGraph: {
      title: showTitle,
      description: summary,
      url: url,
      siteName: 'BestBlogs.dev',
      publishedTime: resourceMeta.publishDateTimeStr,
      authors: resourceMeta.authors,
      tags: resourceMeta.tags,
      images: [
        {
          url: image,
          width: 1200,
          height: 630,
          alt: showTitle
        }
      ],
      locale: language === 'zh' ? 'zh_CN' : 'en_US',
      type: 'article'
    },
    twitter: {
      card: 'summary_large_image',
      title: showTitle,
      description: summary,
      site: '@hongming731',
      images: [{
        url: image,
        width: 1200,
        height: 675,
        alt: showTitle
      }]
    },
    alternates: {
      canonical: url
    }
  }
}

动态图片生成

动态图片生成在 opengraph-image.tsxtwitter-image.tsx 中实现,可以基于页面内容动态生成图片,可参考 官方文档。以下是 BestBlogs.dev 网站文章详情页目前使用的 X Card 动态图片代码,包括了文章的标题、来源、作者、摘要、阅读时间、字数、分类、标签等一系列信息。

// app/[lang]/articles/[id]/twitter-image.tsx
/* eslint-disable @next/next/no-img-element */
import { ImageResponse } from 'next/og'
import { getDirect } from '@/lib/api'
import { QualifiedResource } from '@/types/QualifiedResource'
import { SHARED_STYLES, TWITTER_CONFIG } from '@/lib/social-image'
 
export const runtime = 'edge'
export const size = TWITTER_CONFIG.size
export const contentType = 'image/png'
export const revalidate = 3600
 
export default async function TwitterImage({
  params,
}: {
  params: { id: string; lang: string }
}) {
  const resourceMeta: QualifiedResource | null = await getDirect(
    '/api/resource/meta',
    { id: params.id, language: params.lang }
  )
 
  const { title, oneSentenceSummary, summary, sourceName, authors, tags, readTime, wordCount, mainDomainDesc } = resourceMeta
  const displayTitle = title ?? 'BestBlogs.dev'
  const displaySummary = oneSentenceSummary ?? summary?.substring(0, 80) ?? '阅读最新文章,获取深度分析与详尽信息。'
  const displayTags = tags?.slice(0, 3) ?? ['技术博客']
  const displayReadingTime = readTime ?? 12
  const displayWordCount = wordCount?.toLocaleString('zh-CN') ?? '3,500'
  const displayCategory = mainDomainDesc ?? '人工智能'
 
  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          width: '100%',
          height: '100%',
          background: `
              linear-gradient(135deg, #020617 0%, #0F172A 100%),
              radial-gradient(circle at 95% 5%, rgba(56, 189, 248, 0.2) 0%, transparent 50%),
              radial-gradient(circle at 5% 95%, rgba(56, 189, 248, 0.15) 0%, transparent 50%),
              linear-gradient(90deg, transparent 0%, rgba(56, 189, 248, 0.03) 50%, transparent 100%)
            `,
          position: 'relative',
          overflow: 'hidden',
        }}
      >
        {/* 右侧信息面板 */}
        <div style={{
          position: 'absolute',
          top: '10%',
          right: '4%',
          width: '220px',
          padding: '24px',
          background: 'rgba(15, 23, 42, 0.85)',
          borderRadius: '16px',
          border: '1px solid rgba(56, 189, 248, 0.3)',
          boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(56, 189, 248, 0.1)',
          backdropFilter: 'blur(16px)',
          zIndex: 1,
          display: 'flex',
          flexDirection: 'column',
          gap: '24px',
        }}>
          {/* 文章分类和标签 */}
          <div style={{
            display: 'flex',
            flexDirection: 'column',
            gap: '12px',
          }}>
            <div style={{
              fontSize: '13px',
              color: '#38BDF8',
              letterSpacing: '0.05em',
              fontWeight: 600,
            }}>文章分类</div>
            <div style={{
              display: 'flex',
              alignItems: 'center',
              gap: '8px',
              color: 'rgba(255, 255, 255, 0.9)',
              fontSize: '14px',
              marginBottom: '8px',
            }}>
              <div style={{
                width: '4px',
                height: '4px',
                background: '#38BDF8',
                borderRadius: '50%',
              }} />
              {displayCategory}
            </div>
            <div style={{
              display: 'flex',
              flexDirection: 'column',
              gap: '8px',
            }}>
              {displayTags.map((tag, index) => (
                <div key={index} style={{
                  display: 'flex',
                  padding: '6px 12px',
                  background: 'rgba(56, 189, 248, 0.15)',
                  borderRadius: '8px',
                  fontSize: '13px',
                  color: '#7DD3FC',
                  border: '1px solid rgba(56, 189, 248, 0.2)',
                }}>{tag}</div>
              ))}
            </div>
          </div>
 
          {/* 阅读信息 */}
          <div style={{
            display: 'flex',
            flexDirection: 'column',
            gap: '12px',
          }}>
            <div style={{
              fontSize: '13px',
              color: '#38BDF8',
              letterSpacing: '0.05em',
              fontWeight: 600,
            }}>阅读信息</div>
            <div style={{
              display: 'flex',
              flexDirection: 'column',
              gap: '8px',
            }}>
              <div style={{
                display: 'flex',
                alignItems: 'center',
                gap: '8px',
                color: 'rgba(255, 255, 255, 0.9)',
                fontSize: '14px',
              }}>
                <div style={{
                  width: '4px',
                  height: '4px',
                  background: '#38BDF8',
                  borderRadius: '50%',
                }} />
                全文约 {displayWordCount} 字
              </div>
              <div style={{
                display: 'flex',
                alignItems: 'center',
                gap: '8px',
                color: 'rgba(255, 255, 255, 0.9)',
                fontSize: '14px',
              }}>
                <div style={{
                  width: '4px',
                  height: '4px',
                  background: '#38BDF8',
                  borderRadius: '50%',
                }} />
                预计阅读 {displayReadingTime} 分钟
              </div>
            </div>
          </div>
        </div>
 
        {/* 几何装饰 */}
        <svg
          style={{
            position: 'absolute',
            top: '0',
            right: '0',
            width: '70%',
            height: '100%',
            opacity: 0.15,
            transform: 'translateX(10%)',
          }}
        >
          <path
            d="M 50,0 L 100,0 L 100,100 L 45,100 Z"
            fill="url(#tech-gradient)"
          />
          <defs>
            <linearGradient id="tech-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
              <stop offset="0%" style={{ stopColor: '#38BDF8', stopOpacity: 0.9 }} />
              <stop offset="100%" style={{ stopColor: '#0EA5E9', stopOpacity: 0.3 }} />
            </linearGradient>
          </defs>
        </svg>
 
        {/* 装饰性线条组 */}
        <div style={{
          position: 'absolute',
          top: 0,
          right: '45%',
          width: '1px',
          height: '100%',
          background: 'linear-gradient(180deg, transparent, rgba(56, 189, 248, 0.3), transparent)',
        }} />
        <div style={{
          position: 'absolute',
          top: 0,
          right: '46%',
          width: '1px',
          height: '100%',
          background: 'linear-gradient(180deg, transparent, rgba(56, 189, 248, 0.15), transparent)',
        }} />
 
        {/* 光点效果组 */}
        <div style={{
          position: 'absolute',
          top: '15%',
          right: '25%',
          width: '12px',
          height: '12px',
          background: '#38BDF8',
          borderRadius: '50%',
          boxShadow: '0 0 60px 20px rgba(56, 189, 248, 0.4)',
          opacity: 0.8,
        }} />
        <div style={{
          position: 'absolute',
          top: '55%',
          right: '48%',
          width: '8px',
          height: '8px',
          background: '#7DD3FC',
          borderRadius: '50%',
          boxShadow: '0 0 50px 15px rgba(125, 211, 252, 0.3)',
          opacity: 0.6,
        }} />
        <div style={{
          position: 'absolute',
          top: '75%',
          right: '15%',
          width: '6px',
          height: '6px',
          background: '#BAE6FD',
          borderRadius: '50%',
          boxShadow: '0 0 40px 12px rgba(186, 230, 253, 0.25)',
          opacity: 0.5,
        }} />
 
        {/* 主要内容区域 */}
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            width: '100%',
            height: '100%',
            padding: '48px 56px 80px',
            justifyContent: 'flex-start',
            position: 'relative',
            zIndex: 2,
          }}
        >
          {/* 上半部分 */}
          <div style={{
            display: 'flex',
            flexDirection: 'column',
            gap: '24px',
            maxWidth: '72%',
            marginBottom: 'auto',
          }}>
            {/* 来源信息 */}
            <div style={{
              display: 'flex',
              gap: '16px',
              alignItems: 'center',
              marginBottom: '4px',
            }}>
              <div style={{
                padding: '8px 16px',
                background: 'rgba(56, 189, 248, 0.15)',
                borderRadius: '10px',
                fontSize: '15px',
                color: '#7DD3FC',
                letterSpacing: '0.02em',
                border: '1px solid rgba(56, 189, 248, 0.3)',
                backdropFilter: 'blur(8px)',
                fontWeight: 500,
              }}>
                {sourceName}
              </div>
              {authors?.length ? (
                <div style={{
                  fontSize: '15px',
                  color: 'rgba(255, 255, 255, 0.85)',
                  letterSpacing: '0.02em',
                  background: 'rgba(255, 255, 255, 0.08)',
                  padding: '8px 16px',
                  borderRadius: '10px',
                  backdropFilter: 'blur(8px)',
                  border: '1px solid rgba(255, 255, 255, 0.1)',
                  fontWeight: 500,
                }}>
                  {authors.slice(0, 2).join(', ')}
                </div>
              ) : null}
            </div>
 
            {/* 标题 */}
            <h1
              style={{
                margin: 0,
                fontSize: '60px',
                fontWeight: 700,
                lineHeight: 1.15,
                letterSpacing: '-0.02em',
                color: '#FFFFFF',
                textShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
              }}
            >
              {displayTitle}
            </h1>
 
            {/* 摘要 */}
            <p
              style={{
                margin: 0,
                fontSize: '22px',
                lineHeight: 1.6,
                color: 'rgba(255, 255, 255, 0.9)',
                letterSpacing: '0.01em',
                textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
                fontWeight: 400,
              }}
            >
              {displaySummary}
            </p>
          </div>
 
          {/* Logo 区域 */}
          <div style={{
            position: 'absolute',
            bottom: '80px',
            left: '56px',
            right: '56px',
            display: 'flex',
            alignItems: 'center',
            padding: '20px 24px',
            background: 'rgba(255, 255, 255, 0.03)',
            borderRadius: '16px',
            boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
            backdropFilter: 'blur(16px)',
            border: '1px solid rgba(56, 189, 248, 0.2)',
          }}>
            <img
              src="https://bestblogs.dev/logo.png"
              width={52}
              height={52}
              alt=""
              style={{
                borderRadius: '10px',
                marginRight: '20px',
              }}
            />
            <div style={{
              display: 'flex',
              flexDirection: 'column',
              gap: '4px',
            }}>
              <div style={{
                fontSize: '24px',
                fontWeight: 600,
                color: '#FFFFFF',
                letterSpacing: '-0.01em',
              }}>
                BestBlogs.dev
              </div>
              <div style={{
                fontSize: '15px',
                color: 'rgba(255, 255, 255, 0.8)',
                letterSpacing: '0.02em',
                fontWeight: 400,
              }}>
                遇见更好的技术阅读
              </div>
            </div>
          </div>
        </div>
      </div>
    ),
    {
      ...size,
    }
  )
}

测试效果:

X Card 示例

测试和调试

  1. 直接访问

输入文章的 URL 及后缀 /opengraph-image/twitter-image 查看效果,例如:

http://localhost:3000/article/123/opengraph-image
http://localhost:3000/article/123/twitter-image
  1. OpenGraph 调试工具

使用 OpenGraph Dev 查看在各个社交平台的效果。

OpenGraph 测试

  1. Vercel Open Graph 预览

如果使用 Vercel 部署,可以通过 Open Graph 部署选项卡检查和验证 Open Graph 元数据。

Vercel Open Graph 预览

  1. TDK 预览

使用 TDK 插件 预览 SEO 和社交媒体的展示信息。

TDK 预览

next/og 介绍

在了解了基本的实现方案后,我们来深入探讨 Next.js 提供的动态图片生成工具 —— next/og。这个工具基于 Vercel 的 @vercel/og 库实现,用于在 Vercel Functions 中计算和生成社交卡片图片。

核心优势

  1. 高性能

    • 代码量小,函数启动几乎瞬时
    • 生成过程快速,能被 Open Graph Debugger 等工具识别
    • 支持 Edge Runtime,全球分发
  2. 易用性

    • 使用熟悉的 HTML 和 CSS 语法定义图片
    • 支持动态生成图片
  3. 成本效益

    • 自动添加正确的缓存头
    • 在边缘缓存计算结果
    • 减少重复计算

使用说明

  1. 运行时支持

    // Next.js App Router(推荐)
    export const runtime = 'edge' // 使用 Edge Runtime
     
    // 加载本地资源
    import { readFile } from 'fs/promises'
    const imageData = await readFile('./public/logo.png')
     
    // 加载远程资源
    const response = await fetch('https://example.com/image.jpg')
    const imageData = await response.arrayBuffer()

    说明:支持 Node.js 运行时和 Edge 运行时。本地资源可以直接使用 fs.readFile 加载,远程资源可以使用 fetch 加载。

  2. 字体处理

    export default function OGImage() {
      return new ImageResponse(
        <div style={{ fontFamily: 'NotoSansSC' }}>
          你好,世界!
        </div>,
        {
          width: 1200,
          height: 630,
          fonts: [
            {
              name: 'NotoSansSC',
              data: await fetch(
                new URL('./NotoSansSC-Regular.otf', import.meta.url)
              ).then((res) => res.arrayBuffer()),
              weight: 400,
              style: 'normal',
            },
          ],
        }
      )
    }

    说明:支持自定义字体,建议使用 ttf 或 otf 格式以获得最佳的解析速度。

  3. 性能优化

    // 1. 缓存配置
    export const revalidate = 3600 // 缓存一小时
     
    // 2. 错误处理
    try {
      return new ImageResponse(...)
    } catch (e) {
      console.error('OG Image Generation Error:', e)
      return new Response(
        await readFile('./public/default-og.png'),
        { headers: { 'content-type': 'image/png' } }
      )
    }

技术限制

  1. 布局限制

    • 仅支持 Flexbox 布局(display: flex)和基础 CSS 属性
    • 支持绝对定位(position: absolute
    • 不支持 Grid 布局和一些高级 CSS 特性
    • 支持文本换行、居中和嵌套图片
    • 具体支持的 CSS 属性请参考 Satori 文档
  2. 资源限制

    • bundle 大小限制为 500KB
    • 包括 JSX、CSS、字体、图片等所有资源
    • 超出限制时建议:
      • 压缩资源
      • 运行时加载资源
      • 优化字体子集
  3. 字体支持

    • 支持 ttf、otf 和 woff 格式
    • 推荐使用 ttf 或 otf 格式以获得最佳性能
    • 支持从 Google Fonts 下载字体子集
  4. 运行时要求

    • 需要 Node.js 22 或更高版本
    • Next.js 需要 v12.2.3 或更高版本
    • App Router 项目已内置 @vercel/og
    • 建议在 robots.txt 中允许爬虫访问 OG 图片生成路由

小结

本文深入探讨了使用 Next.js 14 实现社交媒体预览图片生成功能的完整方案。让我们回顾关键内容:

  1. 基础概念

    • OpenGraph 协议的工作原理和关键元数据
    • X Cards 的使用方法和与 OpenGraph 的兼容性
    • 不同平台的图片规格和展示要求
  2. 实现方案

    • 静态元数据:适用于固定内容页面
    • 动态元数据:满足动态内容需求
    • 动态图片生成:实现完全自定义的预览图
  3. next/og 技术要点

    • 高性能的 Edge Runtime 环境
    • 灵活的字体和样式处理
    • 完善的缓存和错误处理
    • 布局和资源使用的限制

通过本文的学习,你应该已经掌握了:

  • 如何选择合适的预览图生成方案
  • 如何实现高性能的动态图片生成
  • 如何处理常见的技术难点
  • 如何优化生成图片的性能

希望这些内容能帮助你在项目中实现更好的社交媒体预览效果。如果你有任何问题或建议,欢迎评论交流。