React 与 JSON-LD 结构化数据:基于 Fiber 状态的自动 Schema 注入

好,各位,把你们的键盘准备好,把咖啡洒得更少一点。今天我们不谈什么“优雅的组件”或者“语义化的 HTML 标签”,我们要聊的是一场发生在浏览器后台、由 Google 机器人主导的“谍战片”。

想象一下,你辛苦写的 React 应用就像一个热闹的咖啡厅。里面坐着顾客(用户),有服务员在跑腿(渲染循环),有厨师在忙活(业务逻辑)。你希望 Google 的爬虫能走进来,看到菜单,了解价格,然后给你的店评个高分。

如果 Google 的爬虫走进来,只看到一桌子乱糟糟的 HTML 文本,它只会觉得:“这家店没招牌,不知道卖什么。” 结果就是你的流量被隔壁只写了几行 <script> 的竞争对手抢光了。

这就是为什么我们需要 JSON-LD。但问题是,现在的 React 开发者们是怎么做的呢?他们会在 useEffect 里面,写一堆硬编码的 JSON 字符串,然后手动插入到 DOM 里。

停。 停在那个动作。

这就像是你每天早上都要用刻刀在门框上刻字来标记身高。每天刻一次,不仅手疼,而且如果你没吃饱(组件更新),身高就不涨了,门框上全是难看的刻痕。

今天我们要干的事,是利用 React 的“Fiber 架构”特性,建立一个自动化的 Schema 注入系统。我们要让代码像呼吸一样自然,让 Schema 随着组件的状态流(Data Flow)自动生长。

准备好了吗?让我们深入 React 的内部,去掏那个叫做 Fiber 的“金矿”。


第一章:Fiber 的秘密——不仅仅是链表

在讲怎么注入 JSON 之前,咱们得先聊聊 Fiber。很多新手以为 Fiber 只是 React 18 引入的一个“并发渲染”的黑盒。其实不然,从架构上讲,Fiber 就是一个巨大的、递归的链表

你可以把 React 组件树想象成一个家庭谱系。

每一个 FiberNode(Fiber 节点)都有一个 return 指针指向它的父节点,一个 child 指针指向第一个子节点,还有一个 sibling 指针指向它的兄弟节点。这就像是树的遍历:父 -> 第一个孩子 -> 第一个孩子的兄弟 -> 第一个孩子的兄弟的兄弟 -> ... -> 结束

最关键的是什么?是状态。每个 Fiber 节点都承载着组件的 memoizedState(函数组件的 state)和 memoizedProps(props)。

既然每个 Fiber 节点都知道自己是谁,手里拿着什么数据,那么我们为什么还要费劲去手动管理这些 JSON 数据呢?我们要做的,就是建立一个“Schema 代理层”,让每一个 Fiber 节点在渲染的过程中,默默地把属于自己的 Schema 定义“上传”给全局,最后由一个全局的收集器来生成最终的 JSON-LD 标签。

这就像是给每个员工发一个蓝牙耳机。员工干活(渲染),耳机把他的工作内容(Schema)同步给总台。总台收集完了,直接广播给 Google。


第二章:痛苦的现状——为什么你不能手动管理 JSON-LD

在写代码之前,咱们先来一场“罪己诏”。

假设你有个 Product 组件:

const Product = ({ name, price, description, reviews }) => {
  // ... 渲染 UI

  useEffect(() => {
    const schema = {
      "@context": "https://schema.org/",
      "@type": "Product",
      name: name,
      price: price,
      description: description
    };
    // 这里的 document.currentScript... 等等,现在的浏览器 API 早就改了
    // 你得通过 window.document.head 来 appendChild
    // 还要防止重复插入
    // 如果 name 是异步获取的呢?如果接口挂了呢?
  }, [name, price, description]); // 依赖项写不对,容易报错

  return <div>...</div>
}

