# 为什么要服务端渲染(ssr)
# 首屏等待
在 SPA 模式下,所有的数据请求和 DOM 渲染都在浏览器端完成,所以当我们第一次访问页面的时候很可能会存在“白屏”等待,而服务端渲染所有数据和 html 内容已在服务端处理完成,浏览器收到的是完整的 html 内容,可以更快地看到渲染内容,在服务端完成数据请求肯定要比浏览器端效率高得多。
# SEO
SPA 由于加载模版的时候页面骨架上面只有一个节点,其余所有节点都是由 JS 动态生成的,因为搜索引擎爬虫只认识 html 结构的内容,不能识别 JS 代码内容,所以对于 SEO,完全无能为力。
# 核心原理
整体来说服务端渲染原理不复杂,核心内容就是同构。
node server 接收到客户端请求,得到当前的 req url path,然后在已有的路有列表内查找对应的组件,拿到需要请求去的数据,将数据作为 props、context 或者 store 形式存入组件,然后基于 react 内置的服务端渲染 api renderToString
或者 renderToNodeStream
把组件渲染为 html字符串 或者 stream流,在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器可以得到数据(脱水),浏览器开始进行渲染和节点比对,然后执行组件的 componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html节点,整个流程结束。
# React SSR
# 从 ejs 开始
实现 ssr 很简单,先看一个 node ejs 的例子
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title><%= title %></title>
</head>
<body>
<%= data %>
</body>
</html>
// node ssr
const ejs = require('ejs')
const http = require('http')
http.createServer((rea, res) => {
if(req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html'
})
ejs.renderFile('./views/index.ejs', {
title: 'react ssr',
data: '首页'
}, (err, data) => {
if(err) console.log(err)
else res.end(data)
})
}
}).listen(8080)
# JSX 到字符串
参考上面,我们使用 React 组建来实现服务端渲染,使用 jsx 来代替 ejs。
const React = require('react')
const { renderToString } = require('react-dom/server')
const http = require('http')
// 组件
class App extends React.Component {
render() {
return <h1>{this.props.data.title}</h1>
}
}
// 模拟数据获取
const fetch = function() {
return {
title: 'react ssr',
data: []
}
}
http.createServer((req, res) => {
if(req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html'
})
const data = fetch()
const html = renderToString(<Index data={data} />)
res.end(html)
}
}).listen(8080)
ps:以上代码不能直接运行,需要结合 babel 使用 @babel/preset-react 进行转换
# 关键问题
在上面非常简单的就实现了 react ssr,它帮助我们引出了一系列关键问题:
# 双端路由如何维护?
首先我们会发现我在 server 端定义了路由 '/',但是在 react SPA 模式下我们需要使用 react-router 来定义路由,那是不是需要维护两套路由?
# 获取数据的方法和逻辑写在哪里?
我们发现获取数据的 fetch 方法是独立的,和组件没有任何关联,我们更希望每个路由组件都有自己的 fetch 方法。
# 服务端 html 节点无法重用
虽然组件在服务端得到了数据,也能渲染到浏览器内,但是当浏览器端进行组件渲染的时候,直出的内容会一闪而过消失。
# 同构才是核心
react ssr 的核心就是同构,没有同构的 ssr 是没有意义的。
所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染无法做到,react ssr 的出现打破了这个瓶颈,并且已经得到了比较广泛的应用。
# 路由同构
双端使用同一套路由规则,node server 通过 req url path 进行组件查找,得到需要渲染的组件。
// 组件和路由配置,供双端使用 routes-config.js
import React from 'react'
class Detail from React.Component {
render() {
return <div>detail</div>
}
}
class Index from React.Component {
render() {
return <div>index</div>
}
}
const routes = [
{
path: '/',
exact: true,
component: Home
},
{
path: '/detail',
exact: true,
component: Detail
},
{
path: '/detail/:a/:b',
exact: true,
component: Detail
}
]
export default routes
// 客户端路由组件
import routes from './routes-config.js'
function App() {
return <Layout>
<Switch>
{routes.map((item, index) => {
return <Route
path={item.path}
key={index}
exact={item.exact}
render={item.component}
/>
})}
</Switch>
</Layout>
}
export default App
node server 进行组件查找
路由匹配其实就是对组件 path 规则的匹配,如果规则不复杂可以自己写,如果情况很多种还是使用官方提供的库来完成。
matchRoutes(routes, pathname)
import { matchRoutes } from 'react-router-config'
import routes from './routes-config.js'
http.createServer((req, res) => {
const url = req.url
// 简单排出图片等资源文件的请求
if(url.indexOf('.') > -1) {
res.end('')
return false
}
res.writeHead(200, {
'Content-Type': 'text/html'
})
const data = fetch()
// 查找组件
const branch = matchRoutes(routes, url)
// 得到组件
const Component = branch[0].route.component
// 将组件渲染为 html 字符串
const html = renderToString(<Component data={data}/>)
res.end(html)
}).listen(8080)
matchRoutes
具体返回值查看官方文档
# 数据同构(预取同构)
这里我们解决【获取数据的方法和逻辑放在哪里?】
数据预取同构,解决双端如何使用同一套数据请求方法来进行数据请求。
在查找到要渲染的组件后,需要预先得到此组件所需要的数据,然后将数据传递给组件后,再进行组件的渲染。
我们可以通过给组件定义静态方法来处理,组件内定义异步数据请求的方法也合情合理,同时声明为 static,在服务端和组件内部都可以通过组件来进行访问,比如:Index.getInitialProps
// 组件
class Index extends React.Component {
static async getInitialProps(opt) {
const fetch1 = await fetch('/xxx.com/a')
const fetch2 = await fetch('/xxx.com/b')
return {
res: [fetch1, fetch2]
}
}
render() {
return <h1>{this.props.data.title}</h1>
}
}
// 服务端
http.createServer((req, res) => {
const url = req.url
if(url.indexOf('.') > -1) {
res.end('')
return false
}
res.writeHead(200, {
'Content-Type': 'text/html'
})
// 组件查找
const branch = matchRoutes(routes, url)
// 得到组件
const Component = branch[0].route.component
// 数据获取
const data = Component.getinitialProps(branch[0].match.params)
// 传入数据,渲染为 html字符串
const html = renderToString(<Component data={data}/>)
res.end(html)
}).listen(8080)
# 渲染同构
假设我们现在基于上面已经实现的代码,同时我们也使用了 webpack 进行了配置,对代码进行了转换和打包,整个服务可以跑起来。
路由能够正确匹配,数据预取正常,服务端可以直出组件的 html,浏览器加载 js 代码正常,查看网页源代码能看到 html 内容,好像我们整个流程都已经走完了。
但是当浏览器端的 js 执行完成后,发现数据重新请求了,组件的重新渲染导致页面看上去有些闪烁。
这是因为在浏览器端,双端节点对比失败,导致组件重新渲染,也就是只有服务端和浏览器端渲染的组件具有相同的 props 和 DOM 结构的时候,组件才能只渲染一次。
刚刚我们实现了双端的数据预取同构,但是数据也仅仅是服务端有,浏览器端并没有获取到这份数据,当浏览器进行首次组件渲染的时候没有初始化的数据,渲染出的节点和服务端直出的节点不同,导致组件重新渲染。
# 数据注水
在服务端将预取的数据注入到浏览器,使浏览器可以访问到,客户端进行渲染前将数据传入对应的组件即可,这样就保证了 props 的一致。
// node server
http.createServer((rea, res) => {
const url = req.url
if(url.indexOf('.') > -1) {
res.end('')
return false
}
res.writeHead(200, {
'Content-Type': 'text/html'
})
// 查找组件
const branch = matchRoutes(routes, url)
// 得到组件
const Component = branch[0].route.component
// 数据预取
const data = Component.getInitialProps(branch[0].match.params)
// 组件渲染
const html = renderToString(<Component data={data} />)
// 数据注水
const propsData = `<textarea style="display: none" id="server-render-data-BOX">${JSON.stringify(data)}</textarea>`
// 通过 ejs 模版引擎将数据注入到页面
ejs.renderFile('./index.html', {
htmlContent: html,
propsData
}, (err, data) => { // 渲染数据的key:对应到了ejs中的index
if(err) console.log(err)
else {
console.log(data)
res.end(data)
}
})
}).listen(8080)
<!-- node ejs html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>
<body>
<div id="rootEle">
<%- htmlContent %> <!-- 组件 html 内容 -->
</div>
<%- propsData %> <!-- 组件 init state ,现在是个字符串 -->
</body>
</html>
</body>
需要借助 ejs 模版,将数据绑定到页面上。
# 数据脱水
上一步数据已经注入到了浏览器端,这一步要在客户端组件渲染前先拿到数据,并且传入组件就可以了。
客户端可以直接使用 id=server-render-data-BOX
进行数据获取
第一个方法简单粗暴,直接在组件构造函数进行获取,后续可以使用高阶组件复用这部分逻辑
第二个方法可以通过 context 传递,只需要在入口处传入,在组件中声明 static contextType 即可,这种方法有利于后续引入 redux。
// 定义 context生产者 组件
import React from 'react'
import RootContext from './route-context'
export default class index extends React.Component {
render() {
return <RootContext.Provider value={this.props.initialData || {}}>
{this.props.children}
</RootContext.Provider>
}
}
// 入口 app.js
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Routes from '../.'
import Provider from './provider'
// 渲染入口 接收脱水数据
function renderUI(initialData) {
ReactDOM.hydrate(<BrowserRouter>
<Provider initialData={initialData}>
<Routes/>
</Provider>
</BrowserRouter>,
document.getElementById('root'),
e => {
})
}
// 函数执行入口
function entryIndex() {
let APP_INIT_DATA = {}
let state = false
// 取得数据
let stateText = document.getElementById('server-render-data-BOX')
if(stateText) {
APP_INIT_DATA = JSON.parse(stateText.value || '{}')
}
if(APP_INIT_DATA) renderUI(APP_INIT_DATA) // 客户端渲染
}
entryIndex() // 入口执行
通过 context 获取数据
import React from 'react'
import '../css/index.scss'
export default class Index extends React.Component {
constructor(props, context) {
super(props, context)
// 将 context 存储到 state
this.state = {
...context
}
}
// 设置此参数,才能拿到 context 数据
static contextType = RootContext
// 数据预取方法
static async getInitialProps(opt) {
if(__SERVER__) {
// 如果是服务端渲染,可以做的处理,node 端设置全局变量
}
const fetch1 = fetch.postForm('/xxx1')
const fetch2 = fetch.postForm('/xxx2')
const resArr = await fetch.multipleFetch(fetch1, fetch2)
return {
page: {},
fetchData: resArr
}
}
componentDidMount() {
if(!this.isSSR) { // 非服务端渲染需要自身进行数据获取
Index.getInitialProps(this.props.opt).then(data => {
this.setState(data)
})
}
render() {
const { page, fetchData } = this.state
const [ res ] = fetchData || []
return <div>{res && res.data.map(item => {
return <div key={item.id}>{item.name}</div>
})}</div>
}
}
# CSS 过滤
我们在写组件的时候大部分都会导入相关的 css 文件,但是 css 文件无法在服务端执行,所以我们需要在服务端渲染的时候将导入的 css 文件去除掉
# 动态路由
在 SPA 模式下,大部分应用都会实现组件分包和按需加载,为了防止所有代码打包在一个文件,导致资源过大影响页面的加载和渲染。
在 SSR 下,我们限定按需的粒度是路由级别,请求不同的路由动态加载对应的组件。
# 如何实现组件的按需加载?
在 webpack 2 时期主要是用 require.ensure 方法来实现,在当下 webpack 4,有了更加规范的方式实现按需加载,那就是 import('xx.js')
。
我们都知道 import 方法传入一个 js 文件地址,返回值是一个 promise 对象,然后再回调中得到按需的组件。它的原理其实就是通过 jsonp 的方式,动态请求脚本,然后在回调内得到组件。
import('../index').then(res => {
// xxx
})
所以我们得到了几个比较有用的信息:
- 如何加载脚本:import 结合 webpack 自动完成;
- 脚本是否加载完成:通过 then 方法回调进行处理;
- 获取异步按需组件:通过 then 方法回调获取。
// 使用 react-loadable
import React from 'react'
import Loadable from 'react-loadable'
// index.js
class Index extends React.Component {
render() {
return <div>index</div>
}
}
// detail.js
class Detail extends React.Component {
render() {
return <div>detail</div>
}
}
// route.js
// 按需加载 index 组件
const AsyncIndex = props => {
return <Async load={() => import('../index')}>
{C => <C {...props} />}
</Async>
}
// 按需加载 detail 组件
const AsyncDetail = props => {
return <Async load={() => import('../detail')}>
{C => <C {...props} />}
</Async>
}
const routes = [
{
path: '/',
exact: true,
component: AsyncIndex,
},
{
path: '/detail',
exact: true,
component: AsyncDetail
}
]
按照这种方式进行配置,会发现 ssr 无效了。
# 动态路由 SSR 双端配置
ssr 无效的原因:
因为我们做路由同构的时候,双端使用的是同一个 route 配置文件 routes-config.js,现在组件修改成了按需加载,所以在路由查找后得到的组件发生了改变,AsyncDetail 和 AsyncIndex 无法转换出组件内容。
# ssr 模式下服务端如何处理路由按需加载
参考客户端的处理方式,对路由配置二次处理,服务端在组件查找前强制指向 import 方法,得到一个全新的静态路由表,然后再去查找组件。
import routes from 'routes-config.js'
export async function getStaticRoutes() {
const staticRoutes = []
for(const route of routes) {
staticRoutes.push({
...route,
{
component: (await item.component().props.load()).default
}
})
}
return staticRoutes
}
# ssr 模式下客户端如何处理路由按需加载
完成服务端配置后,浏览器会一直显示加载中,这是因为双端节点对比失败,重新渲染。
# 解决办法
等待按需组件加载完成后再进行渲染。
# 如何按需
参考服务端设置,不过不转换成静态路由表,只需要找到按需加载组件完成动态加载即可。