ReactSSR - React服务器渲染

748 阅读14分钟

笔记来源:拉勾教育 - 大前端就业集训营

文章内容:学习过程中的笔记、感悟、和经验

提示:项目实战类文章资源无法上传,仅供参考

ReactSSR - React服务器渲染

概述

  • 客户端渲染(CSR):服务器端仅返回JSON数据,客户端接收到DATA后将数据与HTML进行组合然后渲染
  • 服务器端渲染(SSR):服务器直接将需要使用的DATA数据和HTML进行组合,然后把组合后的HTML返回给客户端,客户端只需要直接使用即可

服务器端渲染存在问题

  • 首屏等待时间长,用户体验差
  • 页面结构为空,搜索引擎爬爬不到任何内容,不利于SEO

ReactSSR同构 - 服务器端渲染同构

  • 同构指代码复用,即实现客户端和服务器端最大程度代码复用,客户端和服务器端都可以使用

项目结构初始化

  • 项目结构
    • src源代码文件夹
      • client - 客户端代码目录
      • server - 服务器端代码目录
      • share - 同构代码目录

将课程提供的package.jsonpackage.lock.json文件拷贝指导项目中,然后npm i安装依赖

实现ReactSSR雏形

创建node服务器 - express

  1. 确定是否已经安装了express依赖
  2. server目录下创建http.js文件,在内部创建node服务器
// 引入依赖
import express from 'express';

// 创建node服务器实例对象
const app = express();
// 
//app.use(express.static('public'));
// 监听3000端口,并在成功后打印内容
app.listen(3000, () => console.log('app is running on 3000 port'));

// 导出node
export default app;
  1. server目录下创建index.js文件,作为node服务器入口文件
// 引入服务器实例
import app from './http';

// 接受客户端发来的请求(请求对象、响应对象),请求地址为/
app.get('/', (req, res) => {
  
});

// 到目前为止,node服务器就创建好了

实现ReactSSR

总体步骤

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

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

具体实现

  1. 因为这个组件时客户端与服务端通用的,所以属于同构代码,书写在share目录中

  2. shart目录下创建pages目录放置页面组件

  3. pages下面创建Home.js文件作为首页组件

    import React, { Component } from 'react'
    
    // 首页组件
    export class Home extends Component {
      render() {
        return <div>Home内容</div>
      }
    }
    
    export default Home
    
  4. server/index.js文件中引入Home组件、renderToString方法

  5. 在app.get方法中使用renderToString转换Home组件,然后使用res.send返回HTML页面结构

    // 引入服务器实例
    import app from './http'
    import { renderToString } from 'react-dom/server'
    import Home from '../share/pages/Home'
    
    // 接受客户端发来的请求(请求对象、响应对象),请求地址为/
    app.get('/', (req, res) => {
      // 将首页转换为字符串
      const content = renderToString(<Home />)
      // 响应回去(直接返回HTML,把首页转换后的字符串插进去)
      res.send(`
        <html>
          <head>
            <title>ReactSSR</title>
          </head>
          <body>
          <div id='root'>${content}</div>
          </body>
        </html>
      `)
    })
    

服务端程序webpack打包配置