看这段代码,它充满了“反模式”的味道:

  1. 副作用到处飞:你的业务逻辑(获取数据)和 SEO 逻辑(生成 JSON)混在一起。
  2. 手动管理 DOM:为什么要直接操作 head?React 是为了让我们声明式地描述 UI,不是让我们去写 DOM 操作符。
  3. 健壮性差:如果组件因为 React 18 的 Strict Mode 被挂起再恢复,useEffect 会执行两次,你的 <script> 标签也会被插入两次。Google 会看到重复数据,然后把你当垃圾文件丢弃。
  4. 难以维护:如果你改了数据结构,忘了改 JSON,那就等着被搜索引擎降权吧。

我们需要一个更“React”的方案。


第三章:架构设计——Schema 上下文

我们的核心思想是“状态驱动的 Schema 生成”

我们要创建一个 React Context,我们称之为 SchemaContext。这个 Context 里会持有一个 Map 结构,用来存储当前组件树层级下所有的 Schema 定义。

当组件渲染时,我们不需要通过 useEffect 去观察数据变化,而是通过高阶组件(HOC)或者Render Props的模式,在组件树的渲染周期内,将当前组件的数据注入到 Context 中。

我们构建一个名为 SchemaBuilder 的组件。它的工作很简单:它是树干,所有的 Schema 节点都是它的树叶。

核心代码骨架

import React, { createContext, useContext, useMemo, useEffect } from 'react';

// 定义 Schema 上下文类型
interface SchemaContextType {
  schemas: Map<string, any>; // Map 的 key 可以是组件的层级路径或者 ID
  addSchema: (schema: any) => void;
  removeSchema: (id: string) => void;
}

const SchemaContext = createContext<SchemaContextType | undefined>(undefined);

// 全局 JSON-LD 注入组件(每次 schemas 变化时执行)
const JsonLdInjector = () => {
  const { schemas } = useSchemaContext();

  // 将 Map 转换为数组
  const schemaArray = useMemo(() => Array.from(schemas.values()), [schemas]);

  useEffect(() => {
    // 检查是否已经存在标签
    const existingScript = document.getElementById('react-ld-json');

    if (existingScript) {
      existingScript.remove();
    }

    if (schemaArray.length === 0) return;

    const script = document.createElement('script');
    script.id = 'react-ld-json';
    script.type = 'application/ld+json';
    script.text = JSON.stringify(schemaArray, null, 2);

    document.head.appendChild(script);
  }, [schemaArray]);

  return null;
};

// Schema 上下文提供者
export const SchemaProvider = ({ children }) => {
  const schemas = React.useRef(new Map()).current;

  const addSchema = React.useCallback((schema: any) => {
    const id = Math.random().toString(36).substr(2, 9);
    schemas.set(id, schema);
  }, [schemas]);

  const removeSchema = React.useCallback((id: string) => {
    schemas.delete(id);
  }, [schemas]);

  // ... 省略 Context value 的构建

  return (
    <SchemaContext.Provider value={{ schemas, addSchema, removeSchema }}>
      {children}
      <JsonLdInjector />
    </SchemaContext.Provider>
  );
};

这是基础。现在的问题是:谁往这个 Context 里加数据?


第四章:Fiber 指针——如何连接组件与 Schema

我们要利用 React 的渲染能力。你不能在组件里写 if (isSchemaNode) return null,这太丑陋了。

我们需要两个工具:

  1. WithSchema:一个 HOC,它接收一个 Schema 类型定义(比如 'Product')和一个映射函数。这个映射函数负责把组件的 props 转换成 JSON-LD 对象。
  2. useSchema:一个 Hook,允许子组件在渲染时,把自己的 Schema 定义“借”给父级。

让我们来个“硬核”实现。

