原理实践 搭建简答的SSR

299 阅读2分钟

原理实践 搭建简答的SSR

API须知

ReactDOMServer.renderToString(element)

将一个 React 元素渲染成其初始的 HTML。React 将返回一个 HTML 字符串。你可以使用这种方法在服务器上生产 HTML,并在初始请求中发送标记。以加快页面加载速度,并允许搜索引擎以 SEO 为目的抓取你的页面。

StaticRouter

静态路由,通过初始传入的 location 地址找到相应组件。区别于客户端的动态路由。

hydrate 变为 hydrateRoot(18版本)

// Before
import { hydrate } from 'react-dom';
const container = document.getElementById('app');
hydrate(<App tab="home" />, container);

// After
import { hydrateRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = hydrateRoot(container, <App tab="home" />);
// Unlike with createRoot, you don't need a separate root.render() call here.

前期代码

页面文件

// src/pages/Home
import React from "react";

const Home = () => {
    return(
        <div>首页</div>
    )
}

export default Home
// src/pages/Personal
import React from "react";

const Personal = () => {
    return(
        <div>个人中心页面</div>
    )
}

export default Personal

路由文件

// src/routes
import React from "react";
import {Routes,Route,Link} from "react-router-dom"
import Home from "./pages/Home"
import Personal from "./pages/Personal"

const RoutesList = () => {
    return (
        <div>
            <ul>
                <li>
                    <Link to="/">首页</Link>
                </li>
                <li>
                    <Link to="/personal">个人中心</Link>
                </li>
            </ul>
            <Routes>
                <Route exact path="/" element={<Home/>}></Route>
                <Route path="personal" element={<Personal/>}></Route>
            </Routes>
        </div>
    )
}

export default RoutesList

服务端文件

// src/server
import React from 'react'
import ReactDOMServer from "react-dom/server"
import RoutesList from './routes'
import {StaticRouter} from "react-router-dom/server"

const express = require('express')
const app = new express()
const port = 3000

app.get('*', (req,res) => {
    const content = ReactDOMServer.renderToString(
        <StaticRouter location={req.url}>
            <RoutesList/>
        </StaticRouter>
    )
    
    const html = `
        <html>
            <head></head>
            <body>
                <div id="root">${content}</div>
            </body>x
        </html>
    `

    res.send(html)
})

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

配置文件

// webpack.server.js 服务端配置
const path = require('path')
const webpackNodexternals = require('webpack-node-externals')

module.exports = {
    target:'node',
    mode:process.env?.NODE_ENV === 'production' ? 'production' : 'development',
    entry:path.resolve(__dirname,"../src/server.js"),
    output:{
        filename:'bundle_server.js',
        path:path.resolve(__dirname,'../dist')
    },
    module:{
        rules:[
            {
                test:/\.js$/,
                loader:'babel-loader',
                exclude:'/node_modules/'
            }
        ]
    },
    // webpack-node-externals作用:将后端的模块不被打包
    externals:[webpackNodexternals()]
}
// .babelrc配置
{
    "presets": [
        "@babel/preset-react",
        "@babel/preset-env"
    ]
}

代码解析

  1. 访问页面回向服务端发起请求,req.url即为访问页面的路径

    app.get('*', (req,res) => {
        //...
    } )
    
  2. React元素转化为HTML字符串形式

    • 通过StaticRouter在已有的路由表中查找对应的组件
  3. 组装成完整的HTML代码并从服务端发送给客户端

绑定事件

在原有的代码上添加点击事件:

// src/pages/Home
import React from "react";

const Home = () => {
    const handleClick = () => {
        console.log('点击')
    }

    return(
        <div>首页 <button onClick={handleClick}>点我</button></div>
    )
}

export default Home

但是在页面中点击按钮,发现控制台中并没有打印"点击",我们查看服务端返回的html代码,发现没有相关事件的代码

<html>
    <head></head>
    <body>
        <div id="root">
            <div>
                <ul>
                    <li><a href="/">首页</a></li>
                    <li><a href="/personal">个人中心</a></li>
                </ul>
                <div>首页 <button>点我</button></div>
            </div>
        </div>
    </body>
</html>

解析:

这是因为后端给前端的页面是脱水后的,需要React接管页面后,需要将数据传入组件,使客户端的组件正式由React管理

  1. 向组件内注水

    提示:以下代码是v18后hydrateRoot的用法

    // src/client
    import React from "react";
    import { hydrateRoot } from 'react-dom/client';
    import { BrowserRouter } from "react-router-dom"
    import RoutesList from "./routes";
    
    // 类似render方法,但是基于ssr,只是恢复原本已经存在的DOM节点
    hydrateRoot(
        document.querySelector('#root'),
        (<BrowserRouter>
            <RoutesList/>
        </BrowserRouter>)
    )
    

    前后端双路由,后端根据路由获取对应的数据、渲染组件并转化为html代码;前端接收html再次进行渲染

  2. webpack进行打包

    作用:避免浏览器无法解析代码

    const path = require('path')
    
    module.exports = {
        target:'web',
        mode:process.env?.NODE_ENV === 'production' ? 'production' : 'development',
        entry:path.resolve(__dirname,"../src/client.js"),
        output:{
            filename:'bundle_client.js',
            path:path.resolve(__dirname,'../dist/public')
        },
        module:{
            rules:[
                {
                    test:/\.js$/,
                    loader:'babel-loader',
                    exclude:'/node_modules/'
                }
            ]
        }
    }
    
  3. 浏览器获取客户端代码(src/client.js)

    此处我们使用<script>进行导入

    // src/server
    
    // 代理静态目录
    app.use(express.static('dist/public'))
    
    app.get('*', (req,res) => {
        const content = ReactDOMServer.renderToString(
            <StaticRouter location={req.url}>
                <RoutesList/>
            </StaticRouter>
        )
        
        const html = `
            <html>
                <head></head>
                <body>
                    <div id="root">${content}</div>
                    <script src="bundle_client.js"></script>
                </body>
            </html>
        `
        res.send(html)
    })
    

    注意:

    • <script src="bundle_client.js"></script>一定要置于<div id="root">${content}</div>之后,因为在client.js中使用节点的选择(document.querySelector('#root'))。如果互换了,<div id="root"></div>节点并未完成渲染,会出现报错
  4. 配置命令,方便执行

      "scripts": {
        "webpack:server": "webpack --config ./config/webpack.server.js --watch",
        "webpack:client": "webpack --config ./config/webpack.client.js --watch",
        "webpack:start": "nodemon --watch dist --exec node dist/bundle_server.js",
        "dev": "npm-run-all --parallel webpack:*"
      },