如何用 React 做服務端渲染

大板慄發表於2018-12-18

Photo by Stage 7 Photography

原文連結:如何用 React 做服務端渲染 - 知乎專欄

服務端渲染的一些優缺點這裡就不說了,相信大家都已經非常清楚地知道了,本文意在講述如何將一個簡單的瀏覽器端渲染的 React SPA 循序漸進地升級為支援服務端渲染。

初始化一個普通的單頁應用(瀏覽器端渲染)

在搭建服務端渲染應用之前我們現在搭建一個基於瀏覽器端渲染的單頁應用,該單頁應用包含簡單的路由功能。

mkdir react-ssr
cd react-ssr
yarn init
複製程式碼

依賴安裝:

yarn add react react-dom react-router-dom
複製程式碼

首先建立 App 的入口檔案 src/App.jsx

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.jsxsrc/pages/Post.jsx

// 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

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

{
    "scripts": {
        "build:client": "NODE_ENV=development webpack -w",
    },
}
複製程式碼

到此,一個最簡單的基於 React 帶路由跳轉的單頁應用就完成了,下面是效果:

React-Client-Side-Rendering

加入服務端渲染功能

顧名思義,要加入服務端渲染功能,就必須要有一個伺服器,為了方便起見,這裡就以 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 後會看到如下頁面:

React-Server-Side-Rendering

咋一看和瀏覽器端渲染的結果一樣,但是如果我們分別檢視兩個頁面的原始碼的話,就會發現區別:

React-Client-Side-Rendering-Source

React-Server-Side-Rendering-Source

會很明顯的發現第二張伺服器端渲染的頁面原始碼中的 <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 路由下的元件就能正常渲染了:

React-Server-Side-Rendering-Match-Path

此時你可能又會發現,跟之前的瀏覽器端渲染相比,跳轉到 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 的話,你可能會發現頁面打不開了:

React-Server-Side-Rendering-Error

這是因為請求 /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>

React-Server-Side-Rendering-Success

現在 /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,
                });
            })
        }
    }
    // ...
};
複製程式碼

React-Server-Side-Rendering-Final

你會發現當 /post 路由是由瀏覽器端開啟的時候,元件會去判斷 window.__ROUTE_DATA__ 是否有值,此時會發現 window.__ROUTE_DATA__null,所以會去執行 fetchData 來獲取資料,所以你會看到進入 /post 後等待了 2 秒才顯示資料。而直接重新整理此頁面的話,就無需等待,直接可看到結果。

總結

現在 React 服務端渲染 支援算是基本完成了,當然這還遠遠不夠,實際專案中運用的話肯定會複雜很多,比如通過 Webpack Dynamic Importsreact-loadable 等工具來優化程式碼以及如何配合 Redux 來使用等等等等。

本文的目的是讓一些對 React Server Side Rendering 技術還不太瞭解或者沒什麼概念的同學對服務端渲染有個初步的瞭解。

如需檢視完整的專案,請移步 Github

FEFollow.png

相關文章