先來稍微介紹一下為什麼要做 server side render,另外這邊不會使用 next.js,雖然公司目前專案有用到,但我滿推薦用 next.js 的,很好導入並且解決很多需要處理的問題。(雖然有一些 bug、還會頻繁更新,兩年內 version 3 -> 7…)

Server-side rendering SEO

傳統網站內容是依靠後端 php、jsp 等產生 html 內容,稱之為 Server-side rendering,但隨著前端技術演進,所有動態內容不再是連接資料庫取資料,轉變為使用非同步請求,依照不同需要依靠 JavaScript 直接請求 API,然後更新需要改動的 html,雖然說這樣處理很方便,使用者不用換網址發請求,整個畫面重新閃動。

但這方法背後也產生 SEO 的缺點,動態內容的核心是執行 JavaScript,而網頁爬蟲卻不一定會載入執行網頁上的 JavaScript,雖然 google 官方表示爬蟲會盡可能的執行 script,但實務上當你要優化 SEO,就可能會避免用非同步拉資料,或是處理其他細節。這方式又稱為 Client-side rendering。

PS.google 官方表示爬蟲邏輯大概是 索引 -> (有資源後) -> 執行 JavaScript,核心價值在於 URL,不同內容必須要有對應的 URL,才有可能幫你每個分頁分開索引。

影片推薦觀看,能更了解 JavaScript 與爬蟲之間關係。
Google I/O ‘18 javascript website

React server-side render

使用 React 框架,但又需要讓爬蟲能索引得到 html,就需要轉為使用 server-side render,核心概念就是,原本 JavaScript 是用戶端執行產生內容,轉向依靠 server 來產生內容,請求 API 的部分也交由 server 端處理,直接在 server 端拿到畫面相關的資料,這樣爬蟲來索引的同時,就已經拿到了內容了。

接下來來試著架構出 React server-side render 的架構,會使用到 react 官方的 cli create-react-app,以及 node.js 作為 server。

使用 create-react-app cli

npx create-react-app react-ssr
cd react-ssr

安裝使用 express

server side render 需要後端執行 javascript,因此這邊使用 node 來處理,npm i express,再來建立 server folder,在建立一個 index.js,作為我們 server 執行的 root。

  • src
  • server

    • index.js

純粹只是 client side render,就只要執行 npm run build,再來我們針對 build 出來的資源,用 express 來控制。

const express = require('express');
const app = express();
const path = require('path');

// host build foler resource
app.use(express.static(path.join(__dirname, '../build')));

// settting router
app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname + '../build/index.html'));
});

app.listen(8080);

react csr
react csr

express render REACT

先談談用 node 執行 javascript 會遇到哪些難解問題。

  • 首先 node 無法執行 import。
    依靠 @babel/register 搭配 @babel/plugin-syntax-dynamic-import、@babel-plugin-dynamic-import-node,讓 express 執行轉譯過的 i
    mport。
  • node 無法讀取 css、image 會出現 object
    利用 style-ignore,避開執行 css 內容,並在這邊處理好 image hash name。
  • render react
    透過 react-dom/server 的 renderToString 或 renderToStaticMarkup 執行 react。

剩下 react-router、redux、檔案加入 hash name、hot reload 等等,就先不在這邊討論。
(置底 medium 文章有用到 redux、react-router)

建立 server.js、render.js

再建立 server.js loader.js 兩個檔案,server.js 主要負責 express,index.js則是處理 server 設定 babel、各種預處理修正,render.js 負責 render 內容。

index.js 功能

md5File 是為了讀取 image file name,搭配 ignoreStyles 使用,讓 server 讀取到 npm build 出來的 file name。這邊最黑魔法的是 babel/register,也是第一次看過這個用法,很輕鬆不需要 eject 就導入 babel 到 create react app 內。

npm install md5-file ignore-styles

const md5File = require('md5-file');
const path = require('path');

const ignoreStyles = require('ignore-styles');
const register = ignoreStyles.default;

const extensions = ['.gif', '.jpeg', '.jpg', '.png', '.svg'];

