react同构渲染+实战

2,149 阅读7分钟

1.1 认识同构

1.1.1 前后端分离的历史与发展

前后端不分离(JSP MVC)-> 前后端分离(AJAX)-> SPA(前端路由)-> SSR(前端后端渲染同构)

1.1.2 同构渲染的出现

问题和背景

  • SEO问题
  • 首屏白屏
  • Nodejs
  • mvvm ssr

同构 CSR+SSR

  • 同构:同一套js代码运行在不同的环境
  • CSR:Client-Side Rendering
  • SSR:Server-Side Rendering
  • Node中间层 用数据渲染动态页面

优点

  • 首屏快

服务端内网接口数据渲染页面,无需等待js执行完毕

  • SEO

首屏页面丰富,方便爬虫

  • 保留SPA优点

只有首屏是服务端渲染,之后还是走前端路由,无需刷新切换内容

缺点

  • 门槛高 需要理解服务端渲染,兼容服务端和客户端差异

  • 难以改造

旧SPA项目难以改造成服务端同构渲染

  • 占用服务器资源

动态页面的生成在服务端

同构是唯一方案吗?

也可以尝试预渲染技术,适合每个用户都会返回相同的内容

2.2 同构的实现原理

2.2.1 客户端渲染

简单页面客户端渲染

impot React from 'react'
import ReactDoM from 'react-dom'

ReactDoM.render(
    <h1>Hello world<h1>,
    document.getElementById('root')
)

SPA客户端渲染

加载HTML->js->请求数据->render

加载js到render的过程就是白屏时间

2.2.2 服务端渲染

HTML->js->render的过程在服务端完成

服务端不能访问dom,所以会返回创建好的字符串给浏览器;服务端渲染的优势是让用户更快的看见内容;由于服务端渲染是耗性能的,所以不能每个页面都去这么做,所以接下来我们看看同构渲染。

2.2.3 SSR同构渲染原理

服务端渲染 + SPA = Server-side rendering

用户首次请求会向node服务器去发送请求,node服务收到请求后再去请求数据,做首屏的渲染,渲染以后返回给浏览器,用户就会看到首屏内容; 页面加载js给dom绑定事件,并接管了路由操作和其他操作,这时候就变成了我们熟悉的SPA;这时候我们即消除了SPA的白屏时间,这时候又可以在客户端无刷新的切换页面。在这个过程中得益于虚拟dom的mvvm框架提供的服务端渲染能力;在服务端虚拟dom转换的是字符串,在客户端转换的真实的dom。

优点

  • SEO:首屏HTML内容丰富
  • 白屏时间:没有白屏时间,页面内容直接可见
  • 无刷新路由:继承SAP的优点
  • 同构:一套代码,两端运行

SSR同构难点

  • 服务端开发:Node开发能力和掌握框架提供的服务端渲染技术
  • 性能和监控:服务端渲染性能,服务端异常监控和处理
  • 路由同构:如何同一套路由兼容Node环境和浏览器环境
  • 请求和cookie:如何兼容两端请求,服务端缓存请求用户身份以及cookie的转发
  • 状态数据共享:服务端store的如何共享给客户端
  • 构建和部署:两端js的构建,Node服务的部署和客户端js的部署

2.3 React同构

两端渲染方法概述

// client
import ReactDOM from 'react-dom'
// server
import ReactDOMServer from 'react-dom/server'

ReactDOM 提供客户端渲染方法,将组件渲染为真实DOM

ReactDOMServer 提供服务端渲染方法,这些方法将组件渲染成为静态标记

2.3.1 React服务端渲染方法

基本API

// 参数都传入组件,返回string
 ReactDOMServer.renderToStaticMarkup(element);

 ReactDOMServer.renderToString(element)
  1. renderToStaticMarkup(适用于纯静态页面)
import ReactDOMServer from 'react-dom/server'
const App= ()=>(<h1>Hello</h1>)
const str = ReactDOMServer.renderToStaticMarkup(<App/>)
console.log(str)
// <h1>Hello<h1>
  • 将React 元素渲染为HTML字符串
  • 不会在React 内部创建的额外DOM属性,例如:data-reactroot
  1. renderToString(适用于可交互页面)
