🤔

Next.js + styled-componentsで発生したFOUCの対処方法 + α

2022/10/31に公開

要約

  • Next.jsにstyled-componentsを導入してスタイリングを行なった際に、初期描画時のわずかな時間だけスタイルが適用されていない画面が描画される現象が発生したため、対処方法を調査。
  • SSRでページをレンダリングする際に、styled-componentsで定義したスタイルの展開が遅れていることが原因だった。
  • 通常、styled-componentsで定義したスタイルはクライアント側でhydrateする際に展開されるため、展開されるまでの間はスタイルが効いていないような現象が起きてしまう。
  • styled-componentsの公式が載せているコードを参考に、スタイルを展開するタイミングをSSR用にずらすことで対応。

概要

Next.jsにstyled-componentsを導入した際に、初期描画のタイミングの僅かな時間にスタイルの崩れた画面が描画されてしまう現象が発生したため、原因を探ってみました。
とはいっても、実はstyled-componentsの公式に解決策自体は書いてあったので、本記事では解決策の共有+詳細な部分を見ていければいいなと考えてます。

スタイル崩れが発生した原因

ページの初期描画時にFOUC(Flash of unstyled content)が発生していたことが原因でした。
FOUCは、本来存在する想定のスタイルシートが何らかの要因で読み込めなかったりする場合に発生する現象のことを指しますが、今回はまさに、ページの描画時に本来用意されている想定のスタイルが存在しなかったために、このような現象が起きていたようです。

CSRとSSRによる差

  • CSR: サーバーからは空のHTMLとUI構築用のJavaScriptが返され、ブラウザ側でページの構築を行う
  • SSR: サーバー側で必要なHTMLを生成し、ブラウザ側で受け取ったものを表示する

...といったように、レンダリング方法にも種類があったりします。
今回使っているNext.jsはSSRに対応したフレームワークなので、サーバーから送られてくるHTMLには既に必要なHTMLのコードが揃っていることになります。そのため、ブラウザにHTMLファイルが到達した段階でページにコンテンツを描画することが可能です。が、今回の問題が発生したように、styled-componentsで定義したスタイルは基本的にブラウザでのハイドレーション中にstyleタグを生成し展開がなされます。そのため、SSRの場合はスタイルの適用が若干遅れてしまいます。
styled-componentsは通常、クライアントサイドで実行されることが前提のライブラリですので、いくつか手を加える必要があるようです。

対処方法

この現象の対応として、styled-componentsの公式が示しているように、以下のコードをpages/_document.tsxに記述する必要があります。

_document.tsx
import Document, {
  DocumentContext,
} from 'next/document'
import { ServerStyleSheet } from 'styled-components'

export default class MyDocument extends Document {
    static async getInitialProps(ctx: DocumentContext) {
      const sheet = new ServerStyleSheet()
      const originalRenderPage = ctx.renderPage

      try {
        ctx.renderPage = () =>
          originalRenderPage({
            enhanceApp: (App) => (props) =>
              sheet.collectStyles(<App {...props} />)
          })

        const initialProps = await Document.getInitialProps(ctx)
        return {
          ...initialProps,
          styles: (
            <>
              {initialProps.styles}
              {sheet.getStyleElement()}
            </>
          )
        }
      } finally {
        sheet.seal()
      }
    }

  render() {
    return (
      ...
    )
  }
}

コードを見てみる

結論から書くと

  1. サーバー側でレンダリングを行う際に初期データを投入するためのメソッドを使い、サーバー側でもスタイルの展開を行えるようにする
  2. SSRに対応したスタイル展開用のオブジェクトを作成する
  3. レンダリング時にスタイル情報を専用のProviderを介して受け渡し、hydrateを行う
  4. HTMLコードにhydrateしたスタイルを追加し、クライアント側に受け渡す

getInitialProps

  • getInitialPropsはSSRでサーバー側でのレンダリング時に初期データを投入するためのもの。今回はSSR時にスタイルを展開したいので、このメソッドを活用します。
static async getInitialProps(ctx: DocumentContext) {
  ...
}

スタイル展開用オブジェクト作成

  • SSR時のスタイル展開に使うオブジェクトを作ります。
  • styled-componentsがデフォルトで使っているStyleSheetクラスを、SSRで使えるようにしたものがServerStyleSheetクラスという認識でよさそうです。
const sheet = new ServerStyleSheet()

StyleSheetManagerをアプリ全体にラップ

  • DocumentContextから提供されるrenderPageをカスタマイズし、sheet.collectStyles()を実行するコードを追加して、styled-componentsから提供されるStyleSheetManagerをレンダリングするアプリケーション全体にラップする必要があります。