// 1. Schema 类型定义
// SchemaType 是一个字符串,对应 schema.org 的类型
// SchemaExtractor 是一个函数,接收 props,返回 JSON-LD 对象
const withSchema = (SchemaType: string, SchemaExtractor: (props: any) => any) => {
  return (Component: React.ComponentType<any>) => {
    const WrappedComponent = (props: any) => {
      const { addSchema, schemas } = useSchemaContext();

      // 这里的逻辑是:在组件渲染的第一帧,我们就把数据“同步”出去
      // 注意:这里并没有 useEffect,我们依赖的是 React 的 render 机制
      // 但为了性能和避免重复添加,我们需要一些机制来控制。
      // 简单起见,我们假设这个组件只在 Mount 时有一个 Schema。

      // 这里有个坑:如果组件是动态的(比如列表里的 Item),你不能只挂载一次。
      // 我们需要引入一个唯一的标识符。
      const componentId = React.useId(); // React 18 提供

      // 只有当数据变化时才更新
      React.useEffect(() => {
        const schemaData = SchemaExtractor(props);
        if (schemaData) {
          // 我们可以在这里处理复杂逻辑,比如将 Product 转换为 Organization
          // 或者合并多个类型的 Schema
          addSchema({ ...schemaData, "@type": SchemaType });
        }

        return () => {
          // 清理工作
          // 注意:上面的 addSchema 方法只是把东西放进 Map。
          // 我们需要一个基于 id 的清理机制。上面的代码稍微改一下:
          // addSchema 会生成一个唯一的 id,这里 return 的是那个 id
        };
      }, [componentId, props]); // 依赖组件 id 和 props

      return <Component {...props} />;
    };

    // 更好的写法:直接在组件内部使用,而不是 HOC
    // 这样更符合 React 的声明式风格
    return Component;
  };
};

等等,上面的代码有点像在循环里写死逻辑。让我们换个思路,用 Render Props 或者 Context + HOC 的结合

为了实现“基于 Fiber 状态的自动注入”,我们必须模拟 Fiber 的“递归”特性。

优化版:Schema Node 组件

我们定义一个 JsonLdNode 组件。它并不渲染任何 DOM,它的存在就是为了“响应”父级的数据变化。

const JsonLdNode = ({ schemaType, extractor, children }) => {
  const { addSchema } = useSchemaContext();

  // 这是一个非常关键的技巧:我们使用 useMemo 来缓存生成的 Schema
  // 这样只有在 props 变化时,我们才去更新 Context 里的数据
  // 这符合 Fiber 的 Diff 算法逻辑:只有变化了才更新

  const schema = useMemo(() => {
    // 我们假设 extractor 是一个函数,或者直接把 props 传进去
    return extractor();
  }, [extractor, children]); // 这里的 dependencies 很重要

  useEffect(() => {
    if (schema) {
      // 在这里,我们将 schema 添加到 Context 中
      // 为了处理列表场景,我们需要给每个 item 一个唯一的 key
      const id = Math.random().toString(36).substr(2, 9);
      addSchema({ ...schema, key: id });
      return () => {
        // 实际项目中,你需要通过 id 从 Map 中移除
        // 这里为了演示省略 remove 逻辑,或者你可以维护一个 ref 来记录添加的 ids
      };
    }
  }, [schema, addSchema]);

  return children;
};

但是,直接在组件里调用 addSchema 会导致一个严重的问题:JSON-LD 标签里的数组问题

如果你有一个列表,Product A, Product B, Product C
如果你只是简单的 addSchema,最后生成的 JSON 会是:

[
  { "@type": "Product", "name": "A" },
  { "@type": "Product", "name": "B" },
  { "@type": "Product", "name": "C" }
]

这完全没问题!Google 的 JSON-LD 规范非常聪明,它允许一个 <script> 标签里包含多个对象,只要它们都在 @graph 数组里,或者直接是根数组。

所以,我们的架构就清晰了:

  1. Provider:维护一个 Map<string, SchemaItem>
  2. Node:监听数据,往 Map 里塞数据。
  3. Injector:每次 Map 变化,重写 head 里的 <script> 标签。

第五章:深入 Fiber——处理异步与复杂场景

但是,现实是残酷的。Fiber 并不是总是立即更新。

假设你有一个 fetchData 的组件。