import ReactDOMServer from 'react-dom/server'
const App= ()=>(<h1>Hello</h1>)
const str = ReactDOMServer.renderToString(<App/>)
console.log(str)
// <h1 data-reactroot>Hello<h1>
  • 将React 元素渲染为HTML字符串
  • 并在React 内部创建的额外DOM属性data-reactroot
  • 作用:告诉客户端复用页面提升性能,data-reactroot这个属性就是告诉客户端,服务端已经渲染过了,那么客户端直接可以复用这个组件,然后只绑定事件就可以了。

2.3.2 React客户端渲染方法

基本API

// 两个渲染方法
import ReactDOM from 'react-dom'
// 1
ReactDOM.render(
    element,
    container[,callback]
)
// 回调:在组件被渲染或更新之后被执行,react>15

// 2
ReactDOM.hydrate(
    element,
    container[,callback]
)
// 在ReactDOMServer渲染的容器中对HTML的内容进行hydrate操作。
// React 会尝试在已有标记上绑定事件监听器
  1. ReactDOM.render
import ReactDOM from 'react-dom'
const App= ()=>(<h1>Hello</h1>)
const root = doucment.getElementById('root')
ReactDOM.render(<App/>,root)
  1. ReactDOM.hydrate

ReactDOM.hydrate配合ssr首次渲染,如果用render会重复渲染,hydrate只用于首次渲染,为服务端渲染的html绑定事件。

import ReactDOM from 'react-dom'
const App= ()=>(<h1>Hello</h1>)
const root = doucment.getElementById('root')
ReactDOM.render(<App/>,root)

React 两端渲染差异

suppressHydrationWarning

下面的案例就是在服务端渲染的时间,在客户端渲染的时候已经过去一段时间了,那怎样解决这个问题呢?

单个元素的文本两端渲染有差异,可以使用suppressHydrationWarning这个属性来解决,文本差异可以解决,属性差异不能保证解决。

// 组件
const App=()=>{
    <h1 suppressHydrationWarning>
     {new Date().getTime()}
    </h1>
}
// 服务端渲染
ReactDOMServer.renderToString(<App/>)

// 首次客户端渲染
const root = document.getElementById('root')
ReactDOM.hydrate(<App/>,root)

两端渲染

当有大段文本差异,可以使用以下方法,componentDidMount这个钩子只会在客户端渲染的时候才会执行;在服务端的时候只会执行constructor;所以可以利用在componentDidMount钩子渲染差异内容。

class App extends React.PureComponent{
    constructor(props){
        super(props)
        this.state={mounted:false}
    }
    componentDidMount(){
        this.setState({mounted:true})
    }
    return (
        <div>
            hello:
            {mounted && <Todo>}
        </div>
    )
}

总结: react同构渲染的过程:

  1. 服务端用ReactDOM.renderToString渲染出html字符串
  2. 客户端用首次用ReactDOM.hydrate为其绑定事件
  3. 下次再次更新dom就用ReactDOM.render

2.1 实现一个简单的同构渲染页面

2.1.1 使用express启动Node服务器

源码地址

const express = require('express')

const app = express()

app.get('/',(req,res)=>{
    res.send('hello world')
})

app.listen(3001)

启动服务:nodemon ./server.js

2.1.2 在服务端使用React组件和API渲染

1. 新建document.js 文件

import React from 'react'

const Document = () => {
  return (
    <html>
      <head></head>
      <body>
        <h1>hello ssr</h1>
      </body>
    </html>
  )
}
export default Document

2. server.js

const express = require('express')
const ReactDOMserver=require('react-dom/server')
const Document = require('./documnet')
const app = express()

// renderToStaticMarkup 适用于纯静态页面
const html = ReactDOMserver.renderToStaticMarkup(<Document/>)

app.get('/',(req,res)=>{
    res.send(html)
})

app.listen(3001)

运行server.js 文件发现报以下错误,这是因为不支持jsx语法

const html = ReactDOMserver.renderToStaticMarkup(<Document/>)
                                                 ^
