如何实现 React 的 SSR 服务端渲染?

1,735 阅读8分钟

前言

在接触 Next.js 框架时,在项目打包、启动时,看到 Next.js 的运行命令分别是 yarn buildyarn start。而并不是想 create-react-app 那样,直接 yarn build 完成后,将 build 目录下的 index.html 作为入口文件,再通过 nginx 的路径去访问我们的项目。

Next.js 的 dockerFile 格式是和 Node.js 服务的格式是类似的,那么本质上,Next.js 其实就是一个 Node 后端服务,所以运行生产环境时,访问yarn start打开的端口来访问我们的应用。

那么这个 SSR 服务端渲染是怎么做的呢,服务端和客户端的代码,为什么能同时运行呢?

初代的服务端渲染

最初版本的网页,是前后端不分离的,是通过服务端渲染(与现在的服务端渲染是不同的)。

那个时候的页面渲染大概是这样的,浏览器请求页面URL,然后服务器接收到请求之后,到数据库查询数据,将数据丢到后端的组件模板(php、asp、jsp等)中,并渲染成HTML片段,接着服务器在组装这些HTML片段,组成一个完整的HTML,最后返回给浏览器

但这样的渲染方式就有明显的缺点,每次更新页面的数据,就得重新请求、查数据库、重新组装html。

AJAX 的出现

到后来 AJAX 的出现,出现了前后端分离,前端的先展现一个没有数据的页面,再去请求数据,然后进行动态渲染,完成页面展示。在这过程中,网页需要更新的就只有数据的部分内容。也就是 CSR(客户端渲染)。这也就是 create-react-app 的那一套方案。

新的服务端渲染

由于前端使用的基本都是 SPA ,单页面渲染,大家发现 SEO 出现了问题,而且随着应用复杂程度的提高,js 的代码体积也越来越臃肿,导致首屏渲染速度明显的下降。

由此,前端团队,使用 nodejs 在服务器端完成页面的渲染,由此出现了服务端渲染。

浏览器请求 URL,根据不同的路由,向服务器请求不同的数据,然后服务器拼接一个携带了数据的html字符串,返回给浏览器。同时浏览器执行js脚本,给页面的元素绑定对应的事件,使页面可以进行交互。

服务端渲染的优缺点:

  • 优点:

    • SEO
    • 更快的首屏渲染时间
  • 缺点:

    • 代码更为复杂
    • 对服务器的要求更大,有更大的需求量

这里我们先用下面的代码做一个样板试验

const http = require('http');

http.createServer((req, res) => {
  if (req.url === '/') {
    res.writeHead(200, {
      'Content-Type': 'text/html'
    });

    const html = `
        <html>
            <head>
                <title>SSR</title>
            </head>
            <body>
                <p>hello world</p>
            </body>
        </html>
    `;
    res.end(html);
  }
}).listen(8080);

打开浏览器的 http://localhost:8080 即可看到页面的渲染,因此可以看到,浏览器拿到一段 html 代码时,无需js ,也能把这个页面渲染出来

完成 React 组件的服务端渲染

1. 渲染页面

这里我们使用 express 作为服务框架

// Home.js 
import React from 'react'; 
const Home = () => { 
    return ( 
        <div> 
            <div>This is wbh's ssr demo</div> 
        </div> 
    ) 
} 
export default Home 

// Text.js 
import React from 'react'; 

const Text = (props) => { 
    return ( 
        <div> 
            <div>{props.text}</div> 
        </div> 
    ) 
} 

export default Text
// index.js 
import express from 'express'; 
import { renderToString } from 'react-dom/server'; 
import Home from './Home'; 
import React from 'react' 
import Text from './Text'; 

const app = express(); 

const content = renderToString(<Home />); 
const text = renderToString(<Text text={'hello world!'} />) 

app.get('/', function (req, res) { 
    res.send( ` 
        <html lang=""> 
            <head> 
                <title>ssr</title> 
            </head> 
            <body> 
                <div id="root">${content} ${text}</div> 
            </body> 
        </html> ` 
    ); 
}) 

app.listen(8090, () => { console.log('listen:8090') })

上面我们用了react-domrenderToString 方法,将 react 组件转化为 string 格式的内容。

但是,使用过 nodejs 的应该都知道,node 环境是不支持 ESModule 和 jsx 的代码写法的,因此我们这里借助 webpack 来进行一定的转换。

yarn add webpack babel-loader @babel/preset-react @babel/preset-env @babel/core react react-dom 

yarn add -D webpack-cli

在根目录创建一个名为 webpack.server.config.js 的文件

const path = require('path')

