Remix 首屏数据注入的 HACK 方式
对于一个 SSR 应用,在服务端渲染的时候都会去请求数据然后才能在服务端就渲染出含数据的结构,并且会把数据脱水到 html 上。一般来说,每一个路由都可以去注入一些数据,以便服务端渲染。在 Remix 中,它提供了 loader 和 useLoaderData 去做这件事情。
例如:在 /posts
中,使用如下代码。
// routes/posts.tsx
import { LoaderFunction, useLoaderData } from 'remix'
export const loader: LoaderFunction = async () => {
return [{ title: 'Hello World', body: 'Hello World' }]
}
export default function Posts() {
const data = useLoaderData<{ title: string; body: string }[]>()
console.log(data)
return data.map(({ title, body }) => (
<div key={title}>
<h1>{title}</h1>
<p>{body}</p>
</div>
))
}
LoaderData 中的数据在服务端渲染就被生成,DOM 结构在服务端就被确定。
但是这只是单个路由的,如何让每个路由都能获取到一份全局共享的数据呢,也就是说入口数据,首屏加载时都会被用到的数据。你总不能每个路由都去写一遍吧。
首先想到的是 Remix 有个 Root.tsx 文件,这个一个入口文件。我尝试在这里和上面一样增加一个 Loader,然后通过 ReactContext 方式去共享。
// test.context.tsx
import { AggregateRoot } from '@mx-space/api-client'
import { createContext, useContext } from 'react'
export const RootDataContext = createContext<{ data: AggregateRoot | null }>({
data: null,
})
export const useRootDataContext = () => useContext(RootDataContext)
// Root.tsx
import {
Links,
LiveReload,
LoaderFunction,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from 'remix'
import type { MetaFunction } from 'remix'
import { RootDataContext } from './test.context'
export const meta: MetaFunction = () => {
return { title: 'New Remix App' }
}
export const loader: LoaderFunction = async () => {
return {
title: 'Root title',
message: 'this is root data injection',
nest: {
message: 'this is nest object in root',
},
}
}
export default function App() {
const data = useLoaderData()
return (
<RootDataContext.Provider value={{ data }}>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
</RootDataContext.Provider>
)
}
这样的话,在子路由中的确可以拿到数据,通过 useContext,但是在服务端就拿不到,如果去访问 nest object 的话就直接报错了。因为整个 context 的值是 undefined。
后来想到,可以学习框架 SSR 数据注入的方式去 HACK。一样是通过 Context 实现。这次 Root.tsx 不需要修改,只要修改 entry.client.tsx 和 entry.server.tsx 就行了。在 entry.server.tsx 我们需要去获取数据,这是在服务端就进行的。然后把数据挂载到 Context 上,这部分 Context 是为服务端渲染准备的,并不能作用到浏览器端。所以我们还需要把数据序列化挂载到 DOM 上,代码如下。
import { renderToString } from 'react-dom/server'
import type { EntryContext } from 'remix'
import { RemixServer } from 'remix'
import { RootContext } from './context'
import { client } from './utils/client'
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
const aggregate = await client.aggregate.getAggregateData() // fetch data
const markup = renderToString(
<>
{/* mount data */}
<RootContext.Provider value={{ data: aggregate }}>
{/* serialize data into dom */}
<script
id="initial-data"
dangerouslySetInnerHTML={{ __html: `${JSON.stringify(aggregate)}` }}
type="application/json"
></script>
<RemixServer context={remixContext} url={request.url} />,
</RootContext.Provider>
</>,
)
responseHeaders.set('Content-Type', 'text/html')
return new Response('<!DOCTYPE html>' + markup, {
status: responseStatusCode,
headers: responseHeaders,
})
}
在 entry.client.tsx 只需要用同一个 Context 再次注入数据即可。注意需要把 Context 单独抽离一个文件,不能单独写在 entry.[client|server].tsx 下。
// entry.client.tsx
import { hydrate } from 'react-dom'
import { RemixBrowser } from 'remix'
import { RootContext } from './context'
hydrate(
<RootContext.Provider
value={{
data: JSON.parse(document.getElementById('initial-data')!.innerHTML),
}}
>
<RemixBrowser />
</RootContext.Provider>,
document,
)
这里主要是去找到之前注入的 JSON 数据再次 parse 之后,直接附加到 Context ,浏览器这边就也能获取到初始数据了。
然后各路由使用 useRootData 就能使用这个数据了。
// routes/data.tsx
import { useRootContext } from '~/context'
export default function Index() {
const { data } = useRootContext()
return <pre>{JSON.stringify(data)}</pre>
}