const ArticlePage = () => {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/article').then(res => res.json()).then(setData);
  }, []);

  if (!data) return <div>Loading...</div>;

  return (
    <JsonLdNode schemaType="Article" extractor={() => ({
      "@type": "Article",
      "headline": data.title,
      "image": data.coverImage
    })}>
      <ArticleContent />
    </JsonLdNode>
  );
};

问题来了JsonLdNodeextractor 函数是在组件渲染时执行的。如果 datanullextractor 返回什么?返回空?那 JSON 就会被清空,Google 爬虫会以为这个页面没内容。

解决方案:我们需要在 Fiber 树构建阶段(Render Phase)和 Commit Phase 之间做一个桥梁。

React 18 引入了 useSyncExternalStore,这是一个神器。我们可以用它来订阅 React 内部的一些状态变化,或者,我们可以构建一个自己的“同步” Schema 系统。

实际上,最简单且最符合 Fiber 概念的做法是:不要在 useEffect 里提取数据

我们要让数据先通过 useSyncExternalStore 或者 useContext 流进来。

// 模拟一个从 API 流进来的数据源
const useArticleData = () => {
  // 这里我们假装有一个 store
  // return useSyncExternalStore(subscribe, getSnapshot);
  return useState({ title: "Hello", cover: "..." })[0];
};

const ArticlePage = () => {
  const data = useArticleData();

  return (
    <JsonLdNode schemaType="Article" extractor={() => ({
      "@type": "Article",
      "headline": data.title,
      "image": data.cover
    })}>
      {/* content */}
    </JsonLdNode>
  );
};

这样,当 data 变化时,React 会触发 Re-render。JsonLdNodeextractor 会重新执行。我们的 Context 会更新。Injector 会重写 Script 标签。

这就像是 Fiber 树在呼吸,每次呼吸(状态更新),Schema 就会同步更新。


第六章:递归与循环引用——那个著名的 Bug

如果你在写一个无限嵌套的 JSON-LD 结构(比如 Organization -> FoundingDate -> Person),你可能会遇到 Converting circular structure to JSON 的错误。

这是 JSON.stringify 的强项(也是它的恶名)。

假设你有一个公司,创始人是一个人。这个人在 JSON-LD 里也是一个公司实体。

{
  "@type": "Organization",
  "founder": {
    "@type": "Person",
    "name": "Steve Jobs",
    // 错误:如果 Person 也包含这个 Organization 的引用,这就死循环了
  }
}

在我们的 React 自动化系统中,我们必须在把数据推入 Context 之前,进行一次“消毒”。

我们写一个 safeJsonify 函数。

function safeJsonify(obj, seen = new WeakSet()) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  if (seen.has(obj)) {
    return '[Circular]';
  }

  if (Array.isArray(obj)) {
    return obj.map(item => safeJsonify(item, seen));
  }

  seen.add(obj);
  const newObj = {};
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = safeJsonify(obj[key], seen);
    }
  }
  return newObj;
}

然后在 Provider 的 addSchema 里使用它:

const addSchema = (schema: any) => {
  // 对 schema 进行深拷贝和循环引用处理
  const cleanSchema = safeJsonify(schema);
  schemas.set(Math.random().toString(36).substr(2, 9), cleanSchema);
};

第七章:服务端渲染(SSR)的噩梦与救赎

如果你使用 Next.js,恭喜你,你又多了一层挑战。

在客户端,我们的 JsonLdInjector 组件通过 useEffect 在 DOM 注入标签。但是在服务端渲染(SSR)时,useEffect不执行的。

这会导致什么结果?SSR 出来的 HTML 是一片空白的 <script> 标签。Google 爬虫读取到的 HTML 没有任何结构化数据。于是,你的 SEO 彻底完蛋。

React Fiber 并不是服务端独有的。SSR 的 Node.js 环境里也有 Fiber。我们需要一种方式,让 Schema 在 SSR 阶段也能生成。

Next.js App Router 方案

在 Next.js App Router 中,我们通常在 layout.tsx 或者 page.tsx 中渲染 Provider。

