在 Modern.js 中,SSR 也是开箱即用的。开发者无需为 SSR 编写复杂的服务端逻辑,也无需关心 SSR 的运维,或是创建单独的服务。Modern.js 拥有完备的 SSR 降级策略,保证页面能够安全运行。
启用 SSR 非常简单,只需要设置 server.ssr
为 true
即可:
import { defineConfig } from '@modern-js/app-tools';
export default defineConfig({
"server": {
"ssr": true,
},
})
Modern.js 中提供了 Data Loader,方便开发者在 SSR、CSR 下同构的获取数据。每个路由模块,如 layout.tsx
和 page.tsx
都可以定义自己的 Data Loader:
export default () => {
return {
message: 'Hello World',
};
};
在组件中可以通过 Hooks API 的方式获取 loader
函数返回的数据:
import { useLoaderData } from '@modern-js/runtime/router'
export default () => {
const data = useLoaderData();
return <div>{data.message}</div>;
};
Modern.js 打破传统的 SSR 开发模式,提供了用户无感的 SSR 开发体验。并且提供了优雅的降级处理,一旦 SSR 请求失败,会自动降级在浏览器端重新发起请求。
不过,开发者仍然需要关注数据的兜底处理,例如 null
值或不符合预期的数据返回。避免在 SSR 时产生 React 渲染错误或是返回凌乱的渲染结果。
补充信息
当以客户端路由的方式请求页面时,Modern.js 会发送一个 HTTP 请求,服务端接收到请求后执行页面对应的 Data Loader 函数,然后将执行结果作为请求的响应返回浏览器。
使用 Data Loader 时,数据获取发生在渲染前,Modern.js 也仍然支持在组件渲染时获取数据。更多相关内容可以查看数据获取。 :::
有些业务中,通常需要根据当前的运行容器环境特征做不同的 UI 展示,例如 UA 信息。如果处理不够仔细,此时很有可能出现不符合预期的渲染结果。
这里通过一个例子,演示当 SSR 与 CSR 渲染不一致时出现的问题,在组件中添加以下代码:
{
typeof window !== 'undefined' ? <div>browser content</div> : null;
}
启动应用后,访问页面,会发现浏览器控制台抛出警告信息:
Warning: Expected server HTML to contain a matching <div> in <div>.
这是 React 在客户端执行 hydrate 逻辑时,发现渲染结果与 SSR 渲染结果不一致造成的。虽然页面表现正常,但在复杂应用中,很有可能因此出现 DOM 层级混乱、样式混乱等问题。
:::info 关于 hydrate (注水)逻辑请参考这里。
应用需要保持 SSR 与 CSR 渲染结果的一致性,如果存在不一致的情况,说明这部分内容无需在 SSR 中进行渲染。Modern.js 为这类在 SSR 中不需要渲染的内容提供 <NoSSR>
工具组件:
import { NoSSR } from '@modern-js/runtime/ssr';
在不需要进行 SSR 的元素外部,用 NoSSR
组件包裹:
<NoSSR>
<div>client content</div>
</NoSSR>
修改代码后,刷新页发现之前的 Waring 消失。打开浏览器开发者工具的 Network 窗口,查看返回的 HTML 文档是不包含 NoSSR
组件包裹的内容的。
补充信息
useRuntimeContext
可以获取完整的请求信息,可以利用它保证 SSR 与 CSR 的渲染结果一致。
警告
在 SSR 场景下,开发者需要特别关注内存泄露问题,即使是微小的内存泄露,在大量的访问后也会对服务造成影响。
SSR 时,浏览器的每次请求,都会触发服务端重新执行一次组件渲染逻辑。所以,需要避免在全局定义任何可能不断增长的数据结构,或在全局进行事件订阅,或创建不会被销毁的流。
例如以下代码,使用 redux-observable 时,习惯了 CSR 的开发者通常会在组件中这样编码:
/* 代码仅作为示例,不可运行 */
import { createEpicMiddleware, combineEpics } from 'redux-observable';
const epicMiddleware = createEpicMiddleware();
const rootEpic = combineEpics();
export default function Test() {
epicMiddleware.run(rootEpic);
return <div>Hello Modern.js</div>;
}
在组件外层创建 Middleware 实例 epicMiddleware
,并在组件内部调用 epicMiddleware.run
。
在浏览器端,这段代码不会造成任何问题,但是在 SSR 时,Middleware 实例会一直无法被销毁。每次渲染组件,调用 epicMiddleware.run(rootEpic)
时,都会在内部添加新的事件绑定,导致整个对象不断变大,最终对应用性能造成影响。
CSR 中这类问题不易被发觉,因此从 CSR 切换到 SSR 时,如果不确定应用是否存在这类隐患,可以对应用进行压测。
为了保持 SSR 阶段请求的数据,可以在浏览器端直接使用,Modern.js 会将渲染过程中收集的数据与状态注入到 HTML 内。但是,CSR 应用常常存在接口数据量大、组件状态未收敛的情况,这时如果直接使用 SSR,渲染得到的 HTML 体积可能会存在过大的问题。此时,SSR 不仅无法为应用带来用户体验上的提升,反而可能起到相反的作用。
因此,使用 SSR 时,开发者需要为应用做合理的瘦身:
Modern.js 提供 Serverless Pre-rendering (SPR) 这一特性来提升 SSR 性能。
SPR 利用预渲染与缓存技术,为 SSR 页面提供静态 Web 的响应性能。它让 SSR 应用拥有静态 Web 页面的响应速度与稳定性,同时还能保持数据的动态更新。
在 Modern.js 中使用 SPR 非常简单,只需要在组件中新增 PreRender
组件,该组件所在的页面就会自动开启 SPR。
这里模拟一个使用 useLoaderData
API 的组件,Data Loader 中的请求需要消耗 2s 时间。
import { useLoaderData } from '@modern-js/runtime/router';
export const loader = async () => {
await new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null);
}, 2000);
});
return {
message: 'Hello Modern.js',
};
};
export default () => {
const data = useLoaderData();
return <div>{data?.message}</div>;
};
执行 dev
命令后,打开页面,可以明显的察觉到页面需要等到 2s 后才返回。
接下来使用 PreRender
组件来进行优化,该组件可以直接从 @modern-js/runtime/ssr
中导出:
import { PreRender } from '@modern-js/runtime/ssr';
在路由组件内使用 PreRender
组件,并设置参数 interval
,用于表示该次渲染结果的过期时间为 5s:
<PreRender interval={5} />
修改后,执行 pnpm run build && pnpm run serve
启动应用,并打开页面。
首次打开时,和之前的渲染并没有什么不同,同样存在 2s 延迟。点击刷新,页面瞬间打开,但此时,页面数据并没有因为刷新发生变化,这是因为缓存还没有过期。
等待 5s,重新刷新页面,页面的数据仍然没有变化。再一次刷新页面数据发生变化,但是页面仍然几乎是瞬间响应的。 这是因为在之前的请求时,SPR 已经在后台异步获取了新的渲染结果,本次请求到的页面是已经缓存在服务器中的版本。
可以想象,当 interval
设置为 1 时,用户可以在感知到实时数据的同时,拥有静态页面的响应体验。
补充信息
PreRender
的详细使用可以参考这里。
开启 SSR 时,Modern.js 会用相同的入口,构建出 SSR Bundle 和 CSR Bundle 两份产物。因此,在 SSR Bundle 中存在 Web API,或是在 CSR Bundle 中存在 Node API 时,都可能导致运行出错。
在组件中引入 Web API,通常情况下是要做一些全局监听,或是获取浏览器相关的数据,例如:
document.addEventListener('load', () => {
console.log('document load');
});
const App = () => {
return <div>Hello World</div>;
};
export default App;
在组件文件中引入 Node API,通常情况下是因为使用了 Data Loader,例如:
import fse from 'fs-extra';
export const loader = () => {
const file = fse.readFileSync('./myfile');
return {
...
};
};
对于第一种情况,我们可以直接使用 Modern.js 内置的环境变量 MODERN_TARGET
进行判断,在构建时删除无用代码:
if (process.env.MODERN_TARGET === 'browser') {
document.addEventListener('load', () => {
console.log('document load');
});
}
NOTE
更多内容可以查看环境变量。
但例如第二种情况,Treeshaking 的方式并不能保证代码被完全分离。Modern.js 也支持通过 .node.
后缀的文件来区分 SSR Bundle 和 CSR Bundle 产物的打包文件。
例如在代码中引入了 fs-extra
,这时候直接引用到组件中,会造成 CSR 加载报错。可以创建同名的 .ts
和 .node.ts
文件做一层代理:
export const readFileSync: any = () => {};
export { readFileSync } from 'fs-extra';
在文件中直接引入 ./compat
,此时 SSR 环境下会优先使用 .node.ts
后缀的文件,CSR 环境下会使用 .ts
后缀的文件。
import { readFileSync } from './compat'
export const loader = () => {
const file = readFileSync('./myfile');
return {
...
};
};
上述两种方式,都会为开发者带来一些心智负担。Modern.js 基于嵌套路由开发设计了更简单的方案来分离 CSR 和 SSR 的代码。
在 SSR 中发起接口请求时,开发者有时自己封装了同构的请求工具。部分接口需要传递用户 Cookie,开发者可以通过 useRuntimeContext
API 获取到请求头来实现。
需要注意的是,此时获取到的是 HTML 请求的请求头,不一定适用于接口请求,因此千万不能透传所有请求头。并且,一些后端接口,或是通用网关,会根据请求头中的信息做校验,全量透传容易出现各种难以排查的问题,推荐按需透传。
如果实在需要透传所有请求头,请务必过滤 host
字段。
Modern.js 支持了 React 18 的流式渲染,可以通过如下配置启用:
import { defineConfig } from '@modern-js/app-tools';
export default defineConfig({
"server": {
"ssr": {
"mode": "stream",
},
},
})
Modern.js 的流式渲染基于 React Router 实现,主要涉及 API 有:
defer
:在 Data Loader 中使用,用于支持异步获取数据。Await
:用于渲染 Data Loader 返回的异步数据。useAsyncValue
:用于从最近的父级 Await
组件中获取数据。import { defer, type LoaderFunctionArgs } from '@modern-js/runtime/router';
interface User {
name: string;
age: number;
}
export interface Data {
data: User;
}
export default ({ params }: LoaderFunctionArgs) => {
const userId = params.id;
const user = new Promise<User>(resolve => {
setTimeout(() => {
resolve({
name: `user-${userId}`,
age: 18,
});
}, 200);
});
return defer({ data: user });
};
user
是一个 Promise 类型的对象,表示需要异步获取的数据,通过 defer
处理需要异步获取的 user
。注意,defer
必须接收一个对象类型的参数,
因此, 传入 defer
的参数为 {data: user}
。
defer
还可以同时接收异步数据和同步数据。例如:
// 省略部分代码
export default ({ params }: LoaderFunctionArgs) => {
const userId = params.id;
const user = new Promise<User>(resolve => {
setTimeout(() => {
resolve({
name: `user-${userId}`,
age: 18,
});
}, 200);
});
const otherData = new Promise<string>(resolve => {
setTimeout(() => {
resolve('some sync data');
}, 200);
});
return defer({
data: user,
other: await otherData
});
};
otherData
前加了 await
,所以是同步获取的数据,它可以和异步获取的数据 user
同时传入 defer
。
通过 Await
组件,可以获取到 Data Loader 中异步返回的数据,然后进行渲染。例如:
import { Await, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';
import type { Data } from './page.loader';
const Page = () => {
const data = useLoaderData() as Data;
return (
<div>
User info:
<Suspense fallback={<div id="loading">loading user data ...</div>}>
<Await resolve={data.data}>
{(user) => {
return (
<div id="data">
name: {user.name}, age: {user.age}
</div>
);
}}
</Await>
</Suspense>
</div>
);
};
export default Page;
Await
需要包裹在 Suspense
组件内部,Await
的 resolve
传入的是 Data Loader 异步获取的数据,当数据获取完成后,
通过 Render Props 模式,渲染获取到的数据。在数据的获取阶段,将展示
Suspense
组件 fallback
属性设置的内容。
注意
从 Data Loader 文件导入类型时,需要使用 import type 语法,保证只导入类型信息,这样可以避免 Data Loader 的代码打包到前端产物的 bundle 文件中。
所以,这里的导入方式为:import type { Data } from './page.loader'
;
也可以通过 useAsyncValue
获取 Data Loader 返回的异步数据。例如:
import { useAsyncValue } from '@modern-js/runtime/router';
// 省略部分代码
const UserInfo = () => {
const user = useAsyncValue();
return (
<div>
name: {user.name}, age: {user.age}
</div>
)
}
const Page = () => {
const data = useLoaderData() as Data;
return (
<div>
User info:
<Suspense fallback={<div id="loading">loading user data ...</div>}>
<Await resolve={data.data}>
<UserInfo />
</Await>
</Suspense>
</div>
);
};
export default Page;
Await
组件的 errorElement
属性,可以用来处理当 Data Loader 执行时,或者子组件渲染时抛出的错误。
例如,我们故意在 Data Loader 函数中抛出错误:
import { defer } from '@modern-js/runtime/router';
export default () => {
const data = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error occurs'));
}, 200);
});
return defer({ data });
};
然后通过 useAsyncError
获取错误,并将用于渲染错误信息的组件赋值给 Await
组件的 errorElement
属性:
import { Await, useAsyncError, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';
export default function Page() {
const data = useLoaderData();
return (
<div>
Error page
<Suspense fallback={<div>loading ...</div>}>
<Await resolve={data.data} errorElement={<ErrorElement />}>
{(data: any) => {
return <div>never displayed</div>;
}}
</Await>
</Suspense>
</div>
);
}
function ErrorElement() {
const error = useAsyncError() as Error;
return <p>Something went wrong! {error.message}</p>;
}