node环境下不支持ESModule模块系统和JSX语法,所以写要进行配置

  1. 在项目根目录下创建webpack.server.js文件

    // 引入path以便使用API
    const path = require('path')
    
    // 配置对象
    module.exports = {
      // 生产环境:开发环境
      mode: 'development',
      // 代码运行环境:node
      target: 'node',
      // 入口文件
      entry: './src/server/index.js',
      // 导出
      output: {
        // 打包路径:使用path的API进行路径拼接
        path: path.join(__dirname, 'build'),
        // 打包文件名
        filename: 'bundle.js',
      },
      // 配置打包规则
      module: {
        rules: [
          {
            // js文件规则
            test: /\.js$/,
            // 忽略node_modulse文件夹
            exclude: /node_modulse/,
            use: {
              // 使用babel转换
              loader: 'babel-loader',
              // 配置babel
              options: {
                // babel预制
                presets: ['@babel/preset-env', '@babel/preset-react'],
              },
            },
          },
        ],
      },
    }
    
  2. package.js书写命令,执行打包

    .........
    "scripts": {
        // 执行webpack.server.js配置进行打包
        "dev:server-build": "webpack --config webpack.server.js",
      },
    .........
    
  3. 使用node执行打包后的文件npm run dev:server-build,此时应该会报错

  4. 在src/server/index.js中引入react让node支持jsx语法,避免报错

    // 引入服务器实例
    import app from './http'
    import { renderToString } from 'react-dom/server'
    import Home from '../share/pages/Home'
    // 引入react让程序支持jsx语法
    import React from 'react'
    
    // 接受客户端发来的请求(请求对象、响应对象),请求地址为/
    app.get('/', (req, res) => {
      // 将首页转换为字符串
      const content = renderToString(<Home />)
      // 响应回去(直接返回HTML,把首页转换后的字符串插进去)
      res.send(`
        <html>
          <head>
            <title>ReactSSR</title>
          </head>
          <body>
          <div id='root'>${content}</div>
          </body>
        </html>  
      `)
    })
    
  5. package.js添加自动化命令

    "scripts": {
      	// 添加监听,一旦文件发生变化重新打包
        "dev:server-build": "webpack --config webpack.server.js --watch",
    		// 监听build文件夹,一旦发生变化重新执行node build/bundle.js
        "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
      },
    

为组件添加事件

直接在组件上添加事件是无法生效的,查看源代码可以发现js代码不存在打包好的文件中

实现思路:在客户端对组件进行二次渲染,为组件元素添加事件

  • 使用hydrate方法(通过react-dom引入)在客户端对组件进行二次渲染,为组件元素添加事件
  • 原本我们使用的是render方法渲染,但是render无法复用已经生成的节点,而hydrate可以复用原本已经生成的DOM节点,节省性能开销

目标实现

  • 客户端书写一个js文件,书写二次渲染代码
  • 客户端react的JSX和高级js语法需要进行webpack配置才能运行,目标位置 - public目录
  • 在相应给客户端的HTML代码中添加script标签,让其调用打包后的js文件
  • 服务器端实现静态资源响应,客户端js打包文件作为静态资源使用
// src/share/pages/Home.js  组件添加点击事件

import React, { Component } from 'react'

// 首页组件
// export class Home extends Component {
//   render() {
//     return <div onClick={() => console.log('点击事件触发')}>Home内容</div>
//   }
// }

function Home() {
  // 添加点击事件,打印一段话
  return <div onClick={() => console.log('点击事件触发')}>Home内容</div>
}

export default Home

// 老师使用的函数组件,未避免不必要报错这里改成函数组件,
// src/client/index.js  客户端目录新建文件书写二次渲染

import React from 'react'
import ReactDom from 'react-dom'
import Home from '../share/pages/Home'

// 使用hydrate在客户端进行二次渲染
// hydrate 参数1:目标组件,参数2:找到指定元素
ReactDom.hydrate(<Home />, document.getElementById('root'))
// webpack.client.js  书写客户端需要的webpack配置,这里修改下路径即可,在浏览器运行不需要写运行环境

// 引入path以便使用API
const path = require('path')

