RemixでCSS in JSを扱う覚書
個人的備忘録を兼ねて...
Remixは先日メジャーバージョンがリリースされたばかりのReact+Node.js製フルスタックフレームワークです。
特徴としては、
- 他言語と異なりフロントエンドまで扱えるJavascript or Typescriptを使用
- ブラウザ機能をなんでもいじれちゃうReactで無茶しないための緩めの制約
- HTMLの持つweb標準のAPIを駆使した、Javascript無効の環境でも動くサーバーサイドアプリケーション
などが挙げられます。
Next.jsと比較されているようですが、SSG(静的サイトジェネレータ)指向のNext.jsとは異なり、RailsやDjangoなどと同様サーバーサイド→HTML→Javascript&CSSという実行順序を崩さない硬派なフレームワークです。
そこにReactの持つ型システムを組み込むことで、よりテスタブルかつ安全な開発ができる仕様となっています。
本題
Remixの使い方ではなくRemixでstyled-componentsなどのSSR対応のCSS in JSを使用するためのトピックを備忘録がてら残します。
使用するバージョンは
- Remix: ^1.0.6
となり、リアルタイムにアップデートされているため、将来的には当記事の内容で動作しなくなる可能性はあります。
公式のドキュメントより
公式でもCSS in JSの例は紹介さています。
ですが、私はこの方法でstyled-componentsを実行したところ、本番環境でも読み込み時に一瞬の画面のチラツキが発生しました。
理由は初回レンダリングとReactのhydrationのタイミングにあると見ています。
例えばNext.jsの場合、
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ではどうでしょうか。
公式の方法は下記です。
import { createContext } from "react";
export default createContext<null | string>(null);
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
});
}
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
を見ると
import { hydrate } from "react-dom";
import { RemixBrowser } from "remix";
hydrate(<RemixBrowser />, document);
Remixではhtml
タグからReactでハンドリングしているため、document
に直接マウントします。
しかし、hydrate
する対象には先程のRoot
コンポーネントは含まれるがStylesContext
は含まれません。
これにより、
-
Root
コンポーネントでのuseContext
の値がnull
となり、結果的にサーバー側とのレンダリングの齟齬が発生します。 - 最初のサーバー側でのレンダリング時には
text/html
で送信していますが、そのHTMLを構成するReactではcontextから取得したstyles
は文字列として扱われます。
の2点の問題が発生し、不要なレンダリングと一瞬の画面のチラツキ(`<style data-styled></style>が文字列としてレンダリングされることに起因します。)が発生します。
対処法
styled-componentsを始めとしたCSS in JSはReact界隈では非常に人気があるため、この問題は解消される可能性が高いです。
が、現時点で私か試した解決法だけ残します。
import { createContext, ReactElement } from "react";
type Value = {
elements: ReactElement[];
tags: string | null;
}
export default createContext<Value>({
elements: [],
tags: null
});
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
});
}
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