技术栈: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应用,目的大概有以下几个:
- 解决单页面应用的 SEO
单页应用页面大部分主要的 HTML并不是服务器返回,服务器只是返回一大串的脚本,页面上看到的大部分内容都是由脚本生成,对于一般网站影响不大,但是对于一些依赖搜索引擎带来流量的网站来说则是致命的,搜索引擎无法抓取页面相关内容,也就是用户搜不到此网站的相关信息,自然也就无流量可言。- 解决渲染白屏
因为页面 HTML由服务器端返回的脚本生成,一般来说这种脚本的体积都不会太小,客户端下载需要时间,浏览器解析以生成页面元素也需要时间,这必然会导致页面的显示速度比传统服务器端渲染得要慢,很容易出现首页白屏的情况,甚至如果浏览器禁用了 JS,那么将直接导致页面连基本的元素都看不到。
React中如何使用服务端渲染
react-dom是React专门为web端开发的渲染工具。我们可以在客户端使用react-dom的render方法渲染组件,而在服务端,react-dom/server提供我们将react组件渲染成html的方法。
浏览器渲染与服务端渲染对比如下:(其中红色框内就是服务端渲染,很显然比起浏览器渲染快了很多)
项目搭建
项目结构图如下:
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等文件)
继续完善
我们回到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。
这个问题我们先放在这里 (todo...)
接下来,我们先来看看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已经配置完成
修改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