先來稍微介紹一下為什麼要做 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);
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"
},
心得
整個寫完只需要三個檔案,看似簡單,但其實還有非常多部分還未處理,例如 router,要能夠在 server 處理各種路徑 render。開發時需要 hot reload,否則每次更新都要 build。這邊有看到有人有使用 nodeman 處理。各種檔案資源的壓縮優化,這就要依靠 webpack。
以上問題 next.js 都有提供方法處理,官方還有各種工具整合的 sample code,雖然我自己不太愛 next.js,但它真的解決不少問題。(但是 safari back 存在各種 bug…)
如果有錯誤的地方,還麻煩提出,感謝閱讀。