Closed9

Next.js のソースコードリーディングメモ (next/router, next/link編)

mongolyymongolyy

まずは /packages/next/client/router.ts から読んでみる

/packages/next/shared/lib/router.ts からいろいろ import されていて、その中を見ると、 NextRouter の型定義がされてる

export type NextRouter = BaseRouter &
  Pick<
    Router,
    | 'push'
    | 'replace'
    | 'reload'
    | 'back'
    | 'prefetch'
    | 'beforePopState'
    | 'events'
    | 'isFallback'
    | 'isReady'
    | 'isPreview'
  >

Pickは初めて見が、Tから任意のプロパティだけを抽出した型を作るためのtypeか
https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys

mongolyymongolyy

client/index.tsx の中で AppContainer コンポーネントにて

<RouterContext.Provider value={makePublicRouterInstance(router)}>
  <HeadManagerContext.Provider value={headManager}>
    <ImageConfigContext.Provider
      value={process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete}
    >
      {children}
    </ImageConfigContext.Provider>
  </HeadManagerContext.Provider>
</RouterContext.Provider>

普段、useRouterが使えるのも、RouterContextとして、ルーターのインスタンスがprovideされているからということがわかった。
ルーターのインスタンスは、makePublicRouterInstance(router) で生成されている。

routerは index.tsx のL.181でletで定義されている。
値が代入されるのは initNext 関数の中で、createRouter関数が呼び出されて、その結果が代入されている。
createRouter 関数の中ではsingletonRoute.routerの初期化が行われてそれが返されている。

一方routerを引数にとっている、makePublicRouterInstance はNextRouter型のインスタンスを生成していそう。

NextRouterの型は

export type NextRouter = BaseRouter &
  Pick<
    Router,
    | 'push'
    | 'replace'
    | 'reload'
    | 'back'
    | 'prefetch'
    | 'beforePopState'
    | 'events'
    | 'isFallback'
    | 'isReady'
    | 'isPreview'
  >

なので、内部で使っていたRouter型のインスタンスに対して、APIで公開するメソッドを限定するのが目的っぽい。

makePublicRouterInstance は Router → NextRouterへの変換を担っているわけか

mongolyymongolyy

makePublicRouterInstance のコードをもうちょっと見てみると

// Events is a static property on the router, the router doesn't have to be initialized to use it
instance.events = Router.events

イベントとして、Router.eventsを定義していそう

で、Router.eventsを見てみると

static events: MittEmitter<RouterEvent> = mitt()

mittとはなんぞや?
https://github.com/developit/mitt
https://zenn.dev/ryo_kawamata/articles/vue-flash-message-with-mitt

イベント管理の仕組みっぽい。subscribeはonメソッド、publishはemitメソッドを呼び出すことで動く模様

ちなみに、RouterEventは

const routerEvents = [
  'routeChangeStart',
  'beforeHistoryChange',
  'routeChangeComplete',
  'routeChangeError',
  'hashChangeStart',
  'hashChangeComplete',
] as const
export type RouterEvent = typeof routerEvents[number]

と、定義されている。
enumを定義するときは、個人的にはオブジェクトでやることが多かった。配列でやるパターンは初めて見た。

mongolyymongolyy

makePublicRouterInstance 関数の上には // This function is used to create the withRouter router instance というコメントが、、
useRouterは使うが、withRouterってなんやっけ?
ということで調べてみる

公式のサンプルには

If useRouter is not the best fit for you, withRouter can also add the same router object to any component.

import { withRouter } from 'next/router'

function Page({ router }) {
  return <p>{router.pathname}</p>
}

export default withRouter(Page)

と書かれている。 If useRouter is not the best fit for you というのがどういう状況かわからないが、クラスコンポーネントの中では useRouter が使えないので、クラスコンポーネントを使っている場合は、withRouterを使うという選択肢がありそう。

一方、サンプルは関数コンポーネントで書かれているので、うーんとなる。

https://nextjs.org/docs/api-reference/next/router#withrouter

