实现简易版 react-ssr

314 阅读4分钟

构建 react-ssr

初始化项目

新建项目文件夹 react-ssr,在项目根目录下创建以下目录

|- react-ssr
  |- src
    |- client 存放客户端相关代码
    |- server 存放服务端相关代码
    |- shared 存放同构代码,服务端和客户端都要使用 

安装依赖

复制 package.jsonpackage-lock.json 两个文件,安装项目需要的依赖

创建服务端实例并返回 html

现在,我们要实现创建一个服务器实例,服务器接收到客户端的请求并且由组件渲染而成的 html。

创建组件

由于组件在客户端和服务端都需要用到,因此存放在 src/shared 目录下

src/shared 创建文件夹 pages,并且新建一个 Home.js

// Home.js
import React from 'react'

function Home () {
  return (
    <div>
      home working
    </div>
  )
}

export default Home

创建服务端实例

此时已经创建好了一个 Home 组件,现在要通过 express 创建一个服务端实例,监听客户端的请求,然后将 Home 组件转换为 html 并返回给客户端

src/server 目录下新建 http.js,创建服务端实例并导出

// src/server/http.js

import express from 'express'

const app = express()

const PORT = 8080

app.listen(PORT, () => console.log(`App is running: http://localhost:${PORT}`))

export default app

src/server 目录下新建 index.js,导入服务端实例,并且监听客户端的请求返回 html

// src/server/index.js

import app from './http'
import React from 'react' // 引入 react 组件需要
import { renderToString } from 'react-dom/server' // 将 react 组件转换为 html
import Home from '../shared/pages/Home'

// 监听客户端发送的请求
app.get('/', (req, res) => {
  const content = renderToString(<Home />)
  // 返回 html
  res.send(`
    <html>
    <head>
        <title>React SSR</title>
    </head>
    <body>
        <div id="root">${content}</div>
    </body>
    </html>
  `)
})

src/server/index.jsapp 实例监听客户端的请求,将组件通过 renderToString 方法转换为 html,然后 app 实例返回 html

现在,服务端的实例已经创建完成。但是 node 不支持 esModule 的语法,所以还需要通过 webpack 打包转化 esModuleCommonJS 和处理 JSX 语法

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

// webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')

module.exports = {
  mode: 'development',
  target: 'node',
  externals: [nodeExternals()], // 移除 node 模块不打包
  entry: './src/server/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react']
          }
        }
      }
    ]
  }
}

在 package.json 中添加两个命令

// package.json

...
"scripts": {
  "dev:server-build": "webpack --config webpack.server.js",
  "dev:server-run": "node build/bundle.js",
},
...

webpack --config webpack.server.js 对代码进行打包,node build/bundle.js 启动服务。

分别运行两个命令,服务端启动成功后,就可以在浏览器访问 http://localhost:8080

效果如下

image.png

客户端接管渲染任务

目前已经通过客户端请求访问页面已经通过服务端的渲染返回了,当给 Home 组件添加点击事件时。

import React from 'react'

function Home () {
  return (
    <div onClick={ () => console.log('hello world') }>
      home working
    </div>
  )
}

export default Home

此时会发现,在浏览器中点击文字,控制台没有打印出我们想要的结果。

这是因为,目前服务端只是渲染出了静态的页面,客户端接受到静态页面渲染后没有为 dom 元素添加事件。

所以接下来,我们需要在客户端渲染之后,由客户端接管后面的渲染任务和为元素添加事件

src/client 创建 index.js 文件

// src/client/index.js

import React from 'react'
import { hydrate } from 'react-dom'
import Home from '../shared/pages/Home'

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

src/client/index.js 中,使用 hydrate 方法渲染组件到页面。hydrate 方法和 render 方法主要的区别是 render 方法不会复用已经渲染的 dom 元素。

接下来需要对客户端的代码进行打包,并且在返回的 html 中引入打包后的代码

在根目录中创建 webpack.client.js

// webpack.client.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react']
          }
        }
      }
    ]
  }
}

