React服务端渲染+pm2自动化部署

本文是直接着手SSR部分的并经过实战讲述本身遇到的一些问题和方案,须要你们有必定的React,node和webpack基础能力。skr,skr。css

服务端渲染

Server Slide Rendering服务端渲染,又简写为SSR,他通常被用在咱们的SPA(Single-Page Application),即单页应用。html

为何要用SSR?

首先咱们须要知道SSR对于SPA的好处优点是什么。前端

  • 更好的SEO(Search Engine Optimization)SEO是搜索引擎优化,简而言之就是针对百度这些搜索引擎,可让他们搜索到咱们的应用。这里可能会有误区,就是我也能够在index.html上写SEO,为何会不起做用。由于React、Vue的原理是客户端渲染,经过浏览器去加载js、css,有一个时间上的延迟,而搜索引擎不会管你的延迟,他就以为你若是没加载出来就是没有的,因此是搜不到的。
  • 解决一开始的白屏渲染,上面讲了React的渲染原理,而SSR服务端渲染是经过服务端请求数据,由于服务端内网的请求快,性能好因此会更快的加载全部的文件,最后把下载渲染后的页面返回给客户端。

上面提到了服务端渲染和客户端渲染,那么它们的区别是什么呢?

客户端渲染路线:node

  1. 请求一个html
  2. 服务端返回一个html
  3. 浏览器下载html里面的js/css文件
  4. 等待js文件下载完成
  5. 等待js加载并初始化完成
  6. js代码终于能够运行,由js代码向后端请求数据( ajax/fetch )
  7. 等待后端数据返回
  8. react-dom( 客户端 )从无到完整地,把数据渲染为响应页面

服务端渲染路线:react

  1. 请求一个html
  2. 服务端请求数据( 内网请求快 )
  3. 服务器初始渲染(服务端性能好,较快)
  4. 服务端返回已经有正确内容的页面
  5. 客户端请求js/css文件
  6. 等待js文件下载完成
  7. 等待js加载并初始化完成
  8. react-dom( 客户端 )把剩下一部分渲染完成( 内容小,渲染快 )

其主要区别就在于,客户端从无到有的渲染,服务端是先在服务端渲染一部分,在再客户端渲染一小部分webpack

咱们怎么去作服务端渲染?

咱们这里是用express框架,node作中间层进行服务端渲染。经过将首页进行同构处理,让服务端,经过调用ReactDOMServer.renderToNodeStream方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。nginx

这里项目起步是已经作完前端和后端,是把已经写好的React Demo直接拿来用git

服务端渲染开始

既然是首页SSR,首先咱们要把首页对应的index.js抽离出来放入咱们服务端对应的server.js,那么index.js中组件对应的静态css和js文件咱们须要打包出来。es6

用webpack打包文件到build文件夹

咱们来运行npm run buildweb

咱们能够看到两个重要的文件夹,一个是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守护进程。

  • 首先咱们得有一台云服务器,这里我是在阿里云买的一台ubuntu 14.04
  • 须要一个已经备案后的域名,域名也能够在阿里云买。固然也能够不用,能够直接服务器地址访问。
  • ok让咱们开始吧。

服务器部署

  • 在部署到服务器以前咱们代码中还有些东西须要修改,修改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便可在服务器上自动部署

相关文章
相关标签/搜索