話を戻して、withRouterの実装を確認してみる。next/client/with-router.tsx の中で定義されてた。

export default function withRouter<
  P extends WithRouterProps,
  C = NextPageContext
>(
  ComposedComponent: NextComponentType<C, any, P>
): React.ComponentType<ExcludeRouterProps<P>> {
  function WithRouterWrapper(props: any): JSX.Element {
    return <ComposedComponent router={useRouter()} {...props} />
  }

  WithRouterWrapper.getInitialProps = ComposedComponent.getInitialProps
  // This is needed to allow checking for custom getInitialProps in _app
  ;(WithRouterWrapper as any).origGetInitialProps = (
    ComposedComponent as any
  ).origGetInitialProps
  if (process.env.NODE_ENV !== 'production') {
    const name =
      ComposedComponent.displayName || ComposedComponent.name || 'Unknown'
    WithRouterWrapper.displayName = `withRouter(${name})`
  }

  return WithRouterWrapper
}

useRouterを使って取り出したrouterインスタンスを直下のコンポーネントにpropsとして渡しているだけじゃん!
フックではなくHOCで実装していた頃の名残で残っていると考えても良さそう

mongolyymongolyy

client側のrouterの挙動はちょっとわかったので、次はLinkコンポーネントを見てみる

mongolyymongolyy

next/client/link.tsx を見ていく

Linkコンポーネントを見てみる。
ぱっと見、前半はvalidationチェックとかっぽい。
return されているのは React.cloneElement(child, childProps)
それぞれ見ていく

childchild = React.Children.only(children) と定義されてる。
React.Children自体初めて見た

React.Children はデータ構造が非公開の this.props.children を扱うためのユーティリティを提供します。

https://ja.reactjs.org/docs/react-api.html#reactchildren
そういうAPIがあったのか。
React.Children.only はというと

children が 1 つの子要素しか持たないことを確認し、結果を返します。そうでない場合、このメソッドはエラーを投げます。

https://ja.reactjs.org/docs/react-api.html#reactchildrenonly
リンクコンポーネントの下にaタグが複数あったりすると、処理できないからこういうassertion的な処理を入れているのか

次は childProps を見ていく。次のように定義されている。

  const childProps: {
    onMouseEnter?: React.MouseEventHandler
    onClick: React.MouseEventHandler
    href?: string
    ref?: any
  } = {
    ref: setRef,
    onClick: (e: React.MouseEvent) => {
      if (process.env.NODE_ENV !== 'production') {
        if (!e) {
          throw new Error(
            `Component rendered inside next/link has to pass click event to "onClick" prop.`
          )
        }
      }
      if (child.props && typeof child.props.onClick === 'function') {
        child.props.onClick(e)
      }
      if (!e.defaultPrevented) {
        linkClicked(e, router, href, as, replace, shallow, scroll, locale)
      }
    },
  }

  childProps.onMouseEnter = (e: React.MouseEvent) => {
    if (child.props && typeof child.props.onMouseEnter === 'function') {
      child.props.onMouseEnter(e)
    }
    if (isLocalURL(href)) {
      prefetch(router, href, as, { priority: true })
    }
  }

onMouseEnter もオブジェクトの中で定義しても良い気がするが、まあいいか
onClick から見ていく

      if (child.props && typeof child.props.onClick === 'function') {
        child.props.onClick(e)
      }
      if (!e.defaultPrevented) {
        linkClicked(e, router, href, as, replace, shallow, scroll, locale)
      }

子コンポーネントのonClick属性が実行される。
その処理の中でpreventDefaultされていなければ、linkClicked が実行される模様。
linkClicked を確認してみる