配置启动命令,同时打包客户端和服务端的代码,同时监听服务端目录的变换重新启动服务

// package.json
...
"scripts": {
  "dev": "npm-run-all --parallel dev:*", // 执行所有 dev 开头的命令
  "dev:server-build": "webpack --config webpack.server.js  --watch", // 监听代码的变化,实时打包服务端代码
  "dev:server-run": "nodemon --watch build --exec \"node build/bundle\"", // 监听 build 目录的变化,并在变化时重启服务
  "dev:client-build": "webpack --config webpack.client.js --watch" // 监听代码的变化,实时打包客户端代码
},
...

在返回的 html 中添加 bundle.js 的引入

// src/server/index.js

import app from './http'
import React from 'react' // 引入 react 组件需要
import { renderToString } from 'react-dom/server' // 将 react 组件转换为 html
import Home from '../shared/pages/Home'

// 监听客户端发送的请求
app.get('/', (req, res) => {
  const content = renderToString(<Home />)
  // 返回 html
  res.send(`
    <html>
    <head>
        <title>React SSR</title>
    </head>
    <body>
        <div id="root">${content}</div>
        <script src="bundle.js"></script>
    </body>
    </html>
  `)
})

配置服务端静态资源的访问

// src/server/http.js

import express from 'express'

const app = express()

const PORT = 8080

app.use(express.static('public')) // 设置静态文件访问路径

app.listen(PORT, () => console.log(`App is running: http://localhost:${PORT}`))

export default app

此时访问页面,点击事件可以挂载到 dom 上了

image.png

打包配置文件优化和分离渲染代码

在 webpack.server.js 和 webpack.client.js 中有些相同的配置,我们可以对其相同的部分分离到 webpack.base.js 中

// webpack.base.js

const path = require('path')
const nodeExternals = require('webpack-node-externals')

module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react']
          }
        }
      }
    ]
  }
}
// webpack.client.js

const path = require('path')
const baseConfig = require('./webpack.base')
const merge = require('webpack-merge')

const config = {
  entry: './src/client/index.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'bundle.js'
  }
}

module.exports = merge(baseConfig, config)
// webpack.server.js

const path = require('path')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base')
const merge = require('webpack-merge')

const config = {
  target: 'node',
  externals: [nodeExternals()], // 移除 node 模块不打包
  entry: './src/server/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js'
  }
}

module.exports = merge(baseConfig, config)

接着,将 src/server/index.jshtml 部分分离开来,在当前目录下创建 renderer.js ,默认导出一个函数负责渲染返回 html

// src/server/renderer.js

import React from 'react' // 引入 react 组件需要
import { renderToString } from 'react-dom/server' // 将 react 组件转换为 html
import Home from '../shared/pages/Home'

