Open6

Next.jsでページごとに異なるCSSを読み込む方法

oosawyoosawy

Next.jsにはページ単位でCSSを適応する一般的な方法がなさそうなので、ベストな方法を探す

目的

ページレベルでのテーマを実現すること。具体的には:root { --color: blue; }のようなテーマ情報を持つ複数のCSSをページごとに適応する

複数の方法を検証した結果、現状では<style>をそのまま描画するのが一番よさそうでした
ただReactで<style>をそのまま描画するのはあまり知見が無さそうなのでもし何か問題があれば教えてもらえるとありがたいです。

oosawyoosawy

Custom AppでCSS Modulesを使う

まず最初に思いつくCustom AppでページごとにCSS Modulesから異なるクラスを指定する実装です

テーマとしてCSS変数を配るだけならほとんどのケースで十分そうだれけど、適応範囲がclassNameを指定できる_app内だけなのでhtml,bodyや#_nextに当てるスタイルからもテーマにアクセスしたいとなると厳しそう
htmlタグなどでテーマにアクセスしたくなるようなスタイリングは、ぱっと思いつく限りだとダークモード対応で:root { color-scheme: dark; }ぐらいなのでこれは他の仕組みで対応して問題を回避することはできそうか?

_app.js
import styles from '../styles/Theme.module.css'

function MyApp({ Component, pageProps }) {
  const router = useRouter()

  const themeClassName = useMemo(() => {
    const map = {
      '/foo': styles.FooTheme,
      '/bar': styles.BarTheme,
    }
    return map[router.pathname]
  }, [router.pathname])

  return (
    <div className={themeClassName}>
      <Component {...pageProps} />
    </div>
  )
}

demo

oosawyoosawy

styled-jsxを使う

<style jsx global></style>と書けばglobalなCSSをそのまま挿入できる。これ単体でコンポーネントとして共通化したり、テーマコンポーネントに含めたりすることができる

pages/foo.js
export default function BarPage() {
  return (
    <BarTheme>
      <main>bar page</main>
    </BarTheme>
  )
}

const BarTheme = (props) => {
  return (
    <>
      <style jsx global>{`:root { --color: blue; }`}</style>
      {props.children}
    </>
  )
}

demo

またはCustom Appで要素を出し分けることもできる

_app.js
function MyApp({ Component, pageProps }) {
  const router = useRouter()

  const themeStyles = useMemo(() => {
    switch (router.pathname) {
      case '/foo':
        return <style jsx global>{`:root { --color: blue; }`}</style>
      case '/bar':
        return <style jsx global>{`:root { --color: green; }`}</style>
    }
  }, [router.pathname])

  return (
    <div>
      {themeStyles}
      <Component {...pageProps} />
    </div>
  )
}

demo

