Reactハンズオン「自動見出しコンポーネント」
2024-2025 Shunsuke Watanabe
このハンズオンではページの見出しを自動で作成するコンポーネントを作ります。このハンズオンはReact入門チュートリアルの内容を前提にしています。
このコンポーネントの作成を通じて以下の知識の確認をします。
- useState
- useEffect
- useRef
- StrictMode
1. コンポーネントでやりたいこと
- ページの h2 タグをもとに自動で目次をつくる
- 各 h2 タグにリンク用のハッシュをつける
- フローティングTOCで h2 タグにすぐ飛べるようにする
2. プロジェクト初期化
- 初期化
npm create vite@latest auto-toc
- ライブラリのインストール
cd auto-tocnpm i;npm i -D prettier tailwindcss @tailwindcss/vite daisyui;
vite.config.ts
にtailwindcss
を追加
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()],})
index.css
の中身を消して、tailwindcssとdaisyuiで置き換え
@import "tailwindcss";@plugin "daisyui" { themes: abyss --default;};
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></>
- 開発用サーバ起動
npm run dev
index.css
に見出しのスタイルを追加
h2 { margin-top: 4rem; margin-bottom: 2rem; font-size: x-large; }
h3 { margin-top: 2rem; margin-bottom: 1rem; }
src/components/AutoTOC.tsx
を新規作成
export const AutoTOC = () => { return ( <> <h2>目次</h2> </> )}
App.tsx
でAutoTOC.tsx
を表示
3. DOMからh2タグを抽出する
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);}, []);
- ハッシュリンクが2つついていることを確認する
- StrictModeを外してみる→もどす
useRef
を使って一度だけ実行されるようにする
const hasExecuted = useRef(false);useEffect(() => { if(hasExecuted.current) { return; } hasExecuted.current = true;
...
}, []);
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>);
- 目次も目次に出てしまうのでフィルタする
5. フローティングTOCを作る
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>);
AutoTOC
でFloatingTOC
を表示する- マウスホバーで表示が切り替わるようにする
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. ロケーションバーのハッシュを変更する
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) });
}, []);
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度目のマウントでしか実行されないからです。
まとめ
- 状態に応じてコンポーネントを再レンダリングしたいときは
useState
- コンポーネントを再レンダリングしたくないが、状態を管理したいときは
useRef
- DOMへの参照を保持するときは
useRef
- DOM操作をするときは
useEffect
StrictMode
では、開発環境でのみ、マウント→アンマウント→マウント という動作をシミュレーションする