microCMS+Next.jsで作ったブログに見出しのリンクを作成する

53433 ワード

実現したいこと

microCMSとNext.jsで構築したブログに見出しの一覧とそれに対するリンクを作成して表示する。

使用技術

  • React 16.8~ (hooksが使えるもの)
  • Next.js 10.0~ (hooksが使えるReactに対応しているもの)
  • microCMS (プランは何でもOK)
  • typescript (jsとどっちでも可)

実装手順

最初の状態

[id].tsx
export const getStaticPaths = async () =>{
  const data:{contents:post[]} = await client.get({ endpoint: 'blog' })

  const paths = data.contents.map(content=>`/blog/${content.id}`)
  return {paths, fallback:false}
}

export const getStaticProps = async (context)=>{
  const id = context.params.id
  const data = await client.get({
    endpoint: `blog/${id}`
  })

  return{
    props:{
      blog: data
    }
  }
}

export default function single({ blog }){
  const { content } = blog
  
  return (
    <div
      dangerouslySetInnerHTML={{__html: content}}
    >
    </div>
  )
}

前提知識

  • ページ内リンクは<a href="#対象のID">テキスト</a>で実装できる
  • microCMSでつけた見出しは自動でIDが振られる
  • microCMSの投稿内容は実際にビルドされるまでは扱うことができない

手順1 - DOMを宣言的に扱えるようにする

投稿された内容を表示する要素をロジック内部で扱うためにrefを使って紐づける
(useEffectの中でDOMを取得することもできますが、ミュータブルにDOMにアクセスすることができるのでrefを使用します)

[id].tsx
+import { useRef } from 'react'

export const getStaticPaths = async () =>{
  const data:{contents:post[]} = await client.get({ endpoint: 'blog' })

  const paths = data.contents.map(content=>`/blog/${content.id}`)
  return {paths, fallback:false}
}

export const getStaticProps = async (context)=>{
  const id = context.params.id
  const data = await client.get({
    endpoint: `blog/${id}`
  })

  return{
    props:{
      blog: data
    }
  }
}

export default function single({ blog }){
  const { content } = blog
+  const post = useRef<HTMLDivElement>(null)
  
  return (
    <div
+      ref={ post }
      dangerouslySetInnerHTML={{__html: content}}
    >
    </div>
  )
}

手順2 - 見出しを取得する

querySelectorAll(Element)を使用することで要素を取得することができるが、Reactの場合コンポーネントのマウント後でなければ期待した動作にならない。

useEffect(()=>(),[])の形でコンポーネントマウント後の処理を強制する。

[id].tsx
+import { useRef, useEffect } from 'react'

export const getStaticPaths = async () =>{
  const data:{contents:post[]} = await client.get({ endpoint: 'blog' })

  const paths = data.contents.map(content=>`/blog/${content.id}`)
  return {paths, fallback:false}
}

export const getStaticProps = async (context)=>{
  const id = context.params.id
  const data = await client.get({
    endpoint: `blog/${id}`
  })

  return{
    props:{
      blog: data
    }
  }
}

export default function single({ blog }){
  const { content } = blog
  const post = useRef<HTMLDivElement>(null) 
  
+  useEffect(()=>{
+    /* 投稿内容内の見出し1を全取得 */
+    const headings = post.current.querySelectorAll('h1')
+  },[])

  return (
    <div
      ref={post}
      dangerouslySetInnerHTML={{__html: content}}
    >
    </div>
  )
}

手順3 - 取得した内容からリンクを作成する

[id].tsx
import { useRef, useEffect } from 'react'

export const getStaticPaths = async () =>{
  const data:{contents:post[]} = await client.get({ endpoint: 'blog' })

  const paths = data.contents.map(content=>`/blog/${content.id}`)
  return {paths, fallback:false}
}

export const getStaticProps = async (context)=>{
  const id = context.params.id
  const data = await client.get({
    endpoint: `blog/${id}`
  })

  return{
    props:{
      blog: data
    }
  }
}

export default function single({ blog }){
  const { content } = blog
  const post = useRef<HTMLDivElement>(null) 
  
  useEffect(()=>{
    /* 投稿内容内の見出し1を全取得 */
    const headings = Array.from(post.current.querySelectorAll('h1'))
+   /* ページ内リンクリストのコンポーネントを定義 */
+   const LinkList = (
+     <ul>
+       { headings.map(el=>(
+	 <li key={el.id}>
+	  <a href={`#${el.id}`}>{el.innerHTML}</a>
+	 </li>
+       )) }
+     </ul>
+   )
  },[])

  return (
    <div
      ref={post}
      dangerouslySetInnerHTML={{__html: content}}
    >
    </div>
  )
}

手順4 - コンポーネントをレンダリングする

useEffectのブロック内でコンポーネントが定義されているので、おおもとのコンポーネント内で扱えるようにする。
そのためにstateを利用する。

[id].tsx
+import { useRef, useEffect, useState } from 'react'

export const getStaticPaths = async () =>{
  const data:{contents:post[]} = await client.get({ endpoint: 'blog' })

  const paths = data.contents.map(content=>`/blog/${content.id}`)
  return {paths, fallback:false}
}

export const getStaticProps = async (context)=>{
  const id = context.params.id
  const data = await client.get({
    endpoint: `blog/${id}`
  })

  return{
    props:{
      blog: data
    }
  }
}

export default function single({ blog }){
  const { content } = blog
  const post = useRef<HTMLDivElement>(null)
+ const [LinkContent, setLinkContent] = useState<JSX.Element>(null)
  
  useEffect(()=>{
    /* 投稿内容内の見出し1を全取得 */
    const headings = Array.from(post.current.querySelectorAll('h1'))
    /* ページ内リンクリストのコンポーネントを定義 */
    const LinkList = (
      <ul>
        { headings.map(el=>(
 	 <li key={el.id}>
 	  <a href={`#${el.id}`}>{el.innerHTML}</a>
 	 </li>
        )) }
      </ul>
    )
+   setLinkContent(LinkList)
  },[])

  return (
+   <>
+     <article>
+       { LinkContent }
+     </article>
      <div
        ref={post}
        dangerouslySetInnerHTML={{__html: content}}
      >
      </div>
+   </>
  )
}

2022/05/08 追記

リンクをクリックした際にページコンポーネントが再レンダリングされる挙動を修正

aタグをクリックしたときに、対象の見出しのところまでページ遷移を挟まずにスクロールするように挙動を修正します。
とはいっても遷移先の見出しの位置を取得してスクロールさせるだけですが、、、

const scrollToHeading:MouseEventHandler<HTMLAnchorElement> = e =>{
    e.preventDefault()

    const { currentTarget } = e
    const target = document.querySelector( currentTarget.getAttribute('href') )
    const offsetY = target.getBoundingClientRect()
    const padding = 100

    window.scrollTo({
      top: Math.round(window.scrollY + offsetY.top) - padding,
      behavior: 'smooth'
    })
    
  }

余談ですが、aタグのhref属性を取得するときに、HTMLAnchorElement.hrefプロパティを使用して取得するのとElement.getAttribute('href')を使用して取得するのでは結果が違うんですね。
この機能実装しているときに気づきました。

上のハンドラーをaタグに設定します。

const LinkList = (
      <ul>
        { headings.map(el=>(
 	 <li key={el.id}>
 	  <a href={`#${el.id}`} onClick={ scrollHeading }>{el.innerHTML}</a>
 	 </li>
        )) }
      </ul>
    )

最後に

間違っている部分や改善点などがあればコメントで教えていただけると嬉しいです^0^