本文是直接着手SSR部分的并通过实战讲述自己遇到的一些问题和方案,需要大家有一定的React,node和webpack基础能力。skr,skr。
Server Slide Rendering
服务端渲染,又简写为SSR
,他一般被用在我们的SPA(Single-Page Application)
,即单页应用。
首先我们需要知道SSR对于SPA的好处
,优势
是什么。
SEO(Search Engine Optimization)
,SEO
是搜索引擎优化,简而言之就是针对百度这些搜索引擎,可以让他们搜索到我们的应用。这里可能会有误区,就是我也可以在index.html上写SEO
,为什么会不起作用。因为React、Vue的原理是客户端渲染,通过浏览器去加载js、css,有一个时间上的延迟
,而搜索引擎不会管你的延迟
,他就觉得你如果没加载出来就是没有的,所以是搜不到的。白屏渲染
,上面讲了React的渲染原理,而SSR服务端渲染是通过服务端请求数据,因为服务端内网的请求快,性能好所以会更快的加载所有的文件,最后把下载渲染后的页面返回给客户端。客户端渲染路线:
服务端渲染路线:
其主要区别就在于,客户端从
无到有的
渲染,服务端是先在服务端渲染一部分
,在再客户端渲染一小部分
。
我们这里是用express框架,node做中间层进行服务端渲染。通过将首页进行同构处理
,让服务端,通过调用ReactDOMServer.renderToNodeStream
方法把Virtual DOM
转换成HTML字符串
返回给客户端,从而达到服务端渲染的目的。
这里项目起步是已经做完前端和后端,是把已经写好的React Demo直接拿来用
既然是首页SSR,首先我们要把首页对应的index.js
抽离出来放入我们服务端对应的server.js
,那么index.js
中组件对应的静态css和js文件
我们需要打包出来。
我们来运行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更轻量
npm run server
,就能看到服务端跑起来了。浏览器中
,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文件,我们需要安装一个包,来让服务端处理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
,会发现又报了个错。
npm i asset-require-hook -S
,这个插件用来让服务端处理图片,注意⚠️,前提是客户端代码,引用图片都需要require
server.js
写入代码//解决图片问题,客户端代码引用图片都需要require
import assethook from 'asset-require-hook'
assethook({
extensions:['png'],
//图片大小下于10000的图片会直接base64编码
limit: 10000
})
运行之后发现又报错了,这个很简单,因为我们只有image的引用名字,却没有地址
静态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(`
<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 Apptitle>
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('开启成功')
})
index.js
文件中的渲染机制改成hydrate
,不用render
,他们之间的区别可以看这个(传送门☞render !== hydrate)ReactDOM.hydrate(
(<Provider store={store}>
<BrowserRouter>
<App>App>
BrowserRouter>
Provider>),
document.getElementById('root')
)
到此为止我们开发模式下的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
重点⚠️
node_modules
包打包,因为此项目运行在服务端,直接用外面的node_modules
就行。不然打包后会很大。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文件夹,里面包含了我们的服务端和客户端代码
build/server/static/js
目录下,可直接node文件启动。这就解决了我们生产环境下的问题。现在我们要把我们的项目部署到服务器上,并用pm2守护进程。
ubuntu 14.04
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+.
看隐藏文件(如果你设置过了,这一步就不要了)。(如果服务器已经安装过了,就不需要了)
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
。source .bashrc
重新载入一下.bashrc
文件chmod 666 pm2
pm2 list
,看到成功跑起来了重启
,说明开启失败
了,需要pm2 logs
看看日志服务器地址:8088
,并看到应用跑起来了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
即可在服务器上自动部署
。