// 配置对象
module.exports = {
  // 生产环境:开发环境
  mode: 'development',
  // 入口文件
  entry: './src/client/index.js',
  // 导出
  output: {
    // 打包路径:使用path的API进行路径拼接
    path: path.join(__dirname, 'public'),
    // 打包文件名
    filename: 'bundle.js',
  },
  // 配置打包规则
  module: {
    rules: [
      {
        // js文件规则
        test: /\.js$/,
        // 忽略node_modulse文件夹
        exclude: /node_modulse/,
        use: {
          // 使用babel转换
          loader: 'babel-loader',
          // 配置babel
          options: {
            // babel预制
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
}
// package.json  添加客户端打包命令

"scripts": {
  "dev:server-build": "webpack --config webpack.server.js --watch",
  // 客户端打包命令
  "dev:client-build": "webpack --config webpack.client.js --watch",
  "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
},
// src/server/index.js服务器端响应回的html结构添加script标签,调用客户端打包好的文件

// 引入服务器实例
import app from './http'
import { renderToString } from 'react-dom/server'
import Home from '../share/pages/Home'
// 引入react让程序支持jsx语法
import React from 'react'

// 接受客户端发来的请求(请求对象、响应对象),请求地址为/
app.get('/', (req, res) => {
  // 将首页转换为字符串
  const content = renderToString(<Home />)
  // 响应回去(直接返回HTML,把首页转换后的字符串插进去)
  res.send(`
    <html>
      <head>
        <title>ReactSSR</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script src='bundle.js'></script>
      </body>
    </html>  
  `)
})
// src/server/http.js  将上面打包文件作为静态文件相应给客户端

// 引入依赖
import express from 'express'

// 创建node服务器实例对象
const app = express()

// 将public文件夹作为静态资源使用
app.use(express.static('public'))

// 监听3000端口,并在成功后打印内容
app.listen(3000, () => console.log('app is running on 3000 port'))

// 导出node
export default app

组件书写事件 => 书写二次渲染代码 => 配置打包参数 => 配置打包命令 => (执行打包)=> 将打包文件作为静态文件使用 => 使用打包后的文件

优化代码

合并webpack配置

  • 服务端和客户端webpack有重复配置项,将重复的配置抽象到一个文件中 - webpack.base.js
  • 使用webpack-merge进行合并配置操作
// webpack.base.js  将重复的配置项单独提取一个文件

// 导出重复配置项
module.exports = {
  // 生产环境:开发环境
  mode: 'development',
  // 配置打包规则
  module: {
    rules: [
      {
        // js文件规则
        test: /\.js$/,
        // 忽略node_modulse文件夹
        exclude: /node_modulse/,
        use: {
          // 使用babel转换
          loader: 'babel-loader',
          // 配置babel
          options: {
            // babel预制
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
}
// webpack.client.js  使用提取的配置然后书写自己私有配置合并导出

// 引入path以便使用API
const path = require('path')
// 引入重复配置
const baseConfig = require('./webpack.base')
// 引入合并配置方法
const merge = require('webpack-merge')

// 配置对象,存到一个对象中
const config = {
  // 入口文件
  entry: './src/client/index.js',
  // 导出
  output: {
    // 打包路径:使用path的API进行路径拼接
    path: path.join(__dirname, 'public'),
    // 打包文件名
    filename: 'bundle.js',
  },
}

// 合并导出新的配置
module.exports = merge(baseConfig, config)
// webpack.server.js  使用提取的配置然后书写自己私有配置合并导出

// 引入path以便使用API
const path = require('path')
// 引入重复配置
const baseConfig = require('./webpack.base')
// 引入合并配置方法
const merge = require('webpack-merge')

// 配置对象,存到一个对象中
const config = {
  // 代码运行环境:node
  target: 'node',
  // 入口文件
  entry: './src/server/index.js',
  // 导出
  output: {
    // 打包路径:使用path的API进行路径拼接
    path: path.join(__dirname, 'build'),
    // 打包文件名
    filename: 'bundle.js',
  },
}

// 合并导出新的配置
module.exports = merge(baseConfig, config)

合并项目启动命令

当前想要启动项目需要开启三个命令行工具,都需要输入启动命令,这里将全部命令合并到一个命令中,解决多个启动命令繁琐问题

通过工具 - npm-run-all实现,已经安装好,直接在package.json肿的scripts添加新命令即可

"scripts": {
  // dev 执行 npm-run-all
  // parallel 参数表示同时执行多个命令
  // dev:* 表示所有以 dev: 开头的命令
  "dev": "npm-run-all --parallel dev:*",
  "dev:server-build": "webpack --config webpack.server.js --watch",
  "dev:client-build": "webpack --config webpack.client.js --watch",
  "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
},

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

  • Q:文件打包时,包含了node系统模块,导致文件体积较大
  • A:在webpack中,剔除打包文件中的node模块,因为服务端代码是运行在node环境的,没有必要再打包一份node模块
// 引入剔除方法,在配置中添加调用该方法即可

// 引入path以便使用API
const path = require('path')
// 引入重复配置
const baseConfig = require('./webpack.base')
// 引入合并配置方法
const merge = require('webpack-merge')
// 引入剔除打包 node 模块方法!!!!!!!!!!!!!!!!!!
const nodeExternals = require('webpack-node-externals')

// 配置对象,存到一个对象中
const config = {
  // 代码运行环境:node
  target: 'node',
  // 入口文件
  entry: './src/server/index.js',
  // 导出
  output: {
    // 打包路径:使用path的API进行路径拼接
    path: path.join(__dirname, 'build'),
    // 打包文件名
    filename: 'bundle.js',
  },
  // 调用方法执行剔除打包node模块!!!!!!!!!!!!!!!!
  externals: [nodeExternals()],
}

// 合并导出新的配置
module.exports = merge(baseConfig, config)

代码拆分

  • Q:将启动服务器代码喝渲染代码进行模块化拆分
  • A:渲染react组件代码是独立的功能,所以单独存放到一个文件中
// src/server/renderer.js  单独抽离渲染代码

import { renderToString } from 'react-dom/server'
import Home from '../share/pages/Home'
// 引入react让程序支持jsx语法
import React from 'react'

// 直接导出一个函数
export default () => {
  // 将首页转换为字符串
  const content = renderToString(<Home />)
  // 返回需要的HTML结构
  return `
    <html>
      <head>
        <title>ReactSSR</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script src='bundle.js'></script>
      </body>
    </html>
  `
}
// 引入渲染代码并使用

// 引入服务器实例
import app from './http'
// 引入渲染方法
import renderer from './renderer'

// 接受客户端发来的请求(请求对象、响应对象),请求地址为/
app.get('/', (req, res) => {
  // 直接调用渲染组件方法使用其返回的HTML结构
  res.send(renderer())
})

路由支持

实现思路

  • 再React SSR项目中要实现两端路由,即客户端路由和服务器端路由
    • 客户端路由:支持用户点击链接像是跳转页面
    • 服务器端路由:支持用户直接从浏览器地址栏访问页面
  • 两个路由要公用同一套路由规则 - 同构代码

服务器端路由实现

  • Share/pages目录中新建两个组件
  • Share目录下创建文件书写路由规则 - 对象格式,因为是同构代码所以放在Share中
  • 修改服务器接收其他路由,并向下传递req(访问信息)
  • 渲染代码使用路由 - StaticRouter,因为node不能直接使用对象作为渲染,使用renderRoutes将路由配置转换为组件
  • 注意:测试时需要设置浏览器禁用Javascript,否则会出错
// src/share/pages/List 新建一个组件

import React from 'react'

function List() {
  return <div>List内容</div>
}

export default List
// src/share/router.js  书写路由规则,因为是公共路由规则,放在share中

// 两个组件
import Home from './pages/Home'
import List from './pages/List'

// 路由规则
export default [
  {
    path: '/',
    component: Home,
    exact: true,
  },
  {
    path: '/list',
    component: List,
  },
]
// src/server/index.js  修改接受路由,传递参数

// 引入服务器实例
import app from './http'
// 引入渲染方法
import renderer from './renderer'

// 接受客户端发来的请求(请求对象、响应对象)
// 修改可接收所有请求
app.get('*', (req, res) => {
  // 直接调用渲染组件方法使用其返回的HTML结构,把req传递下去,req包含路由信息
  res.send(renderer(req))
})
// src/server/renderer.js  渲染代码将原本的HOME替换为路由匹配组件

import { renderToString } from 'react-dom/server'
// 引入react让程序支持jsx语法
import React from 'react'
// 实现路由功能
import { StaticRouter } from 'react-router-dom'
// 路由配置信息
import router from '../share/router'
// 将路由配置对象转换为组件
import { renderRoutes } from 'react-router-config'

// 直接导出一个函数
export default req => {
  // 将首页转换为字符串
  const content = renderToString(
    // 替换掉原本的Home组件,StaticRouter根据req.path匹配组件
    // renderRoutes(router)将需要的组件从对象形式转换为组件
    <StaticRouter location={req.path}>{renderRoutes(router)}</StaticRouter>
  )
  // 返回需要的HTML结构
  return `
    <html>
      <head>
        <title>ReactSSR</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script src='bundle.js'></script>
      </body>
    </html>
  `
}

客户端路由实现

  • 直接修改客户端index文件,将原本的Home组件替换为路由匹配组件
  • 在Home组件中添加一个链接点击跳转到list
// src/client/index.js

import React from 'react'
import ReactDom from 'react-dom'
import Home from '../share/pages/Home'
// 客户端路由匹配方法
import { BrowserRouter } from 'react-router-dom'
// 转换路由对象为组件
import { renderRoutes } from 'react-router-config'
// 路由规则
import router from '../share/router'

// 使用hydrate在客户端进行二次渲染
// hydrate 参数1:目标组件,参数2:找到指定元素
ReactDom.hydrate(
  // BrowserRouter用于匹配规则。直接包裹需要使用的路由组件
  // renderRoutes(router)把组件从对象转换为组件
  <BrowserRouter>{renderRoutes(router)}</BrowserRouter>,
  document.getElementById('root')
)
// src/share/pages/Home.js

import React, { Component } from 'react'
import { Link } from 'react-router-dom'

// 首页组件
// export class Home extends Component {
//   render() {
//     return <div onClick={() => console.log('点击事件触发')}>Home内容</div>
//   }
// }

function Home() {
  // 添加点击事件,打印一段话
  return (
    <div onClick={() => console.log('点击事件触发')}>
      Home内容
      {/* 添加一个跳转链接 */}
      <Link to='/list'>跳转list</Link>
    </div>
  )
}

export default Home

redux支持

实现思路

  • React SSR项目中需要实现两端Redux

    • 客户端Redux:之前学习过
    • 服务器端Redux:服务器端搭建一套Redux代码,用于管理组件中的数据
  • 客户端和服务器端可共用一套Reducer代码

  • 但是创建Store代码由于参数传递不同所以不可共用,需要在两端单独创建

客户端Redux实现

  • 客户端创建文件书写Store创建代码
  • 在客户端index文件中传递store,使用thunk中间件
  • reducer属于重构代码,所以写在share中(reducer、action)
  • 分别创建reducr和action目录,书写响应代码
  • reducer最终需要合并导出
  • 可能会报错,因为浏览器默认不支持异步函数,可能需要设置一下bable预制器
// src/client/crateStore.js  新建文件创建store

import { createStore, applyMiddleware } from 'redux'
// 中间件使用thunk
import thunk from 'redux-thunk'
// 引入合并后的reducer
import Reducer from '../share/store/reducers'

// 创建store
const store = createStore(Reducer, {}, applyMiddleware(thunk))

export default store
// src/client/index.js  将创建store传递下去

import React from 'react'
import ReactDom from 'react-dom'
// 客户端路由匹配方法
import { BrowserRouter } from 'react-router-dom'
// 转换路由对象为组件
import { renderRoutes } from 'react-router-config'
// 路由规则
import router from '../share/router'
import { Provider } from 'react-redux'
import store from './crateStore'

// 使用hydrate在客户端进行二次渲染
// hydrate 参数1:目标组件,参数2:找到指定元素
ReactDom.hydrate(
  // 传递store
  <Provider store={store}>
    {/* BrowserRouter用于匹配规则。直接包裹需要使用的路由组件 
    renderRoutes(router)把组件从对象转换为组件 */}
    <BrowserRouter>{renderRoutes(router)}</BrowserRouter>
  </Provider>,
  document.getElementById('root')
)
// src/share/store/actions/user.action.js  新建文件创建action

import axios from 'axios'

// 保存用户action
export const saveUser = 'save_user'

// 获取用户指令
export const getUser = () => async dispatch => {
  // 请求数据
  const { data } = await axios.get('https://jsonplaceholder.typicode.com/users')
  // 调用另一条action
  dispatch({ type: saveUser, payload: data })
}
// src/share/store/reducers/user.reducer.js  新建文件创建reducer

import { saveUser } from '../actions/user.action'

// reducer
const userReducer = (state = [], action) => {
  switch (action.type) {
    case saveUser:
      return action.payload
    default:
      return state
  }
}

export default userReducer
// src/share/store/reducers/index.js  新建文件合并reducer

import { combineReducers } from 'redux'
import userReducer from './user.reducer'

// 合并reducer
export default combineReducers({
  user: userReducer,
})
// src/share/pages/List.js  组件就挂在后获取数据并使用

import React, { useEffect } from 'react'
import { connect } from 'react-redux'
import { getUser } from '../store/actions/user.action'

function List({ user, dispatch }) {
  // 挂载后触发指令获取数据
  useEffect(() => {
    dispatch(getUser())
  }, [])
  return (
    <div>
      List内容
      <ul>
        {/* 遍历用户创建结构 */}
        {user.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

// 获取store数据
const data = state => ({
  user: state.user,
})

export default connect(data)(List)
// webpack.base.js  修改公共webpack配置,以支持异步函数(浏览器默认不支持)

presets: [
  // 改造第一个参数,设置支持异步函数
  [
    '@babel/preset-env',
    {
      useBuiltIns: 'usage',
    },
  ],
  '@babel/preset-react',
],

服务器端Redux实现

  • store创建函数:新建文件创建store创建函数,后面接受请求时候才正式创建

  • 配置store:

    • 服务端接收到请求时创建store,然后把store传递给渲染逻辑代码
    • 在渲染逻辑中将store传递下去
  • 服务器端store数据填充:当前创建的store是空的,组件无法从store中获取数据,需要在服务器端渲染组件之前就获取需要使用的数据

    • 在组件中添加一个loadData方法,用于获取组件所需要使用的数据,方法被服务器端调用
    • 将loadData方法保存在当前组件路由信息对象中
    • 服务器端接受请求后,根据地址匹配要渲染的路由信息
    • 从路由信息中获取loadData方法并调用获取数据
    • 数据获取完成后再进行组件渲染响应给客户端
  • 消除警告:客户端store在初始状态下是没有数据的,在渲染组件的时候生成一个空的ul,但是服务器端是先获取数据才进行渲染的,所以生成的是有子元素的ul,hydrate在做对比的时候发现两者不一致,所以报警告,只需要将服务器端获取到的数据回填给客户端,让客户端也拥有初始数据

    • 服务器端渲染之前获取一下store状态数据
    • 然后把这个数据保存给window对象,注意字符串转换
    • 客户端在创建store的时候吧window属性传递给第二个参数
// src/server/createStore.js  新建文件创建服务器端store创建函数

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
// 和客户端共用的reducer
import reducers from '../share/store/reducers'

// 导出一个函数,函数返回store
export default () => createStore(reducers, {}, applyMiddleware(thunk))
// src/server/index.js  服务端创建store并传递给渲染,进行渲染前准备工作

// 引入服务器实例
// 匹配路由规则信息
import { matchRoutes } from 'react-router-config'
// 路由规则
import router from '../share/router'
// store创建函数
import createStore from './createStore'
import app from './http'
// 引入渲染方法
import renderer from './renderer'

// 接受客户端发来的请求(请求对象、响应对象),请求地址为/
// 修改可接收多个路由
app.get('*', (req, res) => {
  // 创建 store
  const store = createStore()
  // 获取全部路由信息,参数1:路由规则,参数2:匹配规则,匹配当前路由所有相关信息 - 数组
  // 遍历所有匹配成功信息
  // 最终返回一个Promise数组
  const promises = matchRoutes(router, req.path).map(({ route }) => {
    // 如果存在loadData则执行,参数store
    if (route.loadData) return route.loadData(store)
  })
  // 遍历Promise数组,当能执行到then时候说明没问题,继续向下执行渲染
  Promise.all(promises).then(() => {
    // 直接调用渲染组件方法使用其返回的HTML结构,把req传递下去,req包含路由信息
    res.send(renderer(req, store))
  })
})
//src/server/renderer.js  渲染逻辑向下传递接受的store,并存储初始store

import { renderToString } from 'react-dom/server'
// 引入react让程序支持jsx语法
import React from 'react'
// 实现路由功能
import { StaticRouter } from 'react-router-dom'
// 路由配置信息
import router from '../share/router'
// 将路由配置对象转换为组件
import { renderRoutes } from 'react-router-config'
import { Provider } from 'react-redux'

// 直接导出一个函数
export default (req, store) => {
  // 获取当前的store内容
  const myState = store.getState()
  // 将首页转换为字符串
  const content = renderToString(
    // 替换掉原本的Home组件,StaticRouter根据req.path匹配组件
    // renderRoutes(router)将需要的组件从对象形式转换为组件
    // Provider传递store
    <Provider store={store}>
      <StaticRouter location={req.path}>{renderRoutes(router)}</StaticRouter>
    </Provider>
  )
  // 返回需要的HTML结构,将获取到的store内容存储到window中,给客户端使用,避免报警
  return `
    <html>
      <head>
        <title>ReactSSR</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script>window.myState=${JSON.stringify(myState)}</script>
        <script src='bundle.js'></script>
      </body>
    </html>
  `
}
// src/share/pages/List.js  组件创建方法,供服务端调用初始化store

import React, { useEffect } from 'react'
import { connect } from 'react-redux'
// action
import { getUser } from '../store/actions/user.action'

function List({ user, dispatch }) {
  // 挂载后触发指令获取数据
  useEffect(() => {
    dispatch(getUser())
  }, [])
  return (
    <div>
      List内容
      <ul>
        {/* 遍历用户创建结构 */}
        {user.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

// 获取store数据
const data = state => ({
  user: state.user,
})

// 创建函数,给服务端调用,服务端可以直接调用这个方法进行store操作
// 参数 - store
function loadData(store) {
  // 直接调用action获取数据
  return store.dispatch(getUser())
}

// 返回数组,是结构更清晰
export default { component: connect(data)(List), loadData }
// src/share/router.js  路由规则修改list组件规则

// 两个组件
import Home from './pages/Home'
import List from './pages/List'

// 路由规则
export default [
  {
    path: '/',
    component: Home,
    exact: true,
  },
  {
    path: '/list',
    // 这里直接展开List
    ...List,
  },
]
// src/client/crateStore.js  服务端store创建时直接使用服务端存储的store

import { createStore, applyMiddleware } from 'redux'
// 中间件使用thunk
import thunk from 'redux-thunk'
// 引入合并后的reducer
import Reducer from '../share/store/reducers'

// 创建store,第二个参数使用服务端存储在window伤的数据,避免报警
const store = createStore(Reducer, window.myState, applyMiddleware(thunk))

export default store

防止XSS攻击

  • 当服务器端返回的数据中有恶意的js代码,这些代码可能会被浏览器渲染
  • 通过serialize-javascript对数据进行处理,既可避免
  • 在服务器端获取到的store数据进行处理,然后使用处理后的结果
// src/server/renderer.js  存储store的时候对数据进行处理,避免XSS攻击

import { renderToString } from 'react-dom/server'
// 引入react让程序支持jsx语法
import React from 'react'
// 实现路由功能
import { StaticRouter } from 'react-router-dom'
// 路由配置信息
import router from '../share/router'
// 将路由配置对象转换为组件
import { renderRoutes } from 'react-router-config'
import { Provider } from 'react-redux'
import serialize from 'serialize-javascript'

// 直接导出一个函数
export default (req, store) => {
  // 获取当前的store内容,使用serialize处理store避免XSS攻击
  const myState = serialize(store.getState()) // const myState = JSON.stringify(store.getState())
  // 将首页转换为字符串
  const content = renderToString(
    // 替换掉原本的Home组件,StaticRouter根据req.path匹配组件
    // renderRoutes(router)将需要的组件从对象形式转换为组件
    // Provider传递store
    <Provider store={store}>
      <StaticRouter location={req.path}>{renderRoutes(router)}</StaticRouter>
    </Provider>
  )
  // 返回需要的HTML结构,将获取到的store内容存储到window中,给客户端使用,避免报警
  return `
    <html>
      <head>
        <title>ReactSSR</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script>window.myState=${myState}</script>
        <script src='bundle.js'></script>
      </body>
    </html>
  `
}