Photo by Stage 7 Photographyhtml
原文連接:如何用 React 作服務端渲染 - 知乎專欄node
服務端渲染的一些優缺點這裏就不說了,相信你們都已經很是清楚地知道了,本文意在講述如何將一個簡單的瀏覽器端渲染的 React SPA
按部就班地升級爲支持服務端渲染。react
在搭建服務端渲染應用以前咱們如今搭建一個基於瀏覽器端渲染的單頁應用,該單頁應用包含簡單的路由功能。webpack
mkdir react-ssr
cd react-ssr
yarn init
複製代碼
依賴安裝:git
yarn add react react-dom react-router-dom
複製代碼
首先建立 App 的入口文件 src/App.jsx
:github
import React from 'React';
import { Switch, Route, Link } from 'react-router-dom';
import Home from './pages/Home';
import Post from './pages/Post';
export default () => (
<div>
<Switch>
<Route exact path="/" component={ Home } />
<Route exact path="/post" component={ Post } />
</Switch>
</div>
)
複製代碼
其次建立兩個頁面組件 src/pages/Home.jsx
和 src/pages/Post.jsx
:web
// Home.jsx
import React from 'react';
import { Link } from 'react-router-dom';
export default () => (
<div> <h1>Page Home.</h1> <Link to="/post">Link to Post</Link> </div>
);
// Post.jsx
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
export default class Post extends Component {
constructor(props) {
super(props);
this.state = {
post: {},
};
}
componentDidMount() {
setTimeout(() => this.setState({
post: {
title: 'This is title.',
content: 'This is content.',
author: '大板慄.',
url: 'https://github.com/justclear',
},
}), 2000);
}
render() {
const post = this.state.post;
return (
<div> <h1>Page Post</h1> <Link to="/">Link to Home</Link> <h2>{ post.title }</h2> <p>By: { post.by }</p> <p>Link: <a href={post.url} target="_blank">{post.url}</a></p> </div>
);
}
};
複製代碼
而後建立 webpack
的入口文件 src/index.jsx
:express
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
ReactDOM.render(
<BrowserRouter> <App></App> </BrowserRouter>
, document.getElementById('root'));
複製代碼
package.json
:npm
{
"scripts": {
"build:client": "NODE_ENV=development webpack -w",
},
}
複製代碼
到此,一個最簡單的基於 React 帶路由跳轉的單頁應用就完成了,下面是效果:json
顧名思義,要加入服務端渲染功能,就必需要有一個服務器,爲了方便起見,這裏就以 express
框架爲例(固然你也可使用 koa
, fastify
, restify
等等你全部熟悉的框架):
yarn add express
複製代碼
首先建立服務端代碼的入口文件 server/index.js
:
import fs from 'fs';
import path from 'path';
import express from 'express';
import React from 'react';
import { StaticRouter } from "react-router-dom";
import { renderToString } from 'react-dom/server';
import App from '../src/App';
const app = express();
app.get('/*', (req, res) => {
const renderedString = renderToString(
<StaticRouter> <App></App> </StaticRouter>
);
fs.readFile(path.resolve('index.html'), 'utf8', (error, data) => {
if (error) {
res.send(`<p>Server Error</p>`);
return false;
}
res.send(data.replace('<div id="root"></div>', `<div id="root">${renderedString}</div>`));
})
});
app.listen(3000);
複製代碼
其次配置打包服務端代碼的 webpack
配置 webpack.server.js
:
const path = require('path');
module.exports = {
mode: 'development',
entry: './server/index.js',
output: {
filename: 'app.js',
path: path.resolve('server/build'),
},
target: 'node',
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
},
module: {
rules: [{
test: /\.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/,
}],
},
};
複製代碼
package.json
:
{
"scripts": {
"build:server": "NODE_ENV=development webpack -w --config webpack.server.js",
"start": "nodemon server/build/app.js"
},
}
複製代碼
注:若是使用服務端渲染的話,文檔建議須要把
src/index.jsx
中的ReactDOM.render
換成ReactDOM.hydrate
,由於下個主版本ReactDOM.render
將再也不支持服務端渲染。
react-dom docs: Using ReactDOM.render() to hydrate a server-rendered container is deprecated and will be removed in React 17. Use hydrate() instead.
最後 npm start
後會看到以下頁面:
咋一看和瀏覽器端渲染的結果同樣,可是若是咱們分別查看兩個頁面的源代碼的話,就會發現區別:
會很明顯的發現第二張服務器端渲染的頁面源代碼中的 <div id="root"></div>
中多了一些代碼,仔細觀察的話會發現其實就是 Home.jsx
所渲染的代碼。
至此,咱們已經實現了 React
服務端渲染的功能了。
不過此時若是你點擊頁面中的 Link to Post 連接的話,會發現路由跳轉 /post
後渲染的仍是 Home.jsx
的內容,這是由於咱們沒有在服務端中作對應的 路由匹配。
react-router-dom
路由模塊提供一個 matchPath
方法來匹配路由。
在匹配路由以前咱們先來作一件事,就是把路由抽離成 src/routes.js
:
// routes.js
import Home from './pages/Home';
import Post from './pages/Post';
export default [{
path: '/',
exact: true,
component: Home
}, {
path: '/post',
exact: true,
component: Post,
}];
複製代碼
而後在 server/index.js
中引入:
// ...
import { StaticRouter, matchPath } from 'react-router-dom';
import routes from '../src/routes';
// ...
app.get('/*', (req, res) => {
const currentRoute = routes.find(route => matchPath(req.url, route)) || {};
// ...
const renderedString = renderToString(
<StaticRouter location={ req.url }> <App></App> </StaticRouter>
);
});
複製代碼
經過數組的 find
方法配合 matchPath
方法匹配出當前路由的信息,而後在 <StaticRouter></StaticRouter>
組件中加上 location
的屬性並傳入當前的路由 req.url
,此時若是從新點擊頁面中的 Link to Post 連接的話,/post
路由下的組件就能正常渲染了:
此時你可能又會發現,跟以前的瀏覽器端渲染相比,跳轉到 Post
頁面後,並無獲取到 componentDidMount
中定義的異步數據,這是由於 componentDidMount
生命週期函數只會在瀏覽器環境下才會執行,因此服務端是不會執行該函數的,因此也就沒法獲取到數據了,這顯然不是咱們想要的結果。咱們指望的樣子是路由跳轉後能和瀏覽器端渲染同樣,能夠正常獲取這些異步數據。
那咱們如何在服務端中獲取這些數據後再返回給瀏覽器呢?
新建一個 src/helpers/fetchData.js
輔助函數來獲取數據:
export default () => {
return new Promise((resolve) => {
setTimeout(() => resolve({
title: 'This is title.',
content: 'This is content.',
author: '大板慄.',
url: 'https://github.com/justclear',
}), 2000);
})
};
複製代碼
實現的思路是,在匹配路由的時候就判斷當前路由所包含的組件是否須要加載數據,若是須要,則去加載:
// ...
app.get('/*', (req, res) => {
const currentRoute = routes.find(route => matchPath(req.url, route)) || {};
const promise = currentRoute.fetchData ? currentRoute.fetchData() : Promise.resolve(null);
promise.then(data => {
// data here...
}).catch(console.log);
});
複製代碼
這裏的邏輯就是判斷 src/routes.js
中的路由對象中 fetchData
這個 key
是否有值,若是 fetchData
被三目運算判斷爲 true
,則認爲該路由須要獲取數據,因此接下來咱們要給 path
爲 /post
的路由對象加上 fetchData
,表示對應的 Post
組件須要異步獲取數據:
// src/routes.js
import Home from './pages/Home';
import Post from './pages/Post';
import fetchData from './helpers/fetchData';
export default [{
path: '/',
exact: true,
component: Home
}, {
path: '/post',
exact: true,
component: Post,
fetchData,
}];
複製代碼
此時當路由匹配到 /post
的時候,就會執行 currentRoute.fetchData()
這個 promise
,獲取到數據後就能夠渲染 Post
組件了:
promise.then(data => {
const context = {
data,
};
const renderedString = renderToString(
<StaticRouter context={context} location={req.url}> <App></App> </StaticRouter>
);
res.send(template());
function template() {
return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React Server Side Rendering</title> </head> <body> <div id="root">${renderedString}</div> <script>window.__ROUTE_DATA__ = ${JSON.stringify(data)}</script> <script src="dist/app.js"></script> </body> </html> `;
}
}).catch(console.log);
複製代碼
拿到數據 data
後應該傳給 <StaticRouter></StaticRouter>
組件中的 context
屬性中,這樣就能夠在組件自身的 props.staticContext
上獲取到相應的數據,另外你還須要把 JSON.stringify(data)
賦值給 window.__ROUTE_DATA__
,__ROUTE_DATA__
能夠按你想要的方式命名,方便咱們在組件內部經過判斷 window.__ROUTE_DATA__
的值來採起不一樣的獲取數據的策略。
不過此時若是你點擊 Link to Post 的話,你可能會發現頁面打不開了:
這是由於請求 /dist/app.js
被當成了普通的路由了,沒有被當成一個靜態資源來返回有效的 JavaScript
代碼,解決方案就是在 server/index.js
中加入同樣代碼:
// ...
const app = express();
app.use(express.static('dist'));
// ...
複製代碼
而後把 template
函數中的 <script src="dist/app.js"></script>
改爲 <script src="/app.js"></script>
:
如今 /app.js
能夠正確地返回了 JavaScript
代碼了。
如今服務端已經把獲取的 data
經過 window.__ROUTE_DATA__ = JSON.stringify(data)
的方式返回給瀏覽器端了,咱們如今須要在 Post.jsx
組件內部來使用這個狀態:
// ...
export default class Post extends Component {
constructor(props) {
super(props);
if (props.staticContext && props.staticContext.data) {
this.state = {
post: props.staticContext.data
};
} else {
this.state = {
post: {},
};
}
}
componentDidMount() {
if (window.__ROUTE_DATA__) {
this.setState({
post: window.__ROUTE_DATA__,
});
delete window.__ROUTE_DATA__;
} else {
fetchData().then(data => {
this.setState({
post: data,
});
})
}
}
// ...
};
複製代碼
你會發現當 /post
路由是由瀏覽器端打開的時候,組件會去判斷 window.__ROUTE_DATA__
是否有值,此時會發現 window.__ROUTE_DATA__
爲 null
,因此會去執行 fetchData
來獲取數據,因此你會看到進入 /post
後等待了 2 秒才顯示數據。而直接刷新此頁面的話,就無需等待,直接可看到結果。
如今 React
服務端渲染 支持算是基本完成了,固然這還遠遠不夠,實際項目中運用的話確定會複雜不少,好比經過 Webpack Dynamic Imports 和 react-loadable 等工具來優化代碼以及如何配合 Redux
來使用等等等等。
本文的目的是讓一些對 React Server Side Rendering 技術還不太瞭解或者沒什麼概念的同窗對服務端渲染有個初步的瞭解。
如需查看完整的項目,請移步 Github。