const config = {
  mode: 'development',
  // 代码运行环境:node
  target: 'node',
  // 入口文件
  entry: './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'], 
          }, 
        }, 
      }, 
    ], 
  }
}

package.json 添加打包、运行命令

//package.json
"scripts": { 
    "dev:server-build": "webpack --config webpack.server.js --watch", 
    "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\"" 
}

然后,先运行 yarn dev:server-build 在运行 yarn dev:server-run ,此时页面的展示效果和查看源码

image.png

image.png

现在,页面的基础渲染已经完成了,元素事件是否能绑定呢?我们修改 Text 组件

// Text.js 
import React from 'react'; 

const Text = (props) => { 
    return ( 
        <div onClick={() => console.log('click')}> 
            <div>{props.text}</div> 
        </div> 
    ) 
} 
export default Text

刷新页面,点击 Text 组件部分,发现 click 事件并没有和我们预想的一样触发,我们添加的 click 事件是没有绑定到对应的元素上的。我们的 click 事件消失了,这说明,目前我们的服务端渲染是不完全的,事件绑定是无效的,renderToString 函数并不会帮我们做这一步的处理!

2. 事件绑定

这时候需要引入一个 同构 的概念。所谓同构,通俗的讲,就是一套React代码在服务器上运行一遍,到达浏览器又运行一遍。服务端渲染完成页面结构,浏览器端渲染完成事件绑定。

那么怎么让浏览器进行事件绑定呢?

唯一的方式就是让浏览器去拉取JS文件执行,让JS代码来控制。也就是说,服务端返回给浏览器的源码中应该还有一个 js 文件,包含了你的代码的所有的事件绑定。

那么怎么创建出这个 js 文件呢?

这里就需要使用 ReactDomhydrate 方法

首先为了思路清晰,我们把刚刚上一步的文件都放入 server 文件夹内,再创建一个 client 文件夹,用来调用 hydrate

下一步,修改我们的 webpack 配置,创建一个 webpack.base.config.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.config.js 用于打包 client 文件夹的内容

const path = require('path');
const baseConfig = require('./webpack.base.config')
const {merge} = require('webpack-merge')

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

module.exports = merge(baseConfig, config)

创建 webpack.server.config.js 用于打包 server 文件夹的内容

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

const config = {
  // 代码运行环境:node
  target: 'node',
  // 入口文件
  entry: './server/index.js',
  // 导出
  output: {
    // 打包路径:使用path的API进行路径拼接
    path: path.join(__dirname, 'build'),
    // 打包文件名
    filename: 'bundle.js',
  },
  externals: [nodeExternals()],
}

module.exports = merge(baseConfig, config)

package.json 中修改打包命令:

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

接下来运行我们的程序,就需要同时运行这3个命令,这里我们用 concurrently 工具,来帮助我们简化一下:

"scripts": {
    // ...
    "dev": "concurrently \"yarn dev:server-build\" \"yarn dev:client-build\" \"yarn dev:server-run\""
}

然后修改一下我们的 server/index.js 文件

import express from 'express';
import { renderToString } from 'react-dom/server';
import Home from './Home';
import React from 'react'
import Text from './Text';

const app = express();
const text = renderToString( < Text text = {'hello world!'} />) 
const content = renderToString(<Home / > ); 

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

app.get('/', function (req, res) { 
    res.send( ` 
        <html lang=""> 
            <head> 
                <title>ssr</title> 
            </head> 
            <body> 
                <div id="root">${content} ${text}</div> 
            
                <!-- 这里的 bundle.js 文件也就是 client 输出的结果文件--> 
                <script src="bundle.js"></script> 
            </body> 
        </html> 
    ` ); 
}) 

app.listen(8090, () => { console.log('listen:8090') })

接下来给 HomeText 组件添加事件绑定

// Home.js 
const Home = () => { 
    return ( 
        <div> 
            <div onClick={() => alert('Home click')}> Home, This is wbh's ssr demo </div> 
        </div> 
    ) 
} 

// Text.js 
const Text = (props) => { 
    return ( 
        <div> 
            <div onClick={() => alert('this is Text')}> 
                <div>{props.text}</div> 
            </div> 
        </div> 
    ) 
}

接下来用 ReactDom 提供的 hydrate 方法,生成 public/bundle.js 文件,给我们的网页生成一个包含事件绑定的 js 文件

// client/index.js 
import React from 'react' 
import ReactDom from 'react-dom' 
import Home from '../server/Home'; 
import Text from '../server/Text'; 

// 使用hydrate在客户端进行二次渲染 
// hydrate 参数1:目标组件,参数2:找到指定元素 
ReactDom.hydrate(<>
    <Home /> 
    <Text text={'hello, this is Text'}/>
</>, document.getElementById('root'))

