前言
自前端框架(React
,Vue
,Angelar
)出現以來,每個框架攜帶不同理念,分為三大陣營,以前使用JQuery
的時代已經成為過去,以前每個頁面就是一個HTML
,引入相對應的JS
、CSS
,同時在HTML
中書寫DOM
。正因為是這樣,每次使用者訪問進來,由於HTML
中有DOM
的存在,給使用者的感覺響應其實並不是很慢。
但是自從使用了框架之後,無論是多少個頁面,就是單獨一個單頁面,即SPA
。HTML
中所有的DOM
元素,必須在客戶端下載完js
之後,通過呼叫執行React.render()
才能夠進行渲染,所以就有了很多網站上,一進來很長時間的loading
動畫。
為了解決這一併不是很友好的問題,社群上提出了很多方案,例如預渲染
、SSR
、同構
。
當然這篇文章主要講述的是從零開始搭建一個React伺服器渲染同構。
選擇方案
方案一 使用社群精選框架Next.js
Next.js
是一個輕量級的 React
服務端渲染應用框架。有興趣的可以去Next.js
官網學習下。
方案二 同構
關於同構有兩種方案:
通過babel轉義node端程式碼和React程式碼後執行
let app = express();
app.get('/todo', (req, res) => {
let html = renderToString(
<Route path="/" component={ IComponent } >
<Route path="/todo" component={ AComponent }>
</Route>
</Route>)
res.send( indexPage(html) )
}
})
複製程式碼
在這裡有兩個問題需要處理:
Node
不支援前端的import
語法,需要引入babel
支援。Node
不能解析標籤語法。
所以執行Node
時,需要使用babel
來進行轉義,如果出現錯誤了,也無從查起,個人並不推薦這樣做。
所以這裡採用第二種方案
webpack進行編譯處理
使用webpack
打包兩份程式碼,一份用於Node
進行伺服器渲染,一份用於瀏覽器進行渲染。
下面具體詳細說明下。
搭建Node伺服器
由於使用習慣,經常使用Egg
框架,而Koa
是Egg
的底層框架,因此,這裡我們採用Koa
框架進行服務搭建。
搭建最基本的一個Node
服務。
const Koa = require('koa');
const app = new Koa();
app.listen(3000, () => {
console.log("伺服器已啟動,請訪問http://127.0.0.1:3000")
});
複製程式碼
配置webpack
眾所周知,React
程式碼需要經過打包編譯才能執行的,而服務端和客戶端執行的程式碼只有一部分相同,甚至有些程式碼根本不需要將程式碼打包,這時就需要將客戶端程式碼和服務端執行的程式碼分開,也就有了兩份webpack
配置
webpack
將同一份程式碼,通過不同的webpack
配置,分別為serverConfig
和clientConfig
,打包為兩份程式碼。
serverConfig和clientConfig配置
通過webpack文件我們可以知道,webpack不僅可以編譯web端程式碼還可以編譯其他內容。
這裡我們將target
設為node
。
配置入口檔案和出口位置:
const serverConfig = {
target: 'node',
entry: {
page1: './web/render/serverRouter.js',
},
resolve,
output: {
filename: '[name].js',
path: path.resolve(__dirname, './app/build'),
libraryTarget: 'commonjs'
}
}
複製程式碼
注意⚠
服務端配置需要配置libraryTarget
,設定commonjs
或者umd
,用於服務端進行require
引用,不然require
值為{}
。
在這裡客戶端和服務端配置沒有什麼區別,無需配置target
(預設web
環境),其他入門檔案和輸出檔案不一致。
const clientConfig = {
entry: {
page1: './web/render/clientRouter.js'
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, './public')
}
}
複製程式碼
配置babel
由於打包的是React
程式碼,因此還需要配置babel
。
新建.babelrc
檔案。
{
"presets": ["@babel/preset-react",
["@babel/preset-env",{
"targets": {
"browsers": [
"ie >= 9",
"ff >= 30",
"chrome >= 34",
"safari >= 7",
"opera >= 23",
"bb >= 10"
]
}
}]
],
"plugins": [
[
"import",
{ "libraryName": "antd", "style": true }
]
]
}
複製程式碼
這份配置由服務端和客戶端共用,用來處理React
和轉義為ES5
和瀏覽器相容問題。
處理服務端引用問題
服務端使用CommonJS
規範,而且服務端程式碼也並不需要構建,因此,對於node_modules中的依賴並不需要打包,所以藉助webpack
第三方模組webpack-node-externals
來進行處理,經過這樣的處理,兩份構建過的檔案大小已經相差甚遠了。
處理css
服務端和客戶端的區別,可能就在於一個預設處理,一個需要將CSS
單獨提取出為一個檔案,和處理CSS
字首。
服務端配置
{
test: /\.(css|less)$/,
use: [
{
loader: 'css-loader',
options: {
importLoaders: 1
}
},
{
loader: 'less-loader',
}
]
}
複製程式碼
客戶端配置
{
test: /\.(css|less)$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader'
},
{
loader: 'postcss-loader',
options: {
plugins: [
require('precss'),
require('autoprefixer')
],
}
},
{
loader: 'less-loader',
options: {
javascriptEnabled: true,
// modifyVars: theme //antd預設主題樣式
}
}
],
}
複製程式碼
SSR 中客戶端渲染與伺服器端渲染路由程式碼的差異
實現 React
的 SSR
架構,我們需要讓相同的程式碼在客戶端和服務端各自執行一遍,但是這裡各自執行一遍,並不包括路由端的程式碼,造成這種原因主要是因為客戶端是通過位址列來渲染不同的元件的,而服務端是通過請求路徑來進行元件渲染的。
因此,在客戶端我們採用BrowserRouter
來配置路由,在服務端採用StaticRouter
來配置路由。
客戶端配置
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from "react-router-dom";
import Router from '../router';
function ClientRender() {
return (
<BrowserRouter >
<Router />
</BrowserRouter>
)
}
複製程式碼
服務端配置
import React from 'react';
import { StaticRouter } from 'react-router'
import Router from '../router.js';
function ServerRender(req, initStore) {
return (props, context) => {
return (
<StaticRouter location={req.url} context={context} >
<Router />
</StaticRouter>
)
}
}
export default ServerRender;
複製程式碼
再次配置Node進行伺服器渲染
上面配置的伺服器,只是簡單啟動個服務,沒有深入進行配置。
引入ReactDOMServer
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const koaStatic = require('koa-static');
const router = new KoaRouter();
const routerManagement = require('./app/router');
const manifest = require('./public/manifest.json');
/**
* 處理連結
* @param {*要進行伺服器渲染的檔名預設是build資料夾下的檔案} fileName
*/
function handleLink(fileName, req, defineParams) {
let obj = {};
fileName = fileName.indexOf('.') !== -1 ? fileName.split('.')[0] : fileName;
try {
obj.script = `<script src="${manifest[`${fileName}.js`]}"></script>`;
} catch (error) {
console.error(new Error(error));
}
try {
obj.link = `<link rel="stylesheet" href="${manifest[`${fileName}.css`]}"/>`;
} catch (error) {
console.error(new Error(error));
}
//伺服器渲染
const dom = require(path.join(process.cwd(),`app/build/${fileName}.js`)).default;
let element = React.createElement(dom(req, defineParams));
obj.html = ReactDOMServer.renderToString(element);
return obj;
}
/**
* 設定靜態資源
*/
app.use(koaStatic(path.resolve(__dirname, './public'), {
maxage: 0, //瀏覽器快取max-age(以毫秒為單位)
hidden: false, //允許傳輸隱藏檔案
index: 'index.html', // 預設檔名,預設為'index.html'
defer: false, //如果為true,則使用後return next(),允許任何下游中介軟體首先響應。
gzip: true, //當客戶端支援gzip時,如果存在副檔名為.gz的請求檔案,請嘗試自動提供檔案的gzip壓縮版本。預設為true。
}));
/**
* 處理響應
*
* **/
app.use((ctx) => {
let obj = handleLink('page1', ctx.req, {});
ctx.body = `
<!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>koa-React伺服器渲染</title>
${obj.link}
</head>
<body>
<div id='app'>
${obj.html}
</div>
</body>
${obj.script}
</html>
`
})
app.listen(3000, () => {
console.log("伺服器已啟動,請訪問http://127.0.0.1:3000")
});
複製程式碼
這裡涉及一個manifest
檔案,這個檔案是webpack
外掛webpack-manifest-plugin
生成的,裡面包含編譯後的地址和檔案。大概結構是這樣:
{
"page1.css": "page1.css",
"page1.js": "page1.js"
}
複製程式碼
我們把他引入到clientConfig
中,新增如下配置:
...
plugins: [
// 提取樣式,生成單獨檔案
new MiniCssExtractPlugin({
filename: `[name].css`,
chunkFilename: `[name].chunk.css`
}),
new ManifestPlugin()
]
複製程式碼
在上述服務端程式碼中,我們對於ServerRender.js
進行了柯里化處理,這樣做的目的在於,我們在ServerRender
中,使用了服務端可以識別的StaticRouter
,並配置了location
引數,而location
需要引數URL
。
因此,我們需要在renderToString
中傳遞req
,以讓服務端能夠正確解析React元件。
let element = React.createElement(dom(req, defineParams));
obj.html = ReactDOMServer.renderToString(element);
複製程式碼
通過handleLink
的解析,我們可以得到一個obj
,包含三個引數,link
(css
連結),script
(JS
連結)和html
(生成Dom
元素)。
通過ctx.body
渲染html
。
renderToString()
將 React
元素渲染到其初始 HTML
中。 該函式應該只在伺服器上使用。 React
將返回一個 HTML
字串。 您可以使用此方法在伺服器上生成 HTML
,並在初始請求時傳送標記,以加快網頁載入速度,並允許搜尋引擎抓取你的網頁以實現 SEO
目的。
如果在已經具有此伺服器渲染標記的節點上呼叫 ReactDOM.hydrate()
,React
將保留它,並且只附加事件處理程式,從而使您擁有非常高效能的第一次載入體驗。
renderToStaticMarkup()
類似於 renderToString
,除了這不會建立 React
在內部使用的額外DOM
屬性,如 data-reactroot
。 如果你想使用React
作為一個簡單的靜態頁面生成器,這很有用,因為剝離額外的屬性可以節省一些位元組。
但是如果這種方法是在瀏覽訪問之後,會全部替換掉服務端渲染的內容,因此會造成頁面閃爍,所以並不推薦使用該方法。
renderToNodeStream()
將 React
元素渲染到其最初的 HTML
中。返回一個 可讀的 流(stream
) ,即輸出 HTML
字串。這個 流(stream
) 輸出的 HTML
完全等同於 ReactDOMServer.renderToString
將返回的內容。
我們也可以使用上述renderToNodeSteam
將其改造下:
let element = React.createElement(dom(req, defineParams));
ctx.res.write('
<html>
<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>koa-React伺服器渲染</title>
</head><body><div id="app">');
// 把元件渲染成流,並且給Response
const stream = ReactDOMServer.renderToNodeStream(element);
stream.pipe(ctx.res, { end: 'false' });
// 當React渲染結束後,傳送剩餘的HTML部分給瀏覽器
stream.on('end', () => {
ctx.res.end('</div></body></html>');
});
複製程式碼
renderToStaticNodeStream()
類似於 renderToNodeStream
,除了這不會建立 React
在內部使用的額外DOM
屬性,如 data-reactroot
。 如果你想使用 React
作為一個簡單的靜態頁面生成器,這很有用,因為剝離額外的屬性可以節省一些位元組。
這個 流(stream
) 輸出的 HTML
完全等同於 ReactDOMServer.renderToStaticMarkup
將返回的內容。
新增狀態管理redux
以上開發一個靜態網站,或者一個相對於比較簡單的專案已經OK
了,但是對於複雜的專案,這些還遠遠不夠,這裡,我們再給它加上全域性狀態管理Redux
。
伺服器渲染中其順序是同步的,因此,要想在渲染時出現首屏資料渲染,必須得提前準備好資料。
- 提前獲取資料
- 初始化store
- 根據路由顯示元件
- 結合資料和元件生成 HTML,一次性返回
對於客戶端來說新增redux
和常規的redux
並無太大差別,只是對於store
新增了一個初始的window.__INIT_STORE__
。
let initStore = window.__INIT_STORE__;
let store = configStore(initStore);
function ClientRender() {
return (
<Provider store={store}>
<BrowserRouter >
<Router />
</BrowserRouter>
</Provider>
)
}
複製程式碼
而對於服務端來說在初始資料獲取完成之後,可以採用Promise.all()
來進行併發請求,當請求結束時,將資料填充到script
標籤內,命名為window.__INIT_STORE__
。
`<script>window.__INIT_STORE__ = ${JSON.stringify(initStore)}</script>`
複製程式碼
然後將服務端的store
重新配置下。
function ServerRender(req, initStore) {
let store = CreateStore(JSON.parse(initStore.store));
return (props, context) => {
return (
<Provider store={store}>
<StaticRouter location={req.url} context={context} >
<Router />
</StaticRouter>
</Provider>
)
}
}
複製程式碼
整理Koa
考慮後面開發的便利性,新增如下功能:
- Router功能
- HTML模板
新增Koa-Router
/**
* 註冊路由
*/
const router = new KoaRouter();
const routerManagement = require('./app/router');
...
routerManagement(router);
app.use(router.routes()).use(router.allowedMethods());
複製程式碼
為了保證開發時,介面規整,這裡將所有的路由都提到一個新的檔案中進行書寫。並保證如以下格式:
/**
*
* @param {router 例項化物件} router
*/
const home = require('./controller/home');
module.exports = (router) => {
router.get('/',home.renderHtml);
router.get('/page2',home.renderHtml);
router.get('/favicon.ico',home.favicon);
router.get('/test',home.test);
}
複製程式碼
處理模板
將html
放入程式碼中,給人感覺並不是很友好,因此,這裡同樣引入了服務模板koa-nunjucks-2
。
同時在其上在套一層中介軟體,以便傳遞引數和處理各種靜態資源連結。
...
const koaNunjucks = require('koa-nunjucks-2');
...
/**
* 伺服器渲染,渲染HTML,渲染模板
* @param {*} ctx
*/
function renderServer(ctx) {
return (fileName, defineParams) => {
let obj = handleLink(fileName, ctx.req, defineParams);
// 處理自定義引數
defineParams = String(defineParams) === "[object Object]" ? defineParams : {};
obj = Object.assign(obj, defineParams);
ctx.render('index', obj);
}
}
...
/**
* 模板渲染
*/
app.use(koaNunjucks({
ext: 'html',
path: path.join(process.cwd(), 'app/view'),
nunjucksConfig: {
trimBlocks: true
}
}));
/**
* 渲染Html
*/
app.use(async (ctx, next) => {
ctx.renderServer = renderServer(ctx);
await next();
});
複製程式碼
在使用者訪問該伺服器時,通過呼叫renderServer
函式,處理連結,執行到最後,呼叫ctx.render
完成渲染。
/**
* 渲染react頁面
*/
exports.renderHtml = async (ctx) => {
let initState = ctx.query.state ? JSON.parse(ctx.query.state) : null;
ctx.renderServer("page1", {store: JSON.stringify(initState ? initState : { counter: 1 }) });
}
exports.favicon = (ctx) => {
ctx.body = null;
}
exports.test = (ctx) => {
ctx.body = {
data: `測試資料`
}
}
複製程式碼
關於koa-nunjucks-2
中,在渲染HTML
時,會將有< >
進行安全處理,因此,我們還需對我們傳入的資料進行過濾處理。
<!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>koa-React伺服器渲染</title>
{{ link | safe }}
</head>
<body>
<div id='app'>
{{ html | safe }}
</div>
</body>
<script>
window.__INIT_STORE__ = {{ store | safe }}
</script>
{{ script | safe }}
</html>
複製程式碼
文件結構
├── README.md
├── app //node端業務程式碼
│ ├── build
│ │ ├── page1.js
│ │ └── page2.js
│ ├── controller
│ │ └── home.js
│ ├── router.js
│ └── view
│ └── index.html
├── index.js
├── package.json
├── public //前端靜態資源
│ ├── manifest.json
│ ├── page1.css
│ ├── page1.js
│ ├── page2.css
│ └── page2.js
├── web //前端原始碼
│ ├── action //redux -action
│ │ └── count.js
│ ├── components //元件
│ │ └── layout
│ │ └── index.jsx
│ ├── pages //主頁面
│ │ ├── page
│ │ │ ├── index.jsx
│ │ │ └── index.less
│ │ └── page2
│ │ ├── index.jsx
│ │ └── index.less
│ ├── reducer //redux -reducer
│ │ ├── counter.js
│ │ └── index.js
│ ├── render //webpack入口檔案
│ │ ├── clientRouter.js
│ │ └── serverRouter.js
│ ├── router.js //前端路由
│ └── store //store
│ └── index.js
└── webpack.config.js
複製程式碼
最後
目前這個架構目前只能手動啟動Koa
服務和啟動webpack
。
如果需要將Koa和webpack跑在一塊,這裡就涉及另外一個話題了,在這裡可以檢視我一開始寫的文章。
如果需要了解一個完整的伺服器需要哪些功能,可以瞭解我早期的文章。
最後GITHUB
地址如下:
參考資料: