在 remix 使用 @mui/material 主要得克服 SSR 與 CSR 結果不同步的問題,也要確保專案打包後能正常運作。這篇筆記是在參考 @mui 和 remix 的幾個官方範例後兜出來的解法。畢竟 @mui 的 Menu 跟 Dialog 用起來最順手最香 ⋯⋯🫠
流程
- 建立 remix 專案:執行
npx create-remix@latest,如果沒有專案內沒有app/entry.client.tsx和app/entry.server.tsx就追加執行npx remix reveal - 安裝 @mui 相關內容:執行
npm i @mui/material @emotion/react @emotion/styled - 新增
app/mui/createEmotionCache.ts與app/mui/theme.ts - 更新
app/entry.client.tsx/app/entry.server.tsx/vite.config.ts
原始碼與相關注意事項
完整的示範 repo 可參考 tzynwang/remix-mui。
app/mui/createEmotionCache.ts
import createCache from "@emotion/cache";
const cache = createCache({ key: "css" });
export default cache;注意 createCache 的參數 key 如果設定成 'css' 以外的值會造成 @mui 樣式問題,推測是因為這個 key 也會用於 renderStylesToString 和 renderStylesToNodeStream:
@emotion/cache: It will also be set as the value of the
data-emotionattribute on the style tags that emotion inserts and it’s used in the attribute name that marks style elements inrenderStylesToStringandrenderStylesToNodeStream.
但從文件上看不出來要怎麼調整這兩個功能的 key 的值。總之,為了讓 @mui 的樣式能正常運作,請將 key 的值固定為 'css'。
app/mui/theme.ts
在這裡根據需求設定 @mui 的預設樣式。如果預計用其他套件(比如 tailwind)管理樣式的話,這裡直接呼叫 createTheme() 取得 @mui 預設的 theme 物件即可。
import { createTheme } from "@mui/material/styles";
const theme = createTheme();
export default theme;app/entry.client.tsx
重點:對 RemixBrowser 包覆 @emotion 的 CacheProvider 與 @mui 的 ThemeProvider。
import { CacheProvider } from "@emotion/react";import { ThemeProvider } from "@mui/material/styles";import { RemixBrowser } from "@remix-run/react";import { startTransition, StrictMode } from "react";import { hydrateRoot } from "react-dom/client";import emotionCache from "./mui/createEmotionCache";import theme from "./mui/theme";
startTransition(() => { hydrateRoot( document, <CacheProvider value={emotionCache}> <ThemeProvider theme={theme}> <StrictMode> <RemixBrowser /> </StrictMode> </ThemeProvider> </CacheProvider>, );});app/entry.server.tsx
重點:類似在 app/entry.client.tsx 的改動,要對 RemixBrowser 包覆 CacheProvider 與 ThemeProvider
import { PassThrough } from "node:stream";import { CacheProvider } from "@emotion/react";import createEmotionServer from "@emotion/server/create-instance";import { ThemeProvider } from "@mui/material/styles";import type { EntryContext } from "@remix-run/node";import { createReadableStreamFromReadable } from "@remix-run/node";import { RemixServer } from "@remix-run/react";import { isbot } from "isbot";import { renderToPipeableStream } from "react-dom/server";import emotionCache from "./mui/createEmotionCache";import theme from "./mui/theme";
const ABORT_DELAY = 5_000;
export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext,) { return isbot(request.headers.get("user-agent") || "") ? handleBotRequest( request, responseStatusCode, responseHeaders, remixContext, ) : handleBrowserRequest( request, responseStatusCode, responseHeaders, remixContext, );}
function handleBotRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext,) { return new Promise((resolve, reject) => { let shellRendered = false; const { pipe, abort } = renderToPipeableStream( // wrap emotion/mui provider here <CacheProvider value={emotionCache}> <ThemeProvider theme={theme}> <RemixServer context={remixContext} url={request.url} /> </ThemeProvider> </CacheProvider>, { onAllReady() { shellRendered = true; const body = new PassThrough(); const stream = createReadableStreamFromReadable(body); responseHeaders.set("Content-Type", "text/html"); resolve( new Response(stream, { headers: responseHeaders, status: responseStatusCode, }), ); pipe(body); }, onShellError(error: unknown) { reject(error); }, onError(error: unknown) { responseStatusCode = 500; if (shellRendered) { console.error(error); } }, }, ); setTimeout(abort, ABORT_DELAY); });}
function handleBrowserRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext,) { return new Promise((resolve, reject) => { let shellRendered = false; const { pipe, abort } = renderToPipeableStream( // wrap emotion/mui provider here <CacheProvider value={emotionCache}> <ThemeProvider theme={theme}> <RemixServer context={remixContext} url={request.url} /> </ThemeProvider> </CacheProvider>, { onShellReady() { shellRendered = true; const body = new PassThrough(); const stream = createReadableStreamFromReadable(body); responseHeaders.set("Content-Type", "text/html"); resolve( new Response(stream, { headers: responseHeaders, status: responseStatusCode, }), ); pipe(body); }, onShellError(error: unknown) { reject(error); }, onError(error: unknown) { responseStatusCode = 500; if (shellRendered) { console.error(error); } }, }, );
setTimeout(abort, ABORT_DELAY); });}vite.config.ts
重點:在執行 isSsrBuild(即執行預設 npm run build)時,不要排除 @mui 相關內容;但在一般開發(npm run dev)時不做任何處理
import { vitePlugin as remix } from "@remix-run/dev";import { defineConfig } from "vite";import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig(({ isSsrBuild }) => ({ ssr: { noExternal: isSsrBuild ? [/^@mui\/*/] : undefined, }, plugins: [ remix({ future: { v3_fetcherPersist: true, v3_relativeSplatPath: true, v3_throwAbortReason: true, v3_singleFetch: true, v3_lazyRouteDiscovery: true, }, }), tsconfigPaths(), ],}));備註
在 @mui 提供的 remix 範例中,專案的根目錄有一個 remix.config.js 檔,並設定 serverModuleFormat: 'cjs',但我在測試時發現這個設定不會讓「執行 npm run start 時的噴錯」消失 🤷