LoginSignup
2

Next.jsのバンドル問題を解決するServiceLocatorパターン

Last updated at Posted at 2022-12-15

概要

Next.jsはgetServerSidePropsが推奨となり、もはやgetInitialProps(以下gIP)は使っていないかもしれません。しかし2022年現在でも、クライアントサイドでのフェッチとサーバサイドでのフェッチを両立したい場合などでは、gIPが必須になると考えています。

サーバ用のコードとクライアント用のコードを用意し、typeof window === "undefined"で切り分けるようにしたとしても、gIPにサーバサイドモジュールを含めることはできません。そのため、もしgIPにサーバ用のコードを含めたい場合、クライアント用のコードとサーバ用のコードに分離した上でうまくimportする必要があります。

また、Next.jsは異なるページコンポーネントは別々にバンドルされるという特徴があり、異なるモジュールから1つのモジュールでexportした変数の共有はできません。ページ共通で必要な機能を毎回インスタンス生成していてはパフォーマンスが悪いです。

これらの問題は、API RoutesでglobalThisにインスタンスを注入しておき、gIPではglobalThis経由で呼び出すことにより解決はするのですが、globalThisを汚すことが懸念されます。また、そのままglobalThisに突っ込んだのではHot Module Replacementも効きません。本記事では、このグローバルなインスタンスをService Locatorパターンにより管理できないかを考えました。

なお、本記事はRecruit Engineers Advent Calendar 2022、16日目の記事です。

課題

どういうパターンで問題になるかを考えます。以下はfsの例で示します。

Next.jsプロジェクトを作ります。

npx create-next-app -- typescript

page/index.tsxを以下に書き換えてください。

// page/index.tsx
import type { NextPage } from 'next'
import fs from "fs"

const Home: NextPage = ({file}: {file?: string}) => {
  // 省略
  return <div>{file}</div>
}

Home.getInitialProps = async () => {
  // クライアントでの実行時は、コンポーネント側でhttp経由でファイルを取得したい
  if(typeof window !== "undefined") {
    return {
      props: {}
    }
  }

  // サーバでの実行時は、InitialPropsにファイルを詰めて返したい
  const file = fs.readFileSync("./README.md")

  return {
    props: { file }
  }
}

export default Home

これでnext buildを実行してみてください。その結果はエラーになるはずで、fsはサーバサイドモジュールであるにも関わらず、クライアント向けバンドルに含まれるgIPから使われようとしているからです。

解決策1

gIPでは、globalThisを経由して呼び出すようにしてみます。

// Home.tsx
Home.getInitialProps = async () => {
  // ...
  // サーバでの実行時は、InitialPropsにファイルを詰めて返したい
  // const file = fs.readFileSync("./README.md") // これを
  const file = (globalThis as any).readFile("./README.md") // こうじゃ!
  //...
}

これにより、fs依存は消えたのでコンパイルは通るようにはなりますが、
globalThis.readFileの実体はありませんので、どこからか注入する必要があります。

そこで、API Routes側でglobalThisに注入します。

// /api/startup.ts
import { readFileSync } from 'fs'
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {

  (globalThis as any).readFile = (file: string) => readFileSync(file)
  res.status(200)
}

早速起動してみましょう。
gIPでglobalThisreadFileを呼ぶためには先に/startupが呼ばれていないといけませんので、
/api/startup をたたくことを忘れないようにする必要があります。

next build && next start
curl http://localhost:3000/api/startup

その後、ブラウザで http://localhost:3000/ にアクセスすると、gIPで注入されたファイルの内容が確認できると思います。

これでも動きはしますが、globalを汚しますし、globalThisに入れた関数を更新してもHot Module Replacementが効かないという問題が発生するので、これはよくないです。

解決策2

readFile程度であれば構わないですが、実際はもう少し複雑なサービスを複数置いたり、サービスがサービスに依存するようなものを導入したり、gIP用とサーバ専用といったように可視性を持たせたりといった使い方をしたいはずです。

そこで、サービスの依存関係を整理するために、Service Locatorパターンを導入します。サービスの依存関係を解決するための手法としてよくDependency Injectionが用いられると思いますが、それよりも古から存在するのがService Locatorパターン(別名Dependency Lookupパターン)です。詳細な説明はほかの文献に譲りますが、端的に言うと各サービス自身が自身の依存するサービスを能動的に取りに行く仕組みです。なお、Dependency Injectionでも今回やりたいことは可能です。

まずは、可視性をもたせるために、ServiceLocatorGipServiceLocatorを定義し、globalThisからアクセスできるようにしましょう。

// services/types.ts
// 直接はapiRoutesでしか使用しないもの
type ServiceLocator = {
  serviceA: ServiceA
  serviceB: ServiceB
} & GipServiceLocator

// gIPでしか使用しないもの
type GipServiceLocator = {
  serviceX: ServiceX
}

declare global {
  var serviceLocator: ServiceLocator | null
}

続いて中身を定義していきます。まずはAPI側から。

// services/server.ts
let serviceLocator: ServiceLocator | null

const createServiceLocator = (): ServiceLocator => {
  const serviceA = createServiceA()
  const serviceB = createServiceB()
  const serviceX = createServiceX(serviceA)
  return {serviceA, serviceB, serviceC}
}

const getServiceLocator = (): ServiceLocator => {
  if(serviceLocator) return serviceLocator
  serviceLocator = createServiceLocator()
  return serviceLocator
}

続いて、gIP側を定義します。

// services/gip.ts
const getGipServiceLocator = async (): GipServiceLocator => {
  // MEMO: HMR対応のためにfetchする、本番環境では不要
  await fetch("http://localhost:3000/api/startup")
  return globalThis.gipServiceLocator
}

HMRに対応するために/api/startupをたたいていますが、
HMRが不要でしたらfetchもasyncも不要です。

最後に、startupでgetServiceLocatorを呼ぶようにしましょう。

// api/startup.api.ts
export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const serviceLocator = getServiceLocator()
  globalThis.gipServiceLocator = serviceLocator
  res.status(200)
}

ここまでで準備完了です。APIでは、以下のようにして使用します。

// api/index.api.ts
export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { serviceA, serviceB } = getServiceLocator()
  const resultA = serviceA.getResult()
  const resultB = serviceB.getResult()
  res.status(200).json({resultA, resultB})
}

ページでは、以下のようにしてあげればOKです。これでインスタンスが各ページ間で共有されます。

// page1.page.tsx
Page1.getInitialProps = () => {
  const { serviceX } = getGipServiceLocator()
  const props = serviceX.getProps()
  return {
    message: props
  }
}

// page2.page.tsx
Page2.getInitialProps = () => {
  const { serviceX } = getGipServiceLocator()
  const props = serviceX.getProps()
  return {
    message: props
  }
}

最後に起動確認です。

next dev
curl http://localhost:3000/api/startup

その後、

リポジトリ

リポジトリはこちらです。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
2