在上一篇了解為什麼很多開發者希望把CSR做到如SSR的效果,這篇就來介紹如何做到Isomorphic的效果。
以下將會介紹如何讓SPA程式做到類似SSR的效果。
補充:不管你是開發Vue或是React,Server端的程式都是Node.js,因此開發任何框架的開發者都可以觀看這篇參考。


SEO全系列文章

📌 如何加強網站的SEO - 基礎篇
📌 如何加強網站的SEO - 進階篇
📌 如何加強網站的SEO - SSR與CSR篇
📌 如何加強網站的SEO - 同構篇
📌 如何加強網站的SEO - 框架篇
📌 如何加強網站的SEO - 資安篇


認識Isomorphic

Isomorphic的翻譯為同構,你可以理解成,建構出相同程式的概念,有些人又稱它叫做pre-render。
一般傳統的SSR會把所有的程式產生出完整的HTML再丟到Client,但是同構的方式跟SSR不一樣。
同構所做的事情是,他只會把產生出部分的HTML給Client,剩下半殘的未產生的HTML繼續交給Client產生。

關於調整

以下的調整將會分F2E與Node(中間層或API server)的部分改寫
調整的部分其實比較麻煩的不會是程式碼的撰寫,反倒是相依套件的管理比較麻煩
尤其是使用越多套件的專案,越需要花時間盤點和修改

前端調整

React渲染

原本渲染方式為ReactDOM.render,需要改成ReactDOM.hydrate。
簡單來說,render是舊版的渲染方式,早期的同構處理會比較麻煩。
但是React 16之後,官方希望能夠在開發的時候,Client和Server能夠渲染一致,早期會自動fix,但是這應該是由開發者自行發現的,而不應該丟給程式自己去校正,反而會消耗額外的效能。

React-router-config

為了方便讓Node讀取Route,因此建議將Routes包起來以方便使用
這邊推薦使用React-router-config
使用方式其實很簡單,只需要精準地填寫path和component即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Home from './containers/home';
import Subpage from './containers/subpage';
const routes = [
{
path: '/',
exact: true,
component: Home,
},
{
path: '/subpage',
component: Subpage,
},
];
export default routes;

在需要的路徑,我們需要加入getInitialData供後續同構執行的action做使用
這邊先打一下草稿,後續整合再加入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Home from './containers/home';
import Subpage from './containers/subpage';
const routes = [
{
path: '/',
exact: true,
component: Home,
},
{
path: '/subpage',
component: Subpage,
getInitialData: () => {}
},
];
export default routes;

解決React-router-config需要擴充資源的問題

如果你希望讓Route加入其他參數,或是想要套用到現有的頁面,卻被State綁死了
這時候你可以直接把需要的路徑map出來擴充
此外,getInitialData也在這邊一起加入,如此可以避免掉,日後要在指定的頁面執行同構渲染的時候,需要改寫很多components的狀況

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
class Layout extends React.Component {
state = {
active: false,
}

showSearch = (switchActive) => {
this.setState({
active: switchActive,
});
}


render() {
const { active } = this.state;
const { location } = this.props;
return (
<>
<Layout>
<Header active={this.state.active} />
{
Routes.map(({
path, exact, component: Component, getInitialData, ...rest
}) => {
if (path === '') {
return <Route key={path} path={path} exact={exact} render={props => (<Component {...props} {...rest} active={this.state.active} showSearch={this.showSearch} />)} />;
} else {
return <Route key={path} path={path} exact={exact} render={props => <Component getInitialData={getInitialData} {...props} {...rest} />} />;
}
})
}
</Layout>
</>
);
}
}
export default Default;

包裝一個SEO component

這邊推薦你把SEO包裝成一個component,即可方便管理指定的頁面需要什麼樣的meta tags。
這邊使用Helmet方便管理meta tags,並且保護資料不被惡意注入。
以下是範例:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import React, { Component } from 'react';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';