SyntaxError: Unexpected token '<'
    at wrapSafe (internal/modules/cjs/loader.js:915:16)
    at Module._compile (internal/modules/cjs/loader.js:963:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
    at Module.load (internal/modules/cjs/loader.js:863:32)
    at Function.Module._load (internal/modules/cjs/loader.js:708:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

解决Node jsx报错

  • 安装babel yarn add @babel/core @babel/register @babel/preset-env @babel/preset-react -D
  • babel有效范围,当前引入babel的文件无效
  • 拆分router 把expres的router拆分独立文件,在router中执行React服务端渲染API

3. 新建 serverRouter.js

const express = require('express')
import React from 'react'
import ReactDOMserver from 'react-dom/server'
import Document from './documnet'
const router = express.Router()

const html = ReactDOMserver.renderToStaticMarkup(<Document/>)

router.get('/',(req,res)=>{
    res.send(html)
})
module.exports=router

4. 改写 server.js

require('@babel/register')({
    presets:['@babel/preset-env','@babel/preset-react']
})

const express = require('express')

const app = express()
const serverRouter = require('./serverRouter')
app.use('/',serverRouter)

app.listen(3001)

启动服务,打开http://localhost:3001/ 可以看见react渲染出来的内容hello ssr

虽然服务端返回了字符串,显示了内容,但是没有任何交互事件,也就是没有加载js

为什么在服务端不能绑定事件?

  1. 服务端没有dom,不能绑定事件
  2. 服务端返回的是字符串
  3. 服务端没有script
  4. 浏览器只加载了html,没有加载任何script去加载执行js

2.1.3 有交互事件的同构渲染

源码地址

  1. 新建app.js
import React from 'react';

const App = () => {
    return (<div onClick={() => alert('hello')}>
        client
    </div> );
}
 
export default App;
  1. 新建client.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/app'

// hydrate渲染,看见服务端已经渲染好的dom,就不会再次渲染
ReactDOM.hydrate(<App />, document.getElementById('root'))
  1. 我们用webpack构建我们的客户端渲染组件,打包成main.js
 // 下载webpack、webpack-cli
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin =require('html-webpack-plugin')

module.exports = {
  entry: './src/client.js',
  output: {
    // 打包后的main.js放到build文件下
    path: path.resolve(__dirname, 'build'),
    filename: 'main.js'
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
      }
    ]
  }
};

我们客户端渲染已经结束,接下来看看服务端怎么做

  1. document.js
import React from 'react'

const Document = ({ children }) => (
  <html lang="en">
    <head>
      <meta charSet="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>simple-ssr</title>
    </head>
    <body>
      // dangerouslySetInnerHTML 用于在dom中插入字符串,跟vue的v-html类似
      <div id="root" dangerouslySetInnerHTML={{ __html: children }} />
    </body>
    // 加载客户端打包后的main.js
    <script src="./main.js"></script>
  </html>
)

export default Document
  1. serverRouter.js
const express = require('express')
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import Document from './components/Document'
import App from './components/App'

const router = express.Router();

// 渲染app.js ,服务端负责渲染,客户端负责绑定事件
/*
 renderToString 主要用于需要交互的页面
 renderToStaticMarkup 主要用于单纯的展示页面
*/
const appString = ReactDOMServer.renderToString(<App/>)
const html = ReactDOMServer.renderToStaticMarkup(<Document>
  {appString}
</Document>)

router.get("/", function (req, res, next) {
    res.status(200).send(html);
});

module.exports = router

nodemon ./src/server.js 启动服务,可以看见页面用了ssr渲染,又有了点击事件

2.2 实现SPA同构渲染

源码地址

  • react-router 基本的客户端路由实现
  • 理解无状态组件
  • 利用react-router 实现服务端路由

2.2.1 客户端路由

react-router-dom:客户端、服务端都可以用

yarn add react-router-dom

App.js

import React from 'react'
import { Route, Switch, NavLink } from 'react-router-dom';
import routes from '../core/routes.js'

const App = () => {
  return (
    <div>
      <ul>
        <li>
          <NavLink to="/">to Home</NavLink>
        </li>
        <li>
          <NavLink to="/user">to User</NavLink>
        </li>
      </ul>

      <Switch>
        {routes.map(route => (
          <Route key={route.path} exact={route.path === '/'} {...route} />
        ))}
      </Switch>
    </div>
  )
}

export default App

routes.js

import Home from '../components/Home'
import User from '../components/User'
import NotFound from '../components/NotFound'

const routes = [
  {
    path: "/",
    component: Home,
  },
  {
    path: "/user",
    component: User,
  },
  {
    path: "",
    component: NotFound,
  },
];

export default routes

2.2.2 服务端路由

