第二十一章:SEO 优化与元数据管理

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第二十一章:SEO 优化与元数据管理

1. SPA 的 SEO 挑战

单页应用(SPA)的 SEO 面临几个核心问题:

| 问题 | 原因 | 后果 | |------|------|------| | 初始 HTML 为空 | JS 动态渲染,爬虫不执行 JS | 搜索引擎看到空白页 | | 元数据静态 | <title>index.html 中写死 | 所有页面共享同一标题描述 | | 缺少社交标签 | OG/Twitter 标签不随路由变化 | 分享链接无预览 |

解决方案对比:

| 方案 | 优点 | 缺点 | |------|------|------| | SSR(服务端渲染) | 爬虫友好,最完整的 SEO | 需要 Node/Rust 服务器 | | Prerender(预渲染) | 部署简单,静态化 | 动态内容需额外配置 | | 动态 Head 管理 | 轻量,SPA 原生 | 爬虫兼容性需验证 | | 混合式(SSG + CSR) | 兼顾 SEO 和交互 | 构建流程复杂 |

2. 动态 Head 管理

use_head id="2-1-使用-管理标题">2.1 使用 管理标题

Dioxus 的 use_head 允许组件动态修改 <head> 中的内容:

use dioxus::prelude::*;

#[component]
fn BlogPostPage(slug: String) -> Element {
    let article = use_resource(move || async move {
        fetch_article(&slug).await
    });

    // 动态设置页面标题
    use_hook(|| {
        let title = article()
            .as_ref()
            .map(|a| format!("{} - Blog SSR", a.title))
            .unwrap_or_else(|| "加载中...".to_string());

        use_head().title.set_title(&title);
    });

    // ...
}

2.2 完整的元数据 Hook

use dioxus::prelude::*;

fn use_page_meta(title: String, description: String, image: Option<String>) {
    use_effect(move || {
        let head = use_head();

        // 设置标题
        head.title.set_title(&format!("{title} - Blog SSR"));

        // 设置描述
        head.links.push(HeadLink {
            rel: "description".into(),
            href: "".into(),
            sizes: None,
            media: None,
        });
        head.meta.push(HeadMeta {
            name: Some("description".into()),
            property: None,
            content: Some(description.clone()),
        });

        // Open Graph 标签
        let og_tags = vec![
            ("og:title", title.clone()),
            ("og:description", description),
            ("og:type", "article".to_string()),
            ("og:url", web_sys::window()
                .and_then(|w| w.location().href().ok())
                .unwrap_or_default()),
        ];

        for (property, content) in og_tags {
            head.meta.push(HeadMeta {
                name: None,
                property: Some(property.into()),
                content: Some(content),
            });
        }

        // OG 图片
        if let Some(img) = image {
            head.meta.push(HeadMeta {
                name: None,
                property: Some("og:image".into()),
                content: Some(img),
            });
        }

        // Twitter Card
        head.meta.push(HeadMeta {
            name: Some("twitter:card".into()),
            property: None,
            content: Some("summary_large_image".into()),
        });
        head.meta.push(HeadMeta {
            name: Some("twitter:title".into()),
            property: None,
            content: Some(title),
        });
    });
}

// 使用
#[component]
fn ArticlePage(slug: String) -> Element {
    let article = use_resource(move || async move {
        fetch_article(&slug).await
    });

    if let Some(ref a) = article() {
        use_page_meta(
            a.title.clone(),
            a.abstract_field.clone(),
            Some(a.cover_image.clone()),
        );
    }

    // ...
}

3. 结构化数据(Schema.org)

搜索引擎支持结构化数据来增强搜索结果展示。JSON-LD 是最推荐的方式:

fn use_json_ld(json: serde_json::Value) {
    use_effect(move || {
        let head = use_head();
        // JSON-LD 通过 script 标签注入
        head.links.push(HeadLink {
            rel: "".into(),
            href: "".into(),
            sizes: None,
            media: None,
        });
        // 实际注入方式使用 inner_html
    });
}