class SEO extends Component {
render() {
const { location } = this.props;

const title = '首頁';
const description = '這是一個網頁';
const keywords = '美食, 生活';
const link = 'https://www.google.com';
const imgLink = 'https://ex.com';
const author = 'yang yang';
const copyright = 'YY';
const itemType = 'WebSite';
const pageType = location.pathname;
let structuredJSON = null;

return (
<>
<Helmet>
<html lang="zh" itemScope itemType={`http://schema.org/${itemType}`} />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="keywords" content={keywords} />
<meta name="page" content={pageType} />
<meta name="author" content={author} />
<meta name="copyright" content={copyright} />
<meta name="thumbnail" content={imgLink} />

<meta itemProp="name" content={title} />
<meta itemProp="url" content={link} />
<meta itemProp="description" content={description} />
<meta itemProp="about" content={description} />
<meta itemProp="abstract" content={description} />
<meta itemProp="image" content={imgLink} />
<meta itemProp="keywords" content={keywords} />
<meta itemProp="author" content={author} />
<meta itemProp="copyrightHolder" content={copyright} />

<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={link} />
<meta property="og:ttl" content="345600" />
<meta property="og:site_name" content={title} />
<meta property="og:type" content="website" />
<meta property="og:image" content={imgLink} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

<link rel="author" href={link} />
<link rel="publisher" href={link} />

<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:site" content="@YY" />
<meta name="twitter:creator" content={author} />
<meta name="twitter:card" content={imgLink} />
<meta name="twitter:image:src" content={imgLink} />

<script type="application/ld+json">{structuredJSON}</script>

</Helmet>
</>
);
}
}

const mapStateToProps = state => ({
user: state.user,
});

export default withRouter(connect(mapStateToProps)(SEO));

Store準備

記得前端包裝好store,以方便後續供Node同步使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import thunk from 'redux-thunk';
import { apiMiddleware } from 'redux-api-middleware';
import rootReducer from '../reducers';
import config from './config';

const configureStore = (preloadedState) => {
const env = process.env.REACT_APP_NODE_ENV_CLIENT || 'development';
const store = env !== 'production'
? createStore(rootReducer, preloadedState, applyMiddleware(thunk, apiMiddleware, logger))
: createStore(rootReducer, preloadedState, applyMiddleware(thunk, apiMiddleware));
return store;
};

export default configureStore;

Node調整

通常開發的時候,主要的難點都是在以下的部分:

  1. 看懂ES6+語法
    • 由於Node是commonjs的語法,有些語法和JavaScript的版本有差異,因此需要透過套件讓Node看懂Client的程式。
    • 需使用webpack確保能執行 import/es6/es7/jsx…
  2. 在Node編譯過一次前端的程式
    • 由於需要跑遍所有的Routes,因此需要考慮使用React-router-config
  3. 確保Redux可正常運作
    • 其實只需要使用前端寫好的store即可
  4. history
    • React有history可以提供路徑的使用,要在Node模擬可以參考使用staticRouter。

安裝相依套件

以下將以React為例,但是套件只要把React的相關套件拿掉就行了。

  1. 安裝@babel

    • 念法:外國人幾乎都唸:杯bo,但是台灣人喜歡唸:把貝喔
    • 請注意,這裡示範的版本是7.0以上,只要旁邊有@的都是7以上
    • 我這邊列出開發React有用到的babel套件,詳細請參考套件開發者的文件介紹
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      "dependencies": {
      "@babel/polyfill": "^7.8.3",
      "@babel/runtime": "^7.10.5",
      },
      "devDependencies": {
      "@babel/core": "^7.0.0",
      "@babel/plugin-proposal-class-properties": "^7.0.0",
      "@babel/plugin-proposal-object-rest-spread": "^7.0.0",
      "@babel/plugin-syntax-dynamic-import": "^7.0.0",
      "@babel/plugin-transform-runtime": "^7.0.0",
      "@babel/preset-env": "^7.0.0",
      "@babel/preset-react": "^7.0.0",
      "babel-core": "^7.0.0-bridge.0",
      "babel-eslint": "^9.0.0",
      "babel-jest": "^23.4.2",
      "babel-loader": "^8.0.0",
      "babel-plugin-import": "^1.6.7",
      "babel-preset-react-optimize": "^1.0.1",
      }
  2. 根目錄加入babel.config.js
    舊版的babel是使用.babelrc,新版本的統一命名都是babel.config.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    module.exports = () => {
    return {
    presets: [
    '@babel/preset-env',
    '@babel/preset-react'
    ],
    plugins: [
    '@babel/plugin-transform-runtime',
    '@babel/plugin-proposal-class-properties',
    ]
    }
    }
  3. 根目錄加入.eslintrc(非必要 optional)
    加入eslint可以更嚴格把關你的code

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "extends": "airbnb",
    "rules": {
    "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
    },
    "env" :{
    "browser": true,
    }
    }
  4. 加入webpack.config.js
    請注意套件的版本!之前開發一直被套件版本搞混,這邊可能會花你最多時間研究
    例如舊版的這樣就行:

    1
    const CleanWebpackPlugin = require('clean-webpack-plugin');

    但是新版的要這樣使用:

    1
    const { CleanWebpackPlugin } = require('clean-webpack-plugin');

以下是範例:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
const path = require('path');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpackNodeExternals = require('webpack-node-externals');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const publicPath = '/';
const shouldUseRelativeAssetPaths = publicPath === './';

