🐙

RemixでCSS in JSを扱う覚書

2021/12/01に公開

個人的備忘録を兼ねて...


Remixは先日メジャーバージョンがリリースされたばかりのReact+Node.js製フルスタックフレームワークです。

https://remix.run/

特徴としては、

  • 他言語と異なりフロントエンドまで扱えるJavascript or Typescriptを使用
  • ブラウザ機能をなんでもいじれちゃうReactで無茶しないための緩めの制約
  • HTMLの持つweb標準のAPIを駆使した、Javascript無効の環境でも動くサーバーサイドアプリケーション

などが挙げられます。

Next.jsと比較されているようですが、SSG(静的サイトジェネレータ)指向のNext.jsとは異なり、RailsやDjangoなどと同様サーバーサイド→HTML→Javascript&CSSという実行順序を崩さない硬派なフレームワークです。

https://nextjs.org

そこにReactの持つ型システムを組み込むことで、よりテスタブルかつ安全な開発ができる仕様となっています。


本題

Remixの使い方ではなくRemixでstyled-componentsなどのSSR対応のCSS in JSを使用するためのトピックを備忘録がてら残します。

https://styled-components.com

使用するバージョンは

  • Remix: ^1.0.6

となり、リアルタイムにアップデートされているため、将来的には当記事の内容で動作しなくなる可能性はあります。

公式のドキュメントより

https://remix.run/docs/en/v1/guides/styling#css-in-js-libraries

公式でもCSS in JSの例は紹介さています。
ですが、私はこの方法でstyled-componentsを実行したところ、本番環境でも読み込み時に一瞬の画面のチラツキが発生しました。

理由は初回レンダリングとReactのhydrationのタイミングにあると見ています。

例えばNext.jsの場合、

_document.tsx
import Document, {
  DocumentContext,
  Head,
  Html,
  Main,
  NextScript
} from "next/document";
import { ServerStyleSheet } from "styled-components";

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

    try {
      ctx.renderPage = () => {
        return originalRenderPage({
          enhanceApp: (App) => {
            /// ここでレンダリングされるHTMLを読み込み、styled-componentsの使用されるclassを集める
            return (props) => sheet.collectStyles(<App {...props}>)
          }
        });
      };

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        /// ここでstyleタグをレンダリング対象に含める
        styles: (
          <Fragment>
            {initialProps.styles}
            {sheet.getStyleElemet()}
          </Fragment>
        )
      }
    } finally {
      sheet.seal();
    }
  }

  render() {
    return (
      <Html>
        <Head></Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

のように記述することで問題なく実行されます。
これで動作するのは、あくまでこの_document.tsxが初回読み込み時のみ実行され、React.hydrate()の対象では無いからです。


ではRemixではどうでしょうか。
公式の方法は下記です。

app/StylesContext.tsx
import { createContext } from "react";

export default createContext<null | string>(null);
app/entry.server.tsx
import ReactDOMServer from "react-dom/server";
import type { EntryContext } from "remix";
import { RemixServer } from "remix";
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import StylesContext from "./StylesContext";

export default function handleRequest({
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
}) {
  const sheet = new ServerStyleSheet();

  renderToString(
    /// ここで一度レンダリングを実行してstyled-componentsの使用しているclassを集める
    sheet.collectStyles(
      <StylesContext.Provider value={null}>
        <RemixServer context={remixContext} url={request.url} />
      </StylesContext.Provider>
    )
  );
  
  /// styleタグを取得
  const styles = sheet.getStyleTags();
  sheet.seal();

  /// ここで実際のHTMLを取得、そのときにcontextにstyleタグを渡す。
  const markup = ReactDOMServer.renderToString(
    <StylesContext.Provider value={styles}>
        <RemixServer context={remixContext} url={request.url} />
    </StylesContext.Provider>
  );

  responseHeaders.set("Content-Type", "text/html");
  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: responseHeaders
  });
}
app/root.tsx
import { Meta, Scripts, Outlet } from "remix";
import { useContext } from "react";
import StylesContext from "./StylesContext";

export default function Root() {
  /// ここでstyleタグを取得
  const styles = useContext(StylesContext);

  return (
    <html>
      <head>
        <Meta />
        {styles}
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  )
}