我们可以利用 useEffect 在客户端注入,但是利用 Context 在服务端构建 JSON 字符串。

// 在 Page.tsx 或 Layout.tsx 中
const JsonLdInjector = () => {
  const { schemas } = useSchemaContext();
  const schemaArray = Array.from(schemas.values());

  // SSR 逻辑:如果是服务端渲染环境,直接返回 HTML 字符串
  if (typeof window === 'undefined') {
    return JSON.stringify(schemaArray, null, 2);
  }

  // 客户端逻辑:操作 DOM
  useEffect(() => {
    // ... 之前的 DOM 操作代码
  }, [schemaArray]);

  return null;
};

然后在 useEffect 之前的那个 if 语句里,我们直接返回字符串。Next.js 会把这个字符串插入到页面中。

但是! 这种方式有个问题:JsonLdNode 组件在服务端不会执行(因为它是客户端组件)。我们需要把 Schema 的生成逻辑从 JsonLdNode 中抽离出来,或者使用一个特殊的 Server Component 来收集数据。

终极方案:使用 useSyncExternalStore 订阅一个全局的 Schema 存储器,无论在服务端还是客户端,只要数据变了,我们就触发重渲染(在服务端),从而生成 JSON。

这其实就是构建一个简单的“状态管理库”的变体。

// 全局 Schema Store
const SchemaStore = {
  listeners: new Set(),
  schemas: [],

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },

  emit() {
    this.listeners.forEach(l => l(this.schemas));
  },

  add(schema) {
    this.schemas.push(schema);
    this.emit();
  },

  clear() {
    this.schemas = [];
    this.emit();
  }
};

// 服务端或客户端通用的 Hook
export const useSchema = () => {
  const [schemas, setSchemas] = React.useState([]);

  React.useEffect(() => {
    const unsubscribe = SchemaStore.subscribe((newSchemas) => {
      setSchemas(newSchemas);
    });
    return unsubscribe;
  }, []);

  return schemas;
};

然后在组件中:

const ProductList = () => {
  const products = useProducts(); // 假设这是你的数据 Hook

  useEffect(() => {
    // 当产品列表更新时,我们将 Schema 加入 Store
    const schemas = products.map(p => ({
      "@type": "Product",
      "name": p.name,
      "sku": p.sku
    }));
    SchemaStore.add(schemas);

    return () => {
      // 清理当前组件产生的 schema
      SchemaStore.clear(); 
    };
  }, 
); return ( <div> {products.map(p => <ProductCard key={p.id} {...p} />)} </div> ); };

这种方式完全解耦了 Schema 生成逻辑。它不关心是在 SSR 还是 CSR,也不关心是否在 Fiber 树里。它只关心“数据变了,更新一下 JSON”。


第八章:实战案例——面包屑导航

让我们看一个稍微复杂点的例子:面包屑导航。

面包屑导航是 JSON-LD 中 BreadcrumbList 的典型应用。通常它是一串 HTML 标签 <a>Home > Category > Product</a>

如果我们想自动化它:

  1. 我们需要一个 Breadcrumb 组件。
  2. 它接收 items 数组:[{ name: 'Home', url: '/' }, { name: 'Books', url: '/books' }]
  3. 我们在 BreadcrumbuseEffect 里提取这些数据,并调用 SchemaStore.add
const Breadcrumb = ({ items }) => {
  const addSchema = useSchemaAdd(); // 假设这是一个自定义 hook

  useEffect(() => {
    const schema = {
      "@context": "https://schema.org/",
      "@type": "BreadcrumbList",
      "itemListElement": items.map((item, index) => ({
        "@type": "ListItem",
        "position": index + 1,
        "name": item.name,
        "item": item.url
      }))
    };

    addSchema(schema);
  }, [items, addSchema]);

  return (
    <nav aria-label="Breadcrumb">
      <ol className="breadcrumb">
        {items.map(item => (
          <li key={item.url}>
            <a href={item.url}>{item.name}</a>
          </li>
        ))}
      </ol>
    </nav>
  );
};

