首先我们先了解下,以及是否符合我们的业务场景,再决定是否需要使用。
服务端渲染(Server-Side Rendering),是指由服务侧完成页面的 HTML 结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。
这么讲可能比较学术,那通过两张图来更容易地说清楚。
第一张,单页应用(SPA)和服务端渲染过的(SSR)站点在社交分享时的区别:
第二张,白屏时间上 SSR 较少,因为当 HTML 文档返回时,已经有对应的内容。(见 Network)
综上两图可知,SSR 常用于以下两个场景:
也就是说,如果你是中后台应用(如 antd pro、管理后台等),请谨慎考虑是否使用 SSR。
服务端渲染,首先得有后端服务器(一般是 Node.js)才可以使用,如果我没有后端服务器,也想用在上面提到的两个场景,那么推荐使用预渲染。
预渲染与服务端渲染唯一的不同点在于渲染时机,服务端渲染的时机是在用户访问时执行渲染(即实时渲染,数据一般是最新的),预渲染的时机是在项目构建时,当用户访问时,数据不一定是最新的(如果数据没有实时性,则可以直接考虑预渲染)。
预渲染(Pre Render)在构建时执行渲染,将渲染后的 HTML 片段生成静态 HTML 文件。无需使用 web 服务器实时动态编译 HTML,适用于静态站点生成。
早在 Umi 2.8+ 版本时,Umi 已具备 SSR 能力,只是使用上对新手而言,门槛较高。
Umi 3 结合自身业务场景,在 SSR 上做了大量优化及开发体验的提升,具有以下特性:
umi dev
即 SSR 预览,开发调试方便。dynamicImport
开启后,Umi 3 中会根据不同路由加载对应的资源文件(css/js)。ssr
和 exportStatic
,在 umi build
构建时会编译出渲染后的 HTML。ssr: { mode: 'stream' }
即可开启流式渲染,流式 SSR 较正常 SSR 有更少的 TTFB(发出页面请求到接收到应答数据第一个字节所花费的毫秒数) 时间。默认情况下,服务端渲染功能是关闭的,你需要在使用之前通过配置开启:
export default {ssr: {},}
执行 umi dev
,访问页面,即是服务端渲染后的。
如果与后端框架在开发模式下一起使用时,可通过配置来关闭 umi dev
下的服务端渲染行为:
export default {ssr: {// 默认为 truedevServerRender: false,},}
服务端渲染的数据获取方式与 SPA(单页应用)有所不同,为了让客户端和服务端都能获取到同一份数据,我们提供了 页面级数据 预获取。
每个页面可能有单独的数据预获取逻辑,这里我们会获取页面组件上的 getInitialProps
静态方法,执行后将结果注入到该页面组件的 props
中,例如:
// pages/index.tsximport { IGetInitialProps } from 'umi';import React from 'react';const Home = (props) => {const { data } = props;return ({/* <div>Hello World</div> */}<div>{data.title}</div>)}Home.getInitialProps = (async (ctx) => {return Promise.resolve({data: {title: 'Hello World',}})}) as IGetInitialProps;/** 同时也可以使用 class 组件class Home extends React.Component {static getInitialProps = (async (ctx) => {return Promise.resolve({data: {title: 'Hello World',}})}) as IGetInitialPropsrender() {const { data } = props;return (<div>{data.title}</div>)}}*/export default Home;
getInitialProps
中有几个固定参数:
match
: 与客户端页面 props 中的 match
保持一致,有当前路由的相关数据。isServer
:是否为服务端在执行该方法。route
:当前路由对象history
:history 对象为了结合数据流框架,我们提供了 modifyGetInitialPropsCtx
方法,由插件或应用来扩展 ctx
参数,以 dva
为例:
// plugin-dva/runtime.tsexport const ssr = {modifyGetInitialPropsCtx: async (ctx) => {ctx.store = getApp()._store;},}
然后在页面中,可以通过获取到 store
:
// pages/index.tsxconst Home = () => <div />;Home.getInitialProps = async (ctx) => {const state = ctx.store.getState();return state;}export default Home;
同时也可以在自身应用中进行扩展:
// app.(ts|js)export const ssr = {modifyGetInitialPropsCtx: async (ctx) => {ctx.title = 'params';return ctx;}}
同时可以使用 getInitialPropsCtx
将服务端参数扩展到 ctx
中,例如:
app.use(async (req, res) => {// 或者从 CDN 上下载到 server 端// const serverPath = await downloadServerBundle('http://cdn.com/bar/umi.server.js');const render = require('./dist/umi.server');res.setHeader('Content-Type', 'text/html');const context = {};const { html, error, rootContainer } = await render({// 有需要可带上 querypath: req.url,context,getInitialPropsCtx: {req,},});})
在使用的时候,就有 req
对象,不过需要注意的是,只在服务端执行时才有此参数:
Page.getInitialProps = async (ctx) => {if (ctx.isServer) {// console.log(ctx.req);}return {};}
则在执行 getInitialProps
方法时,除了以上两个固定参数外,还会获取到 title
和 store
参数。
关于 getInitialProps
执行逻辑和时机,这里需要注意:
forceInitial
,首屏不触发 getInitialProps
,切换页面时会执行请求,和客户端渲染逻辑保持一致。forceInitial
,无论是首屏还是页面切换,都会触发 getInitialProps
,目的是始终以客户端请求的数据为准。(有用在静态页面站点的实时数据请求上)getInitialProps
静态方法,则会执行该方法。执行 umi build
,除了正常的 umi.js
外,会多一个服务端文件: umi.server.js
(相当于服务端入口文件,类比浏览器加载 umi.js 客户端渲染)
- dist- umi.js- umi.css- index.html+ - umi.server.js
然后在后端框架中,引用该文件:
// Expressapp.use(async (req, res) => {// 或者从 CDN 上下载到 server 端// const serverPath = await downloadServerBundle('http://cdn.com/bar/umi.server.js');const render = require('./dist/umi.server');res.setHeader('Content-Type', 'text/html');const context = {};const { html, error, rootContainer } = await render({// 有需要可带上 querypath: req.url,context,// 可自定义 html 模板// htmlTemplate: defaultHtml,// 启用流式渲染// mode: 'stream',// html 片段静态标记(适用于静态站点生成)// staticMarkup: false,// 扩展 getInitialProps 在服务端渲染中的参数// getInitialPropsCtx: {},// manifest,正常情况下不需要});// support stream contentif (content instanceof Stream) {html.pipe(res);html.on('end', function() {res.end();});} else {res.send(res);}})
render
方法参数和返回值如下:
参数:
{// 渲染页面路由,支持 `base` 和带 query 的路由,通过 umi 配置path: string;// 可选,初始化数据,传透传到 getInitialProps 方法的参数中initialData?: object;// 可选,html 模板,这里可自定义模板,默认是用 umi 内置的 htmlhtmlTemplate?: string;// 可选,页面内容挂载节点,与 htmlTemplate 配合使用,默认为 rootmountElementId?: string;// 上下文数据,可用来标记服务端渲染页面时的状态context?: object// ${protocol}://${host} 扩展 location 对象origin?: string;}
返回值:
{// html 内容,服务端渲染错误后,会返回原始 htmlhtml?: string | Stream;// 挂载节点中的渲染内容(ssr 渲染实际上只是渲染挂载节点中的内容),同时你也可以用该值来拼接自定义模板rootContainer: string | Stream;// 错误对象,服务端渲染错误后,值不为 nullerror?: Error;}
目前做了两个示例分别是基于koa和egg的,示例内置dva数据流和国际化解决方案,代码部分有注释,可参照进行个性化的修改。
Umi 3 默认移除了 DOM/BOM 浏览器 API 在 Node.js 的 polyfill,如果应用确实需要 polyfill 一些浏览器对象,可以使用 beforeRenderServer
运行时事件 API 进行扩展
// app.tsexport const ssr = {beforeRenderServer: async ({env,location,history,mode,context,}) => {// global 为 Node.js 下的全局变量// 避免直接 mock location,这样会造成一些环境判断失效global.mockLocation = location;// 国际化if (location.pathname.indexOf('zh-CN') > -1) {global.locale = 'zh-CN'}}}
完美兼容客户端动态加载,配置如下:
// .umirc.tsexport default {ssr: {},dynamicImport: {},}
使用动态加载后,启动和构建会自动开启 manifest 配置,并在产物目录中生成 asset-manifest.json
做资源映射,并自动将页面对应的资源注入到 HTML 中,避免开启动态加载后,页面首屏闪烁的问题。
- dist- umi.server.js- asset-manifest.json
则页面返回的 HTML 将增加对应 chunk(资源):
<!DOCTYPE html><html><head><link rel="stylesheet" href="/umi.css" />+ <link rel="stylesheet" href="/p__index.chunk.css" /></head></html>
提供开箱即用的流式渲染功能,开启方式:
export default {ssr: {mode: 'stream',},}
通过 exportStatic
结合 ssr
开启预渲染
export default {ssr: {},exportStatic: {},}
执行 umi build
,会将输出渲染后的 HTML
预渲染默认情况下不会渲染动态路由里的所有页面,如果需要渲染动态路由中的页面,通过配置 extraRoutePaths
,例如:
export default {ssr: {},exportStatic: {+ extraRoutePaths: async () => {+ // const result = await request('https://your-api/news/list');+ return Promise.resolve(['/news/1', 'news/2']);+ }},routes: [{path: '/',component: '@/layout',routes: [{ path: '/', component: '@/pages/index' },{ path: '/news', component: '@/pages/news' },{ path: '/news/:id', component: '@/pages/news/detail' }]}]}
则会生成以下产物:
- dist- umi.js- umi.css- index.html- news- :id- index.html+ - 1+ - index.html+ - 2+ - index.html- index.html
默认情况下,预渲染后会删除
umi.server.js
服务端入口文件,如果需要保留,可使用变量RM_SERVER_FILE=none
来保留umi.server.js
。
@umijs/preset-react 插件集中已内置对标题的渲染,通过以下步骤使用:
安装:
$ yarn add @umijs/preset-react
在页面中,即直接可以渲染标题:
// pages/bar.tsximport React from 'react';import { Helmet } from 'umi';export default props => {return (<>{/* 可自定义需不需要编码 */}<Helmet encodeSpecialCharacters={false}><html lang="en" data-direction="666" /><title>Hello Umi Bar Title</title></Helmet></>);};
dumi:基于 Umi、为组件开发场景而生的文档工具,Umi 官网文档即使用 dumi 编写并结合预渲染,让文档内容具备 SEO,可使用源代码查看,开启方法:
export default {ssr: {},exportStatic: {},}
@umijs/preset-react 插件集中已内置 dva,通过以下步骤使用:
$ yarn add @umijs/preset-react
开启 dva,并在 models
目录下创建 dva model:
export default {ssr: {},dva: {}}
这时候 getInitialProps(ctx)
中的 ctx
就会有 store
属性,可执行 dispatch
,并返回初始化数据。
Page.getInitialProps = async (ctx) => {const { store } = ctx;store.dispatch({type: 'bar/getData',});return store.getState();}
Umi 同时支持对服务端和客户端包大小的分析
# 服务端包大小分析$ ANALYZE_SSR=1 umi build# 客户端包大小分析$ ANALYZE=1 umi build
SSR 因为会在服务端执行 render 渲染方法,而服务端没有 DOM/BOM 变量和方法,为解决这类问题,提供以下几个方法:
componentDidMount
、useEffect
中(服务端不会执行),避免服务端执行时报错,例如:import React from 'react';export default () => {- window.alert(1);React.useEffect(() => {+ window.alert(1);}, []);return (<div>Hello</div>)}
isBrowser
方法做环境判断,例如:import React from 'react';+ import { isBrowser } from 'umi';export default () => {- window.alert(1);+ if (!isBrowser()) {window.alert(1);+ }return (<div>Hello</div>)}
3.如果是第三方库可以通过 umi 提供的 dynamic
动态加载组件
import React from 'react';import { dynamic } from 'umi';const renderLoading = () => <p>组件动态加载中...</p>export default dynamic({loader: async () => {// 动态加载第三方组件const { default: DynamicComponent } = await import(/* webpackChunkName: "dynamic-component" */ 'dynamic-component');return DynamicComponent;},loading: () => renderLoading(),});
避免ssr渲染时报 did not match.
警告,使用时候ssr应当渲染相同loading
组件
import React from 'react';import { isBrowser } from 'umi';import DynamicComponent from 'DynamicComponent';export default () => {if(isBrowser()) return <DynamicComponent />return renderLoading()}
因为 react-helmet 暂不支持 stream 渲染,如果使用 Helmet ,请使用 mode: 'string'
方式渲染。nfl/react-helmet#322
首先,antd pro 作为中后台项目,没有 SEO 需求,不适合做服务端渲染; 从技术角度来讲,antd pro 在 render 里大量使用 DOM/BOM 方法,服务端渲染将 DOM/BOM 操作改至副作用(useEffect
或 componentDidMount
周期中),可以给 antd pro 提 PR。
alias
,如果做 external 会做大量模块的路径映射,真正在服务端使用时,会出现某些包路径不对,加载不了的报错综合考虑,Umi 3 SSR 不会对服务端文件(umi.server.js
)做 external。
Prop
dangerouslySetInnerHTML did not match.
报错只有 div
标签 dangerouslySetInnerHTML
属性才能被 SSR 渲染,正常的写法应该是:
- <p dangerouslySetInnerHTML={{ __html: '<p>Hello</p>' }} />+ <div dangerouslySetInnerHTML={{ __html: '<p>Hello</p>' }} />
查看网页源代码,如果 <div id="root">
DOM 里的元素不为空,则是 SSR,否则为 CSR。