本文是直接著手SSR部分的並通過實戰講述自己遇到的一些問題和方案,需要大家有一定的React,node和webpack基礎能力。skr,skr。
服務端渲染
Server Slide Rendering
服務端渲染,又簡寫為SSR
,他一般被用在我們的SPA(Single-Page Application)
,即單頁應用。
為什麼要用SSR?
首先我們需要知道SSR對於SPA的好處
,優勢
是什麼。
- 更好的
SEO(Search Engine Optimization)
,SEO
是搜尋引擎優化,簡而言之就是針對百度這些搜尋引擎,可以讓他們搜尋到我們的應用。這裡可能會有誤區,就是我也可以在index.html上寫SEO
,為什麼會不起作用。因為React、Vue的原理是客戶端渲染,通過瀏覽器去載入js、css,有一個時間上的延遲
,而搜尋引擎不會管你的延遲
,他就覺得你如果沒載入出來就是沒有的,所以是搜不到的。 - 解決一開始的
白屏渲染
,上面講了React的渲染原理,而SSR服務端渲染是通過服務端請求資料,因為服務端內網的請求快,效能好所以會更快的載入所有的檔案,最後把下載渲染後的頁面返回給客戶端。
上面提到了服務端渲染和客戶端渲染,那麼它們的區別是什麼呢?
客戶端渲染路線:
- 請求一個html
- 服務端返回一個html
- 瀏覽器下載html裡面的js/css檔案
- 等待js檔案下載完成
- 等待js載入並初始化完成
- js程式碼終於可以執行,由js程式碼向後端請求資料( ajax/fetch )
- 等待後端資料返回
- react-dom( 客戶端 )從無到完整地,把資料渲染為響應頁面
服務端渲染路線:
- 請求一個html
- 服務端請求資料( 內網請求快 )
- 伺服器初始渲染(服務端效能好,較快)
- 服務端返回已經有正確內容的頁面
- 客戶端請求js/css檔案
- 等待js檔案下載完成
- 等待js載入並初始化完成
- react-dom( 客戶端 )把剩下一部分渲染完成( 內容小,渲染快 )
其主要區別就在於,客戶端從
無到有的
渲染,服務端是先在服務端渲染一部分
,在再客戶端渲染一小部分
。
我們怎麼去做服務端渲染?
我們這裡是用express框架,node做中間層進行服務端渲染。通過將首頁進行同構處理
,讓服務端,通過呼叫ReactDOMServer.renderToNodeStream
方法把Virtual DOM
轉換成HTML字串
返回給客戶端,從而達到服務端渲染的目的。
這裡專案起步是已經做完前端和後端,是把已經寫好的React Demo直接拿來用
服務端渲染開始
既然是首頁SSR,首先我們要把首頁對應的index.js
抽離出來放入我們服務端對應的server.js
,那麼index.js
中元件對應的靜態css和js檔案
我們需要打包出來。
用webpack打包檔案到build資料夾
我們來執行npm run build
我們可以看到兩個重要的資料夾
,一個是js資料夾,一個是css資料夾,他就是我們專案的js和css靜態資原始檔
將打包後的build
檔案能在服務端server.js
中訪問到
因為是服務端,我們需要用到express
import express from `express`
import reducers from `../src/reducer`;
import userRouter from `./routes/user`
import bodyParser from `body-parser`
import cookieParser from `cookie-parser`
import model from `./model`
import path from `path`
import https from `http`
import socketIo from `socket.io`
const Chat = model.getModel(`chat`)
//新建app
const app = express()
//work with express
const server = https.Server(app)
const io = socketIo(server)
io.on(`connection`,function(socket){
socket.on(`sendmsg`,function(data){
let {from,to,msg} = data
let chatid = [from,to].sort().join(`_`)
Chat.create({chatid,from,to,content:msg},function(e,d){
io.emit(`recvmsg`,Object.assign({},d._doc))
})
// console.log(data)
// //廣播給全域性
// io.emit(`recvmsg`,data)
})
})
app.use(cookieParser())
app.use(bodyParser.json())
app.use(`/user`,userRouter)
app.use(function(req,res,next){
if(req.url.startsWith(`/user/`) || req.url.startsWith(`/static/`)){
return next()
}
//如果訪問url根路徑是user或者static就返回打包後的主頁面
return res.sendFile(path.resolve(`build/index.html`))
})
//對映build檔案路徑,專案上要使用
app.use(`/`,express.static(path.resolve(`build`)))
server.listen(8088, function () {
console.log(`開啟成功`)
})
複製程式碼
- 主要看上面的
app.use(`/`,express.static(path.resolve(`build`)))
和res.sendFile(path.resolve(`build/index.html`))
這兩段程式碼。 - 他們把打包後的主頁放入服務端程式碼中返回給客戶端。
- 因為上面我用了
import
程式碼,所以我們在開發環境中需要用到babel-cli
裡的babel-node
來編譯。 - 安裝
npm --registry https://registry.npm.taobao.org
i babel-cli -S`,大家如果覺得這樣切換源麻煩,可以下個nrm,360度無死角切換各種源,好用! - 我們需要修改
package.json
的啟動伺服器的npm scripts
。"server": "NODE_ENV=test nodemon --exec babel-node server/server.js"
cross-env
跨平臺設定node環境變數的外掛。- nodemon和supervisor一樣是watch服務端檔案,只要一改變就會重新執行,相當於
熱過載
。nodemon更輕量
- 最後我們來跑一下
npm run server
,就能看到服務端跑起來了。
ReactDOMServer.renderToString/ReactDOMServer.renderToNodeStream
- 這裡我們先講一下在
瀏覽器中
,React.createElement
把React的類進行例項化
,例項化後的元件可以進行mount
,最後通過React.render
渲染到我們的客戶端瀏覽器介面。 - 而在伺服器中我們可以通過
renderToString
或者renderToNodeStream
方法把React例項化的元件,直接渲染生成html標籤。那麼這倆個有什麼區別呢? renderToNodeStream
是React 16最新發布的東西,它支援直接渲染到節點流。渲染到流可以減少你的內容的第一個位元組(TTFB)
的時間,在文件的下一部分生成之前,將文件的開頭至結尾傳送到瀏覽器。 當內容從伺服器流式傳輸時,瀏覽器將開始解析HTML文件。速度是renderToString的三倍
,所以我們在這裡使用renderToNodeStream
import express from `express`
import React from `react`
import {renderToStaticMarkup,renderToNodeStream} from `react-dom/server`
import thunk from `redux-thunk`;
import { Provider } from `react-redux`;
import {StaticRouter} from `react-router-dom`
import {
createStore,
applyMiddleware,
//組合函式用的
compose
} from `redux`;
import App from `../src/App`
import reducers from `../src/reducer`;
import userRouter from `./routes/user`
import bodyParser from `body-parser`
import cookieParser from `cookie-parser`
import model from `./model`
import path from `path`
import https from `http`
import socketIo from `socket.io`
const Chat = model.getModel(`chat`)
//新建app
const app = express()
//work with express
const server = https.Server(app)
const io = socketIo(server)
io.on(`connection`,function(socket){
socket.on(`sendmsg`,function(data){
let {from,to,msg} = data
let chatid = [from,to].sort().join(`_`)
Chat.create({chatid,from,to,content:msg},function(e,d){
io.emit(`recvmsg`,Object.assign({},d._doc))
})
// console.log(data)
// //廣播給全域性
// io.emit(`recvmsg`,data)
})
})
app.use(cookieParser())
app.use(bodyParser.json())
app.use(`/user`,userRouter)
app.use(function(req,res,next){
if(req.url.startsWith(`/user/`) || req.url.startsWith(`/static/`)){
return next()
}
const store = createStore(reducers,compose(
applyMiddleware(thunk)
))
//這個 context 物件包含了渲染的結果
let context = {}
const root = (<Provider store={store}>
<StaticRouter
location={req.url}
context={context}
>
<App></App>
</StaticRouter>
</Provider>)
const markupStream = renderToNodeStream(root)
markupStream.pipe(res,{end:false})
markupStream.on(`end`,()=>{
res.end()
})
})
//對映build檔案路徑,專案上要使用
app.use(`/`,express.static(path.resolve(`build`)))
server.listen(8088, function () {
console.log(`開啟成功`)
})
複製程式碼
此時將服務端renderToNodeStream後的程式碼返回給前端,但是這個時候還是不行,我們執行一下npm run server
,可以看到報錯了。
css-modules-require-hook/asset-require-hook
css-modules-require-hook
- 因為服務端此時
不認識
我們的css檔案,我們需要安裝一個包,來讓服務端處理css檔案。 npm i css-modules-require-hook -S
安裝在生產環境下。- 在專案根目錄建立一個
crmh.conf.js
鉤子檔案進行配置,看下圖。
寫入程式碼
// css-modules-require-hook
module.exports = {
generateScopedName: `[name]__[local]___[hash:base64:5]`,
//下面的程式碼在本專案中暫時用不到,但是以下配置在我另一個專案中有用到,我來講一下他的配置
//副檔名
//extensions: [`.scss`,`.css`],
//鉤子,這裡主要做一些預處理的scss或者less檔案
//preprocessCss: (data, filename) =>
// require(`node-sass`).renderSync({
// data,
// file: filename
// }).css,
//是否匯出css類名,主要用於CSSModule
//camelCase: true,
};
複製程式碼
- 修改我們的
server.js
檔案,新增import csshook from `css-modules-require-hook/preset`
,注意⚠️,一定要把這行程式碼放在匯入App模組之前
。
import csshook from `css-modules-require-hook/preset`
//我們的首頁入口
import App from `../src/App`
複製程式碼
此時在執行server.js
,會發現又報了個錯。
asset-require-hook
- 這個錯誤是因為服務端沒有處理前端程式碼需要的圖片
- 需要安裝
npm i asset-require-hook -S
,這個外掛用來讓服務端處理圖片,注意⚠️,前提是客戶端程式碼,引用圖片都需要require
- 在
server.js
寫入程式碼
//解決圖片問題,客戶端程式碼引用圖片都需要require
import assethook from `asset-require-hook`
assethook({
extensions:[`png`],
//圖片大小下於10000的圖片會直接base64編碼
limit: 10000
})
複製程式碼
執行之後發現又報錯了,這個很簡單,因為我們只有image的引用名字,卻沒有地址
- 所以此時要在外面加個殼,把之前build之後的
靜態js、css檔案
引入進去,新增html、head這些標籤。來看完整程式碼
import `babel-polyfill`
import express from `express`
import React from `react`
import {renderToString,renderToStaticMarkup,renderToNodeStream} from `react-dom/server`
//引入css檔案和js檔案
import staticPath from `../build/asset-manifest.json`
import thunk from `redux-thunk`;
import { Provider } from `react-redux`;
import {StaticRouter} from `react-router-dom`
import {
createStore,
applyMiddleware,
//組合函式用的
compose
} from `redux`;
//解決服務端渲染的圖片問題 必須放在App之前
import csshook from `css-modules-require-hook/preset`
//解決圖片問題,需要require
import assethook from `asset-require-hook`
assethook({
extensions:[`png`],
limit: 10000
})
import App from `../src/App`
import reducers from `../src/reducer`;
import userRouter from `./routes/user`
import bodyParser from `body-parser`
import cookieParser from `cookie-parser`
import model from `./model`
import path from `path`
import https from `http`
import socketIo from `socket.io`
const Chat = model.getModel(`chat`)
//新建app
const app = express()
//work with express
const server = https.Server(app)
const io = socketIo(server)
io.on(`connection`,function(socket){
socket.on(`sendmsg`,function(data){
let {from,to,msg} = data
let chatid = [from,to].sort().join(`_`)
Chat.create({chatid,from,to,content:msg},function(e,d){
io.emit(`recvmsg`,Object.assign({},d._doc))
})
// console.log(data)
// //廣播給全域性
// io.emit(`recvmsg`,data)
})
})
app.use(cookieParser())
app.use(bodyParser.json())
app.use(`/user`,userRouter)
app.use(function(req,res,next){
if(req.url.startsWith(`/user/`) || req.url.startsWith(`/static/`)){
return next()
}
const store = createStore(reducers,compose(
applyMiddleware(thunk)
))
const obj = {
`/msg`:`聊天訊息列表`,
`/me`:`個人中心列表`
}
//這個 context 物件包含了渲染的結果
let context = {}
res.write(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta name="description" content="${obj[req.url]}"/>
<meta name="keywords" content="SSR">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="stylesheet" href="/${staticPath[`main.css`]}">
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root">`)
const root = (<Provider store={store}>
<StaticRouter
location={req.url}
context={context}
>
<App></App>
</StaticRouter>
</Provider>)
const markupStream = renderToNodeStream(root)
markupStream.pipe(res,{end:false})
markupStream.on(`end`,()=>{
res.write(`</div>
<script src="/${staticPath[`main.js`]}"></script>
</body>
</html>`)
res.end()
})
})
//對映build檔案路徑,專案上要使用
app.use(`/`,express.static(path.resolve(`build`)))
server.listen(8088, function () {
console.log(`開啟成功`)
})
複製程式碼
- 這個時候我們可以在html標籤里加上SEO的meta
<meta name="keywords" content="SSR">
- 最後還要把客戶端的
index.js
檔案中的渲染機制改成hydrate
,不用render
,他們之間的區別可以看這個(傳送門☞render !== hydrate)
ReactDOM.hydrate(
(<Provider store={store}>
<BrowserRouter>
<App></App>
</BrowserRouter>
</Provider>),
document.getElementById(`root`)
)
複製程式碼
到此為止我們開發模式下的SSR搭建完畢,接下來生產模式的坑我來講一下。
生產環境SSR準備
我們上面所講的只是開發模式下的SSR,因為我們是通過
babel-node
編譯jsx和es6程式碼
的,只要一脫離babel-node
就會全錯,所以我們需要webpack打包服務端程式碼
我們需要建立一個webserver.config.js
,用來打包server的程式碼
const path = require(`path`),
fs = require(`fs`),
webpack = require(`webpack`),
autoprefixer = require(`autoprefixer`),
HtmlWebpackPlugin = require(`html-webpack-plugin`),
ExtractTextPlugin = require(`extract-text-webpack-plugin`)
cssFilename = `static/css/[name].[contenthash:8].css`;
CleanWebpackPlugin = require(`clean-webpack-plugin`);
nodeExternals = require(`webpack-node-externals`);
serverConfig = {
context: path.resolve(__dirname, `..`),
entry: {server: `./server/server`},
output: {
libraryTarget: `commonjs2`,
path: path.resolve(__dirname, `../build/server`),
filename: `static/js/[name].js`,
chunkFilename: `static/js/chunk.[name].js`
},
// target: `node` 指明構建出的程式碼是要執行在node環境裡.
// 不把 Node.js 內建的模組打包進輸出檔案中,例如 fs net 模組等
target: `node`,
//指定在node環境中是否要這些模組
node: {
__filename: true,
__dirname: true,
// module:true
},
module: {
loaders: [{
test: /.js$/,
exclude: /node_modules/,
loader: `babel-loader?cacheDirectory=true`,
options: {
presets: [`es2015`, `react-app`, `stage-0`],
plugins: [`add-module-exports`,
[
"import",
{
"libraryName": "antd-mobile",
"style": "css"
}
],"transform-decorators-legacy"]
},
},{
test: /.css$/,
exclude: /node_modules|antd-mobile.css/,
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve(`style-loader`),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve(`css-loader`),
options: {
importLoaders: 1,
minimize: true,
modules: false,
localIdentName:"[name]-[local]-[hash:base64:8]",
// sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve(`postcss-loader`),
options: {
ident: `postcss`,
plugins: () => [
require(`postcss-flexbugs-fixes`),
autoprefixer({
browsers: [
`>1%`,
`last 4 versions`,
`Firefox ESR`,
`not ie < 9`, // React doesn`t support IE8 anyway
],
flexbox: `no-2009`,
}),
],
},
},
],
},
)
),
},
{
test: /.css$/,
include: /node_modules|antd-mobile.css/,
use: ExtractTextPlugin.extract({
fallback: require.resolve(`style-loader`),
use: [{
loader: require.resolve(`css-loader`),
options: {
modules:false
},
}]
})
}, {
test: /.(jpg|png|gif|webp)$/,
loader: require.resolve(`url-loader`),
options: {
limit: 10000,
name: `static/media/[name].[hash:8].[ext]`,
},
}, {
test: /.json$/,
loader: `json-loader`,
}]
},
// 不把 node_modules 目錄下的第三方模組打包進輸出檔案中,
externals: [nodeExternals()],
resolve: {extensions: [`*`, `.js`, `.json`, `.scss`]},
plugins: [
new CleanWebpackPlugin([`../build/server`]),
new webpack.optimize.OccurrenceOrderPlugin(),
//把第三方庫從js檔案中分離出來
new webpack.optimize.CommonsChunkPlugin({
//抽離相應chunk的共同node_module
minChunks(module) {
return /node_modules/.test(module.context);
},
//從要抽離的chunk中的子chunk抽離相同的模組
children: true,
//是否非同步抽離公共模組,引數boolean||string
async: false,
}),
new webpack.optimize.CommonsChunkPlugin({
children:true,
//若引數是string即為抽離出來後的檔名
async: `shine`,
//最小打包的檔案模組數,即要抽離的公共模組中的公共數,比如三個chunk只有1個用到就不算公共的
//若為Infinity,則會把webpack runtime的程式碼放入其中(webpack 不再自動抽離公共模組)
minChunks:2
}),
//壓縮
new webpack.optimize.UglifyJsPlugin(),
//分離css檔案
new ExtractTextPlugin({
filename: cssFilename,
}),
new webpack.IgnorePlugin(/^./locale$/, /moment$/),
],
}
module.exports = serverConfig
複製程式碼
重點⚠️
- 指定target,打包出來的程式碼執行在哪裡
- 指定externals不要把
node_modules
包打包,因為此專案執行在服務端,直接用外面的node_modules
就行。不然打包後會很大。 - loader中用babel對js的處理
ok,現在來我們改一下package.json的
npm scripts
,新增一個packServer
,順便改一下build
的scripts
"scripts": {
"clean": "rm -rf build/",
"dev": "node scripts/start.js",
"start": "cross-env NODE_ENV=development npm run server & npm run dev",
"build": "npm run clean && node scripts/build.js && npm run packServer",
"test": "nodemon scripts/test.js --env=jsdom",
"server": "cross-env NODE_ENV=test nodemon --exec babel-node server/server.js",
"gulp": "cross-env NODE_ENV=production gulp",
"packServer": "cross-env NODE_ENV=production webpack --config ./config/webserver.config.js"
},
複製程式碼
packServer
指定了生產環境,這在之後會用到。build
是先clean掉build資料夾,在去打包客戶端的程式碼
,打包完之後再去打包服務端的程式碼
那麼到這裡為止我們差不多可以自己試試了
- 先
npm run build
,會生成打包後的build資料夾,裡面包含了我們的服務端和客戶端程式碼
- 找到打包後的node檔案執行它,在
build/server/static/js
目錄下,可直接node檔案啟動。這就解決了我們生產環境下的問題。
pm2,伺服器自動部署
現在我們要把我們的專案部署到伺服器上,並用pm2守護程式。
伺服器部署
- 在部署到伺服器之前我們程式碼中還有些東西需要修改,修改mongod的連線地址.
const env = process.env.NODE_ENV || `development`
//當生產環境時,需要改變mongodb的連線埠,根據你伺服器的mongodb埠來,我這裡是19999
const BASE_URL = env == `development`?"mongodb://localhost:27017/chat":"mongodb://127.0.0.1:19999/chat";
複製程式碼
- 修改客戶端
socket.io
的連結地址const socket = io(`ws://host:port`)
,改成你自己的伺服器地址和埠號 - 我們需要將自己的專案上傳至碼雲。這裡我使用碼雲,主要是因為碼雲的私倉是免費的。
- 我們需要進入伺服器的
ssh目錄
下複製id_rsa.pub
裡的公鑰放在碼雲的ssh公鑰
中,可進入設定
,具體看圖
- 我們也要把自己電腦上的
ssh公鑰
在碼雲中設定,我這裡是mac,在自己的使用者目錄下,可以按cmd+shift+.
看隱藏檔案(如果你設定過了,這一步就不要了)。 - 伺服器安裝git,mongodb,pm2,nginx
(如果伺服器已經安裝過了,就不需要了)
- 需要開啟mongodb
- 我們在專案根目錄新建一個
ecosystem.json
檔案,這個檔案是pm2的配置檔案,具體的我就不說了,大家如果感興趣可以去官網看看,(傳送門☞pm2官網)
{
"apps": [
{
//應用名稱
"name": "chat",
//執行檔案的路徑
"script": "./build/server/static/js/server.js",
"env": {
"COMMON_VARIABLE": "true"
},
"env_production": {
"NODE_ENV": "production"
}
}
],
"deploy": {
"production": {
//伺服器使用者
"user": "xxx",
//伺服器地址
"host": ["xxx"],
//伺服器埠
"port": "xxx",
"ref": "origin/master",
//這裡填你的專案git ssh
"repo": "xxx",
//伺服器的存放專案路徑
"path": "/www/chat/production",
"ssh_options": "StrictHostKeyChecking=no",
//鉤子
"post-deploy": "npm --registry https://registry.npm.taobao.org install && npm run build && pm2 startOrRestart ecosystem.json --env production",
"env": {
//環境
"NODE_ENV": "production"
}
}
}
}
複製程式碼
- 在伺服器新建專案目錄新建
/www/chat/
資料夾。 - 在本地電腦執行
pm2 deploy ecosystem.json production setup
- 這裡大家肯定會報錯,這是我故意埋的坑,因為
chat
資料夾的許可權不夠,需要進入伺服器的www
資料夾,執行sudo chmod 777 chat
。 - 進入伺服器的.bashrc檔案,注視掉上面的幾行程式碼
source .bashrc
重新載入一下.bashrc
檔案- 開啟pm2服務 pm2 deploy ecosystem.json production
- 這裡可能有的人會報錯,主要原因是本地電腦的pm2的許可權問題,需要找到pm2資料夾,
chmod 666 pm2
- 如果上述問題都解決了最後會如圖所示
- 最後我們可以進入伺服器,
pm2 list
,看到成功跑起來了
- 如果應用在不斷的
重啟
,說明開啟失敗
了,需要pm2 logs
看看日誌
- 我們可以訪問
伺服器地址:8088
,並看到應用跑起來了
域名代理
- 我們進入阿里雲控制檯解析自己的域名(傳送門☞阿里雲)
- 新增一條記錄
- 回到伺服器,我們修改nginx配置檔案,通過反向代理,讓我們通過域名也可以訪問他
upstream chat {
server 127.0.0.1:8088;
}
server {
listen 80;
server_name www.webman.vip;
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Nginx-Proxy true;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://chat;
proxy_redirect off;
}
# 靜態檔案地址
location ~* ^.+.(jpg|jpeg|gif|png|ico|css|js|pdf|txt){
root /www/website/production/current/build;
}
}
複製程式碼
-
在伺服器執行
sudo nginx -s reload
,重啟nginx。此時我們就可以通過我們的域名地址訪問到我們的應用了。 -
這裡可能訪問會
404
,這個時候我們需要看一下我們伺服器的防火牆,sudo vi /etc/iptables.up.rules
,修改mongodb的對外埠,並且重啟防火牆sudo iptables-restore < /etc/iptables.up.rules
-A INPUT -s 127.0.0.1 -p tcp --destination-port 8088 -m state --state NEW,ESTABLISHED -j ACCEPT
-A OUTPUT -d 127.0.0.1 -p tcp --source-port 8088 -m state --state ESTABLISHED -j ACCEPT
複製程式碼
- 檢視阿里雲控制檯的安全組是否開了對應的埠
-
最後最後!!!,終於成功了。可以點選連結檢視一下。 走你!
-
當然下次如果你想直接更新專案,可以在專案對應的路徑提交到
git
上,然後再使用pm2 deploy ecosystem.json production
即可在伺服器上自動部署
。