React原生实现SSR(二十)

504 阅读3分钟

1.React SSR 介绍

1.1 客户端渲染

CSR:Client Side Rendering

服务器端仅返回 JSON 数据, DATA 和 HTML 在客户端进行渲染

存在的问题:

  1. 首屏等待时间长, 用户体验差
  2. 页面结构为空,不利于 SEO
image-20220215140142813

1.2 什么是服务器端渲染

SSR:Server Side Rendering

服务器端返回HTML,DATA 和 HTML 在服务器端进行渲染

image-20220215140259152

1.3 React SSR 同构

同构指的是代码复用. 即实现客户端和服务器端最大程度的代码复用

2.服务器端渲染开始

案例代码:React/React-SSR · 云牧/exampleCode - 码云 - 开源中国 (gitee.com)

项目结构:

react-ssr
	src 源代码文件夹
        client 客户端代码
        server 服务器端代码
        share 同构代码

实现 React SSR:

  1. 引入要渲染的 React 组件
  2. 通过 renderToString 方法将 React 组件转换为 HTML 字符串
  3. 将结果 HTML 字符串响应到客户端

renderToString 方法用于将 React 组件转换为 HTML 字符串,通过 react-dom/server 导入

配置对应webpack,因为 Node 环境不支持 ESModule 模块系统,不支持 JSX 语法

配置服务器端打包命令: "dev:server-build": "webpack --config webpack.server.js --watch" 配置服务端启动命令: "dev:server-run": "nodemon --watch build --exec\"node build/bundler.js\""

3.客户端 React 附加事件

在客户端可以使用 hydrate 方法对组件进行二次"渲染", 为组件元素附加事件

hydrate 方法在实现渲染的时候,会复用原本已经存在的 DOM 节点,减少重新生成节点以及删除原本 DOM 节点的开销

通过 react-dom 导入 hydrate

ReactDOM.hydrate(<Home/>, document.getElementById('#root'))

给客户端代码添加webpack打包配置后添加命令:

"dev:client-build": "webpack --config webpack.client.js --watch"

在响应给客户端的 HTML 代码中添加 script 标签,请求客户端 JavaScript 打包文件:

  <html>
    <head>
      <title> React SSR</title>
    </head>
    <body>
      <div id="root">${content}</div>
      <script src="bundle.js"></script>
    </body>
  </html>

服务器端程序实现静态资源访问功能,客户端 JavaScript 打包文件会被作为静态资源使用:

