技術棧:webpack3.9.1+webpack-dev-server2.9.5+React16.x + express4.xjavascript
(好慌!多是由於我很懶,致使...,而後,好吧,我比較懶,沒有而後了。。。切入正題ing,let's do it!!!)html
網上關於React的SSR也不少,但都不夠詳細,有的甚至讓初學者一頭霧水。不過這篇文章我將一步步詳細的介紹,從0開始配置React SSR,讓每一個看到文章的人都能上手。java
Server Slide Rendering,縮寫爲 SSR,即服務器端渲染,由於是以前搞java出身,也明白是怎麼回事,其實SSR主要針對 SPA應用,目的大概有如下幾個:node
- 解決單頁面應用的 SEO
單頁應用頁面大部分主要的 HTML並非服務器返回,服務器只是返回一大串的腳本,頁面上看到的大部份內容都是由腳本生成,對於通常網站影響不大,可是對於一些依賴搜索引擎帶來流量的網站來講則是致命的,搜索引擎沒法抓取頁面相關內容,也就是用戶搜不到此網站的相關信息,天然也就無流量可言。- 解決渲染白屏
由於頁面 HTML由服務器端返回的腳本生成,通常來講這種腳本的體積都不會過小,客戶端下載須要時間,瀏覽器解析以生成頁面元素也須要時間,這必然會致使頁面的顯示速度比傳統服務器端渲染得要慢,很容易出現首頁白屏的狀況,甚至若是瀏覽器禁用了 JS,那麼將直接致使頁面連基本的元素都看不到。
react-dom是React專門爲web端開發的渲染工具。咱們能夠在客戶端使用react-dom的render方法渲染組件,而在服務端,react-dom/server提供咱們將react組件渲染成html的方法。react
瀏覽器渲染與服務端渲染對好比下:(其中紅色框內就是服務端渲染,很顯然比起瀏覽器渲染快了不少)webpack
項目結構圖以下: ios
build文件夾 用來配置webpack環境git
package.json:github
{
"name": "juejin-reactssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build:client": "webpack --config build/webpack.config.client.js",
"build:server": "webpack --config build/webpack.config.server.js",
"clear": "rimraf dist",
"build": "npm run clear && npm run build:client && npm run build:server",
"start":"node server/server.js"
},
"author": "Jerry",
"license": "ISC",
"dependencies": {
"express": "^4.16.3",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-preset-es2015": "^6.24.1",
"babel-preset-es2015-loose": "^8.0.0",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"cross-env": "^5.1.1",
"file-loader": "^1.1.5",
"html-webpack-plugin": "^2.30.1",
"http-proxy-middleware": "^0.17.4",
"memory-fs": "^0.4.1",
"react-hot-loader": "^3.1.3",
"rimraf": "^2.6.2",
"uglifyjs-webpack-plugin": "^1.1.2",
"webpack": "^3.9.1",
"webpack-dev-server": "^2.9.5",
"webpack-merge": "^4.1.2"
}
}
webpack.config.base.js:
```javascript const path = require('path') module.exports = { output: { path: path.join(__dirname, '../dist'), publicPath: '/public/', }, devtool:"source-map", module: { rules: [ { test: /.(js|jsx)$/, loader: 'babel-loader', exclude: [ path.resolve(__dirname, '../node_modules') ] } ] }, } 複製代碼
webpack.config.server.js:
```javascript
//此js用來將client/server-entry.js 打包成node可以執行的文件
const path = require('path')
const webpackMerge = require('webpack-merge')
const baseConfig = require('./webpack.config.base')
const config=webpackMerge(baseConfig,{
target: 'node',//打包成node端執行
entry: {
app: path.join(__dirname, '../client/server-entry.js'),
},
output: {
filename: 'server-entry.js',
libraryTarget: 'commonjs2'//使用配置方案 commonjs2
},
})
module.exports = config
複製代碼
client文件夾 客戶端用來打包上線web
app.js:
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App.jsx'
ReactDOM.render(<App/>, document.getElementById('root'))
複製代碼
App.jsx:
import React from 'react'
export default class App extends React.Component{
render(){
return (
<div> App </div>
)
}
}
複製代碼
server-entry.js:此文件用來生成服務器渲染所需模板
//服務端用來渲染的模板
import React from 'react'
import App from './App.jsx'
export default <App/>
複製代碼
template.html:
<!DOCTYPE html>
<html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <div id="root"><!-- app --></div> </body> </html> 複製代碼
server文件夾 對應服務端
const express = require('express')
const ReactSSR = require('react-dom/server')
const serverEntry = require('../dist/server-entry')
const app = express()
app.get('*', function (req, res) {
//ReactDOMServer.renderToString則是把React實例渲染成HTML標籤
let appString = ReactSSR.renderToString(serverEntry.default);
//返回給客戶端
res.send(appString);
})
app.listen(3000, function () {
console.log('server is listening on 3000 port');
})
複製代碼
咱們運行 npm start ,打開瀏覽器輸入http://localhost:3000/ 咱們發現服務器返回渲染的模板 ,到這裏爲止咱們達到了最簡單的SSR的目的(可是這還不是咱們的最終目的,由於這裏單單返回的只有渲染的模板,咱們須要返回整個頁面,頁面中可能還引用其餘的js等文件)
咱們回到server端,改進咱們的server.js, + 所在行表示新增的內容
const express = require('express')
const ReactSSR = require('react-dom/server')
const serverEntry = require('../dist/server-entry')
+ const fs=require('fs')
+ const path=require('path')
const app = express()
// 引入npm run build生成的index.html文件
+ const template=fs.readFileSync(path.join(__dirname,'../dist/index.html'),'utf8')
app.get('*', function (req, res) {
//ReactDOMServer.renderToString則是把React實例渲染成HTML標籤
let appString = ReactSSR.renderToString(serverEntry.default);
//<!--App-->位置 就是咱們渲染返回的結果插入的位置
+ appString=template.replace('<!--App-->',appString);
//返回給客戶端
res.send(appString);
})
app.listen(3000, function () {
console.log('server is listening on 3000 port');
})
複製代碼
控制檯 npm start ,打開瀏覽器輸入http://localhost:3000/ 發現,頁面引用的app.js文件也一樣返回的是整個頁面,這顯然不是咱們所想要的
那是由於咱們server.js中 app.get('*', function (req, res) {}
這個是對全部請求都是同樣的處理返回整個頁面 ,因此咱們要對靜態頁面單獨處理,咱們加上static中間件j就能夠了
const express = require('express')
const ReactSSR = require('react-dom/server')
const serverEntry = require('../dist/server-entry')
const fs=require('fs')
const path=require('path')
const app = express()
//處理靜態文件 凡是經過 /public訪問的都是靜態文件
+ app.use('/public',express.static(path.join(__dirname,"../dist")))
const template=fs.readFileSync(path.join(__dirname,'../dist/index.html'),'utf8')
app.get('*', function (req, res) {
//ReactDOMServer.renderToString則是把React實例渲染成HTML標籤
let appString = ReactSSR.renderToString(serverEntry.default);
//<!--App-->位置 就是咱們渲染返回的結果插入的位置
appString=template.replace('<!-- app -->',appString);
//返回給客戶端
res.send(appString);
})
app.listen(3000, function () {
console.log('server is listening on 3000 port');
})
複製代碼
這樣app.js返回的就是對應的js內容了,而不是整個頁面了
以上就是咱們服務端ssr的整個流程(PS:固然目前還有個很差的地方就是,咱們都直接命令行啓動webpack進行打包,就能夠知足咱們的需求。但畢竟計劃趕不上變化,有時候你會發現用命令行啓動webpack變得不是那麼方便。好比咱們在調試react的服務端渲染的時候,咱們不可能每次有文件更新,等着webpack打包完輸出到硬盤上某個文件,而後你重啓服務度去加載這個新的文件,由於這太浪費時間了,畢竟開發時你隨時均可能改代碼,並且改動可能還很小。)
那麼要解決這個問題怎麼辦呢?咱們能夠在啓動nodejs服務的時候,順帶啓動webpack打包服務,這樣咱們能夠在nodejs的執行環境中拿到webpack打包的上下文,就能夠不重啓服務但每次文件更新均可以拿到最新的bundle。
接下來,咱們先來看看wepack-dev-server 以及 模塊熱替換(Hot Module Replacement 或 HMR)是 webpack 提供的最有用的功能之一。它容許在運行時更新各類模塊,而無需進行徹底刷新。)
wepack-dev-server 和 HMR 不適用於生產環境,這意味着它應當只在開發環境使用,接下來咱們來配置開發環境
首先,package.json
"scripts": {
"build:client": "webpack --config build/webpack.config.client.js",
"build:server": "webpack --config build/webpack.config.server.js",
+ "dev:client":"cross-env NODE_ENV=development webpack-dev-server --config build/webpack.config.client.js",
"clear": "rimraf dist",
"build": "npm run clear && npm run build:client && npm run build:server",
"start":"node server/server.js"
}
複製代碼
webpack.config.client.js
const path = require('path')
const webpackMerge = require('webpack-merge')
const baseConfig = require('./webpack.config.base')
+ const webpack=require('webpack')
const HTMLWebpackPlugin = require('html-webpack-plugin')
//判斷當前是否是開發環境
+ const isDev = process.env.NODE_ENV === 'development'
const config=webpackMerge(baseConfig,{
entry: {
app: path.join(__dirname, '../client/app.js'),
},
output: {
filename: '[name].[hash].js',
},
plugins: [
new HTMLWebpackPlugin({
template: path.join(__dirname, '../client/template.html')
})
]
})
// localhost:8888/filename
+ if (isDev) {
config.entry = {
app: [
'react-hot-loader/patch',
path.join(__dirname, '../client/app.js')
]
}
config.devServer = {
host: '0.0.0.0',//表明任何方式進行訪問 本地ip localhost均可以
compress: true,
port: '8888',
contentBase: path.join(__dirname, '../dist'),//告訴服務器從哪裏提供內容。只有在你想要提供靜態文件時才須要
hot: true,//開啓HMR模式
overlay: {
errors: true //是否顯示錯誤
},
publicPath: '/public',
historyApiFallback: {//404 對應的路徑配置
index: '/public/index.html'
}
}
config.plugins.push(new webpack.NamedModulesPlugin(),
new webpack.HotModuleReplacementPlugin())
}
module.exports = config
複製代碼
app.js:
import React from 'react'
import ReactDOM from 'react-dom'
+ import {AppContainer} from 'react-hot-loader'
import App from "./App.jsx";
+ const root=document.getElementById('root');
+ const render=Component=>{
ReactDOM.render(<AppContainer><Component/></AppContainer>,root)
}
+ render(App);
+ if(module.hot){
module.hot.accept('./App.jsx',()=>{
const NextApp =require('./App.jsx').default;
render(NextApp);
})
}
複製代碼
以上,devServer以及HMR已經配置完成
修改App.jsx內容 能夠看到頁面無刷新就改變內容了
在server.js中咱們區分環境變量
const express = require('express')
const ReactSSR = require('react-dom/server')
const fs = require('fs')
const path = require('path')
const app = express()
+ const isDev = process.env.NODE_ENV === 'development'
+ if (!isDev) {//生產環境 直接到生成的dist目錄讀取文件
const serverEntry = require('../dist/server-entry')
//處理靜態文件 凡是經過 /public訪問的都是靜態文件
app.use('/public', express.static(path.join(__dirname, "../dist")))
const template = fs.readFileSync(path.join(__dirname, '../dist/index.html'), 'utf8')
app.get('*', function (req, res) {
//ReactDOMServer.renderToString則是把React實例渲染成HTML標籤
let appString = ReactSSR.renderToString(serverEntry.default);
//<!--App-->位置 就是咱們渲染返回的結果插入的位置
appString = template.replace('<!-- app -->', appString);
//返回給客戶端
res.send(appString);
})
} else {//開發環境 咱們從內存中直接讀取 減去了寫到硬盤上的時間
const devStatic = require('./util/dev-static')
devStatic(app);
}
app.listen(3000, function () {
console.log('server is listening on 3000 port');
})
複製代碼
server目錄下新建dev-static.js 用來處理開發時候的服務端渲染
const axios = require('axios')
const webpack = require('webpack')
const path = require('path')
const serverConfig = require('../../build/webpack.config.server')
const ReactSSR = require('react-dom/server')
const MemoryFs = require('memory-fs')
const proxy = require('http-proxy-middleware')
//getTemplate用來獲取打包後的模板(內存中)
const getTemplate = () => {
return new Promise((resolve, reject) => {
//http去獲取dev-server中的index.html
axios.get('http://localhost:8888/public/index.html')
.then(res => {
resolve(res.data)
}).catch(reject)
})
}
const Module = module.constructor;
//node環境中啓動一個webpack 來獲取打包後的server-entry.js
const mfs = new MemoryFs
//服務端使用webpack
const serverCompiler = webpack(serverConfig);
serverCompiler.outputFileSystem = mfs
let serverBundle
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJSON()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(warn => console.warn(warn))
// 獲取bundle文件路徑
const bundlePath = path.join(
serverConfig.output.path,
serverConfig.output.filename
)
const bundle = mfs.readFileSync(bundlePath, 'utf8')
const m = new Module()
m._compile(bundle, 'server-entry.js')
serverBundle = m.exports.default
})
module.exports = function (app) {
//http 代理:全部經過/public訪問的 都代理到http://localhost:8888
app.use('/public', proxy({
target: 'http://localhost:8888'
}))
app.get('*', function (req, res) {
getTemplate().then(template => {
let content = ReactSSR.renderToString(serverBundle);
res.send(template.replace('<!-- app -->', content));
})
})
}
複製代碼
同時,npm scripts配置以下:
"scripts": {
"build:client": "webpack --config build/webpack.config.client.js",
"build:server": "webpack --config build/webpack.config.server.js",
"dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.config.client.js",
"dev:server": "cross-env NODE_ENV=development node server/server.js",
"clear": "rimraf dist",
"build": "npm run clear && npm run build:client && npm run build:server"
},
複製代碼
運行 npm run dev:client 和npm run dev:server,修改App.jsx的內容 瀏覽器無刷新更新
以上就是最基礎的React SSR和HMR的配置,但還未涉及到數據以及路由等狀況,接下來有時間我會在這個基礎上爲你們帶來mobx和react-router等整個項目的配置和部署,github 歡迎你們follow