このコード上は本番環境でも動作します。
しかし、app/entry.client.tsxを見ると

app/entry.client.tsx
import { hydrate } from "react-dom";
import { RemixBrowser } from "remix";

hydrate(<RemixBrowser />, document);

RemixではhtmlタグからReactでハンドリングしているため、documentに直接マウントします。
しかし、hydrateする対象には先程のRootコンポーネントは含まれるがStylesContextは含まれません。
これにより、

  1. RootコンポーネントでのuseContextの値がnullとなり、結果的にサーバー側とのレンダリングの齟齬が発生します。
  2. 最初のサーバー側でのレンダリング時にはtext/htmlで送信していますが、そのHTMLを構成するReactではcontextから取得したstylesは文字列として扱われます。

の2点の問題が発生し、不要なレンダリングと一瞬の画面のチラツキ(`<style data-styled></style>が文字列としてレンダリングされることに起因します。)が発生します。

対処法

styled-componentsを始めとしたCSS in JSはReact界隈では非常に人気があるため、この問題は解消される可能性が高いです。
が、現時点で私か試した解決法だけ残します。

app/StylesContext.tsx
import { createContext, ReactElement } from "react";

type Value = {
  elements: ReactElement[];
  tags: string | null;
}

export default createContext<Value>({
  elements: [],
  tags: null
});
app/entry.server.tsx
import ReactDOMServer from "react-dom/server";
import type { EntryContext } from "remix";
import { RemixServer } from "remix";
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import StylesContext from "./StylesContext";

export default function handleRequest({
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
}) {
  const sheet = new ServerStyleSheet();

  renderToString(
    sheet.collectStyles(
      <StylesContext.Provider value={{
        elements: [],
        tags: null
      }}>
        <RemixServer context={remixContext} url={request.url} />
      </StylesContext.Provider>
    )
  );
  
  /// styleのタグとReactElementを両方取得
  const styleTags = sheet.getStyleTags();
  const styleElements = sheet.getStyleElement();
  sheet.seal();

  const markup = ReactDOMServer.renderToString(
    <StylesContext.Provider value={{
      elements: styleElements,
      tags: styleTags
    }}>
        <RemixServer context={remixContext} url={request.url} />
        {/** body内のscriptにReactElementオブジェクトを埋め込む */}
        <script
          id="__SERVER_STYLESHEET" 
          type="application/json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(css.styleElements)
          }}
        />
    </StylesContext.Provider>
  );

  responseHeaders.set("Content-Type", "text/html");
  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: responseHeaders
  });
}
app/root.tsx
export default function Root() {
  /// contextを確認
  const styles = useContext(StylesContext);
  
  const elements = useMemo(() => {
    if (typeof window !== "undefined") {
      /// クライアント側のレンダリング = contextが存在しない場合
      /// server.tsxで埋め込んだscriptからReactElemntを取り出す
      const json = document.querySelector("script#__SERVER_STYLESHEET")?.innderHTML;
      const styleElements = json && JSON.parse(json) as ReactElement[] | undefined;

      return styleElemens;
    }
    /// サーバー側のレンダリング = stylesが存在する
    return styles.elements;
  }, [styles.elements]);

  return (
    <html lang="ja" data-package-env={env}>
      <head>
        <meta charSet="utf-8" />
        <meta name="X-UA-Compatible" content="IE=edge,chrome=1" />
        <meta
          name="viewport"
          content="width=device-width,initial-scale=1,viewport-fit=cover"
        />
        <Meta />
        <Links />
        {elements?.map((el) => {
          /// ReactElementとして出力することで文字列としてレンダリングされるのを防ぐ
          /// また、data-styledの値がサーバーとクライアントで一致しないため警告が出るため、
          /// 必要ならsuppressHydrationWarningをtrueに設定する
          return (
            <style key={el.key} {...el.props} suppressHydrationWarning />
          )
        })}
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}

これで画面のチラツキなく動作しますね!
ただあまりにも力技なため、他に対処法をご存知の方はぜひご教授ください。

また、公式で対応が入るのも楽しみにしています。

以上です。

Discussion