app.use(express.static('public')

4.优化

1.合并Webpack配置

服务器端 webpack 配置和客户端 webpack 配置存在重复,将重复配置抽象到 webpack.base.js 配置文件中,然后在对应的client 和 servese文件中 module.exports = merge(baseConfig, config) 合并导出对应的配置对象

2. 合并项目启动命令

使用一个命令启动项目,解决多个命令启动的繁琐问题,通过 npm-run-all 工具实现:

"dev": "npm-run-all --parallel dev:*"

3. 服务器端打包文件体积优化

在服务器端打包文件中,包含了 Node 系统模块,导致打包文件本身体积庞大,可通过 webpack 配置剔除打包文件中的 Node 模块:

const nodeExternals = require('webpack-node-externals')
const config = {
  externals: [nodeExternals()]
}
module.exports = merge(baseConfig, config)

4. 将启动服务器代码和渲染代码进行模块化拆分

优化代码组织方式,渲染 React 组件代码是独立功能,所以把它从服务器端入口文件中进行抽离

5.路由支持

在 React SSR 项目中需要实现两端路由

客户端路由是用于支持用户通过点击链接的形式跳转页面

服务器端路由是用于支持用户直接从浏览器地址栏中访问页面

客户端和服务器端共用一套路由规则

1.编写路由规则

// share/routes.js
import Home from './pages/Home'
import List from './pages/List'

export default [
  {
    path: '/',
    component: Home,
    exact: true
  }, {
    path: '/list',
    component: List,
  }
]

2.实现服务器端路由

Express 路由接受所有 Get 请求,服务器端 React 路由通过请求路径匹配要进行渲染的组件

服务端路由配置:

import React from 'react'
import {renderToString} from 'react-dom/server'
import { StaticRouter } from "react-router-dom";
import routes from '../share/routes'
import { renderRoutes } from "react-router-config";

export default (req) => {
  const content = renderToString(
    <StaticRouter location={req.path}>
      {renderRoutes(routes)}
    </StaticRouter>
  )
  return `
  <html>
    <head>
      <title> React SSR</title>
    </head>
    <body>
      <div id="root">${content}</div>
      <script src="bundle.js"></script>
    </body>
  </html>
  `
}

3.实现客户端路由

添加客户端路由配置:

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from "react-router-dom"
import { renderRoutes } from "react-router-config";
import routes from '../share/routes'

ReactDOM.hydrate(
  <BrowserRouter>
    {renderRoutes(routes)}
  </BrowserRouter>
  , document.getElementById('root')
 )

6.Redux 支持

在实现了React SSR 的项目中需要实现两端 Redux

客户端 Redux 就是通过客户端 JavaScript 管理 Store 中的数据

服务器端 Redux 就是在服务器端搭建一套 Redux 代码,用于管理组件中的数据

客户端和服务器端共用一套 Reducer 代码

创建 Store 的代码由于参数传递不同所以不可以共用

创建异步 dispatch 时报错,因为浏览器默认不支持异步函数

1.实现客户端 Redux

  1. 创建 Store
  2. 配置 Store
  3. 创建 Action 和 Reducer
  4. 配置 polyfill 由于浏览器不能识别异步函数代码, 所以需要 polyfill 进行填充

2.实现服务器端 Redux

  1. 创建 Store
// server/createStore.js
import { createStore, applyMiddleware } from "redux";
import thunk from 'redux-thunk'
import reducer from '../share/store/reducers'

export default () => createStore(reducer, {}, applyMiddleware(thunk))
  1. 配置 Store
// server/index.js
import app from './http'
import renderer from './renderer'
import createStore from './createStore'

app.get('*', (req, res) => {
  const store = createStore()
  res.send(renderer(req, store))
})
// server/renderer.js
import React from 'react'
import {renderToString} from 'react-dom/server'
import { StaticRouter } from "react-router-dom";
import routes from '../share/routes'
import { renderRoutes } from "react-router-config";
import { Provider } from "react-redux";

export default (req, store) => {
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path}>
        {renderRoutes(routes)}
      </StaticRouter>
    </Provider>
  )
  return `
  <html>
    <head>
      <title> React SSR</title>
    </head>
    <body>
      <div id="root">${content}</div>
      <script src="bundle.js"></script>
    </body>
  </html>
  `
}

3.服务器端 store 数据填充

问题:服务器端创建的store 是空的,组件并不能从 Store 中获取到任何数据

解决:服务器端在渲染组件之前获取到组件所需要的数据

  1. 在组件中添加 loadData 方法,此方法用于获取组件所需数据,方法被服务器端调用
  2. 将 loadData 方法保存在当前组件的路由信息对象中
  3. 服务器端在接收到请求后,根据请求地址匹配出要渲染的组件的路由信息
  4. 从路由信息中获取组件中的 loadData 方法并调用方法获取组件所需数据
  5. 当数据获取完成以后再渲染组件并将结果响应到客户端

4.React 警告消除

此时浏览器会显示一个警告

警告原因:客户端 Store 在初始状态下是没有数据的,在渲染组件的时候生成的是空 ul ,但是服务器端是先获取数据再进行组件的渲染,所以生成的是有子元素的 ul , hydrate 方法在对比的时候发现两者不一致, 所以报了个警告

解决思路:将服务器端获取到的数据回填给客户端,让客户端拥有初始数据.

  1. 服务器响应 Store 初始状态
// server/renderer.js
import React from 'react'
import {renderToString} from 'react-dom/server'
import { StaticRouter } from "react-router-dom";
import routes from '../share/routes'
import { renderRoutes } from "react-router-config";
import { Provider } from "react-redux";
import serialize from 'serialize-javascript'

export default (req, store) => {
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path}>
        {renderRoutes(routes)}
      </StaticRouter>
    </Provider>
  )
  const initialState = JSON.stringify(JSON.parse(serialize(store.getState())))
  return `
  <html>
    <head>
      <title>React SSR</title>
    </head>
    <body>
      <div id="root">${content}</div>
      <script>window.INITIAL_STATE = ${initialState} </script>
      <script src="bundle.js"></script>
    </body>
  </html>
  `
}
  1. 客户端设置 Store 初始状态
// client/createStore.js
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducer from '../share/store/reducers'

const store = createStore(reducer, window.INITIAL_STATE, applyMiddleware(thunk))

export default store

5.防止 XSS 攻击

转移状态中的恶意代码

let response = {
  data: [{id: 1, name: '<script>alert(1)</script>'}]
}
import serialize from 'serialize-javascript'

const initialState = serialize(store.getState())