WordPress API から取得したHTMLコンテンツから目次を生成するReactコンポーネントを作ってみた

この記事は、React Advent Calendar 2024の24日目に公開されたもので、Remixを使用したReactアプリでWordPressなどのAPIから取得したコンテンツを目次を自動生成するReactコンポーネントを実装した内容です。再利用性、保守性、パフォーマンス、型安全性、アクセシビリティを重視し、コンポーネントの構造を適切に設計しています。ただし、HTMLの解析方法やパフォーマンス最適化、アクセシビリティ向上、エラーハンドリングに改善の余地があります。そして、React/Remixを使ったレイアウトの自由度を生かしてカスタマイズを行い、今後も改善を進めていく意向を述べています。

広告ここから
広告ここまで

目次

    この記事は「React Advent Calendar 2024」24日目として公開しました。

    やりたかったこと

    このサイトはRemixを使ったReactアプリで実装されています。WordPressなどのAPIから取得したコンテンツを表示する形で運用していますが、ここに目次を自動作成するコンポーネントを作ろうとしました

    具体的には、WordPress APIから取得したHTMLコンテンツから自動的に目次を生成し、ページ内リンクを提供するReactコンポーネントを作ってみようと思います。WordPress側で目次を生成しても良いのですが、その場合目次の表示位置が調整しづらくなります。そのため、React側で処理する形に挑戦しました。

    実装の考え方

    実装にあたり、以下の点を重視しました:

    1. 再利用性: コンポーネントを他のプロジェクトでも容易に使用できるようにする。
    2. 保守性: コードを理解しやすく、将来の変更に対応しやすい構造にする。
    3. パフォーマンス: 不要な再レンダリングを避け、効率的に動作するようにする。
    4. 型安全性: TypeScriptを使用して、バグを早期に発見し、コードの品質を向上させる。
    5. アクセシビリティ: スクリーンリーダーなどの支援技術でも使いやすい実装にする。

    実装方法

    コンポーネントの構造

    コンポーネントを以下の部分に分割しました:

    1. 型定義(types.ts
    2. 見出し抽出用カスタムフック(useExtractHeadings.ts
    3. スムーズスクロール用カスタムフック(useSmoothScroll.ts
    4. メインコンポーネント(WordPressHeadings.tsx

    型定義

    // types.ts
    export interface Heading {
    level: number;
    text: string;
    id: string;
    }

    export interface WordPressHeadingsProps {
    content: string;
    }

    見出し抽出用カスタムフック

    import { useState, useEffect } from 'react';
    import { WordPressHeading } from './types';
    
    export const useExtractHeadings = (content: string): WordPressHeading[] => {
      const [headings, setHeadings] = useState<WordPressHeading[]>([]);
    
      useEffect(() => {
        const parseHTML = (html: string): Document => {
          const parser = new DOMParser();
          return parser.parseFromString(html, 'text/html');
        };
    
        const extractHeadings = (doc: Document): WordPressHeading[] => {
          const headingElements = doc.querySelectorAll('h1, h2, h3, h4');
          return Array.from(headingElements).map((element, index) => {
            const spanElement = element.querySelector('span.ez-toc-section');
            const id = spanElement?.id || `heading-${index}`;
            return {
              level: parseInt(element.tagName.charAt(1)),
              text: element.textContent?.trim() || '',
              id: id
            };
          });
        };
    
        const parsedContent = parseHTML(content);
        const extractedHeadings = extractHeadings(parsedContent);
        setHeadings(extractedHeadings);
      }, [content]);
    
      return headings;
    };
    

    メインコンポーネント

    import React from 'react';
    import { WordPressHeadingsProps } from './types';
    import { useExtractHeadings } from './hook';
    
    export const WordPressHeadings: React.FC<WordPressHeadingsProps> = ({ content }) => {
      const headings = useExtractHeadings(content);
    
      return (
        <div className="wp-headings">
          <h2 className="text-xl font-bold mb-4">目次</h2>
          <ul className="space-y-2">
            {headings.map((heading, index) => (
              <li
                key={index}
                className={`pl-${(heading.level - 1) * 4} text-${['lg', 'base', 'sm', 'xs'][heading.level - 1]}`}
              >
                <a
                  href={`#${heading.id}`}
                  className="text-blue-600 hover:underline"
                >
                  {heading.text}
                </a>
              </li>
            ))}
          </ul>
        </div>
      );
    };

    注意すべきポイント

    1. HTMLの解析: DOMParserを使用してHTMLを解析していますが、これはブラウザ環境でのみ動作します。サーバーサイドレンダリングを行う場合は、別の方法(例:jsdom)を検討する必要があります。
    2. パフォーマンス: useEffectフックを使用して、contentプロップが変更されたときのみ見出しの再計算を行うようにしています。大量のコンテンツを扱う場合は、さらなる最適化(例:仮想化)を検討する必要があるかもしれません。
    3. スタイリング: この例ではTailwind CSSを使用していますが、プロジェクトの要件に応じて別のスタイリング方法に変更可能です。
    4. アクセシビリティ: 現在の実装では基本的なアクセシビリティに対応していますが、より高度なアクセシビリティ要件(例:ARIAロールの追加)が必要な場合は、さらなる改善が必要です。
    5. エラーハンドリング: 現在の実装では、HTMLの解析に失敗した場合のエラーハンドリングが不十分です。プロダクション環境では、適切なエラーハンドリングとフォールバックメカニズムを実装する必要があります。

    まとめ

    初めての挑戦でしたが、生成AIの助けも得つつ無事作ることができました。これによってレイアウトの自由度が大きく増しましたので、今後も見た目に関するカスタマイズはReact / Remix側で挑戦するようにしたいなと思います。

    ただ一方でSSRでは使いづらい実装を選んでいるなど、汎用性は少なめかもとは思います。この辺りは初期レンダリングで表示しなくても良いコンテンツだろうという判断の上で選択しましたが、他にも方法はあったかもしれないなとは思います。

    この辺も含めて、改善なども進めていけば理想ですね。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark