React SSR 初实践(一)

1,361 阅读4分钟

前言

ssr(Server-Side Rendering):服务器端渲染,原理就是将平时写的组件,页面通过服务器生成html字符串,再发送到浏览器,交给浏览器渲染。

csr(client-Side Render):客户端渲染,我们平时写的spa应用,一般都是将react应用挂载到root节点上,由react接管页面。

ssr的核心就是同构,就是一套代码,可以服务器端运行和客户端运行。在应用上就是,服务器端渲染首页的html,然后根据js,在客户端让react接管应用,变成类似csr 单页面应用的效果。

图片来源:神三元的文章

image-20220216204654941

本文主要是从头搭建并记载实践中遇到的问题

初始化项目:

用过pnpm之后就回不去了(-。-)

pnpm init

本项目由webpack进行构建打包,所以要安装webpack,下面是package.json:

{
  "name": "react-ssr-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev:start": "nodemon build/server.js",
    "dev:build:client": "webpack --config webpack.client.js --watch",
    "dev:build:server": "webpack --config webpack.server.js --watch",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.17.2",
    "@babel/preset-env": "^7.16.11",
    "@babel/preset-react": "^7.16.7",
    "babel-loader": "^8.2.3",
    "css-loader": "^6.6.0",
    "style-loader": "^3.3.1",
    "webpack": "^5.68.0",
    "webpack-cli": "^4.9.2",
    "webpack-merge": "^5.8.0",
    "webpack-node-externals": "^3.0.0"
  },
  "dependencies": {
    "core-js": "^3.21.0",
    "koa": "^2.13.4",
    "koa-body": "^4.2.0",
    "koa-static": "^5.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "regenerator-runtime": "^0.13.9"
  }
}

目录结构

准备好目录结构:

image.png

server目录

放置服务端运行的代码

client目录

放置客户端运行的代码

containers目录

放置我们平时编写的react组件

webpack.base.js

公共的webpack配置文件

webpack.client.js

打包客户端运行代码的webpack配置文件

webpack.server.js

打包服务端运行代码的webpack配置文件

配置先行

//webpack.base.js
const path = require('path');
module.exports = {
    mode:'development',
    module:{
        rules:[
            {
                test:/\.js$/,
                  //配置babel
                loader:'babel-loader',
                exclude:/node_modules/,
                options:{
                    presets:[
                        "@babel/preset-env",
                        "@babel/preset-react"
                    ],
                }
            }
        ]
    }
}



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

module.exports = merge(base, {
    entry: './src/client/index.js',
    output: {
        path: path.resolve('public'),
        filename: 'client.js'
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'style-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            modules: true
                        }
                    }
                ]
            }
        ]
    }
});

//webpack.server.js
const path = require('path');
const nodeExternal = require('webpack-node-externals');
const {merge} = require('webpack-merge');
const base = require('./webpack.base');
module.exports = merge(base, {
    // 注意这个值
    target: 'node',
    //'core-js/stable', 'regenerator-runtime/runtime',这两个是为了兼容async语法,因为服务端用的是koa构建
    entry: ['core-js/stable', 'regenerator-runtime/runtime','./src/server/index.js'],
    output: {
        path: path.resolve('build'),
        filename: 'server.js'
    },
    // 排除 Webpack 中的node模块
    externals: [nodeExternal()], 
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
         
                    {
                        loader: 'css-loader',
                        options: {
                            modules: true
                        }
                    }
                ]
            }
        ]
    }
});



这里entry使用 'core-js/stable', 'regenerator-runtime/runtime' 是为了兼容async语法,babel-polyfill也能达到目的,不过

babel-polyfill从 Babel 7.4.0 开始,已经被弃用。

组件

// containers/App.js
import React, { useState} from 'react';
import './App.css'
export default () => {
    const [data, setData] = useState(0)
    return <div>
        <button onClick={() => {
        setData(data+1)
        }}>add
        </button>
        {data}
    </div>
}

css

button{
    color: red;
}

为了方便解释什么是让react接管页面,所以采用的组件是一个button

原理解析

服务端渲染:renderToString、renderToStaticMarkup——>string, 通过服务端渲染,提前渲染页面。 客户端渲染:render,hydrate——>HTML结构 通过客户端渲染,让react接管页面

1.renderToString:渲染的结果是带有data-reactid属性的,此时,在服务端的基础上,客户端的hydrate不会重新渲染,只会执行组件挂载的事件,以及绑定事件等等。

2.renderToStaticMarkup:渲染的结果是不带有data-reactid属性的,此时不管服务端有没有渲染,在客户端中都会重新渲染该组件。