気づいたことなど

  • Next.jsなら追加の設定なしにそのまま使えるが、<style jsx>を書いた段階でstyled-jsxがバンドルされるようでバンドルサイズが3kB弱増える
  • 開発サーバーでもJavaScriptなしに動作する(なぜかCSS Modulesは開発サーバーだとJSを無効にすると反映されない)
  • テンプレートリテラル内に分岐した値を埋め込んでも完全に静的にコンパイルされる(demo
  • 上記のような出し分けのような分岐が入っても、各ページで使われているCSSがコンパイル時に抽出されてhead内に出力される
  • <style jsx></style>の中身はコンパイル時に解釈されているようでJavaScriptの式として完全に自由に書くことはできない(コンパイルエラーになることがあった)
oosawyoosawy

全てのテーマをGlobal Stylesheetとして読み込みCustom Documentで属性を付けて適用する

結論から言うとこれはできませんでした。もしできたなら一番理想形に近かったと思います。 これができたとしてもHTMLタグに直接属性を付与することはできても、クライアント側ナビゲーションで更新されることはなくCustom Appなどで更新処理を実装することが必要になるので、完全な理想形とまではいきませんでした。

やりたかったこととしては、テーマごとのCSSをそれぞれhtml[data-theme=foo]のようなセレクタでまとめてグローバルに読み込んでから、Custom Documentのrender関数内でhtmlやbodyタグにパスに基づいて属性を付けることでテーマを指定することでしたが、render関数内から外部の情報にアクセスする手段がなさそうでした

ちなみにここでクラスではなくデータ属性を使っていることに深い意味はありません

実際に試したコード(動作しません)
global.css
html[data-theme='foo'] {
  --color: blue;
}

html[data-theme='bar'] {
  --color: green;
}
_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx)

    // 1, ここで ctx.pathname でアクセスされたページのパスが取得できる

    return initialProps
  }

  render() {
    return (
      // 2, どうにかして1のパスを参照して、ページごとにクラスや属性をここに指定したいが難しい
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

他に気づいたことなど

  • そもそもNext.jsのCustom Documentというのは、Reactアプリケーションを埋め込む外枠のHTMLを、文字列テンプレートの代わりにJSXで書けるようにしているだけで、Custom Appと見た目は似ているが解釈されるタイミングが全く異なる
  • つまりCustom Documentでページに依存したUIを実装しても最初にアクセスしたページ用のHTMLが返されるだけで、その後の面倒は誰も見てくれないので、Next.jsでは意図的にCustom Documentのrender関数内から他の情報にアクセスできないようにしているのではないかと感じた
  • 調査していると実はrender関数内からもthis.props.__NEXT_DATA__.pageでpathname相当の値にアクセスすることはできるが、内部実装に依存している感じがとても強いので扱いませんでした(使った場合の demo
oosawyoosawy

Custom Documentでstylesに初期ページのテーマを返し、Custom Appでstyleタグを更新する

前述したようにCustom Documentではrenderからページ情報などが入ったcontextにアクセスできず、ページごとに異なるHTMLを返すことが難しいのですが、CSSだけはgetInitialPropsから返すオブジェクト内にstylesで指定することができます。これはCSS in JSをSSR対応させるためのエスケープハッチ的なAPIだと考えられます。

getInitialProps内で指定しますが、動作としてはnext/documentの<Head />内に出力するだけのようなので、Custom Documentのrender関数内と同じようにその後の面倒は見てくれないため、追加でCustom Appなどに更新処理も実装する必要もあります。

_document.js
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx)

    const themeStyle = {
      '/foo': <style>{`:root { --color: blue; }`}</style>,
      '/bar': <style>{`:root { --color: green; }`}</style>,
    }[ctx.pathname]

    return {
      ...initialProps,
      styles: (
        <>
          {initialProps.styles}
          {themeStyle}
        </>
      ),
    }
  }
_app.js
  const router = useRouter()

  useInsertionEffect(() => {
    let styleEl = document.getElementById('theme-style')

    if (!styleEl) {
      styleEl = document.createElement('style')
      styleEl.id = 'theme-style'
      document.head.appendChild(styleEl)
    }

    const themeStyle = {
      '/foo': `:root { --color: blue; }`,
      '/bar': `:root { --color: green; }`,
    }[router.pathname]

    styleEl.innerText = themeStyle
  }, [router.pathname])

demo

oosawyoosawy

<style>をそのまま描画する

今更ながら<style>タグをそのまま描画するのを思いつき、試してみたらなんと普通に動きました。クラスなども使わないのでCSS内のセレクターも:rootのままで問題ないですし、実装もシンプルでコンパクトなので、問題が何も起こらなければこれが理想的だと思います。

_app.js
function MyApp({ Component, pageProps }) {
  const router = useRouter()

  const themeStyles = {
    '/foo': `:root { --color: blue; }`,
    '/bar': `:root { --color: green; }`,
  }[router.pathname]

  return (
    <div>
      <style>{themeStyles}</style>
    </div>
  )
}

demo

CSSを外部ファイルから読み込んで使う

それからCSSを文字列として扱っている部分も、css-loaderを活用して外部ファイルからそのまま取ってくることでもっと簡潔にすることもできました。ここでは横着してwebpackのinline syntaxを使っているので分かりにくいかもしれませんが、Next.js標準に入っているloaderの動作を無効化しつつcss-loaderを使ってCSSファイルの内容を読み込んでいるだけです。

_app.js
import ThemeFoo from '!css-loader!../styles/themes/foo.css'
import ThemeBar from '!css-loader!../styles/themes/bar.css'

function MyApp({ Component, pageProps }) {
  const router = useRouter()

  const themeStyles = {
    '/foo': ThemeFoo.toString(),
    '/bar': ThemeBar.toString(),
  }[router.pathname]

  return (
    <div>
      <style>{themeStyles}</style>
    </div>
  )
}

demo

next/headで<head>内に<style>を挿入する

<body>内に<style>が挿入されるのが気になる場合はnext/headを使うことで<head>内に入れることもできるみたいです。

_app.js
import Head from 'next/head'

      <Head>
        <style>{themeStyles}</style>
      </Head>

demo