我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

jerry591發表於2018-05-03

技術棧:webpack3.9.1+webpack-dev-server2.9.5+React16.x + express4.x

前言

(好慌!可能是因為我很懶,導致...,然後,好吧,我比較懶,沒有然後了。。。切入正題ing,let's do it!!!)

網上關於React的SSR也很多,但都不夠詳細,有的甚至讓初學者一頭霧水。不過這篇文章我將一步步詳細的介紹,從0開始配置React SSR,讓每個看到文章的人都能上手。

SSR的概念

Server Slide Rendering,縮寫為 SSR,即伺服器端渲染,因為是之前搞java出身,也明白是怎麼回事,其實SSR主要針對 SPA應用,目的大概有以下幾個:

  1. 解決單頁面應用的 SEO
    單頁應用頁面大部分主要的 HTML並不是伺服器返回,伺服器只是返回一大串的指令碼,頁面上看到的大部分內容都是由指令碼生成,對於一般網站影響不大,但是對於一些依賴搜尋引擎帶來流量的網站來說則是致命的,搜尋引擎無法抓取頁面相關內容,也就是使用者搜不到此網站的相關資訊,自然也就無流量可言。
  2. 解決渲染白屏
    因為頁面 HTML由伺服器端返回的指令碼生成,一般來說這種指令碼的體積都不會太小,客戶端下載需要時間,瀏覽器解析以生成頁面元素也需要時間,這必然會導致頁面的顯示速度比傳統伺服器端渲染得要慢,很容易出現首頁白屏的情況,甚至如果瀏覽器禁用了 JS,那麼將直接導致頁面連基本的元素都看不到。

React中如何使用服務端渲染

react-dom是React專門為web端開發的渲染工具。我們可以在客戶端使用react-dom的render方法渲染元件,而在服務端,react-dom/server提供我們將react元件渲染成html的方法。

瀏覽器渲染與服務端渲染對比如下:(其中紅色框內就是服務端渲染,很顯然比起瀏覽器渲染快了很多)

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

專案搭建

專案結構圖如下:

專案結構

build資料夾 用來配置webpack環境

  • webpack.config.base.js是基礎配置
  • webpack.config.client.js是客戶端打包配置
  • webpack.config.server.js是用來打包伺服器渲染的配置

package.json:

{
 "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資料夾 客戶端用來打包上線

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等檔案)

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

繼續完善

我們回到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檔案也同樣返回的是整個頁面,這顯然不是我們所想要的

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

那是因為我們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');
})
複製程式碼

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

這樣app.js返回的就是對應的js內容了,而不是整個頁面了

以上就是我們服務端ssr的整個流程(PS:當然目前還有個不好的地方就是,我們都直接命令列啟動webpack進行打包,就可以滿足我們的需求。但畢竟計劃趕不上變化,有時候你會發現用命令列啟動webpack變得不是那麼方便。比如我們在除錯react的服務端渲染的時候,我們不可能每次有檔案更新,等著webpack打包完輸出到硬碟上某個檔案,然後你重啟服務度去載入這個新的檔案,因為這太浪費時間了,畢竟開發時你隨時都可能改程式碼,而且改動可能還很小。)

那麼要解決這個問題怎麼辦呢?我們可以在啟動nodejs服務的時候,順帶啟動webpack打包服務,這樣我們可以在nodejs的執行環境中拿到webpack打包的上下文,就可以不重啟服務但每次檔案更新都可以拿到最新的bundle。

這個問題我們先放在這裡 (todo...)

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& HMR

接下來,我們先來看看wepack-dev-server 以及 模組熱替換(Hot Module Replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允許在執行時更新各種模組,而無需進行完全重新整理。)

wepack-dev-server 和 HMR 不適用於生產環境,這意味著它應當只在開發環境使用,接下來我們來配置開發環境

webpack-dev-server配置

首先,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已經配置完成

我很懶,什麼都沒留下系列 之 教你上手React服務端渲染(React SSR)&& 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服務端渲染(React SSR)&& HMR

以上就是最基礎的React SSR和HMR的配置,但還未涉及到資料以及路由等情況,接下來有時間我會在這個基礎上為大家帶來mobx和react-router等整個專案的配置和部署,github 歡迎大家follow

相關文章