3.render:渲染组件,将组件挂载到真实的dom节点上,由于服务端是没有真实的dom的,所以需要到客户端中执行。

4.,hydrate:渲染组件,将组件挂载到真实的dom节点上,如果挂载的组件在客户端上有服务端的标记,则不重新渲染。

render和hydrate

render 将清除指定元素中的任何内容(在大多数情况下称为“root”)并重建它,而 hydrate 将保留指定元素中已经存在带有标记的元素(就是用renderToString渲染的组件),并从中构建,从而使初始页面加载更快。

服务端

根据客户端发起的请求,找到相对应的组件,然后把组件渲染成html字符串,放回一个html给客户端,我这里采用koa搭建服务。

//   server/index.js
import Koa from 'koa';
import koaBody from 'koa-body';
import koaStatic from 'koa-static';
import path from "path";
import React from 'react';
import {renderToString} from 'react-dom/server'
import App from "../containers/App";
const app=new Koa();
const content=renderToString(<App/>);
app.use(koaStatic(path.join(__dirname, '../public')))
// 请求体 parse 中间件,用于 parse json 格式请求体
app.use(koaBody())
app.use(async function errorHandler(ctx, next) {
    try {
        await next()
    } catch (err) {
        // 抛出的错误可以附带 status 字段,代表 http 状态码
        // 若没有提供,则默认状态码为 500,代表服务器内部错误
        console.log(err)
        ctx.status = err.status || 500
        ctx.body = {
            success: false,
            message: err.message
        }
    }
})
app.use((ctx)=> {
    ctx.body= `
   <html>
     <head>
       <title>hello</title>
     </head>
     <body>
      <div id="root">${content}</div>
     </body>
     <script  src="./client.js"></script>
   </html>
 `
})
app.listen(3001,()=>{
     console.log('http://localhost:3001/ start')
})

解析

将组件渲染成html字符串

const content=renderToString(<App/>); // content="<div data-reactroot=""><button>add</button>0</div>"

让react接管页面,是通过这串代码实现的

<script  src="./client.js"></script>

如果没有这串代码:

image.png

会发现,button是没有绑定事件的,因为renderToString渲染的html字符串是没有绑定事件的,所以button在客户端(浏览器)是没有点击事件的。

如何让button的点击事件触发?

通过script引入client.js,就是执行一遍hydrate函数,把事件注入到组件中,才能成功绑定事件。

这个client.js,就由webpack.client.js配置的webpack进行打包

注意一
import React from 'react';

要引入React

原因:

image.png

从webpack打包的代码中可以看到,renderToString方法传入的jsx组件是编译成React.createElement,然后由React.createElement构建v-dom。导致结果就是 react is not defined

image.png

注意二

服务端的html模板,挂载的root节点不能有空隙,这是为了服务端渲染的dom和prop与客户端渲染的dom和prop保持一致,从而使得hydrate不会重复渲染组件。

//bad
<div id="root">  ${content}  </div>

//good
<div id="root">${content}</div>

image.png

不然会有个警告:

image.png

客户端

//  client/index.js
import {hydrate} from 'react-dom'
import React from 'react'
import App from "../containers/App";
hydrate(<App/>,document.getElementById('root'))

通过hydrate,渲染react组件,让react接管客户端。

注意

如果用了激活客户端react的方法,如果用render渲染react组件,

//  如果这样写
import {render} from 'react-dom'
import React from 'react'
import App from "../containers/App";
render(<App/>,document.getElementById('root'))

将会有下面这个警告

image.png

用了renderToString,生成的dom会有一个标记,这里是data-reactroot属性。

image.png

这个标记是renderToString 方法生成的,当想让客户端接管react应用,如果是用ReactDom.render渲染有这样标记的代码,将在React v18 失效。需要使用ReactDom.hydrate渲染。

通过执行package.json的script命令

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

需要全局安装nodemon, 它是一种工具,可以自动检测到目录中的文件更改时通过重新启动应用程序来调试基于node.js的应用程序。简单来说就是用来热更新的。

命令行执行:

pnpm dev:strat
pnpm dev:build:client
pnpm dev:build:server

然后打开网页:http://localhost:3001/

image.png

总结

可以看到,html已经有相对应的html结构了,ssr成功。

不过现在项目还有不少问题:

1.如何在同构中引入路由

2.如何在同构中进行状态管理

3.如何在同构中进行异步数据注入

这三个问题,将在后面的文章中介绍。

react ssr 初实践(二)

参考

具体原理可以看这两篇文章

【长文慎入】一文吃透 React SSR 服务端渲染和同构原理

从头开始,彻底理解服务端渲染原理(8千字汇总长文)