React 实现一个简单的 SSR

1,404 阅读5分钟

为了学习一下 React 实现 SSR 的整体过程,并了解过程中会遇到哪些坑,所以着手写了这篇文件,记录一下。

note:项目中依赖还是比较多的,感兴趣的小伙伴可以去我的 github 仓库查看具体代码。

项目结构

├── build             # 打包目录
├── src
│   ├── client        # 客户端代码
│   ├── server        # 服务端代码
│   └── share         # 同构代码
├── babel.config.js
├── package.json
├── webpack.base.js		# 公共配置
├── webpack.client.js # 客户端配置
└── webpack.server.js # 服务端配置

使用 Express 搭建简单的服务

首先我们用 Express 来搭建一个服务,后续用来渲染 React 组件。

// server/index.js
import app from './http'

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

// server/http.js
import express from 'express'

const app = express()

app.listen(3000, () => {
  console.log('app is listening on 3000 port')
})

export default app

因为代码要运行在 Node 环境,所以会报这样的错 SyntaxError: Cannot use import statement outside a module,所以我们需要使用 webpackbabel 对代码进行打包和编辑,其实这也是为了之后服务端渲染 React 组件做准备:

// babel.config.js
module.exports = {
  presets: ['@babel/preset-env'],
}

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

// 这里还有一个小技巧,这样写可以在 js 文件中让编辑器提供代码提示
/** @type import('webpack').Configuration */
module.exports = {
  mode: 'development',
  target: 'node',
  entry: './src/server/index.js',
  output: {
    path: path.join(__dirname, 'build'),
    filename: 'bundle.js',
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  // 不去打包 node 原生模块,因为我们会在 node 环境中运行,本身就有这些模块
  externals: [nodeExternals()],
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      },
    ],
  },
}

并在 package.json 中添加如下命令:

"scripts": {
  "dev": "npm run dev:server-build",
  "dev:server-build": "webpack -c webpack.server.js --watch",
  "start": "nodemon build/bundle.js"
},

现在分别执行 npm devnpm start ,服务便运行起来了,浏览器访问 http://localhost:3000/ 也是可以看到 hello world! 的。

服务端渲染

我们先去编写一个 React 组件,由于这个组件浏览器也要渲染它,所以要把它写到 share 中:

// share/pages/index.js
import React from 'react'

export default function Index() {
  return <div>Home Page</div>
}

接下来修改服务端入口文件。服务端渲染的原理就是服务器将 React 组件转换为字符串,然后拼接到 HTML 中,React 中为我们提供了 renderToString() 去处理这件事。

// server/index.js
import app from './http'
import renderer from './renderer'

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

