使用React API和Express实现SSR

349 阅读3分钟

前言

在日常开发中,SSR的使用是非常常见的,可以解决CSR SEO的弊端也能优化首屏加载速度等。

下面,通过React 自带的API和Express(SSR涉及到服务端渲染,所以这里使用Express作为服务端)简单实现一下SSR,加深对SSR的认识。

初始化项目

创建一个文件夹,并初始化package.json

mkdir ssr 
cd ssr
npm init

创建一个名为ssr的文件夹,进入文件夹,init package.json

安装所需要的依赖

npm install webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-react react react-dom

因为需要将jsx格式的文件转换为js文件,所以需要webpack和loader打包转换。

  1. webpack、webpack-cli 用于 webpack 打包
  2. babel-loader、@babel/core、@babel/preset-env、 @babel/preset-react 用于编译 React
  3. react、react-dom 用于书写 React 代码

实现CSR

目前项目环境搭建完毕,我们先简单的实现CSR

创建一个index.html文件

<html>

<head>
  <title>Tiny React SSR</title>
</head>

<body>
  <div id='root'>
  </div>
  <script src="./index.js"></script>
</body>

</html>

使用webpack打包出index.js用浏览器打开html文件,就可以看到我们CSR是否实现成功。

现在开始编写React代码 创建client.js文件

import React from 'react';
import { createRoot } from 'react-dom/client';
import App from'./app'

const root = createRoot(document.getElementById('root'));
root.render(<App />);


创建app.js文件

import { useState } from 'react';

export default function MyApp() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Counters { count } times</h1>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

逻辑很简单,就是一个计时器的demo。

随后我们开始配置webpack配置文件,因为需要将jsx代码转换为浏览器可以识别的js文件 创建webpack.client.js文件,这里命名为webpack.client.js是因为要区分客户端和服务端的配置文件,webpack是无法自动识别的,需要自己配置。

const path = require('path')

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

修改packag.json文件

"start": "webpack --config webpack.client.js"

随后运行npm run start

image.png

项目根目录就会生成index.js文件,浏览器打开index.html就能看到内容。

实现SSR

首先下载express

npm install express

创建server.js文件

import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import App from "./app"

const app = express()
const content = renderToString(<App />)

app.get('/', (req, res) => res.send(`
<html>
   <head>
       <title>Tiny React SSR</title>
   </head>
   <body>
    <div id='root'>${content}</div>
   </body>
</html>
`))

app.listen(3000, () => console.log('listening on port 3000!'))

使用React提供的renderTostring API将渲染的内容转换为HTML字符串,再使用Express接口返回。

现在使用 node server.js 启动express服务肯定会报错,因为node环境不支持import导入的语法。

所以要使用webpack进行打包,解决import的问题以及将jsx转换为js。

创建webpack.server.js文件

const path = require('path') 

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

配置packag.json文件

"scripts": {
    "start": "webpack --config webpack.client.js",
    "test": "echo \"Error: no test specified\" && exit 1",
    "server-start": "webpack --config webpack.server.js && node ./build/server.bundle.js"
  }

运行 npm run server-start

image.png 用浏览器访问localhost:3000端口

image.png

但是你发现点击按钮,没有反应,这是因为没有进行水合,服务端只是返回了一个静态的HTML文件。

接下来,为静态的HTML绑定事件(水合)

首先我们捋一下思路,服务端返回了静态HTML文件,客户端绑定事件,那我们就可以结合一下,只不过这样会渲染两次,服务端渲染一次,客户端渲染一次,我们返回的静态HTML文件引入打包的客户端代码就可以实现,我们先来实现一下。

修改server.js文件,注意创建pages/index.js文件,内容和app.js文件一致。

//server.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import App from './pages/index'

const app = express()
app.use(express.static('public'));
const content = renderToString(<App />)

app.get('/', (req, res) => res.send(`
<html>
   <head>
       <title>Tiny React SSR</title>
   </head>
   <body>
    <div id='root'>${content}</div>
    <script src="/client.bundle.js"></script>
   </body>
</html>
`))

app.listen(3000, () => console.log('listening on port 3000!'))

引用打包后的客户端js文件,这里声明了public静态目录,所以需要创建一个public目录

//client.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from'./pages/index'

const root = createRoot(document.getElementById('root'));
root.render(<App />);

修改webpack.client.js

const path = require('path')

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

梳理下现在的流程:

我们先打包客户端 JS,将引用 pages/index.js核心 React 代码的 client.js 打包到 public下的 client.bundle.js 中。

然后将同样引用 pages/index.js核心 React 代码的 server.js 打包到 build 下的 server.bundle.js 中,然后 node 开启 server.bundle.js。

这样当访问 localhost:3000的时候,服务端会先渲染一遍组件代码,然后输出到 HTML 中,然后引用 client.bundle.js,然后用 JS 重新渲染一遍,并同时绑定上事件。

修改package.json

"scripts": {
    "start": "webpack --config webpack.client.js",
    "test": "echo \"Error: no test specified\" && exit 1",
    "server-start": "webpack --config webpack.server.js && node ./build/server.bundle.js",
    "all-start": "webpack --config webpack.client.js && webpack --config webpack.server.js && node ./build/server.bundle.js"
  
  },

运行 npm run all-start

image.png 现在就可以正常点击了。

但是现在有一个弊端,就是要渲染两次,所以我们要优化一下。

优化SSR

使用 React提供的 hydrateRoot API 就可以避免渲染两次的情况。

我们常用的 createRoot 会重新渲染,hydrateRoot 会复用已有的 DOM 节点(当然前提是服务端和客户端渲染一致,这样才能够复用)。

hydrateRoot 通常就是搭配 React 的服务端 API react-dom/server 而使用的:react-dom/server 负责服务端渲染,hydrateRoot 负责复用 DOM 进行水合。 修改client.js文件

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from'./pages/index'

hydrateRoot(document.getElementById('root'), <App />);

重新运行,这时,实现了服务端渲染,客户端绑定,不会出现渲染两次的效果。