← ハンズオン一覧に戻る

Reactハンズオン「自動見出しコンポーネント」

2024-2025 Shunsuke Watanabe

このハンズオンはReact入門チュートリアルの内容を前提にしています。

このハンズオンではページの見出しを自動で作成するコンポーネントを作ります。

このコンポーネントの作成を通じて以下の知識の確認をします。

1. コンポーネントでやりたいこと

2. プロジェクト初期化

  1. 初期化
npm create vite@latest auto-toc
  1. ライブラリのインストール
cd auto-toc
npm i;
npm i -D prettier tailwindcss @tailwindcss/vite daisyui;
  1. vite.config.tstailwindcss を追加
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})
  1. index.css の中身を消して、tailwindcssとdaisyuiで置き換え
@import "tailwindcss";
@plugin "daisyui" {
themes: abyss --default;
};
  1. App.tsx の中身をダミーテキストで置き換え
ダミーテキスト
<>
<h1>日本の四季とその魅力</h1>
<p>日本は四季がはっきりしており、それぞれの季節に特徴的な風景や文化があります。春には桜が咲き誇り、夏には祭りが賑わい、秋には紅葉が美しく色づき、冬には雪景色が広がります。これらの変化は日本人の生活や文化に深く根付いており、季節ごとの楽しみ方も豊富です。本記事では、日本の四季の魅力を詳しく紹介していきます。</p>
<h2>春:桜と新しい始まり</h2>
<p>春は、日本の象徴的な季節の一つです。特に桜の花が咲く時期には、多くの人々が公園や河川敷で花見を楽しみます。桜の開花は短く、満開の時期を迎えると数日で散ってしまうため、その儚さが日本人の心に響きます。また、春は新しい生活の始まりの季節でもあり、入学式や新社会人のスタートといった重要なイベントが多くあります。</p>
<h3>春の風物詩</h3>
<ul>
<li><strong>桜の開花前線</strong>:日本各地で異なる開花時期を楽しめる</li>
<li><strong>入学式と新生活</strong>:学校や企業で新たなスタート</li>
<li><strong>春の味覚</strong>:筍や菜の花などの旬の食材</li>
</ul>
<h3>春の楽しみ方</h3>
<ul>
<li>お花見:家族や友人とともに桜を楽しむ</li>
<li>ハイキング:山や公園で春の景色を満喫する</li>
<li>春祭り:全国各地で開催される春の祭りに参加する</li>
</ul>
<h2>夏:祭りと海</h2>
<p>夏は暑く湿度が高いものの、日本各地で多くの祭りや花火大会が開催されるため、活気に満ちています。特に、京都の祇園祭や青森のねぶた祭は全国的に有名です。また、海水浴やキャンプなどのアウトドアアクティビティも人気があります。</p>
<h3>夏の楽しみ方</h3>
<ul>
<li>海水浴とアウトドア:沖縄の美しいビーチやキャンプ</li>
<li>花火大会:夜空を彩る大輪の花</li>
<li>冷たい食べ物:かき氷や冷やし中華</li>
</ul>
<h3>日本の夏祭り</h3>
<ul>
<li>青森ねぶた祭:巨大なねぶた(灯籠)が街を練り歩く</li>
<li>仙台七夕祭り:美しい七夕飾りが街を彩る</li>
<li>徳島阿波踊り:踊り手たちがリズムに合わせて踊る</li>
</ul>
<h2>秋:紅葉と実りの季節</h2>
<p>秋は気温が下がり、空気が澄んで過ごしやすい季節です。山々が赤や黄色に染まり、紅葉狩りが楽しめます。また、新米や果物など、食べ物が一層美味しくなる季節でもあります。</p>
<h3>秋の風景</h3>
<ul>
<li>京都の紅葉:清水寺や嵐山の美しい景色</li>
<li>秋の味覚:栗や柿、サンマなどの旬の食材</li>
<li>芸術の秋:文化祭や読書の時間</li>
</ul>
<h3>秋のイベント</h3>
<ul>
<li>紅葉狩り:全国の名所で色づく木々を楽しむ</li>
<li>秋の味覚祭り:新米や秋の果物を堪能する</li>
<li>運動会:学校や地域で開催されるスポーツイベント</li>
</ul>
<h2>冬:雪と温泉</h2>
<p>冬は寒さが厳しくなるものの、雪景色や温泉、冬のスポーツを楽しめる季節です。北海道や東北地方では、スキーやスノーボードが人気です。また、温泉地では雪見風呂を堪能できます。</p>
<h3>冬の楽しみ</h3>
<ul>
<li>温泉旅行:箱根や草津などの有名な温泉地</li>
<li>冬の味覚:鍋料理やおでんで体を温める</li>
<li>イルミネーション:都市部の華やかな光のイベント</li>
</ul>
<h3>冬のイベントと伝統</h3>
<ul>
<li>大晦日とお正月:年越しそばを食べ、初詣に行く</li>
<li>節分:鬼を追い払うために豆まきをする</li>
<li>冬の雪祭り:北海道札幌の雪まつりなど</li>
</ul>
<h2>まとめ</h2>
<p>日本の四季は、それぞれ異なる魅力があり、訪れるたびに新しい発見があります。春の桜、夏の祭り、秋の紅葉、冬の雪景色など、どの季節にも特別な魅力があります。日本に訪れる際は、ぜひ季節ごとの風景や文化を楽しんでみてください。</p>
<p>あなたのお気に入りの季節はどれですか?</p>
</>
  1. 開発用サーバ起動