const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.(scss|sass)$/;

const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
{
loader: MiniCssExtractPlugin.loader,
options: Object.assign(
{},
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
)
},
{
loader: require.resolve('css-loader'),
options: cssOptions
},
{
loader: require.resolve('postcss-loader'),
options: {
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009'
},
stage: 3
})
],
sourceMap: true
}
}
].filter(Boolean);
if (preProcessor) {
loaders.push({
loader: require.resolve(preProcessor),
options: {
sourceMap: true
}
});
}
return loaders;
};

module.exports = {
entry: ['@babel/polyfill', './src/server.js'],
output: {
filename: 'server.js',
path: path.resolve(__dirname, '..', 'server'),
},
resolve: {
extensions: ['.js', '.jsx', '.json'],
modules: [path.resolve(__dirname, '..', 'src'), 'node_modules']
},
node: {
__dirname: false
},
target: 'node',
module: {
rules: [
{
oneOf: [
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
publicPath: publicPath
}
},
{
test: /\.jsx?$/,
exclude: /(\/|\\)node_modules(\/|\\)/,
use: {
loader: 'babel-loader'
},
},
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: true
}),
sideEffects: true
},
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: true,
modules: true,
getLocalIdent: getCSSModuleLocalIdent
})
},
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: true
},
'sass-loader'
),
sideEffects: true
},
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: true,
modules: true,
getLocalIdent: getCSSModuleLocalIdent
},
'sass-loader'
)
},
{
loader: require.resolve('file-loader'),
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
name: 'static/media/[name].[hash:8].[ext]'
}
}
]
}],
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.BABEL_ENV': 'node'
}),
new ExtractTextPlugin({
filename: 'css/style.[hash].css',
allChunks: true,
}),
new MiniCssExtractPlugin({
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css'
}),
new webpack.optimize.OccurrenceOrderPlugin(),
]
};

相信你看到需要裝這麼多相依套件有點頭暈了吧?
但是很多主流的工具,例如:Sass,都是必須經過轉譯才能讓Node解讀的。
如果有對套件本身不理解的部分,可以去查看官方文件。

在server的進入點加入渲染

這裡展示Node的渲染進入點,渲染的方式需要模擬啟動前端(React)所需要執行的步驟。
這裡大概展示了基本的骨架,另外也引入了前端寫好的一些元件
例如:store, routes……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import nodeFetch from 'node-fetch';
import fs from 'fs';
import React from 'react';
import { Provider } from 'react-redux';
import { renderToString } from 'react-dom/server';
import { matchRoutes } from 'react-router-config';
import { StaticRouter, Switch } from 'react-router-dom';
import Helmet from 'react-helmet';
import App from './App.js';
import configureStore from './config/configureStore.js';
import Routes from './Routes';
app.get('/*', (req, res) => {
// ...
});

處理路徑與Store

再來我們會在裡面添加之前在前端寫好的store
下面這個路徑會在每次切換路徑的時候執行
因此需要讓在此判斷網址的路徑為何,再跑指定的Route
這裡解析路徑的方式直接調用request的參數即可

1
2
3
4
5
app.get('/*', (req, res) => {
let store = configureStore({});
const queryString = Object.keys(req.query).map(key => key + '=' + req.query[key]).join('&');
const location = { query: req.query, pathname: req.path, search: queryString };
});

遍歷前端的Routes

這邊使用react-router-config提供的matchRoutes搭配剛剛取得的指定路徑來走訪component
補充:網路上大部分的寫法都是直接把loadData寫在每個components裡面,但是為了保持每次專案開發的整潔度
不要動到太多component以利之後管理git追蹤歷史紀錄,因此建議把loadData固定寫在Routes裡面即可
相較其他人寫的版本,這裡算是優化過的寫法:

1
2
3
4
5
6
7
8
9
app.get('/*', (req, res) => {
let store = configureStore({});
const queryString = Object.keys(req.query).map(key => key + '=' + req.query[key]).join('&');
const location = { query: req.query, pathname: req.path, search: queryString };

const promises = matchRoutes(Routes, req.path).map(({ route, match }) => {
return route.getInitialData ? route.getInitialData(store, match, location) : Promise.resolve(null);
});
});

開始在Node渲染HTML