StaticRouter

  • 无状态组件
  • 什么是无状态:它永远不会更改位置,服务端不会有用户点击切换路由,已经渲染的路由组件不会在更改
  • location: string | object
  • context: object
<StaticRouter
 location={req.url}
 context={context}
>
<App/>
</StaticRouter>

serverRouter.js


const express = require('express')
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import Document from './components/documnet'
import App from './components/app'
import { StaticRouter } from 'react-router-dom'
const router = express.Router()

router.get("*",  function (req, res, next) {

// 第一次加载或者刷新页面都有服务端渲染,然后客户端接管路由跳转渲染页面
  const appString = ReactDOMServer.renderToString(
    <StaticRouter
      location={req.url}
    >
      <App />
    </StaticRouter>)

  const html = ReactDOMServer.renderToStaticMarkup(<Document>
    {appString}
  </Document>)
  console.log('html', html)

  res.status(200).send(html);
  
});

module.exports = router

spa.png

到此为止我们用react-router-dom实现了服务端路由,客户端路由的使用

2.3 何时请求异步数据

源码地址

2.3.1 客户端请求的时机和实现

推荐:componentDidmount、useEffect中发送请求

不推荐:componentWillmount、componentWillReceiveProps、componentWillUpdate

为什么不在componentWillmount请求数据?

  1. 执行完componentWillmount后,会立即执行render方法,这时候接口数据还没有返回,提前请求并没有减少render方法的调用
  2. 过期警告componentWillmount、componentWillReceiveProps、componentWillUpdate,在新版本的react将移除这些生命周期; 在新的版本中将采用fiber架构:fiber架构将导致这些生命周期多次执行。

fiber-line.png

同步:是一次性渲染全部组件

异步:分片多次渲染,高优先级任务可以打断渲染(遇到点击,滚动这样的任务把它作为高优先级任务优先响应用户,浏览器空闲时间再次接着渲染,所以会导致上3个生命周期多次执行)

2.3.2 服务端请求的时机和实现

服务端不会执行componentDidmount、useEffect,所以服务端要在渲染组件之前要拿到数据

axios发送请求(支持服务端和客户端)

yarn add axios
  1. 新建apiRouter.js

模拟一些接口,并返会一些数据

const express = require('express')

const router = express.Router();

router.get("/home", function (req, res, next) {
  res.json({ title: 'Home', desc: '这是home页面' })
});

router.get("/user", function (req, res, next) {
  res.json({ name: '张三', age: '21', id: '1' })
});

module.exports = router
  1. 改写server.js
require('@babel/register')({
    presets:['@babel/preset-env','@babel/preset-react']
})

const express = require('express')
const app = express()
const serverRouter = require('./server/serverRouter')
const apiRouter = require('./server/apiRouter')

// api接口
+ app.use("/api/", apiRouter);
// 用于加载静态资源
app.use("/build", express.static('build'));
// 服务端渲染
app.use('/',serverRouter)

app.listen(3003)
  1. api.js

请求数据的封装

import axios from 'axios'

const req = axios.create({
  baseURL:'http://localhost:3003/api',
});

req.interceptors.response.use(function (response) {
  return response.data;
});

// 请求首页
export const fetchHome = () => req.get('/home')
// 请求用户信息
export const fetchUser = () => req.get('/user')
  1. user组件
import React,{useEffect} from 'react';
import { fetchUser } from '../core/api'

const User = ({staticContext}) => {
  // staticContext 用于服务端渲染,staticContext是请求接口返回的值,具体可以看serverRouter.js
  console.log('staticContext',staticContext)
  // 客户端请求的时机,在服务端渲染的时候,useEffect并不会执行
  useEffect(()=>{
    fetchUser().then(data=>console.log('User data:',data))
  },[])
  return (
    <main>
      <h1>User</h1>
      <button onClick={()=>{alert('user!')}}>click me</button>
    </main>
  )
}

User.getData = fetchUser
export default User
  1. serverRouter.js

const express = require('express')
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import Document from '../components/documnet'
import App from '../components/app'
import { StaticRouter,matchPath } from 'react-router-dom'
import routes from '../core/routes'
const router = express.Router()

