LOST IN BLUE

2021/12/22

Next.jsのComponentでgetStaticPropsをしたい

Next.jsをSSG目当てに使用している場合、あるcomponentのpropsについて、プリレンダリング時にAPIからfetchしたデータなどを使いたいという場合があると思います。
Next.jsのNextPageでは、getStaticPropsを用いることで、ビルド時にgetStaticPropsから返される propsを使ってプリレンダリングしてくれます。

例えば、上のリンクカードコンポーネントはリンク先がAccess-Control-Allow-Origin: *になっている保障も無く、またビルド時に内容が決定できるので、getStaticPropsでビルド時にプリレンダリングしてあげたいところです。
しかし、上のリンクカードにある通り、getStaticPropsが使えるのはNextPageのみです。

調べてみると、やはり同じようなことについて書いている先駆者さんがいました。

この記事では、next-cmsでどのようにComponent-level static propsをしているかについて解説されています。

詳しくは以上の記事を読んで頂くこととして、このブログで使用しているOGPを取得してリンクカードにするものを例に簡単に解説します。
まず、createContextを用いてOGPContextを作成し、コンポーネントをそのProviderでラップするwithOPGPageを定義します。

import { createContext } from 'react'

export const OGPContext = createContext({})

export const withOGPPage = (Component) =>
  function inject(props) {
    if (props.__next_ssg_error) {
      // render error page
      return <h1>{props.__next_ssg_error} Error</h1>
    }

    return (
      <OGPContext.Provider value={props.__next_ssg_data || {}}>
        <Component {...props} />
      </OGPContext.Provider>
    )
  }

続いて、以下のようなコンポーネントを作成します。
このコンポーネントは、プリレンダリング時とレンダリングで動作が変わります。
レンダリング時の動作は、後述するとして、プリレンダリング時はグローバルな配列global.__next_ssg_requestsendpointを追加し、何も返さないコンポーネントとなっています。

import { useContext } from 'react'
import { OGPContext } from '../withOGPPage'

export default function OGP({ endpoint, children }) {
  const data = useContext(OGPContext)

  // プリレンダリング時はここがtrueにならない
  if (typeof data[endpoint] !== 'undefined') {
    if (typeof children === 'function') {
      return children(data[endpoint])
    }

    return data[endpoint]
  }

  const IS_SSG =
    typeof window === 'undefined' &&
    typeof global !== 'undefined' &&
    global.__next_ssg_requests

  // SSGの場合、グローバルな配列にpushする
  if (IS_SSG) {
    global.__next_ssg_requests.push(endpoint)
  }

  return null
}

このOGPコンポーネントは以下のようにコンポーネントをラップする形で使われます。
例として、以下のコンポーネントでは、propsで渡されたhrefをプリレンダリング時にglobal.__next_ssg_requestsに追加します。

export const LinkCard: React.FC<LinkCardProps> = ({ href }) => {
  return (
    <OGP endpoint={href}>
  {(data) => {
    return (
      <div>
        {data['og:title']}
      </div>
    )
  }}
</OGP>
  )
}

このコンポーネントが用いられるページは以下のように、withOGPPageでラップしてあげます。
そして、このページのgetStaticProps中では、実際にfetchしてOGPを取得するgetOGPStaticPropsを実行します。

const Hoge: NextPage<HogeProps> = withOGPPage(({ href, fuga }) => {

  return (
    <LinkCard href={href} />
  )
})

export default Hoge

export const getStaticProps: GetStaticProps = async (ctx) => {
  const fuga = "fuga"
  const props = await getOGPStaticProps(Hoge, {
    fuga: fuga,
  })

  return { props }
}

getOGPStaticPropsは以下のようになっています。
やっていることは、グローバルな配列global.__next_ssg_requestsに格納されているendpointを1つづつ取り出してfetchした結果を連想配列__next_ssg_dataendpointをキーとして格納し、propsに追加して返しています。

export const getOGPStaticProps = async (Page, extraProps) => {
  const ReactDOMServer = require('react-dom/server')
  const fetch = require('node-fetch')
  const JSDOM = require('jsdom')

  const props = { __next_ssg_data: {}, ...extraProps }
  const pendingPromises = {}

  while (true) {
    global.__next_ssg_requests = []
    ReactDOMServer.renderToStaticMarkup(<Page {...props} />)

    if (!global.__next_ssg_requests.length) break

    const endpoints: string[] = Array.from(new Set(global.__next_ssg_requests))

    try {
      await Promise.race(
        endpoints.map((endpoint) => {
          if (!pendingPromises[endpoint]) {
            pendingPromises[endpoint] = pendingPromises[endpoint] = fetch(
              endpoint,
              {
                mode: 'cors',
                credentials: 'omit',
              }
            )
              .then((res) => {
                if (!res.ok) {
                  delete pendingPromises[endpoint]
                  throw res.status
                }

                return res.text()
              })
              .then((text) => {
                const jsdom = new JSDOM.JSDOM()
                const parser = new jsdom.window.DOMParser()
                const el = parser.parseFromString(text, 'text/html')
                const headEls = el.head.children
                const data = {}
                Array.from(headEls).map((v: any) => {
                  const prop = v.getAttribute('property')
                  const name = v.getAttribute('name')
                  if (!prop && !name) {
                    return
                  }
                  data[prop ?? name] = v.getAttribute('content')
                })

                props.__next_ssg_data[endpoint] = data
                delete pendingPromises[endpoint]
              })
          }

          return pendingPromises[endpoint]
        })
      )
    } catch (err) {
      return { __next_ssg_error: err }
    }
  }

  return props
}

ここまで、プリレンダリング時の動作をまとめると、OGPコンポーネントのendpointが集約され、getOGPStaticPropsで格納されているendpointのデータをfetchし、ページのprops__next_ssg_dataにデータが格納されます。

先述した通り、OGPコンポーネントはプリレンダリング時とレンダリング時で動作が変わります。 レンダリング時のOGPコンポーネントでは、プリレンダリング時はdataが空でしたが、withOPGPageによって、ページのprops__next_ssg_dataOGPContextに格納されているので、はじめのifの条件式がtrueとなり、取得したdataに基づき、childrenがレンダリングされます。

import { useContext } from 'react'
import { OGPContext } from '../withOGPPage'

export default function OGP({ endpoint, children }) {
  const data = useContext(OGPContext)

  // ここを通るようになる
  if (typeof data[endpoint] !== 'undefined') {
    if (typeof children === 'function') {
      return children(data[endpoint])
    }

    return data[endpoint]
  }

  const IS_SSG =
    typeof window === 'undefined' &&
    typeof global !== 'undefined' &&
    global.__next_ssg_requests

  if (IS_SSG) {
    global.__next_ssg_requests.push(endpoint)
  }

  return null
}

以上のように、React Contextを巧みにつかうことで、Component-level static propsができるようになります。
プリレンダリング・レンダリングの2つのレンダリングとReact Contextが絡み合っているため、複雑ですが、一度動作を追ってみると理解しやすいと思います。

自分のブログの全体のコードはあまり人に見せられるものではないので、今回はスニペットだけ書きました。
途中で言及した以下のリポジトリでは、説明した仕組みを含めたコード全体が確認できるので、参考になると思います。