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

react lifecycle

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

1
2
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來控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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指的是使用者載入時。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    "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…)

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