// 生成 Article 结构化数据
fn article_schema(article: &ArticleDto) -> serde_json::Value {
    serde_json::json!({
        "@context": "https://schema.org",
        "@type": "Article",
        "headline": article.title,
        "description": article.abstract_field,
        "datePublished": article.created_at,
        "dateModified": article.updated_at,
        "author": {
            "@type": "Person",
            "name": "Rabit Logic",
            "url": "https://github.com/RabitLogic"
        },
        "publisher": {
            "@type": "Organization",
            "name": "Blog SSR",
            "logo": {
                "@type": "ImageObject",
                "url": "https://blog.example.com/logo.png"
            }
        },
        "mainEntityOfPage": {
            "@type": "WebPage",
            "@id": format!("https://blog.example.com/article/{}", article.slug)
        },
        "image": article.cover_image
    })
}

4. 面包屑导航

面包屑不仅改善用户体验,也是重要的 SEO 信号:

#[component]
fn Breadcrumb(items: Vec<BreadcrumbItem>) -> Element {
    use_effect(move || {
        // 注入 BreadcrumbList 结构化数据
        let schema = serde_json::json!({
            "@context": "https://schema.org",
            "@type": "BreadcrumbList",
            "itemListElement": items.iter().enumerate().map(|(i, item)| {
                serde_json::json!({
                    "@type": "ListItem",
                    "position": i + 1,
                    "name": item.label,
                    "item": item.url
                })
            }).collect::<Vec<_>>()
        });
        // 写入 head
    });

    rsx! {
        nav { class: "text-sm mb-4", "aria-label": "breadcrumb",
            ol { class: "flex items-center gap-1.5",
                for (i, item) in items.iter().enumerate() {
                    li { class: "flex items-center gap-1.5",
                        if i > 0 {
                            span { class: "text-gray-400", "/" }
                        }
                        if item.active {
                            span { class: "font-medium",
                                style: "color: var(--text);",
                                "{item.label}"
                            }
                        } else {
                            a {
                                class: "transition-colors",
                                style: "color: var(--tertiary);",
                                href: "{item.url}",
                                "{item.label}"
                            }
                        }
                    }
                }
            }
        }
    }
}

// 使用
#[component]
fn ArticlePage(slug: String) -> Element {
    rsx! {
        Breadcrumb {
            items: vec![
                BreadcrumbItem { label: "首页".into(), url: "/".into(), active: false },
                BreadcrumbItem { label: "文章".into(), url: "/blog".into(), active: false },
                BreadcrumbItem { label: slug.clone(), url: format!("/article/{slug}"), active: true },
            ],
        }
        // 文章内容
    }
}

5. 站点地图

5.1 sitemap.xml 生成

SPA 需要生成 sitemap.xml 供搜索引擎爬取:

// 在服务端生成 sitemap.xml
async fn generate_sitemap() -> String {
    let articles = load_all_articles();

    let urls: Vec<String> = articles.iter().map(|a| {
        format!(
            r#"  <url>
    <loc>https://blog.example.com/article/{}</loc>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>"#,
            a.slug
        )
    }).collect();

    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://blog.example.com/</loc>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://blog.example.com/blog</loc>
    <changefreq>daily</changefreq>
    <priority>0.9</priority>
  </url>
{}
</urlset>"#,
        urls.join("\n")
    )
}

5.2 robots.txt

# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://blog.example.com/sitemap.xml

6. 页面加载性能与 SEO

搜索引擎考虑 Core Web Vitals:

| 指标 | 要求 | Dioxus 优化策略 | |------|------|----------------| | LCP | ≤ 2.5s | 关键 CSS 内联,图片懒加载 | | FID | ≤ 100ms | 代码分割,长任务拆分 | | CLS | ≤ 0.1 | 图片/广告预留尺寸,字体预加载 |