export default function renderer () {
  const content = renderToString(<Home />)

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

import app from './http'
import renderer from './renderer'

// 监听客户端发送的请求
app.get('/', (req, res) => {
  // 返回 html
  res.send(renderer())
})

实现服务端路由

接下来,我们要实现服务端路由的功能。先创建一个 List 组件

// src/shared/page/List.js

import React from 'react'

function List () {
  return (
    <div>
      list is working
    </div>
  )
}

export default List

要实现的功能是:当客户端在不执行 javascript 的情况下,也就是在客户端没有路由实例的情况下,服务端对客户端不同路由的访问都能返回相对应的组件渲染后的 html

首先,先创建路由信息。客户端和服务端都需要这个信息来创建对应的路由组件,所以在 src/shared 目录下新建 routes.js 文件

// src/shared/routes.js

import Home from './pages/Home'
import List from './pages/List'

const routes = [
  {
    path: '/',
    component: Home,
    exact: true
  },
  {
    path: '/list',
    component: List
  }
]

export default routes

src/shared/routes.js 中,导出了路由路径和组件对应的数组。接下来就需要在服务端中根据客户端访问的路径和这个路由信息来返回对应的组件渲染的 html

src/server/index.js 中,需要实例 app 监听所有客户端的监听,并且将 req 传递给 renderer 函数获取路由的请求路径

// src/server/index.js

import app from './http'
import renderer from './renderer'

// 监听客户端发送的请求
app.get('*', (req, res) => {
  // 返回 html
  res.send(renderer(req))
})

src/server/renderer.js 中,根据将路由信息转换成路由组件,并且根据请求路径定位要渲染的组件,最终返回渲染结果

// src/server/renderer.js

import React from 'react' // 引入 react 组件需要
import { renderToString } from 'react-dom/server' // 将 react 组件转换为 html
import { renderRoutes } from 'react-router-config'
import { StaticRouter } from 'react-router-dom'
import routes from '../shared/routes' // 路由配置

export default function renderer (req) {
  // renderRoutes(routes) 将路由配置转换成路由组件
  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>
  `
}

禁止浏览器页面执行 javascript,然后访问对应的链接,此时可以获取到服务器返回对应的静态页面

image.png

创建客户端路由

此时客户端访问服务端可以获取到对应的静态页面,接下来需要实现当客户端渲染页面后,接管路由的功能。

首先,在 Home 组件添加个跳转组件跳转到 List

// src/shared/pages/Home.js

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

function Home () {
  return (
    <div onClick={ () => console.log('hello world') }>
      home working
      <Link to="/list">to list</Link>
    </div>
  )
}

export default Home

然后,在客户端中添加路由组件的生成和渲染

// src/client/index.js

import React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../shared/routes'

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

此时,打开浏览器的执行 javascript 功能,重新加载页面,可以从 Home 跳转到 List,并且查看 network 可以看到不是服务端返回的静态页面

image.png

实现客户端 redux

现在要实现的的是在客户端中异步请求数据,然后通过 redux 保存数据并且将数据渲染到页面。

先在 client 中创建 store。在 src/client/ 下创建 createStore.js 文件。

// src/client/createStore.js

import { applyMiddleware, createStore } from 'redux'
import thunk from 'redux-thunk' // thunk 中间件
import reducer from '../shared/store/reducers' // reducer

const store = createStore(reducer, {}, applyMiddleware(thunk))

export default store

src/client/createStore.js 放回 store 实例

接着在 src/client/index.js 中注入 store

// src/client/index.js

import React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../shared/routes'
import { Provider } from 'react-redux'
import store from './createStore'

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

在组件获取 store 之前还需要实现 reducer

shared 目录下创建两个目录:actionsreducers

现在 actions 中创建 user.action.js,返回两个 action 指令

fetchUser 返回一个函数,接受 dispatch 参数,函数里先去请求数据,等待数据返回后再将数据通过 dispatch 传递给 SAVE_USER

// src/shared/actions/user.action.js

import axios from "axios"

export const SAVE_USER = "save_user"

export const fetchUser = () => async dispatch => {
  const { data } = await axios.get("https://jsonplaceholder.typicode.com/users")
  dispatch({
    type: SAVE_USER,
    payload: data
  })
}

然后在 reducers 下创建 user.reducer.js 实现和 user 相关的 reducer

// src/shared/reducers/user.reducer.js

import { SAVE_USER } from "../actions/user.action";

export default function userReducer (state = [], action) {
  switch (action.type) {
    case SAVE_USER:
      return action.payload
    default:
      return state
  }
}

最后合并 reducer 并且导出。在 reducers 目录下创建 index.js

// src/shared/reducers/index.js

import { combineReducers } from "redux";
import userReducer from "./user.reducer";

export default combineReducers({
  user: userReducer
})

最后在组件中获取 store。在 List 组件中挂载完成时获取数据并且渲染数据

// src/shared/pages/List.js

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

function List ({ user, dispatch }) {
  useEffect(() => {
    dispatch(fetchUser())
  }, [])
  return (
    <div>
      list is working
      <ul>
        {
          user.map(item => (
            <li key={item.id}>
              { item.name }
            </li>
          ))
        }
      </ul>
    </div>
  )
}

const mapStateToProps = state => ({ user: state.user })

export default connect(mapStateToProps)(List)

打开浏览器从 Home 去到 List,组件获取数据并且渲染到页面上

image.png

image.png

实现服务端 redux

在服务端实现 redux,其实就是在服务端中执行组件请求数据的操作,然后将组件和数据渲染成静态页面,返回到客户端。最后由客户端的 redux 实例接收服务端已经请求的数据和接管后面的 redux 操作。

首先先将组件请求数据的操作封装为一个函数,并保存在路由信息中,服务端根据客户端的请求判断获取路由信息中相对应的加载数据的函数并执行。等到数据加载完成后,再将数据和组件一起渲染并且返回渲染结果,同时已经请求到的数据也一并返回到客户端,客户端的接收到数据后,整个服务端的 redux 操作就完成了

List 组件中,添加数据加载函数 loadData

// src/shared/pages/List.js

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

// 服务端数据加载
function loadData (store) {
  return store.dispatch(fetchUser())
}

function List ({ user, dispatch }) {
  useEffect(() => {
    dispatch(fetchUser())
  }, [])
  return (
    <div>
      list is working
      <ul>
        {
          user.map(item => (
            <li key={item.id}>
              { item.name }
            </li>
          ))
        }
      </ul>
    </div>
  )
}

const mapStateToProps = state => ({ user: state.user })

// 返回路由信息
export default {
  component: connect(mapStateToProps)(List),
  loadData
}

src/shared/routes.js 添加路由信息

// src/shared/routes.js

import Home from './pages/Home'
import List from './pages/List'

const routes = [
  {
    path: '/',
    component: Home,
    exact: true
  },
  {
    path: '/list',
    ...List
  }
]

export default routes

src/server 下创建 createStore.js 返回一个创建 store 的函数

// src/server/createStore.js

import { applyMiddleware, createStore } from "redux";
import thunk from "redux-thunk";
import reducer from "../shared/store/reducers";

// 避免请求污染 通过函数创建 store 并返回
export default () => createStore(reducer, {}, applyMiddleware(thunk))

src/server/index.js 中引入 store,并且加载数据

// src/server/index.js

import app from './http'
import renderer from './renderer'
import createStore from './createStore'
import { matchRoutes } from 'react-router-config'
import routes from '../shared/routes'

// 监听客户端发送的请求
app.get('*', (req, res) => {
  const store = createStore()
  // 匹配路由 执行请求数据操作
  const promises = matchRoutes(routes, req.path).map(({ route }) => {
    if (route.loadData) {
      // 传入 store 以便调用 dispatch 函数
      return route.loadData(store)
    }
  })
  // 判断数据是否请求完成
  Promise.all(promises).then(() => {
    // 返回 html
    res.send(renderer(req, store))
  })
})

接着在 renderer 函数中给组件注入 store,同时将已经请求的数据添加到 html 中返回给客户端

// src/server/renderer.js

import React from 'react' // 引入 react 组件需要
import { renderToString } from 'react-dom/server' // 将 react 组件转换为 html
import { Provider } from 'react-redux'
import { renderRoutes } from 'react-router-config'
import { StaticRouter } from 'react-router-dom'
import routes from '../shared/routes' // 路由配置


export default function renderer (req, store) {
  const initialState = JSON.stringify(store.getState())
  // renderRoutes(routes) 将路由配置转换成路由组件
  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>window.INITIIAL_STATE=${initialState}</script>
        <script src="bundle.js"></script>
    </body>
    </html>
  `
}

最后客户端接收服务端发送过来的初始数据初始化 store

// src/client/createStore.js

import { applyMiddleware, createStore } from 'redux'
import thunk from 'redux-thunk'
import reducer from '../shared/store/reducers'

// 接受 window.INITIIAL_STATE 创建 store
const store = createStore(reducer, window.INITIIAL_STATE, applyMiddleware(thunk))

export default store

完整代码仓库:地址