router.get("*", async function (req, res, next) {
  let data = {}
  let getData = null

  // 匹配当前路由,然后拿到当前要渲染组件的静态属性getData;getData就是请求的接口函数
  routes.some(route => {
    const match = matchPath(req.path, route);
    if (match) {
      getData = (route.component || {}).getData
    }
    return match
  });
  
  if (typeof getData === 'function') {
    try {
      data = await getData()
    } catch (error) { }
  }
  const appString = ReactDOMServer.renderToString(
    <StaticRouter
      location={req.url}
      // context传的值在组件中staticContext可以获取到对应的值
      context={data}
    >
      <App />
    </StaticRouter>)

  const html = ReactDOMServer.renderToStaticMarkup(<Document data={data}>
    {appString}
  </Document>)

  res.status(200).send(html);

});

module.exports = router

总结:

  1. 服务端渲染是在渲染组件之前请求数据,然后利用context把值传到对应组件,这样就渲染出了有数据的组件。
  2. 客户端渲染可以在componentDidmount、useEffect中请求数据进行客户端渲染。

2.4 客户端复用服务端数据

源码地址

服务端怎样向客户端传递数据

  • 通过window全局变量

利用window全局变量传递数据

  1. 改写serverRouter.js
  const html = ReactDOMServer.renderToStaticMarkup(<Document data={data}>
    {appString}
  </Document>)
  1. 改写 doucment.js

我们可以将传递过来的数据转换成JSON字符串,赋值给window.__APP_DATA;然后放到script标签中,在客户端就会执行以下代码。

import React from 'react'

const Document = ({ children ,data}) => {
  return (
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>simple-ssr</title>
      </head>
      <body>
        <div id="root" dangerouslySetInnerHTML={{ __html: children }}></div>
+        <script
+         dangerouslySetInnerHTML={{
+          __html: `window.__APP_DATA__=${JSON.stringify(data)}`,
+         }}
+      />
        <script src="/build/main.js"></script>
      </body>
    </html>
  )
}
export default Document
  1. 改写home.js
import React, { useState } from 'react';
import {fetchHome} from '../core/api'
const Home = ({staticContext}) => {
  console.log('staticContext',staticContext)
  const getInitialData = () => {
    // 服务端渲染拿到的数据
    if (staticContext) {
      return staticContext
    }
    // 客户端渲染,拿到服务端传递过来的数据
    if (window.__APP_DATA__) {
      return window.__APP_DATA__
    }
    return {}
  }
  const [data, setData] = useState(getInitialData())

  return (
    <main>
      <div>{data.title}</div>
      <div>{data.desc}</div>
    </main>
  )
}
Home.getData = fetchHome

export default Home

客户端路由跳转数据获取

上面home.js的写法有一定问题?

  • home.js客户端渲染从window.__APP_DATA__上获取数据,如果home跳转到user,那么user.js数据从哪获取呢?不能从window.__APP_DATA__获取了,user.js需要不同的数据。

  • window.__APP_DATA__ 只能应用于首屏获取数据。

  1. 新建useData.js

useData.js 是封装的一个hooks,用于处理数据

import { useState, useEffect } from 'react'

const useData = (staticContext, initial, getData) => {

  // 初始化数据
  const getInitialData = () => {
    //  server render
    if (staticContext) {
      return staticContext
    }
    // client first render
    if (window.__APP_DATA__) {
      return window.__APP_DATA__
    }
    return initial
  }
  const [data, setData] = useState(getInitialData())

  useEffect(() => {
    // 客户端首次执行完以后,把window.__APP_DATA__清除掉;下个路由就可以请求数据了
    if (window.__APP_DATA__) {
      window.__APP_DATA__ = undefined
      return
    }
    if (typeof getData === 'function') {
      console.log('spa render')
      getData().then(res => setData(res)).catch()
    }
  }, [])

  return [data, setData]

}

export default useData
  1. 改写home.js
import React, { useState } from 'react';
import {fetchHome} from '../core/api'
import useData from '../core/useData'

const Home = ({staticContext}) => {
  const [data, setData] = useData(staticContext, { title: '', desc: ''}, fetchHome)
  return (
    <main>
      <h1>{data.title}</h1>
      <p>{data.desc}</p>
    </main>
  )
}
Home.getData = fetchHome

export default Home

package.json

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "rm -rf build && webpack --config ./webpack.config.js",
    "start": "npm run build && nodemon ./src/server.js"
  },

项目地址