// ignore image and style request
register(ignoreStyles.DEFAULT_EXTENSIONS, (module, filename) => {
    if (!extensions.find(f => filename.endsWith(f))) {
        // use for style
        return ignoreStyles.noOp();
    } else {
        // use for image and add hash follow react cli
        const hash = md5File.sync(filename).slice(0, 8);
        const bn = path.basename(filename).replace(/(\.\w{3})$/, `.${hash}$1`);
        module.exports = `/static/media/${bn}`;
    }
});
require('@babel/polyfill');
require('@babel/register')({
    ignore: [/\/(build|node_modules)\//],
    presets: ['@babel/preset-env', '@babel/preset-react'],
    plugins: ['@babel/plugin-syntax-dynamic-import', 'dynamic-import-node', 'react-loadable/babel']
});

// it will run express
require('./server');

server.js 功能

這邊主要就是處理 express 路由,static file 路徑,非常簡單的基本設定,比較特別的是用到 Loadable 來確保有 render component 有執行完成。

npm install react-loadable express

import express from 'express';
import path from 'path';
import Loadable from 'react-loadable';

import render from './render';

const app = express();
const PORT = process.env.PORT || 4000;

app.use(express.Router().get('/', render));
app.use(express.static(path.resolve(__dirname, '../build')));
app.use(render);

// Loadable listener to make sure that all of your loadable components are already loaded
// https://github.com/jamiebuilds/react-loadable#preloading-all-your-loadable-components-on-the-server
Loadable.preloadAll().then(() => {
    app.listen(PORT, console.log(`App listening on port ${PORT}!`));
});

render.js 功能

這邊就是實際 render react,主要依賴 renderToString 來取得 react 執行後的 html,之後再將 react 的 html 組裝成完整頁面的資料。

這邊我有傳遞資料給 App wording,假設直接看 view-source:http://localhost:4000/ 會看到 THIS IS Server Side Render ,但是 client side init 會瞬間不見,這邊可以讓你做一些 call api 後的資料傳遞,但這邊要記得要設定成某個變數名,讓 client 抓取這個變數。

ps.client 指的是使用者載入時。

import path from 'path';
import fs from 'fs';

import React from 'react';
import { renderToString } from 'react-dom/server';
import Helmet from 'react-helmet';

import App from '../src/app';

export default (req, res) => {
    fs.readFile(path.resolve(__dirname, '../build/index.html'), 'utf8', (err, htmlData) => {
        if (err) {
            console.error(`Error page ${err}`);
            return res.status(404).end();
        }

        const helmet = Helmet.renderStatic();

        const html = injectHTML(htmlData, {
            html: helmet.htmlAttributes.toString(),
            title: helmet.title.toString(),
            meta: helmet.meta.toString(),
            body: renderToString(<App wording="THIS IS Server Side Render" />)
        });
        res.send(html);
    });
};

const injectHTML = (data, { html, title, meta, body, state }) => {
    data = data.replace('<html>', `<html ${html}>`);
    data = data.replace(/<title>.*?<\/title>/g, title);
    data = data.replace('</head>', `${meta}</head>`);
    data = data.replace('<div id="root"></div>', `<div id="root">${body}</div>`);
    return data;
};
  • package.json
  "dependencies": {
    "@babel/core": "^7.1.2",
    "@babel/plugin-syntax-dynamic-import": "^7.0.0",
    "@babel/polyfill": "^7.0.0",
    "@babel/register": "^7.0.0",
    "babel-plugin-dynamic-import-node": "^2.1.0",
    "ignore-styles": "^5.0.1",
    "md5-file": "^4.0.0",
    "react-frontload": "^1.0.3",
    "react-helmet": "^5.2.0",
    "react-loadable": "^5.5.0",
    "react": "^16.7.0",
    "react-dom": "^16.7.0",
    "react-scripts": "2.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "dev": "NODE_ENV=development node ./server/index.js",
    "prod": "NODE_ENV=production node ./server/index.js"
  },

Source Code Github

心得

整個寫完只需要三個檔案,看似簡單,但其實還有非常多部分還未處理,例如 router,要能夠在 server 處理各種路徑 render。開發時需要 hot reload,否則每次更新都要 build。這邊有看到有人有使用 nodeman 處理。各種檔案資源的壓縮優化,這就要依靠 webpack。

以上問題 next.js 都有提供方法處理,官方還有各種工具整合的 sample code,雖然我自己不太愛 next.js,但它真的解決不少問題。(但是 safari back 存在各種 bug…)

如果有錯誤的地方,還麻煩提出,感謝閱讀。