// server/renderer.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from '../share/pages'

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>
  `
}

这时刷新页面就可以看到 Home Page 了。

<div onClick={() => console.log('Home Page')}>Home Page</div>

但当为组件绑定点击事件后,发现事件没有被触发,这是怎么回事呢。因为 renderToString() 并不会帮我们绑定事件,我们需要在客户端进行二次渲染。

客户端渲染

往常我们都是用 render() 去渲染组件,但它并不会保留服务端返回的标签。React 中还提供了 hydrate() ,这个方法可以复用标签,所以我就用它来解决这个问题。

首先我们创建一个 client 的入口文件:

// client/index.jsx
import React from 'react'
import { hydrate } from 'react-dom'
import Home from '../share/pages'

// 渲染到服务端返回的 id 为 root 的标签中
hydrate(<Home />, document.getElementById('root'))

当然了,客户端也需要 webpack 帮我们去打包一下。

// webpack.client.js
const path = require('path')

/** @type import('webpack').Configuration */
module.exports = {
  mode: 'development',
  entry: './src/client/index.jsx',
  output: {
    path: path.join(__dirname, 'public'),
    filename: 'bundle.js',
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      },
    ],
  },
}

这里将 bundle.js 打包到了 public 中,服务端返回的 HTML 中应该引用这个文件,Express 中还要配置一下静态资源的目录。

// server/http.js
// ...其它代码
app.use(express.static('public'))

// server/index.js
// ...其它代码
function renderer(content) {
  return `
    <html>
      <head>
        <title>React SSR</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `
}

现在刷新页面,点击 Home page 控制台可以正常输出了,证明事件绑定没问题。

路由

接下来添加一个 list.jsx ,实现服务端和客户端的路由功能。

// share/pages/list.jsx
import React from 'react'

export default function List() {
  return <div>List Page</div>
}

服务端和客户端要公用一套规则,所以也把它存放到 share 文件夹中,并且采用数组对象的形式,方便分别渲染成两端不同的格式。

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

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

服务端路由

服务端需要用到 StaticRouter, 匹配到哪个路径就去渲染哪个组件,不会做跳转。使用 renderRoutes 渲染路由数组对象。并且需要把 Express 匹配的路由规则改为 * ,让他去监听所有的请求。

// server/index.js
// ...其它代码
app.get('*', (req, res) => {
  res.send(renderer(req))
})

// server/renderer.js
// ...其它代码
import { StaticRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../share/routes'

export default function renderer(req) {
  const content = renderToString(
    <StaticRouter location={req.path}>{renderRoutes(routes)}</StaticRouter>
  )
  
  // ...其它代码
}

这时我们可以分别访问 http://localhost:3000/http://localhost:3000/list 查看结果。注意,需要禁用浏览器的 javascript ,不然之前做好的客户端渲染会覆盖服务端传过来的内容。

客户端路由

客户端所要做的工作和服务端差别不大,只不过要用 BrowserRouter

// client/index.jsx
// ...其它代码
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../share/routes'

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

然后在 Home 组件中加入导航。

// share/pages/index.jsx
// ...其它代码
import { Link } from 'react-router-dom'

// ...其它代码
<ul>
  <li>
    <Link to="/">Home</Link>
  </li>
  <li>
    <Link to="/list">List</Link>
  </li>
</ul>

这样就可以测试客户端的路由跳转了。之前禁用了 javascript 的小伙伴不要忘记改为允许。(我就忘了,找了半天,哈哈哈)

数据请求

每个页面都应该有自己独有的服务端请求方法,所以约定导出一个 getServerSideProps() ,并挂载到 routes 数组对象上。

// share/pages/index.jsx
export default function Index({ title }) {
  return (
    <div onClick={() => console.log('Home Page')}>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/list">List</Link>
        </li>
      </ul>
      {title}
    </div>
  )
}

export async function getServerSideProps() {
  return {
    props: {
      title: 'Home Page',
    },
  }
}

// share/pages/list.jsx
export default function List({ title }) {
  return <div>{title}</div>
}

export async function getServerSideProps() {
  return {
    props: {
      title: 'List Page',
    },
  }
}

// share/routes.js
import * as Home from './pages'
import * as List from './pages/list'

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

然后服务端匹配当前路由对应的组件,使用 matchRoutes(),执行组件上的 getServerSideProps() 获取 Props ,传递给组件,并挂载到全局的 INITIAL_PROPS 属性上,方便客户端获取。

// server/index.js
// ...其它代码
import { matchRoutes } from 'react-router-config'

app.get('*', async (req, res) => {
  const promise = () => Promise.resolve({ props: {} })
  // 这里考虑路由组件只会匹配到一个,所以直接获取了数组中的第一项
  const getServerSideProps =
    matchRoutes(routes, req.path)[0]?.route?.getServerSideProps || promise

  const { props } = await getServerSideProps()

  res.send(renderer(req, props))
})

// server/renderer.js
// ...其它代码
export default function renderer(req, props) {
  const INITIAL_PROPS = JSON.stringify(props)
  const content = renderToString(
    <StaticRouter location={req.path}>
      {renderRoutes(routes, props)}
    </StaticRouter>
  )

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

而客户端我目前的解决办法也是比较暴力,直接每个组件都都传递了 INITIAL_PROPS ,这样做的问题是客户端切换路由后,别的组件也可以拿到 INITIAL_PROPS

因为不想使用 Redux ,所以陷入了这样的困局,如果有好的想法的小伙伴,欢迎来讨论讨论。

总结

这样一个简单的 React ssr 就搭建好了,更加详细的代码可以去我的 github 仓库查看。