將要指定路徑要回傳的DOM包裝成promise之後,再來就可以渲染成HTML了
下面就是大家熟悉的進入點-root,將要渲染的HTML放在裡面,就可以完成伺服器產生HTML的過程了。

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
app.get('/*', (req, res) => {
let store = configureStore({});
const queryString = Object.keys(req.query).map(key => key + '=' + req.query[key]).join('&');
const location = { query: req.query, pathname: req.path, search: queryString };

const promises = matchRoutes(Routes, req.path).map(({ route, match }) => {
return route.getInitialData ? route.getInitialData(store, match, location) : Promise.resolve(null);
});

Promise.all(promises).then(() => {
const rootString = renderToString(
<Provider store={store}>
<StaticRouter location={location} context={context}>
<Switch>
<App />
</Switch>
</StaticRouter>
</Provider>
);

fs.readFile('./build/index.html', 'utf8', (err, data) => {
if (err) throw err;
const document = data.replace(/<div id="root"><\/div>/, `<div id="root">${rootString}</div>`);
res.status(200).send(document);
});
}).catch(err => console.error(err));
});

不過這樣還不是完美的同構程式,你會發現前端在渲染的時候會有initial State,但是Node這邊卻沒有
因此需要在後面加上initial State讓Node取得初始資料。

使用Helmet

另外為了方便管理和同步meta tags,這邊會使用Helmet來inject前端所用到的meta tags。
同時也會使用Helmet來保護HTML的tags不被注入惡意程式。

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
app.get('/*', (req, res) => {
let store = configureStore({});
const queryString = Object.keys(req.query).map(key => key + '=' + req.query[key]).join('&');
const location = { query: req.query, pathname: req.path, search: queryString };

const promises = matchRoutes(Routes, req.path).map(({ route, match }) => {
return route.getInitialData ? route.getInitialData(store, match, location) : Promise.resolve(null);
});

Promise.all(promises).then(() => {
const rootString = renderToString(
<Provider store={store}>
<StaticRouter location={location} context={context}>
<Switch>
<DefaultLayout />
</Switch>
</StaticRouter>
</Provider>
);

fs.readFile('./build/index.html', 'utf8', (err, data) => {
if (err) throw err;
const initState = store.getState();
const helmet = Helmet.renderStatic();
const html = injectHTML(data, {
html: helmet.htmlAttributes.toString(),
title: helmet.title.toString(),
meta: helmet.meta.toString(),
script: helmet.script.toString(),
body: rootString,
initState: initState
});
res.status(200).send(html);
});
}).catch(err => console.error(err));
});

注入tags

這邊主要展示React如何注入初始化資料,還有其他你想加入的tags。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const injectHTML = (data, {
html, title, meta, script, body, initState
}) => {
meta = decodeEntity(meta);
data = data.replace('<html>', `<html ${html}>`);
data = data.replace(/<title>.*?<\/title>/g, title);
data = data.replace('</head>', `${meta}</head>`);
data = data.replace('<body>', `<body> \n ${script}<script>window.__INITIAL_STATE__ =${JSON.stringify(initState)}</script>`);
data = data.replace(
'<div id="root"></div>',
`<div id="root">${body}</div>`
);
return data;
};

過濾編碼

這是我額外Bonus給讀者的,如果你發現從Node產生出來的HTML會把一些特殊符號變成其他編碼方式
擔心會影響SEO的話,我這邊展示一個方式,搭配正規運算式可以修正HTML tags。

1
2
3
function decodeEntity(htmlTags) {
return htmlTags.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
}

開始整合前端與Node

Node和前端都準備好之後,再來就是一起整合了,讓我們回到前端的routes
getInitialData需要放入actions來初始化資料。
網路上有很多人的做法是,把很多actions都擠在裡面
不過這邊建議讀者可以直接另外寫一個SSR的action方便管理
因此這邊使用到的initialUserPage是另外寫的一個action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Home from './containers/home';
import Subpage from './containers/subpage';
import { initialUserPage } from './actions/ssr';
const routes = [
{
path: '/',
exact: true,
component: Home,
},
{
path: '/subpage',
component: Subpage,
getInitialData: (store, match, location) => {
const userId = location.query.userId;
return initialUserPage(store, userId);
}
},
];

在這裡你可能會疑惑,為什麼要從Node引入store,主要是為了確保資料一開始產生的時候,就產生在store
如果產生好store,後面再呼叫action,其實是無法達到資料初始化的效果,也因此就無法同構了
所以,這邊不建議直接在這個action裡面寫dispatch,應該使用store的dispatch

1
2
3
4
5
6
import { loadUser, loadArea } from './common';

export const initialUserPage = async (store, userId) => {
await store.dispatch(loadUser(userId));
await store.dispatch(loadArea());
};

總結

這樣大體上就完成了SPA的同構渲染了!
有沒有覺得挺繁瑣的呢?主要是因為,需要考量到套件, 前端, Node等…
太多相依功能了,所以開發起來很不容易