让我们仔细研究下这个能让你用 React 构建通用应用程序的特性吧。
服务端渲染(以下简称 SSR)是前端框架在后端系统上运行时渲染。如果一个应用程序在服务端和客户端都可以渲染,那么它被称作通用应用程序(universal app)。
为何需要 SSR 呢?
我们应该先了解 Web 应用程序在过去 10 年的发展历程,这有助于我们理解这个问题。
这与 单页应用(以下简称 SPA)的兴起密切相关。与传统的服务端渲染应用相比,SPA 在速度和用户体验方面具有巨大优势。
但问题随之而来。SPA 的初始服务端请求,通常返回带有一组 CSS 和 JavaScript(JS)链接的空 HTML 文件,然后需要提取外部文件以呈现相关标记。
这意味着用户将等待更长的时间才能进行初始渲染,也意味着爬虫可能会将你的页面解析为空白。
因此,其解决方法是首先在服务端渲染应用,然后在客户端使用 SPA。
SSR + SPA = 通用应用
你可能会在其他文章中看到“同构应用(isomorphic app)”这个词,这跟“通用应用(universal app)”是一回事。
现在,用户不必等待 JS 加载,并能在初始请求返回响应后,立即获得完全渲染的 HTML。
试想一下,这能给使用 3G 网络的用户带来多么大的便利。你几乎可以立即在屏幕上获取内容,而不必浪费 20 多秒等它加载完成。
现在,所有发至服务器的请求都将返回完全渲染后的 HTML,这对 SEO 部门来说是个好消息!
爬虫会像索引网络上的其他静态网站那样,索引你在服务器上呈现的所有内容。
综上所述,SSR 有两个主要优点:
- 更快的初始页面渲染
- 完全可索引的 HTML 页面
逐步理解 SSR
我们采取迭代的方法来构建完整的 SSR 示例,从用于服务端渲染的 React API 开始,逐步添加内容。
你可以 follow 这个仓库并查看为每个构建步骤定义的标签。
基本设置
首先,为了使用 SSR,我们需要一台服务器。我们将使用一个简单的 Express 应用来渲染 React 应用程序。
import express from "express";
import path from "path";
import React from "react";
import { renderToString } from "react-dom/server";
import Layout from "./components/Layout";
const app = express();
app.use( express.static( path.resolve( __dirname, "../dist" ) ) );
app.get( "/*", ( req, res ) => {
const jsx = ( <Layout /> );
const reactDom = renderToString( jsx );
res.writeHead( 200, { "Content-Type": "text/html" } );
res.end( htmlTemplate( reactDom ) );
} );
app.listen( 2048 );
function htmlTemplate( reactDom ) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>React SSR</title>
</head>
<body>
<div id="app">${ reactDom }</div>
<script src="./app.bundle.js"></script>
</body>
</html>
`;
}
在第十行,我们指定了 Express 要服务的位于输出文件夹中的静态文件。
我们创建了一个处理所有非静态请求的路由,该路由将返回渲染完毕的 HTML。
我们使用 renderToString
(第 13 到 14 行)来将起始 JSX 转化成要插入到 HTML 模板中的 string
。
请注意,我们在客户端和服务端代码中使用了相同的 Babel 插件,因此 JSX 和 ES Modules 可以在 server.js
中运行。
客户端上相应的函数目前是 ReactDOM.hydrate
,该函数将使用服务端渲染的 React 应用程序,并将附加事件处理程序。
import ReactDOM from "react-dom";
import Layout from "./components/Layout";
const app = document.getElementById( "app" );
ReactDOM.hydrate( <Layout />, app );
你可以查看仓库中的 basic
标签来浏览完整示例。
搞定了,你刚刚创建了第一个服务端渲染的 React 应用!
React 路由
坦白讲,该应用的功能还不够丰富。所以我们添加一些路由,来看看如何处理服务端部分。
import { Link, Switch, Route } from "react-router-dom";
import Home from "./Home";
import About from "./About";
import Contact from "./Contact";
export default class Layout extends React.Component {
/* ... */
render() {
return (
<div>
<h1>{ this.state.title }</h1>
<div>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
</div>
<Switch>
<Route path="/" exact component={ Home } />
<Route path="/about" exact component={ About } />
<Route path="/contact" exact component={ Contact } />
</Switch>
</div>
);
}
}
现在 Layout
组件会在服务端渲染多个路由。
我们需要模拟服务器端的路由,主要的更改如下所示。
/* ... */
import { StaticRouter } from "react-router-dom";
/* ... */
app.get( "/*", ( req, res ) => {
const context = { };
const jsx = (
<StaticRouter context={ context } location={ req.url }>
<Layout />
</StaticRouter>
);
const reactDom = renderToString( jsx );
res.writeHead( 200, { "Content-Type": "text/html" } );
res.end( htmlTemplate( reactDom ) );
} );
/* ... */
在服务端,我们需要将 React 应用程序封装进 StaticRouter
组件中并为其提供 location
。
附带说明一下,context
用于在渲染 React DOM 时追踪潜在的重定向。这需要通过服务端的 3XX 响应来处理。
你可以查看相同仓库中的 router
标签来浏览有关路由的完整示例。
Redux
现在我们已经具备了路由功能,我们来整合 Redux。
在简单的情况下,我们需要 Redux 来处理客户端的状态管理。但如果我们需要基于状态来渲染部分部分 DOM 呢?这就需要在服务端初始化 Rudux 了。
如果你的应用在服务端上调度操作 ,那就需要捕获该状态并将其和 HTML 一同发送。在客户端,我们将初始状态输入 Redux。
首先来看服务端:
/* ... */
import { Provider as ReduxProvider } from "react-redux";
/* ... */
app.get( "/*", ( req, res ) => {
const context = { };
const store = createStore( );
store.dispatch( initializeSession( ) );
const jsx = (
<ReduxProvider store={ store }>
<StaticRouter context={ context } location={ req.url }>
<Layout />
</StaticRouter>
</ReduxProvider>
);
const reactDom = renderToString( jsx );
const reduxState = store.getState( );
res.writeHead( 200, { "Content-Type": "text/html" } );
res.end( htmlTemplate( reactDom, reduxState ) );
} );
app.listen( 2048 );
function htmlTemplate( reactDom, reduxState ) {
return `
/* ... */
<div id="app">${ reactDom }</div>
<script>
window.REDUX_DATA = ${ JSON.stringify( reduxState ) }
</script>
<script src="./app.bundle.js"></script>
/* ... */
`;
}
它看上去一点也不美观,但我们需要将完整的 JSON 状态和 HTML 一起发送。
接下来看看客户端:
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider as ReduxProvider } from "react-redux";
import Layout from "./components/Layout";
import createStore from "./store";
const store = createStore( window.REDUX_DATA );
const jsx = (
<ReduxProvider store={ store }>
<Router>
<Layout />
</Router>
</ReduxProvider>
);
const app = document.getElementById( "app" );
ReactDOM.hydrate( jsx, app );
请注意,我们调用了两次 createStore
,首先在服务端,然后在客户端。但是,我们使用保存在服务端的任一状态来初始化客户端。这个过程就像 DOM hydration。
你可以查看 相同仓库 的 redux
标签来浏览完整示例。
获取数据
最后一个难题就是数据的加载,这有点棘手。
假设我们有一个提供 JSON 数据的API。
在我们的代码库中,我利用一个公共的 API 获取了 2018 年 Formula 1 赛季的所有事件。假设我们要在主页显示所有事件。
在 React 应用挂载完成并渲染完所有内容后,我们能从客户端调用 API。但这会导致糟糕的用户体验,可能在用户看到相关内容之前,页面会显示正在加载。
我们已经有了 Redux 来在服务端存储数据以及发送数据至客户端。
如果我们在服务端调用 API,将结果存在 Redux 中,然后用相关数据来渲染完整的 HTML 给客户端,会如何呢?
但我们怎么知道应该调用哪些呢?
首先,我们需要以一种不同的方式来声明路由,所以我们看一下路由配置文件。
export default [
{
path: "/",
component: Home,
exact: true,
},
{
path: "/about",
component: About,
exact: true,
},
{
path: "/contact",
component: Contact,
exact: true,
},
{
path: "/secret",
component: Secret,
exact: true,
},
];
然后我们静态声明每个组件的数据需求。
/* ... */
import { fetchData } from "../store";
class Home extends React.Component {
/* ... */
render( ) {
const { circuits } = this.props;
return (
/* ... */
);
}
}
Home.serverFetch = fetchData; // static declaration of data requirements
/* ... */
请注意,你可以随意命名 serverFetch
。
附带说明一下, fetchData
是一个 Redux thunk action,它返回一个 Promise。
在服务端,我们使用一个名为 matchRoute
的特别的函数,它来自 react-router
。
/* ... */
import { StaticRouter, matchPath } from "react-router-dom";
import routes from "./routes";
/* ... */
app.get( "/*", ( req, res ) => {
/* ... */
const dataRequirements =
routes
.filter( route => matchPath( req.url, route ) ) // filter matching paths
.map( route => route.component ) // map to components
.filter( comp => comp.serverFetch ) // check if components have data requirement
.map( comp => store.dispatch( comp.serverFetch( ) ) ); // dispatch data requirement
Promise.all( dataRequirements ).then( ( ) => {
const jsx = (
<ReduxProvider store={ store }>
<StaticRouter context={ context } location={ req.url }>
<Layout />
</StaticRouter>
</ReduxProvider>
);
const reactDom = renderToString( jsx );
const reduxState = store.getState( );
res.writeHead( 200, { "Content-Type": "text/html" } );
res.end( htmlTemplate( reactDom, reduxState ) );
} );
} );
/* ... */
这样,我们就获得了当 React 在当前 URL 下被渲染成字符串时所需装载的组件列表。
我们收集了 data requirements 并等待所有调用的 API 的返回值。最终,我们恢复服务端渲染,这时 Redux 中已经得到了数据。
可以查看相同仓库的 fetch-data
标签来浏览完整示例。
你可能会注意到,这导致了性能损失,因为我们在获取数据之后才进行渲染。
这时你就需要权衡了,你需要尽可能弄清楚哪些调用是必不可少的。举例来说,对于一个电商应用,获取产品列表是至关重要的,需要尽快加载,但是价格以及侧边栏选择器可以被延迟加载。
Helmet
作为 SSR 的奖励,让我们来看看 SEO。在使用 React 时,你可能想在 <head>
标签中设置不同的值,例如 title , meta tags , key words 等等。
请注意, <head>
标签通常不属于 React 应用。
在这种情况下,react-helmet 提供了相应的解决方案,并对 SSR 有着很好的支持。
import React from "react";
import Helmet from "react-helmet";
const Contact = () => (
<div>
<h2>This is the contact page</h2>
<Helmet>
<title>Contact Page</title>
<meta name="description" content="This is a proof of concept for React SSR" />
</Helmet>
</div>
);
export default Contact;
你只需要将 head
数据添加到组件树的任意位置,这使得你可以在客户端更改已挂载的 React 应用以外的值。
现在,我们添加了对 SSR 的支持:
/* ... */
import Helmet from "react-helmet";
/* ... */
app.get( "/*", ( req, res ) => {
/* ... */
const jsx = (
<ReduxProvider store={ store }>
<StaticRouter context={ context } location={ req.url }>
<Layout />
</StaticRouter>
</ReduxProvider>
);
const reactDom = renderToString( jsx );
const reduxState = store.getState( );
const helmetData = Helmet.renderStatic( );
res.writeHead( 200, { "Content-Type": "text/html" } );
res.end( htmlTemplate( reactDom, reduxState, helmetData ) );
} );
} );
app.listen( 2048 );
function htmlTemplate( reactDom, reduxState, helmetData ) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${ helmetData.title.toString( ) }
${ helmetData.meta.toString( ) }
<title>React SSR</title>
</head>
/* ... */
`;
}
现在,我们得到了一个功能完备的 React SSR 示例!
我们从使用 Express 应用进行简单的 HTML 渲染开始,逐步增加路由、状态管理和数据获取,最终,我们处理了 React 应用以外的更改。
最终的代码库位于前面提到的相同仓库的 master
分支上。
总结
如你所见,SSR 算不上多大的难题,但它可以变得复杂。如果你一步步构建自己的需求,那会简单很多。
在应用中添加 SSR 有必要吗?和往常一样,这需要结合实际情况。如果你的网站是公开的,并且可以供成千上万的用户访问,那答案就是必须的。但如果你要构建一个工具/仪表板之类的应用,那就没什么必要了。
无论如何,利用好通用应用,是前端社区的一大进步。
你用过类似 SSR 的方法吗?或者你是否认为这篇文章存在纰漏?欢迎留言讨论。
如果你认为这篇文章对你有帮助,请在社区中分享吧!
原文:https://www.freecodecamp.org/news/demystifying-reacts-server-side-render-de335d408fe4/,作者:Alex Moldovan