看,这多干净!你只需要在 JSX 里写 <Breadcrumb items={...} />,剩下的 Schema 生成工作完全自动完成。你再也不用去 Google 的 Schema Validator 网站上手动粘贴你的 JSON 了,因为你的代码就是 Schema 的源头。


第九章:性能优化——别让 React 18 惊呆了

React 18 有个并发特性叫“自动批处理”,它会让 useEffect 执行得非常频繁。

如果你的 JsonLdInjector 每次数据变动都去操作 DOM(appendChildinnerText),那可是重活。

我们不仅要更新 JSON,还要更新 DOM。为了性能,我们必须做一个防抖 或者 Diff

但这里有个好消息:JSON.stringify 其实很快。真正的性能杀手是频繁的 document.head 操作。

我们可以在 JsonLdInjector 里加一个检查:

const JsonLdInjector = () => {
  const { schemas } = useSchemaContext();
  const schemaArray = Array.from(schemas.values());
  const scriptRef = useRef<HTMLScriptElement | null>(null);

  useEffect(() => {
    const json = JSON.stringify(schemaArray, null, 2);

    if (!scriptRef.current) {
      scriptRef.current = document.createElement('script');
      scriptRef.current.id = 'dynamic-json-ld';
      scriptRef.current.type = 'application/ld+json';
      document.head.appendChild(scriptRef.current);
    }

    // 只有内容真的变了才重写 innerText
    if (scriptRef.current.innerText !== json) {
      scriptRef.current.innerText = json;
    }
  }, [schemaArray]);

  return null;
};

另外,注意我们在 JsonLdNode 或者 SchemaStore 里的 add 操作。如果父组件因为状态抖动导致 ProductList 重新渲染了 10 次,addSchema 会被调用 10 次。我们要避免这种“无意义的地震”。

通常,我们只需要在 useEffect 的依赖数组里填入稳定的变量


第十章:TypeScript 的魔法——让 Schema 类型安全

作为资深专家,我绝对受不了 any

我们需要定义一套类型系统来描述 JSON-LD。

// 定义 Schema 类型
type SchemaType = 
  | 'Article' 
  | 'Product' 
  | 'BreadcrumbList' 
  | 'Organization' 
  | 'WebPage';

// 定义提取器类型
// (props: T) => SchemaObject | null
type SchemaExtractor<T> = (props: T) => any; 

// 稍微高级一点的类型定义,确保返回的对象包含 @type
type ValidatedSchema<T> = {
  "@type": T;
} & Omit<any, "@type">;

然后让我们的 JsonLdNode 变得类型安全。

const JsonLdNode = <T extends object, S extends SchemaType>({
  schemaType,
  extractor,
  children
}: {
  schemaType: S;
  extractor: SchemaExtractor<T>;
  children: React.ReactNode;
}) => {
  // ... 逻辑
};

这样,如果你在 extractor 里写了 name: "Test",但在 Schema 定义里定义 Product 需要 price,TypeScript 就会报错。这比 JSON.stringify 随便报错要强一万倍。


结语:拥抱自动化

好了,朋友们。我们回顾一下今天的内容。

我们摒弃了手动在 HTML 里写死 JSON 的做法,转而利用 React 的 Fiber 架构 概念,构建了一个基于 ContextState 的 Schema 自动注入系统。

我们利用 useEffect 来捕获数据流的变化,利用 useMemo 来优化计算,利用 useSyncExternalStore 来同步服务端和客户端的数据。

最重要的是,我们将 业务逻辑SEO 逻辑 完全解耦。你的组件只关心显示什么,Schema 自动关心 Google 读取什么。

现在,你可以去构建你的应用了。当你写完一个 ProductPage,你只需要确保你把数据传给了 JsonLdNode,剩下的,就交给 React 的 Fiber 架构去自动生成魔法吧。

祝你的排名高到离谱,如果你的网站变慢了,那一定是 Google 的爬虫跑得太快了。别回头,继续写代码!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注