此时,运行 yarn dev ,打开http://localhost:8090/,就能看到我们的页面内容,点击 Home 或者 Text 的内容,都能触发我们添加的 alert 事件

image.png

image.png

我们看下网站的源码:

image.png

跟我们预先设想的一样,除了页面元素内容,还有一个js 文件,包含了事件绑定等代码

这里总结一下我们同构代码的流程,如下图所示

image.png

3. 美化代码

美化一下我们的代码,把 render 的 html 部分提出作为一个函数来使用

//server/renderer.js

import React from 'react'
import { renderToString } from 'react-dom/server'
import Text from '../pages/Text'
import Home from '../pages/Home'

export default req => {
  const text = renderToString(<Text text={'hello, this is Text'}/>)
  const content = renderToString(<Home />);

  return `
    <html lang=""> 
        <head> 
            <title>ssr</title> 
        </head> 
        <body> 
            <div id="root">${content} ${text}</div> 

            <!-- 这里的 bundle.js 文件也就是 client 输出的结果文件--> 
            <script src="bundle.js"></script> 
        </body> 
    </html> 
  `
}
// server/index.js
import express from 'express';
import renderer from './renderer';

const app = express();

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

app.get('/', function (req, res) {
  res.send(renderer());
})
app.listen(8090, () => {
  console.log('listen:8090')
})

4. 路由同构

在服务端渲染中,还要解决双端的路由同步问题,也就是 服务端 和 浏览器端。

  • 服务端路由:支持用户点击链接,像是在跳转页面
  • 浏览器端:支持用户直接从浏览器路由访问对应页面

这里我们再次整理一下我们的代码,在根目录创建一个 pages 文件夹,然后将我们之前在 server 文件夹下的 HomeText 组件放入 pages 文件夹中,这时我们的根目录是这样的:

image.png

现在我们给 Home 组件添加一个跳转到 Text 的链接

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

const Home = () => {
  return (
    <div>
      <div onClick={() => alert('Home click')}>
        Home, This is wbh's ssr demo
      </div>
      <Link to='/text'>跳转到 text</Link>
    </div>
  )
}
export default Home

然后,我们在 pages 目录下,创建一个 router.js 文件,用于存放路由

// router.js
import Home from'./Home'
import Text from'./Text'

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

注意一下,这里component 字段匹配的组件要用上面的形式引入,而不是 这样组件的形式,不然会出现这个错误:

image.png

修改 server/index.js 的路由参数

// server/index.js
import express from 'express';
import renderer from './renderer';

const app = express();

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

// 可以接受所有请求
app.get('*', function (req, res) {
  //将请求的 req 传给 renderer 函数
  res.send(renderer(req));
})
app.listen(8090, () => {
  console.log('listen:8090')
})

renderer 函数接受 req 参数,根据请求不同,渲染不同的组件,这里我们用 react-routerStaticRouter 服务端渲染来帮我们做路由匹配,要注意的是,在使用react-router-config 时,需要将 react router 降低到 v5版本

// server/renderer.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
// 将路由配置对象转换为组件
import { renderRoutes } from 'react-router-config'
import router from '../pages/router';

export default req => {
  const content = renderToString(
    <StaticRouter location={req.path}>{renderRoutes(router)}</StaticRouter>
  )

  return `
    <html lang="">
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="bundle.js"></script>
      </body>
    </html>
  `
}

修改一用于生成 js 的文件

// client/index.js
import React from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import router from '../pages/router';

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

这时候访问http://localhost:8090/ 的页面展示

image.png

事件绑定,也没有问题 image.png

点击跳转,事件绑定,也没有问题

image.png

然后,测试一下 hook 能否正常使用

// Text.js
import React, {useEffect, useState} from 'react';

const Text = () => {
  const [a, setA] = useState(0)
  
  useEffect(() => {
    console.log('text')
  }, [])

  return (
    <div>
      <div onClick={() => alert('this is Text')}>
        <div>{'this is Text'}</div>
      </div>
      <button onClick={() => setA(a + 1)}> +++ </button>
      <div>{a}</div>
    </div>
  )
}
export default Text

打开http://localhost:8090/text,useState 和 useEffect 也能正常使用

image.png

能看到,控制台有一个 Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17

按照提示,我们把 hydrate 方法替换成 hydrateRoot

import React from 'react'
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import router from '../pages/router';

hydrateRoot(document.getElementById('root'), <BrowserRouter>{renderRoutes(router)}</BrowserRouter>)

再次刷新页面,警告消除,功能无异常

image.png

到这里,我们基本就完成了一个简单的 React SSR 服务端渲染了