function linkClicked(
  e: React.MouseEvent,
  router: NextRouter,
  href: string,
  as: string,
  replace?: boolean,
  shallow?: boolean,
  scroll?: boolean,
  locale?: string | false
): void {
  const { nodeName } = e.currentTarget

  // anchors inside an svg have a lowercase nodeName
  const isAnchorNodeName = nodeName.toUpperCase() === 'A'

  if (isAnchorNodeName && (isModifiedEvent(e) || !isLocalURL(href))) {
    // ignore click for browser’s default behavior
    return
  }

  e.preventDefault()

  // replace state instead of push if prop is present
  router[replace ? 'replace' : 'push'](href, as, {
    shallow,
    locale,
    scroll,
  })
}

aタグの場合に、何かキーを押しながらリンクをクリックしたり、新しいタブを開いたり、外部のドメインのURLがリンク先の場合はearly returnされて、そうでない場合はページが遷移される。

ここまでの流れ的に、aタグでonClick属性が与えられていればその挙動があればそれが優先されるし、もしそれがなければデフォルトの挙動としてページ遷移が発生すると考えれば良さそう

mongolyymongolyy

では次に、childProps.onMouseEnter を見ていく
気になるのは prefetch なので、それを見ていく

function prefetch(
  router: NextRouter,
  href: string,
  as: string,
  options?: PrefetchOptions
): void {
  if (typeof window === 'undefined' || !router) return
  if (!isLocalURL(href)) return
  // Prefetch the JSON page if asked (only in the client)
  // We need to handle a prefetch error here since we may be
  // loading with priority which can reject but we don't
  // want to force navigation since this is only a prefetch
  router.prefetch(href, as, options).catch((err) => {
    if (process.env.NODE_ENV !== 'production') {
      // rethrow to show invalid URL errors
      throw err
    }
  })
  const curLocale =
    options && typeof options.locale !== 'undefined'
      ? options.locale
      : router && router.locale

  // Join on an invalid URI character
  prefetched[href + '%' + as + (curLocale ? '%' + curLocale : '')] = true
}

router.prefetchを呼び出して、完了したら、prefechedという名前のオブジェクトに値をtrueとして格納していく感じっぽい

もうちょっと詳しく見たいので、router.prefetch を見てみる
処理の中身はここらへん

    await Promise.all([
      this.pageLoader._isSsg(route).then((isSsg: boolean) => {
        return isSsg
          ? fetchNextData(
              this.pageLoader.getDataHref({
                href: url,
                asPath: resolvedAs,
                ssg: true,
                locale:
                  typeof options.locale !== 'undefined'
                    ? options.locale
                    : this.locale,
              }),
              false,
              false, // text
              this.sdc,
              true
            )
          : false
      }),
      this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route),
    ])

Promise.allの対象の配列の1つ目の要素では、ssgのページか判定して(_isSsg)、ssgページであればfetchするという処理を行っている
2つ目の要素は、priorityによってloadPageかprefetchかに切り替えている
自分だったら

options.priority ? this.pageLoader.loadPage(route) : this.pageLoader.prefetch(route)

感じで書いちゃうが、こういう書き方もできたんだ、、

_isSsg の挙動が気になってちょっと調べてみる。
処理の中身はこんな感じ
this.promisedSsgManifest.then((manifest) => manifest.has(route))
promisedSsgManifest に routeが登録されているか確認している
next/client/page-loader.ts で定義されてた。

    this.promisedSsgManifest = new Promise((resolve) => {
      if (window.__SSG_MANIFEST) {
        resolve(window.__SSG_MANIFEST)
      } else {
        window.__SSG_MANIFEST_CB = () => {
          resolve(window.__SSG_MANIFEST!)
        }
      }
    })

windowオブジェクトの__SSG_MANIFESTがssgManifestか。

この __SSG_MANIFEST に値が設定がされている場所がよくわからなかったが、
https://github.com/serverless-nextjs/serverless-next.js/issues/798
を見てみると、ビルド時に_ssgManifest.jsファイルが生成されている模様(自分のnext.jsのアプリでもそうなっているようだった)
この中にSSGされたページのURLが記載されていて、アプリケーション起動時にこのファイルを読み込んでいる?(未検証)

このスクラップは2022/02/23にクローズされました