本文是直接着手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
即可在服务器上自动部署
。