const originalRenderPage = ctx.renderPage;

try {
  ctx.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />),
    });

StyleSheetManagerってなんぞ

  • StyleSheetManagerは以下のような実装になっており、sheet.collectStyles()を実行することで、StyleSheetContextのProviderをアプリケーション上にラップすることになります。
  • StyleSheetManagerで実装されているProviderを経由して、styled-componentsで定義したスタイルを取得する必要があるようです。
  • 実装は以下のようになってます。
import React, { useContext, useEffect, useMemo, useState } from 'react';
import shallowequal from 'shallowequal';
import StyleSheet from '../sheet';
import { Stringifier } from '../types';
import createStylisInstance from '../utils/stylis';

type Props = {
  children?: React.ReactChild;
  disableCSSOMInjection?: boolean;
  disableVendorPrefixes?: boolean;
  sheet?: StyleSheet;
  stylisPlugins?: stylis.Middleware[];
  target?: HTMLElement;
};

export const StyleSheetContext = React.createContext<StyleSheet | void>(undefined);
export const StyleSheetConsumer = StyleSheetContext.Consumer;
export const StylisContext = React.createContext<Stringifier | void>(undefined);
export const StylisConsumer = StylisContext.Consumer;

export const mainSheet: StyleSheet = new StyleSheet();
export const mainStylis: Stringifier = createStylisInstance();

export function useStyleSheet(): StyleSheet {
  return useContext(StyleSheetContext) || mainSheet;
}

export function useStylis(): Stringifier {
  return useContext(StylisContext) || mainStylis;
}

export default function StyleSheetManager(props: Props): JSX.Element {
  const [plugins, setPlugins] = useState(props.stylisPlugins);
  const contextStyleSheet = useStyleSheet();

  const styleSheet = useMemo(() => {
    let sheet = contextStyleSheet;
    
    if (props.sheet) {
      // eslint-disable-next-line prefer-destructuring
      sheet = props.sheet;
    } else if (props.target) {
      sheet = sheet.reconstructWithOptions({ target: props.target }, false);
    }

    if (props.disableCSSOMInjection) {
      sheet = sheet.reconstructWithOptions({ useCSSOMInjection: false });
    }

    return sheet;
  }, [props.disableCSSOMInjection, props.sheet, props.target]);

  const stylis = useMemo(
    () =>
      createStylisInstance({
        options: { prefix: !props.disableVendorPrefixes },
        plugins,
      }),
    [props.disableVendorPrefixes, plugins]
  );

  useEffect(() => {
    if (!shallowequal(plugins, props.stylisPlugins)) setPlugins(props.stylisPlugins);
  }, [props.stylisPlugins]);

  return (
    <StyleSheetContext.Provider value={styleSheet}>
      <StylisContext.Provider value={stylis}>
        {process.env.NODE_ENV !== 'production'
          ? React.Children.only(props.children)
          : props.children}
      </StylisContext.Provider>
    </StyleSheetContext.Provider>
  );
}

受け取ったスタイルについては、hydrateを行いやすい形に加工した後にhydrateを行い、StyleSheetクラスに用意されているメソッドから適切な形で返されます。

スタイルの展開

  • styled-componentsで作成したスタイルをReactの要素として展開し、initialProps.stylesに追加しています。
  • スタイル展開用のメソッドとして、getStyleTagsメソッドも用意されていますが、Next.jsではReactの要素を返してくれるgetStyleElementメソッドを利用する方が適切っぽいです。
const initialProps = await Document.getInitialProps(ctx);
return {
  ...initialProps,
  styles: (
    <>
      {initialProps.styles}
      {sheet.getStyleElement()}
    </>
  ),
};

例外処理

  • 何かしらの理由でレンダリングに失敗した場合、StyleSheetクラスの機能を利用不可能にし、常にガベージコレクションで処理できるようにしておくことが推奨されています。
  • sheet.seal()が実行されることで、対象のServerStyleSheetクラスの各種メソッドが利用不可となり、getStyleElementメソッドなど利用しようとした場合はエラーが返されるようになるっぽいです。
} finally {
  sheet.seal();
}

おわりに

書いてて思ったけど、特に有益な情報ないな!!!(でも勿体無いんで記事にしました)
間違ってる箇所があれば指摘していただけると非常にありがたいです...🙏🙏

参考サイト

https://styled-components.com/docs/advanced#server-side-rendering

https://nextjs-ja-translation-docs.vercel.app/docs/advanced-features/custom-document#renderpage-のカスタマイズ

株式会社ゆめみ

Discussion