npm run dev
  1. index.css に見出しのスタイルを追加
h2 {
margin-top: 4rem;
margin-bottom: 2rem;
font-size: x-large;
}
h3 {
margin-top: 2rem;
margin-bottom: 1rem;
}
  1. src/components/AutoTOC.tsx を新規作成
export const AutoTOC = () => {
return (
<>
<h2>目次</h2>
</>
)
}
  1. App.tsxAutoTOC.tsx を表示

3. DOMからh2タグを抽出する

  1. AutoTOC.tsx にuseEffectを追加
useEffect(() => {
const headings = document.querySelectorAll("h2");
const toc = [];
[...headings].forEach((heading) => {
// 目次のデータを作る
const hash = `#${encodeURIComponent(heading.innerText)}`;
toc.push({
hash,
text: heading.innerText,
});
// h2にIDとハッシュの追加
const aTag = document.createElement("a");
aTag.innerText = "#";
aTag.href = hash;
heading.id = encodeURIComponent(heading.innerText);
heading.appendChild(aTag);
});
console.log("----- toc", toc);
}, []);
  1. ハッシュリンクが2つついていることを確認する
  2. StrictModeを外してみる→もどす
  3. useRefを使って一度だけ実行されるようにする
const hasExecuted = useRef(false);
useEffect(() => {
if(hasExecuted.current) {
return;
}
hasExecuted.current = true;
...
}, []);
  1. const toc に型をつける
export type TOCItem = {
hash: string;
text: string;
};
...
const toc: TOCItem[] = [];
...

4. 目次の表示

const [toc, setToc] = useState<TOCItem[]>([]);
...
useEffect(() => {
...
setToc(toc)
console.log("----- toc", toc);
}, [setToc]);
return (
<div className="my-10">
<h2 id="toc">目次</h2>
<ul>
{toc.map((x, i) => (
<li key={i} className="list-none">
<a href={x.hash}>{x.text}</a>
</li>
))}
</ul>
</div>
);
  1. 目次も目次に出てしまうのでフィルタする

5. フローティングTOCを作る

  1. AutoTOC.tsx 内に FloatingTOC コンポーネントを作る
type FloatingTOCProps = {
toc: TOCItem[];
};
export const FloatingTOC = ({ toc }: FloatingTOCProps) => {
return (
<div
id="floating-toc"
className="fixed right-0 top-[50%] z-10 mr-4 -translate-y-1/2 bg-[var(--color-base-200)] border rounded"
>
<div
className="p-2 transition-all duration-200 ease-out "
>
<MenuIcon />
</div>
</div>
);
};
const MenuIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M2 8a1 1 0 0 1 1-1h10.308a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1m0-4a1 1 0 0 1 1-1h14a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1m0 8a1 1 0 0 1 1-1h14a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1m0 4a1 1 0 0 1 1-1h10.308a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1"
clipRule="evenodd"
/>
</svg>
);
  1. AutoTOCFloatingTOC を表示する
  2. マウスホバーで表示が切り替わるようにする
const FloatingTOC = ({ toc }: FloatingTOCProps) => {
const [isHover, setIsHover] = useState(false);
return (
<div
id="floating-toc"
className="fixed right-0 top-[50%] z-10 mr-4 -translate-y-1/2 bg-[var(--color-base-200)] border rounded"
>
{isHover ? (
<div
className="p-4 flex flex-col items-end transition-all duration-200 ease-out"
onMouseLeave={() => setIsHover(false)}
>
{toc.map((x, i) => (
<a key={i} href={x.hash} onClick={() => setIsHover(false)}>
{x.text}
</a>
))}
</div>
) : (
<div
className="p-2 transition-all duration-200 ease-out "
onMouseEnter={() => setIsHover(true)}
>
<MenuIcon />
</div>
)}
</div>
);
}

6. ロケーションバーのハッシュを変更する

  1. h2 を保持しておくための headingRefs を作成し、DOMへの参照を保存する
const headingRefs = useRef<HTMLHeadingElement[]>([]);
useEffect(() => {
const headings = document.querySelectorAll("h2");
const toc = [];
[...headings].forEach((heading) => {
// 目次のデータを作る
const hash = `#${encodeURIComponent(heading.innerText)}`;
toc.push({
hash,
text: heading.innerText,
});
// h2にIDとハッシュの追加
const aTag = document.createElement("a");
aTag.innerText = "#";
aTag.href = hash;
heading.id = encodeURIComponent(heading.innerText);
heading.appendChild(aTag);
// refを保存
headingRefs.current.push(heading)
});
}, []);
  1. IntersectionObserver を使い、スクロールしてH2が表示されたときにブラウザのロケーションバーのハッシュを変更する
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries.length > 0 && entries[0].isIntersecting) {
window.history.pushState(null, '', `#${entries[0].target.id}`)
}
},
{ threshold: 1 }
)
headingRefs.current.forEach((heading) => {
if (heading) observer.observe(heading)
})
return () => {
observer.disconnect()
}
}, [])

ここで別の useEffect を使っているのは、目次を作っている useEffect が1度目のマウントでしか実行されないからです。

まとめ