SSR
CSR
介绍在介绍SSR
之前,我们可以先了解CSR
。那什么是CSR
呢?CSR
即Client Side Render
在我们传统的REACT/VUE
项目中采取的是单页(SPA
)方式,即只有一个html
,结构大致如下:
<html> <head> <title>传统spa应用</title> </head> <body> <script src="bundle.js"></script> </body> </html> 复制代码
所有的页面元素都是由打包后的bundle.js
渲染出来的。
client
(客户端)向server
(服务端)发送ajax
请求,服务端向客户端返回json
数据,很明显,这样的前后端分离使得前后端职责更加清晰,后端只负责给前端提供数据,前端渲染界面。所谓CSR
为客户端渲染,即页面是由js
渲染出来的,不是由服务端直接返回的。
SSR
介绍SSR
即Server Slide Render
,服务端渲染。即后端直接返回一个HTML
页面。我们写一个服务端渲染的简单内容。新建文件夹react-ssr-frame
->文件目录下执行npm init
->在入口文件src/index.js
编写如下代码:
var http=require("http"); const server=http.createServer(function(req,res){ res.writeHead(200,{'Content-Type':"text/html"}) res.end(` <!doctype html> <html> <head> <title>REACT SSR</title> </head> <body> <p>Hello World</p> </body> </html> `) }) server.listen(3000,'127.0.0.1'); 复制代码
执行node src/index
命令并打开localhost:3000
可以看到后端返回了一个HTML
页面并直接渲染到页面上。为了更确定我们页面的内容是服务端直接返回的。谷歌浏览器有个关闭js
开关的选项,我们将其关闭。可以看到页面依旧显示正常(正常情况下CSR
框架下页面会渲染失败)。
SSR VS CSR
CSR和SSR
流程。
CSR
HTML
页面HTML
时解析到script
标签,下载JS
文件JS
文件,执行运行React
代码SSR
1.浏览器下载HTML
页面,页面开始渲染HTML
页面。
由上面的流程我们不难发现,SSR
只需要发送1次http
请求,CSR
需要发送2次http
请求(实际上不止2次)因为还需要发送http
请求图标等。由于CSR
的步骤繁杂,这就造成了一个CSR
劣势,也就是我们经常头疼的单页应用的通病-首屏加载速度很慢。由于客户端只有一个HTML
页面,在搜索引擎SEO爬虫
爬行时只认识HTML
中的内容,并不认识JS
内的内容,所以不利于我们在搜索引擎上的排名。由此我们总结出SSR
相比CSR
的优势。
1.首屏加载速度较快。
2.
SEO搜索引擎
排名较好。
虽然SSR
优势很多,但是对于SSR
非常的消耗服务器端的性能。我们在项目首屏速度已经很快的时候并且不需要引擎排名的时候尽量还是使用CSR
技术。
React
流行SSR
框架现有比较主流的
React SSR
流行框架为next.js
,next.js
封装的很好,但是由于封装的太好了,对于我们扩展起来十分的不方便。有看Next.js
文档的时间我们不如自己实现一个SSR服务端渲染框架
,岂不乐哉?接下来我们就一步步为大家实现一个可以在实际项目中使用的SSR
框架。
React
代码我们接着使用上一章搭建的服务端项目。
既然是React
项目,我们使用npm install
安装react
,还记得在传统React/Vue
项目中,浏览器端由于不认识我们的React
等语法,我们需要使用Webpack
打包,那么在服务器端呢?在服务器端node
环境下也是不认识我们的React
语法的,那么意味着我们也需要在服务器端渲染中使用Webpack
进行打包。
const path=require("path"); const nodeExternals=require("webpack-node-externals"); module.exports={ //由于在相同的require引用下,浏览器和服务器端效果是不一样的,所以需要指定目标平台 target:"node", entry:"./src/index.js", mode:"development", output:{ filename:"bundle.js", path:path.resolve(__dirname,'build') }, externals:[nodeExternals()], //配置以后 require引用的node_modules不会被打包,还会保存之前的引用形式https://www.cnblogs.com/fanqshun/p/10073493.html module:{ rules:[ { test:/\.js?$/, loader:"babel-loader", exclude:/node_modules/ } ] } } 复制代码
在react-dom
中给我们提供了服务端渲染的方法renderToString
,这个方法可以将react
节点渲染成字符串。改造后的index.js
页面如下:
var http=require("http"); import React from 'react'; import Hello from './pages/Hello'; import { renderToString } from 'react-dom/server'; const content=renderToString(<Hello />) const server=http.createServer(function(req,res){ res.writeHead(200,{'Content-Type':"text/html; charset=utf-8"}) res.end(` <!doctype html> <html> <head> <title>REACT SSR</title> </head> <body> ${content} </body> </html> `) }) server.listen(3000,'127.0.0.1'); 复制代码
在package.json
中的script标签
下
"build":"webpack --config webpack.server.config.js", "start":"node build/bundle.js" 复制代码
在项目根目录下新建.babelrc
配置文件
{ "presets":[ ["@babel/preset-env", { "modules": false, "targets": { "browsers": ["> 1%", "last 2 versions", "ie >= 10"] }, "useBuiltIns": "usage" }], "@babel/preset-react" ] } 复制代码
现在让我们来回顾下项目目录
首先我们要在命令行安装安装项目所需要的依赖包yarn add react-dom webpack webpack-cli webpack-node-externals babel-loader @babel/core @babel/node @babel/preset-react @babel/preset-env --save-dev 复制代码
npm run build
打包项目npm run start
运行项目执行完打包后,我们会发现根目录下生成一个build
目录,目录下的bundle.js
就是本次打包后生成的源码目录。然后执行运行命令,启动打包后的bundle.js
。然后我们打开浏览器看到:
有时候我们在运行项目中会出先莫名奇妙的错误,原因是
node
包有错误,这时候我们需要删除node_modules
目录进行重新安装,但是删除node_modules
本身就会出各种各样的异常,在这里我们借助rimraf
来删除我们的node_modules
包
rimraf :npm install rimraf --save-dev
package.json
中的script
定义delete字段 "delete": "rimraf node_modules"
npm run delete
在服务器端编写React
算是基本成功了。但是有没有发现这样的一个问题?在我们每次修改完代码。我们首先需要打包再运行,但是正常情况下这样肯定是不行的。我们借助--watch
来监听文件的变化,借助nodemon
来监听打包文件的变化并执行node命令运行项目。
首先更改build
命令
"build": "webpack --config webpack.server.config.js --watch" 复制代码
--watch
可以监听文件的变化并生成打包文件
npm install nodemon --save
"start": "nodemon --watch build --exec node build/bundle.js" 复制代码分别执行命令,发现他们都好像在就绪状态,仿佛一直在监控着代码,如果你看不出来,说明你没有想象力(开个玩笑😂),我们更改下代码试下。发现这2个控制台分别又打包并执行了一次。这下子我们的目标已经达成了。但是这样依旧很烦,因为每次需要开2个命令行。那么有没有方法可以只开一个命令行呢?
npm-all-run
可以帮助我们完成这个任务,首先安装。
npm install npm-run-all --save "scripts": { "delete": "rimraf node_modules", //--parallel表示并行执行后面的那个文件 "dev": "npm-run-all --parallel dev:**", "dev:build": "webpack --config webpack.server.config.js --watch", "dev:start": "nodemon --watch build --exec node build/bundle.js" }, 复制代码
执行npm run dev
,如下图所示即大功告成
上一章,同学们都写出来了代码,我猜很多同学都以为服务端渲染仅仅如此?但是事实并非如此。我们在页面添加一个点击事件。
<button onClick={()=>alert("2")}>点击我!!</button> 复制代码当我们点击这个按钮时,发现并没有任何反应,正常情况下是会有个弹框出来。控制台没报错,终端也没有报错。这时我们查看
network
。
发现button按钮上没有click事件,小朋友,你是否有很多问号❓在我们反复确定我们是有写click事件后。我们将目光移到了enderToString
方法。原来是因为renderToString
方法仅仅是将React组件变成了字符串组件,但是并没有将其的事件绑定。那这就完了。服务端渲染怎么可以连最常见的点击事件都没有呢?这就要用到我们的同构。
简而言之,一套代码在服务端跑一遍,同时在客户端再跑一遍。这样的应用则为同构应用。
服务端的代码我们已经有了,那么我们接下来编写客户端的代码。我们将src中的index.js
文件移至新建的server
文件夹下,该文件夹下编写的是服务端的代码。再新建客户端的文件夹取名为client
,其中也放置一个index.js
文件来写客户端渲染的代码。
//这是我们典型的客户端渲染挂载节点的方式,不同的是这里我们需要使用hydate,而不是render import React from 'react'; import ReactDom from 'react-dom'; import Hello from './pages/Hello'; ReactDom.hydrate(<Hello />,document.getElementById("root")) 复制代码
然后我们需要创造一个id为root
的节点方便挂载。
<!doctype html> <html> <head> <title>REACT SSR</title> </head> <body> <div id="root"> ${content} </div> <script src="./public/index.js"></script> </body> </html> 复制代码
body
里面script
标签里面放置的是客户端渲染的代码。那么我们依旧需要将客户端代码打包。新建客户端打包文件webpack.client.config.js
。
const Path=require("path") module.exports={ entry:"./src/client/index.js", mode:"development", output:{ filename:"index.js", path:Path.resolve(__dirname,'public') }, module:{ rules:[{ test:/\.js?$/, loader:"babel-loader", exclude:/node_modules/ }] } } 复制代码
script标签
内声明一个命令
"dev:build:client":"webpack --config webpack.client.config.js --watch", 复制代码
执行命令npm run dev
可见public
目录下生成了public
目录以及目录下的index.js
文件。
我们需要将node
服务器文件改一下
const server = http.createServer(function (req, res) { let path = req.url; if (path === "/") { res.writeHead(200, { 'Content-Type': "text/html; charset=utf-8" }); res.end(` <!DOCTYPE html> <html> <head> <title>REACT SSR</title> </head> <body> <div id="root"> ${content} </div> <script src="/index.js"></script> </body> </html> `) }else if(path==="/index.js"){ let filePath=Path.join("public",req.url); fs.readFile(filePath,function(err,data){ if(err){ throw err; } // 设置请求头,访问文件类型为js文件 res.setHeader("Content-Type", "text/js"); res.end(data); }) } }) 复制代码
当访问路径为index.js
时,将public
目录下的index.js
文件返回。
接下来我们点击按钮就可以弹出框了。
首先我们分析一下:
当用户输入localhost:3000
,浏览器请求路径为/。
node
服务器返回一个html
。
浏览器接受到html
渲染html
。
浏览器加载到JS
文件。
浏览器中执行JS
中的React
代码
JS
中的React
代码接管页面的操作
由此我们总结出:服务端渲染只发生在第一次进入页面的时候,当页面渲染完以后,路由跳转由
JS
进行控制,React
中的JS
文件将接管页面。服务端渲染并不是每一个页面都进行服务端渲染,二是第一个访问的页面进行服务端渲染。
由此我们大概知道了SSR
主要作用就是使白屏事件缩短
和SEO
。SSR
离不开CSR
的配合。
解决了事件处理,接下来便是重头戏了--路由。大家都知道在一个项目中肯定会有多个页面,每个页面都有相应的路由配置。路由的配置向来是react
的一个重难点。在服务端渲染路由配置是和客户端渲染配置不一样的。
首先我们安装react-router-dom
,新建router
文件夹并在其下新建index.js
文件,存放服务端渲染和客户端渲染的公共页面路由文件。
import Hello from './../pages/Hello'; import { Route } from 'react-router-dom'; import React from 'react'; export default( <div> <Route path="/" exact component={Hello}/> </div> ) 复制代码
客户端渲染文件中
import React from 'react'; import ReactDom from 'react-dom'; import Hello from './../pages/Hello'; import Routes from './../router/index'; import { BrowserRouter } from 'react-router-dom'; const App=()=>{ return( <BrowserRouter> {Routes} </BrowserRouter> ) } ReactDom.hydrate(<App />,document.getElementById("root")) 复制代码
服务端渲染则和客户端渲染路由不同,BrowserRouter
可以智能感知你页面路径的变化并匹配相应的路由文件,但是服务端渲染并不能直接感应你路由的变化,所以我们将服务端路由写在请求方法里面,并将路径传给服务端渲染组件。此时路由文件:
var http = require("http"); var fs =require("fs"); var Path=require("path"); import Routes from './../router/index'; import React from 'react'; import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router-dom'; const server = http.createServer(function (req, res) { const content = renderToString(( <StaticRouter location={req.path} context={{}}> {Routes} </StaticRouter> )) let path = req.url; if(path.split(".").length===1){//判断是否是资源文件 res.writeHead(200, { 'Content-Type': "text/html; charset=utf-8" }); res.end(` <!DOCTYPE html> <html> <head> <title>REACT SSR</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `) }else if(path==="/index.js"){ let filePath=Path.join("public",req.url); fs.readFile(filePath,function(err,data){ if(err){ throw err; } // 设置请求头,访问文件类型为js文件 res.setHeader("Content-Type", "text/js"); res.end(data); }) } }) server.listen(3000, '127.0.0.1'); 复制代码
我们新建一个LinkDemo.js
页面查看效果是否生效
import React from 'react'; import { Link } from 'react-router-dom'; export default class LinkDemo extends React.Component{ render(){ return ( <div> LINK </div> ) } } 复制代码
Link
标签实现路由间的跳转import React from 'react'; import { Link } from 'react-router-dom'; export default class LinkDemo extends React.Component{ render(){ return ( <div> LINK <Link to="hello">hello</Link> </div> ) } } 复制代码
点击hello
会跳转到Hello
页面。
我们需要将路由进行改造
//根路由文件 import Hello from './../pages/Hello'; import Link from './../pages/Link'; export default [ { path:"/", exact:true, component:Hello, key:"/" }, { path:"/link", exact:true, component:Link, key:"/link" } ] //客户端渲染 <BrowserRouter> { Routes.map(props=>( <Route {...props} /> )) } </BrowserRouter> //服务端渲染 <StaticRouter location={req.path} context={{}}> { Routes.map(props=>( <Route {...props} /> )) } </StaticRouter> 复制代码
首先安装react-router-config
nm install react-router-config 复制代码
纵观大大小小的网站,图标都是一个很常见的一部分,那么如何实现这样的功能呢?其实浏览器在我们输入地址的时候,会默默的发送一个获取网站图标的请求,用于获取网站的图标。
首先我们需要一个类型为x-icon
,即后缀名为.ico
的图标文件,我一般是在在线生成ico这个网站生成自己喜欢的图标,然后我们放进public
文件夹下,public
文件夹存放网站的静态资源文件。然后node
代码需要修改为:
if(path.split(".").length===1){ res.writeHead(200, { 'Content-Type': "text/html; charset=utf-8" }); res.end(` <!DOCTYPE html> <html> <head> <title>REACT SSR</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `) }else if(path.split(".").length===2&&path.split(".")[1]==="js"){//判断请求js文件 let filePath=Path.join("public",req.url); fs.readFile(filePath,function(err,data){ if(err){ throw err; } // 设置请求头,访问文件类型为js文件 res.setHeader("Content-Type", "text/js"); res.end(data); }) }else if(path.split(".").length===2&&path.split(".")[1]==="ico"){//判断请求ico文件 let filePath=Path.join("public",req.url); fs.readFile(filePath,function(err,data){ if(err){ throw err; } // 设置请求头,访问文件类型为icon文件 res.setHeader("Content-Type", "image/x-icon"); res.end(data); }) } 复制代码
重新启动npm run dev
,在tab标签的左上角你会发现
这部分内容主要与webpack相关,小伙伴可以看我的另一篇文章webpack4搭建开发环境。
我们新建css文件并在Hello.js文件引入css样式
import React from 'react'; import { Link } from 'react-router-dom'; //引入样式 import "./index.css"; export default class Hello extends React.Component{ render(){ return ( <div className="hello"> hello world changes 啊啊啊啊我会 <button onClick={()=>alert("2")}>点击我!!</button> <Link to="/link">Link</Link> </div> ) } } //样式 .hello{ background-color: aqua; } 复制代码
npm install style-loader css-loader --save 复制代码
{ test:/\.css?$/, use:[ 'style-loader','css-loader' ], exclude:/node_modules/ } 复制代码
重新运行代码,我们会发现终端报了这样一个错误
document is not defined?为什么会产生这样的错误呢,原因是style-loader需要将css挂在到document对象上,但是在服务器端没有document对象,所以需要借助另一个和style-loader相同的库来处理,这里我们安装npm install isomorphic-style-loader --save 复制代码
然后我们在服务器端打包文件中将style-loader改为isomorphic-style-loader,这个库可以在服务端渲染css。
{ test:/\.css?$/, use:[ 'isomorphic-style-loader','css-loader' ], exclude:/node_modules/ } 复制代码
重启项目,样式已经成功!