原文:Demystifying server-side rendering in React
揭祕 React 服務端渲染
讓我們來近距離接觸一個能夠讓你使用 React 構建 universal 應用的特性——React 服務端渲染( Server-Side Rendering )。
服務端渲染(以下簡稱 SSR )是一個將通過前端框架構建的網站通過後端渲染模板的形式呈現的過程。
能夠在服務端和客戶端上渲染的應用稱為 universal 應用。
為什麼要 SSR
為了弄明白我們為什麼需要 SSR,我們首先需要了解過去 10 年 Web 應用的發展歷程。
這與單頁應用(以下簡稱 SPA )的興起息息相關。與傳統的 SSR 應用相比, SPA 在速度和使用者體驗方面具有很大的優勢。
但是這裡有一個問題。SPA 的初始服務端請求通常返回一個沒有 DOM 結構的 HTML 檔案,其中只包含一堆 CSS 和 JS links。然後,應用需要另外 fetch 一些資料來呈現相關的 HTML 標籤。
這意味著使用者將不得不等待更長時間的初始渲染。這也意味著爬蟲可能會將你的頁面解析為空。
因此,關於這個問題的解決思路是:首先在服務端上渲染你的 app(渲染首屏),接著再在客戶端上使用 SPA。
SSR + SPA = Universal App
你會在別的文章中發現 Isomorphic App 這個名詞,這和 Universal App 是一回事。
現在,使用者不必等待載入你的 JS,並且能夠在初始請求返回響應後立即獲取完全渲染完成的 HTML。
想象一下,這能給使用者在緩慢的 3G 網路上的操作帶來多大的速度提升。你幾乎可以立即在螢幕上獲取內容,而不是花了 20s 才等到網站載入完畢。
現在,所有向您的伺服器發出的請求都會返回完全呈現的 HTML。對你的 SEO 部門來說是個好訊息! 網路爬蟲會索引你在伺服器上呈現的任何內容,就像它對網路上其他靜態網站所做的那樣。
回顧一下,SSR 有以下兩個好處:
- 加快了首屏渲染時間
- 完整的可索引的 HTML 頁面(有利於 SEO)
一步一步理解 SSR
讓我們採用一步步迭代的方式去構建一個完整的 SSR 例項。我們從 React 的服務端渲染相關的 API開始,然後逐漸新增內容。
你可以通過 follow 這個倉庫和檢視定義在那兒的 tag 來理解每一個構建步驟。
基本設定
首先,為了使用 SSR,我們需要一個 server。我們將使用一個簡單的 Express 服務來渲染我們的 React 應用。
server.js:
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>
`;
}
複製程式碼
在第 10 行,我們指定了 Express 需要 serve 的靜態檔案所在的資料夾。
我們建立了一個路由來處理所有非靜態的請求。這個路由會返回一個已渲染完畢的 HTML 字串。
需要注意的是,我們為客戶端程式碼和服務端程式碼使用了相同的 Babel 外掛,所以 JSX 和 ES6 Modules 可以在server.js
中工作。
客戶端上相對應的渲染函式為ReactDOM.hydrate
。該函式將接收已由服務端渲染的 React app, 並將附加事件處理程式。
要檢視完整示例,請檢視倉庫中的basic
tag。
好了!你剛剛建立了你的第一個服務端渲染的 React app!
React Router
我們必須誠實地說,這個 app 目前還沒有太多功能。所以讓我們再新增幾個路由,思考一下我們該如何在服務端處理這個部分。
/components/Layout.js:
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 元件會在客戶端上渲染多個路由。
我們需要模擬伺服器上的路由。你可以在下面看到應該要完成的更改。
server.js:
/* ... */
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
,並且給StaticRouter
提供location
。
備註:context
用於在渲染 React DOM 時跟蹤潛在的重定向操作。這需要通過來自服務端對 3XX 的響應來處理。
可以在相同倉庫中的router
標籤看到關於路由的完整例子。
Redux
既然我們已經擁有路由的功能,那就讓我們來整合 Redux 吧。
在簡單場景下,我們通過 Redux 來處理客戶端的狀態管理。但是,如果我們需要根據狀態來渲染部分的 DOM 呢?這時,就有必要在服務端初始化 Redux 了。
如果你的 app 在服務端上dispatch actions
的話,那麼它就需要捕獲狀態並通過網路將其與 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 格式的 state 與我們的 HTML 一起傳送給客戶端。
然後讓我們來看看客戶端:
app.js
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
標籤看到關於 Redux 的完整例子。
Fetch Data
最後一個比較棘手的難題是載入資料。假設我們有一個提供 JSON 資料的 API。
在我們的程式碼倉庫中,我從一個公共的 API 中獲取了 2018 年 F1 賽季的所有事件。假設我們想要在主頁上顯示所有時間。
我們可以在 React app 掛載完畢( mounted )並渲染完所有內容後再從客戶端呼叫我們的 API。但這會造成不好的使用者體驗,可能需要在使用者看到相關內容之前展示一個 loader 或 spinner。
我們的 SSR app 中,Redux 首先在服務端上儲存資料,再將資料傳送客戶端。我們可以利用到這一點。
如果我們在服務端上進行 API 呼叫,將結果儲存在 Redux 中,然後使用再渲染攜帶著相關資料的完整的 HTML 渲染給客戶端,會怎麼樣?
但是,我們如何才能分辨某次 API 呼叫對應的是什麼頁面呢?
首先,我們需要一種不同的方式來宣告路由。讓我們建立一個路由配置檔案。
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,
},
];
複製程式碼
然後我們靜態宣告每個元件的 data requirements:
/* ... */
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,當它被 dispatched 時,返回一個 Promise。
在服務端,我們可以使用一個來自react-router
的函式——matchPath
。
/* ... */
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 下開始被渲染成字串時,列表中的元件才會 mount。
我們收集了 data requirements,並且等待所有 API 呼叫返回資料。最後,我們繼續進行服務端渲染,這時 Redux 中已有資料可用了。
可以在相同倉庫中的fetch-data
標籤看到關於資料獲取的完整例子。
你可能會注意到,這帶來了效能損失,因為我們將渲染延遲到了資料被 fetch 完成之後。
這時就需要你自己來權衡了,而且你需要盡力去弄明白哪些呼叫是重要的而哪些又是不重要的。舉個例子,在一個電商 app 中,fetch 產品列表是較為重要的,而產品價格和在 sidebar 的 filters 可以被延遲載入。
Helmet
讓我們來看看作為 SSR 的福利之一的 SEO。在使用 React 時,你可能想要在<head>
標籤中設定不同的 title, meta tags, keywords 等等。
請記住,通常情況下<head>
標籤並不屬於 React app 的一部分。
在這種情況下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 app 以外的值。
現在,我們新增對 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 應用範圍以外的程式更改(處理head
標籤)
完整的例子請檢視 https://github.com/alexnm/react-ssr。
小結
正如你所見, SSR 也並不是什麼大難題。但它可能會變得複雜。如果你一步步地構建你的需求,它會更容易掌握。
值得將 SSR 應用到你的 app 中嗎?一如既往,這需要看情況。如果你的網站是面向成千上萬的使用者,則這是必須的。如果你正在構建一個類似於工具/儀表板之類的應用程式,你可能並不需要它。
當然,利用好 universal apps 的確能夠讓前端社群得到進步。
你有與 SSR 類似的方法嗎?或者你認為我在這篇文章中遺漏了什麼嗎?請在 Twitter上給我留言。
如果你認為這篇文章有用,請幫我在社群中分享它。