// 图片懒加载 —— 减少 LCP 阻塞
#[component]
fn LazyImage(src: String, alt: String) -> Element {
    let loaded = use_signal(|| false);
    let in_view = use_signal(|| false);

    // IntersectionObserver 检测进入视口
    use_effect(move || {
        // 使用 web-sys 创建 observer
        // 进入视口时设置 in_view = true
    });

    rsx! {
        div {
            class: "bg-gray-100 rounded overflow-hidden",
            style: "aspect-ratio: 16/9;", // 预留尺寸防 CLS
            if in_view() {
                img {
                    src: "{src}",
                    alt: "{alt}",
                    class: "w-full h-full object-cover transition-opacity duration-300",
                    style: "opacity: {if loaded() { \"1\" } else { \"0\" }};",
                    onload: move |_| loaded.set(true),
                }
            }
        }
    }
}

// 字体预加载
#[component]
fn App() -> Element {
    use_effect(move || {
        let head = use_head();
        head.links.push(HeadLink {
            rel: "preload".into(),
            href: "/assets/fonts/inter.woff2".into(),
            sizes: None,
            media: None,
        });
    });

    rsx! { Router::<Route> {} }
}

7. 社交分享预览

7.1 动态 OG 图片

可以动态生成带文章标题的 OG 图片:

// 使用 Vercel OG 或自建服务生成社交分享图
fn og_image_url(title: &str, slug: &str) -> String {
    format!(
        "https://og.example.com/og?title={}&slug={}",
        url::encode(title),
        slug
    )
}

7.2 分享按钮

#[component]
fn ShareButtons(title: String, url: String) -> Element {
    let encoded_url = url::encode(&url);
    let encoded_title = url::encode(&title);

    rsx! {
        div { class: "flex items-center gap-2",
            span { class: "text-xs", style: "color: var(--tertiary);", "分享:" }

            // Twitter
            a {
                class: "share-btn",
                href: "https://twitter.com/intent/tweet?text={encoded_title}&url={encoded_url}",
                target: "_blank",
                "𝕏"
            }

            // 微信(显示二维码弹窗)
            button {
                class: "share-btn",
                onclick: move |_| show_qrcode(url.clone()),
                "微信"
            }

            // 复制链接
            button {
                class: "share-btn",
                onclick: move |_| {
                    // navigator.clipboard.writeText(url)
                    console_log!("已复制: {url}");
                },
                "复制"
            }
        }
    }
}

8. SEO 检查清单

发布前逐项检查:

#[derive(Debug)]
struct SeoCheck {
    title: bool,
    description: bool,
    og_tags: bool,
    canonical: bool,
    structured_data: bool,
    breadcrumb: bool,
    image_alt: bool,
    heading_structure: bool,
    mobile_friendly: bool,
    loading_speed: bool,
}

fn seo_audit(page: &str) -> SeoCheck {
    // 检查各项 SEO 指标
    SeoCheck {
        title: true,   // <title> 唯一且包含关键词
        description: true, // meta description 存在且不重复
        og_tags: true,     // og:title, og:description, og:image, og:url 完备
        canonical: true,   // 有 rel=canonical 链接
        structured_data: true, // JSON-LD 存在
        breadcrumb: true,  // 面包屑导航就位
        image_alt: true,   // 所有 img 有 alt 属性
        heading_structure: true, // h1→h2→h3 层级合理
        mobile_friendly: true,   // 移动端适配
        loading_speed: false,    // 需要 Lighthouse 验证
    }
}

9. 小结

  • SPA 的 SEO 核心是动态管理 <head> 元数据
  • use_head API 允许组件在渲染时动态设置 title、meta、link
  • Open Graph 标签控制社交分享预览
  • JSON-LD 结构化数据增强搜索引擎展示
  • 面包屑 = 用户体验 + SEO 信号
  • 懒加载和尺寸预留改善 Core Web Vitals
  • 建立 SEO 检查清单,确保每个页面都满足基本要求
  • SSR 仍是 SPA SEO 的终极方案,结合预渲染可达到最佳效果
dioxusseometadataog-tagsheadsocial