react 实现 ssr 和原理讲解
使用 react 实现 ssr,并讲解实现 ssr 的原理。
服务端渲染
服务端渲染很简单,就是在服务端生成 html 代码字符串,然后返回给浏览器。
所以对前端来说就是一个请求。
本课程使用 express 启动服务。
在服务端写 react 代码
首先同样需要先安装 react 的包,babel 进行转义,不然因为写的是 node 代码,连 import 都不能直接使用,使用了 babel 这些,自然就需要使用 webpack 进行打包转义了。
webpack
不仅可以打包前端代码,服务端代码也可以,只是在webpack
的配置文件中,需要添加target:'node'
,告诉 webpack 这是服务端代码,不然像path
这样的工具包会被打包进代码中,在服务端path
工具全局环境中的,是可以直接使用的。
1 | npm i webpack webpack-cli react react-dom babel-loader babel-core babel-preset-react@latest babel-preset-stage-0 babel-preset-env --save |
babel 是个很好的转义工具,但是各种转义都需要安装对应的 preset,比如上面的 react,stage-0 语法等。
webpack-node-externals
虽然上面通过设置target
可以避免 node 环境中已有包不会被打包到代码中,但是node_modules
中安装的第三方包比如 express
还是被打包到引用它的文件中。这样会导致 express 被重复打包。
使用 webpack-node-externals 可以避免这种情况发生,这会保证各个文件中保持require('express')
的写法。(有点像前端优化的动态链接库,自我感觉)
️⚠️ 不做这个处理,打包会报警告!
使用 renderToString 服务端渲染 react 组件
1 | import React from "react"; |
前端渲染(CSR)和服务端渲染(SSR)的优缺点
SSR 有利于 SEO,但是对服务端性能消耗比较大。
webapck.server.js
1 | const path = require("path"); |
自动打包和服务自动启动
打包和启动是两个命令,前端由 webpack-dev-server 可以帮忙解决这个问题,但是服务端,目前还没有这样的工具,只能把代码用 webpack 打包,然后用 node 启动,命令如下:
1 | "scripts": { |
解决上面的问题也很简单,webpack
自带了检测功能,增加--watch
参数即可;node
需要借助一个工具nodemon
,nodemon
启动的node
服务可以自动检测启动文件所在目录是否发生变化,如果发生变化则自动重启服务。nodemon
也可以设置监控多个文件夹。
nodemon
还有一个同类工具叫supervisor
1 | npm i nodemon -g |
修改后启动配置
1 | "scripts": { |
使用 npm-run-all 进一步提升开发效率
上面的配置虽然实现了自动打包,服务自动启动的功能,但是还是需要打开两个命令窗口,还是很麻烦。
使用 npm-run-all 就可以只打开一个窗口就可以了。
1 | sudo npm i npm-run-all -g |
配置如下:
1 | "scripts": { |
parallel
指定并行执行dev:**
指定并行执行以”dev:
“开头的所有 npm 命令
这样执行npm run dev
两个服务就都启动了。
同构
服务端渲染时,如果在 react 组件上增加事件,当渲染到浏览器上时,事件是无法被绑定到组件上的。
这该怎么解决呢?这就需要使用同构。
同构:就是一套 react 代码,在服务端执行一次,然后在客户端再执行一次。
让 react 组件在客户端再运行一次
这就比较简单了,这就是正常的前端 react 开发,把 react 组件和挂载打包后,在入口 html 引用就可以了。打包后的文件就是 js 吗,让打包的组件在客户端再挂载一次。
不同的是,这次挂载用的不是 ReactDom.render 而是 ReactDom.hydrate()方法。因为 react 组件已经被服务端渲染了一遍,做同构,就不要使用 render,而是使用 dydrate
服务端渲染挂载组件两边不要有文本节点,也就是把 react 组件转换成字符串 A 后,传给 html 字符串 B 时,A 要和它的父元素在一行,不要有空格、换行,挨着写就行了。
1 | <div id="root">${renderToString(<Home />)}</div> |
- 前端执行的 js 包入口
// src/client/index.js
1 | import React from "react"; |
- 配置 webpack 打包前端 js 包
把webpack.server.js
中node
的相关配置删除即可。把js文件
打包到public
文件夹中。
- 增加打包命令
1 | "dev:build:client": "webpack --config webpack.client.js --watch" |
- 在首页 html 中引入 client 打包的静态 js
使用express
的static
中间件,把public
文件夹设置成静态资源请求目录。
1 | /** |
1 | app.get("/", (req, res) => { |
经过上述的修改,按钮的事件就被绑定到元素上了,点击就有反应了。
react 说白了就是 dom 操作,所以客户端再绑定一次,元素就被 dom 动态的又创建一次,事件什么的当然有了。
webpack 复用公共部分
由于webpack.server.js
和webpack.client.js
大部分都相同,所以可以抽出公共部分,两个“继承”后各自扩展就行了;使用webpack-merge
可以达到这个目的。
1 | npm i webpack-merge |
服务端路由
路由同样需要进行同构操作,但服务端路由不是使用BrowserRouter
,而是需要使用StaticRouter
首先安装 react 的 router 包react-router-dom
,BrowserRouter
和StaticRouter
是这个包中的两个模块
1 | npm i react-router-dom --save |
改造客户端和服务端使用路由进行渲染
前端使用BrowserRouter
进行路由,改造client
中的index.js
1 | import React from "react"; |
服务端代码改造
1 | <div id="root"> |
StaticRouter
和BrowserRouter
不同,它不能自动感知 url 中的路由信息,所以需要设置location
属性来告知服务端的 router 当前的路径是什么。
多个路由时的处理
因为 react 是单页面,所以服务端的路由需要都指定到入口文件上。比如增加了一个 login 页面,不能增加 get(/login),把入口的路由改成全部(*)即可。
1 | app.get("*", (req, res) => { |
同构 redux
加上 redux 比较简单,和正常的开发一样就行。客户端和服务端同样都做相同处理,不然报错。
1 | npm i react-redux redux redux-thunk |
相关代码:
1 | const reducer = (state = { name: "温木" }, action) => { |
抽离公共store
由于服务端和客户端的 store 是一样的,因此可以把公共的部分抽成公共的模块, 但是需要注意的是,createStore
应该每次调动时都生成新的 store,不然所有的服务就会调用的是同一个 store
1 | import { createStore, applyMiddleware } from "redux"; |
Warning: Expected server HTML to contain a matching <div> in <div>.
引起这种 bug 我遇到有两种:
- 只在客户端渲染使用了路由,服务端渲染没有使用,服务端加上即可
- 服务端在渲染是多嵌套了 div,比如我遇到的就是把 html 字符串封装成了方法 render,但是原来的 html 字符串忘记删除,在 html 字符串中又调用了 render,所以导致了这个警告。
服务端渲染时加载 store 数据
通常情况下,我们请求数据都在componentDidMount
声明周期中进行,但是声明周期函数componentDidMount
(useEffect
也一样)在服务端渲染时是不执行的,只有在客户端渲染时才会触发。因此React
为服务端渲染提供了专门的数据加载方法。
详情参考react router
的Server Rendering
的Data Loading
部分
- 首先每个组件需要增加一个静态的
loadData
函数,用于服务端渲染时获取组件的异步数据 - 给路由增加
loadData
属性,官网推荐用一个数组 map 输出 - 服务端渲染时,绑定 store 之前,使用
matchPath
向 store 中填充数据,不过 matchPath 只能匹配一层路由,多级路由需要使用react-router-config
的matchRoutes
, - 由于获取数据是异步,所以需要数据请求结束后借助
Promise.all
的回调返回html
字符串(注意:action
和loadData
中要把promise
对象透传出去,把promise
对象return
出去)
node 不能使用 fetch
fetch 在 node 中不能使用,所以有服务端渲染时最好使用axios
发请求。
切记loadData
要把结果return
同构存在的问题!!!
由于同构服务端渲染一遍,客户端又渲染了一遍,那就有一个问题了,当服务端把数据已经渲染了,异步数据也拿到了,由于客户端还会再执行一遍,那数据还会再执行并渲染一般,那前端由的延迟不是还是能感觉到吗,这不是脱裤放屁了,当禁用掉js
,由于前端js
无法执行。渲染异步请求的数据的部分会是空白;(当然 SEO 不影响,因为数据确实已经在页面了)
如何解决这个问题呢?
这需要用到数据的注水和脱水。
说白了就是当服务端已经拿到的
state
数据放到window
的全局变量里面,当前端再创建store
的时候,取出来全局中存储的state
数据传给store
当做默认值,这就没抖动了。
都是笨方法新概念,听着挺高大上
创建 store 时候,服务端和客户端分开使用两个方法就行了。
1 | export function getStore() { |
那客户端不发出请求不行了吗?
不行,因为服务端渲染只是渲染首屏 A,当手动输入页面路径首先访问 B 时,再回到 A 这是就无法获取 A 的数据了,所以客户端的请求不能省略;为了避免客户端重复发出请求,可以加个判断,有数据就不请求。
让客户端的请求都通过node
中间件
到目前为止,action
发出的请求是直接访问服务器的,既然搭建了 node 中间件,所有的请求应该都通过中间件发出去,客户端不应该直接和服务端进行交互。
实现也简单,因为现在有了 node 服务,如果发出的请求是相对路径,那么都会被 node 服务接收,我们只要制定好规则,把获取数据的请求都转到数据服务器就行了,说白了就是加个代理。
代理需要用到express-http-proxy
,通过这个插件可以轻松的把请求代理到指定的服务器。
1 | npm i express-http-proxy |
我这里做的约定是,所有数据的请求都以/api
开头。
1 | // 设置代理,代理只能处理客户端发出的请求,服务端的请求不走这块,需要额外处理 |
⚠️ 服务端渲染的时候有问题。
比如我当时报错信息是:Error: connect ECONNREFUSED 127.0.0.1:80
因为action
中的请求链接现在写的相对路径(比如:/api/list.json
),当浏览器发出请求的时候,浏览器会想网站挂载的服务器发出请求,也就是使用网站的域名和端口。
但是服务端在发出请求的时候,由于这本身就在服务端,所以对服务器来说,这是请求根目录下的api
目录下list.json
文件,由于根目录下没有这个文件,所以就报错了,请求接口同样的道理。
要解决这个问题也很简单,就是大家都能想到的最笨的方法。
做个判断处理,服务端发起的请求就把请求路径写成数据服务器的绝对路径,比如:https://www.baidu.com/data/api/list.json
,如果是客户端发起的请求就写相对路径/api/list.json
;
1 | let url = `/api/list`; |
这样做很丑陋,如果这样处理,首屏上用到的异步数据 action 都要这么处理,太 low 了。使用axios
的instance
可以改善这个问题(其实差别不大,还是需要判断)。
原理就是创建两个axios
的实例,一个客户端的,一个服务端的,把上面的 url 换成两个实例就行了。axios
的实例可以指定发出请求的前缀内容,所以方法中的url
可以写成相对路径,拼接工作嫁给了axios
的实例来完成。
1 | // client |
不使用判断更优雅的解决
由于客户端渲染和服务端渲染分别使用各自的stroe
,因此可以在创建store
的时候,绑定axios
的实例,这样就不用传参判断是否是客户端还是服务端请求了。
给store
绑定axios
实例,需要用到redux-thunk
的withExtraArgument
方法,这个方法允许给thunk
传递一个参数,这个参数在异步调用的时候可以使用。
1 | // store绑定axios实例 |
有没有想过为什么客户端和服务端渲染不用同一个 store 呢?,这样还不用注水和脱水呢!
因为客户端和服务端运行环境不同,一个运行客户端也就是浏览器中,一个运行在 node 环境中,变量怎么可能共享呢,哈哈 😀
node
代理时没有携带解决cookie
问题
服务端代理请求时,携带上客户单请求上的cookie
就行了;更新创建axios
实例的地方
1 | import axios from "axios"; |
共用部分实现,比如Header
项目中总会有很多页面的共用部分,比如header
,footer
等等,这些如何像使用模板那样在一个地方写,而不需要在每个页面中都写一遍呢?
使用router
多级路由就可以轻松解决这个问题。
- 写一个共用组件,如
App.js
,组件中放入公共部分,比如Header
、Footer
、Menu
等等。 - 在
App.js
中增加各个组件的路由,但是为了能显示多级路由的内容,需要用到react-router-config
的renderRoutes
- 把
App
组件作为路由对象的根对象,子集路由就是要在内容区域显示的各个子组件了。
1 | // 路由对象 |
由于一级路由渲染完组件后,路由会把路由对象的信息传递给组件,因此 App 公共组件不需要专门引入定义的 routes,在 props 中可以直接获取到
1 | const App = memo(props => { |
通过axios
给请求添加公共参数
1 | export default axios.create({ |
404 页面
当访问的路由不存在时,我们需要显示404
页面,这实现其实很简单。
写一个 404 的页面,然后在配置路由的地方配置下就行,但是配置只需要一个component
属性即可。
1 | { |
当访问路由中没有的路由时,会自动跳转到 404 页面。
借助router
的context
来返回 404 状态码
上面的 404 页面配置完后,虽然其作用了,但是 http 状态码是 200,为了更真实的显示,我们应该让找不到路由时 http 的状态码是404
给StaticRouter
组件传递context
属性,当返回 html 字符串时判断 context 对象是否被标记访问的是 404 页面,比如添加了 notFound 属性,如果是,则设置状态码为 404
1 | Promise.all(promises).then(() => { |
服务端重定向
前面我们已经做了,当退出登录时,使用Redirect
调回到首页的功能,但是Redirect
仅限于前端重定向,不能做服务端重定向;
在非登录状态下,如果我们直接手动访问其他页面,其实服务端是已经访问了的,只是客户端帮我们跳转到了首页。
服务端该如何重定向呢?
这仍需要借助router
的context
对象,当我们有重定向时,context
会被填充一些属性,通过打印 context 对象,可以发现,填充的属性有:
action
字符串,描述操作类型,重定向就是REPLACE
location
一个对象,描述重定向前的数据url
字符串,描述重定向后的url
所以在服务端渲染时,可以像处理 404 时那样,判断 context 中的 action,如果是REPLACE
则跳转到url
指定的路径
1 | if (context.action === "REPLACE") { |
解决请求失败的问题
在循环组件并加载数据的地方再嵌套一层 promise,无论组件中加载数据的部分是成功还是失败都把结果传递下去,这样只有失败的组件不显示,成功的组件仍显示。
并且当有多个请求时,如果第一个失败了,后面的数据还没返回也不影响,因为 catch
也让触发的 resolve
1 | matchedRoutes.forEach(item => { |
服务端渲染css
配置
服务端渲染css
需要使用到isomorphic-style-loader
1 | npm install isomorphic-style-loader --save-dev |
然后把style-loader
改成isomorphic-style-loader
。
配置完后,下面的处理就和 404 页面的处理类似,当是服务端渲染时,就把css
字符串添加到router
的context
对象中,然后在服务端渲染那块,从context
中拿出css
字符串,拼接到head
中即可。
如果获取css
字符串呢?
首先把 css
引入方式配置成模块引入的方式。isomorphic-style-loader
提供了_getCss()
的方法可以获取到 css
字符串。
这样禁用掉 js
后,样式仍能直接有效。
1 | // 判断是否是服务端渲染 |
但是这样有很多问题:
- 因为需要在每个组件中把
css
添加到context
对象中,所以 css 会被后面的覆盖 - 不在路由中的组件没有
context
属性
解答:
- css 属性用数组类型
- 手动把
context
对象传递过去。因为App
组件是在路由中的,所以可以在App
中把conterxt
传递给header
组件中
使用高阶组件解决需要在每个组件中添加css
字符串的问题
在每个组件中都需要把 css 字符串添加到 context 对象中很麻烦,可以写一个高阶组件,来解决这个问题。
1 | import React from "react"; |
SEO
title 和 Description 主要是为了提高网站的点击率,对SEO
意义不大。
使用react-helmet
定制 title 和 Description
react-helmet
不仅可以帮助动态生成title
和description
,并且可以在服务端渲染时引用,让服务端渲染也是动态生成title
和description
。
在每个组件中使用helmet
,则当路由到这个组件时,title
和 description
就自动发生了变化。
1 | // 拿到客户端渲染定义的helmet |
预渲染
当对首屏时间要求不高,但是对 SEO 要求较高时,可以使用预渲染方案来解决这个问题。
预渲染就是当访问的是人时,就是正常的客户端渲染,但当访问的是爬虫蜘蛛时,则走预渲染服务器,预渲染服务器去取项目的页面,把页面的dom
结构生成后再返回给蜘蛛。
预渲染服务器是如何获取页面的 dom 结构呢?
其实就是把页面上生成的 html 获取过来,就像我们使用开发者工具查看 html 结构一样,预渲染服务器就是拿的这个 html 字符串。
搭建预渲染服务器
预渲染需要预渲染服务,蜘蛛访问的是预渲染服务器,不是我们直接的网站页面。
预渲染服务器需要用到prerender
,这个插件可以帮助我们轻松搭建预渲染服务器。
1 | $ npm install prerender |
1 | const prerender = require("prerender"); |
访问预渲染服务器的时候需要通过以下方式:
1 | http://localhost:3200/render?url=https://www.example.com/ |
请求参数url
的内容就是要抓取的页面。
PreRender 原理
当访问预渲染服务器时,预渲染服务器会创建一个小浏览器,然后在这个小浏览器中访问 url 指定的页面,然后再在这个小浏览器中拿到html
的内容,拿到后关掉这个小浏览器,把内容返回给蜘蛛。
由于这个过程,耗时可能比较长,但是蜘蛛只关心内容,不太关心响应时间,所以问题不大。
预渲染参考网站 https://prerender.io/
这个网站详细讲解了预渲染原理和使用方式,并提供了便捷的的使用接口。
如何判断是否是蜘蛛在访问
可以通过 nigix 进行判断,然后根据结果路由到不同的服务器上,如果是机器蜘蛛则路由到预渲染服务器,如果是客户在访问则路由到网站服务器