如何指南:在 remix 專案使用 @mui/material
在 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-emotion
attribute on the style tags that emotion inserts and it’s used in the attribute name that marks style elements inrenderStylesToString
andrenderStylesToNodeStream
.
但從文件上看不出來要怎麼調整這兩個功能的 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 { 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 { PassThrough } from 'node:stream';
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
時的噴錯」消失 🤷