美文网首页
服务端渲染SSR及React实现

服务端渲染SSR及React实现

作者: 小道小姐姐 | 来源:发表于2022-10-17 10:25 被阅读0次

前言

SSR服务端渲染各位前端朋友们应该都或多或少了解过,本文作为服务端渲染系列文章中的第一篇,主要包括以下内容:

  • 什么是SSR
  • SSR的适用场景
  • React实现SSR
    1. 服务端
    2. 降级
    3. 样式

什么是SSR

SSR全称Server-Side Rendering,即服务端渲染的英文缩写。与SSR相对的是客户端渲染CSR。
客户端渲染是指浏览器首先下载一个空白的HTML文本,然后下载执行JS代码,直至完成HTML构建,而服务端渲染则在服务端就完成页面的构建,浏览器拿到的是一个比较完备的HTML文本。


ssr&csr.png

其实在前端刀耕火种时代,服务端渲染就已经存在,写过php的同学应该知道,php里面有很多html模版代码,这些就是服务端渲染,后来随着前后端分离的流行,页面渲染逐渐从后端剥离出来,直到node出现,可以支持CSR和SSR的同构,加上服务端渲染自身的一些优势,SSR再次流行起来。

SSR的适用场景

  • 更快的首屏加载:在网速较慢或者设备性能较差的情况下尤为适用。SSR渲染的HTML无需等待所有JS代码都加载并执行完成才显示,用户能够更快地看到完整的渲染页面。另外相比客户端,服务端数据请求更快。
  • 更好的SEO: 搜索引擎爬虫可以直接看到渲染完成的页面,Google等可以很好地支持对同步JavaSrcipt应用进行索引,但如果你的应用是异步获取内容,爬虫不会等到页面加载完成再抓取,如果SEO对你的应用很重要,那么SSR可能是必需的。

React实现SSR

React支持将组件在服务端直接渲染成HTML字符串,作为服务端响应返回给浏览器,最后在浏览器端将静态的HTML“激活”(hydrate)为能够交互的客户端应用。
一个由服务端渲染的React应用应该是“同构的”(Isomorphic),即大部分代码可以同时运行在服务端和客户端。接下来就基于React实现一个简单的支持服务端和客户端运行的TODO List应用

第一步:项目初始化,安装npm包。

我们的应用依赖以下npm包:

react, react-dom
@babel/core,
@babel/preset-env,
@babel/preset-react,
babel-loader,
webpack,
webpack-cli
express

第二步:搭建后端服务,返回一个简单的html字符串

server/app.js

import express from 'express';
const app = express();
const PORT = 3000;

app.listen(PORT, () => console.log(`listening on ${PORT}`));

export default app;    

server/index.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';

app.get('/', (req, res) => {
    const toDoItemString = renderToString(<ToDoItem />);
    res.send(`
        <html>
            <head>
                <title>hello world</title>
            </head>
            <body>
                <div>${toDoItemString}</div>
            </body>
        </html>
    `)
})

第三步:增加待办事项ToDoItem组件,并在服务端渲染

src/ToDoItem/index.js

 import React, { useState } from 'react';
 export const ToDoItem = (props) => {
     const [inputVal, setInputVal] = useState('')
     return <li>
         <input value={inputVal} onChange={(val) => {
             setInputVal(val)
         }} />
     </li>;
 };

server/index.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';

app.get('/', (req, res) => {
    const toDoItemString = renderToString(<ToDoItem />);
    res.send(`
        <html>
            <head>
                <title>hello world</title>
            </head>
            <body>
                <div>${toDoItemString}</div>
            </body>
        </html>
    `)})

我们需要增加webpack配置,处理React,ES Module

webpack.server.js

const path = require('path');
module.exports = {
     mode: 'development',
     target: 'node',
     entry: './server/index.js',
     output: {
         path: path.join(__dirname, 'server_dist'),
         filename: 'index.js'
     },
     module: {
         rules: [{
             test: /\.js$/,
             exclude: /node_modules/,
             use: {
                 loader: 'babel-loader',
                 options: {
                     presets: ['@babel/preset-env', '@babel/preset-react']
                 }
             }
         }]
     }
 }

第四步:给ToDoItem组件增加用户点击事件

我们的待办事项组件需要支持用户输入。点击完成按钮,显示输入的待办事项,点击待办事项进入编辑模式。

import React, { useCallback, useState } from 'react';
export const ToDoItem = (props) => {
   const [inputVal, setInputVal] = useState('')
   const [isFinished, setIsFinished] = useState(false)
   const finishInput = useCallback(() => {
       setIsFinished(true);
   }, [inputVal])
   return <li>
       {!isFinished ? <>
           <input value={inputVal} onChange={(e) => {
               setInputVal(e.target.value)
           }} />
           <button onClick={finishInput}>完成</button>
       </> : <span onClick={() => {
           setIsFinished(false)
       }}>{inputVal}</span>}
   </li>;
};

事件绑定只能在浏览器端执行,React提供了hydrate api(在React 18中请使用hydrateRoot替代hydrate), hydrate与render方法类似,但hydrate可以复用原本已经存在的DOM节点,并给已标记的DOM节点添加事件。

先给需要hydrate的DOM增加id

server/index.js

    import React from 'react';
    import { renderToString } from 'react-dom/server';
    import { ToDoItem } from '../src/ToDoItem/';
    
    app.get('/', (req, res) => {
        const toDoItemString = renderToString(<ToDoItem />);
        res.send(`
            <html>
                <head>
                    <title>hello world</title>
                </head>
                <body>
                    <div id='root'>${toDoItemString}</div> // 增加id,方便获取需要hydrate的DOM节点
                </body>
            </html>
        `)})

增加client/index.js文件,实现在客户端获取DOM节点绑定事件

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ToDoItem } from '../src/ToDoItem';

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

增加webpack.client.js,将client/index.js打包成静态文件保存到public目录下

const path = require('path');

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

在server/app.js中使用public静态文件目录

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

在server/index.js中引入client/index.js打包后的静态文件,这样待客户端加载完script脚本后便可以执行绑定事件

import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';

app.get('/', (req, res) => {
    const toDoItemString = renderToString(<ToDoItem />);
    res.send(`
        <html>
            <head>
                <title>hello world</title>
            </head>
            <body>
                <div id='root'>${toDoItemString}</div>
                <script src='/index.js'></script> // 引入client/index.js打包后的静态文件
            </body>
        </html>
    `)
})

第五步:支持数据请求

我们通过一个简单的方法模拟数据请求

src/mock.js

export const fetchData = () => {
   return new Promise((resolve, _) => setTimeout(() => {
       resolve();
       return [
       '吃饭',
       '睡觉',
       '写代码'
   ]}, 500));
}

增加一个新组件,待办事件列表组件ToDoList,支持新增待办事项。

src/ToDoList/index.js

import React from 'react';
import { ToDoItem } from '../ToDoItem';

export const ToDoList = ({ defaultToDoList }) => {
    const [toDoList, setToDoList] = useState(defaultToDoList)
    const addNewToDo = useCallback(() => {
        const newToDoList = [...toDoList];
        newToDoList.push('');
        setToDoList(newToDoList);
    }, [toDoList])
    return (
        <ul>
            {
                todoList.map(todo => <ToDoItem key={todo.id} defaultToDo={todo.title} />)
            }
            <button onClick={addNewToDo}>
                新增
            </button>
        </ul>
    );
};

然后对我们的ToDoItem组件稍微优化一下,支持待办事项的显示、修改。

src/ToDoItem/index.js

import React, { useState } from 'react';

export const ToDoItem = ({defaultToDo}) => {
    const [inputVal, setInputVal] = useState(defaultToDo)

    return <li>
            <span contentEditable onChange={(e) => {
                setInputVal(e.target.value)
            }}>{inputVal}</span>
    </li>;
};

这边为了方便直接使用contentEditable属性,实际使用中要注意兼容性。

server/index.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
import { fetchData } from '../mock';
import { ToDoList } from '../src/ToDoLIst';

app.get('/', async (req, res) => {
    const result = await fetchData(); //获取数据
    const toDoItemString = renderToString(<ToDoList defaultToDoList={result} />);
    res.send(`
        <html>
            <head>
                <title>hello world</title>
            </head>
            <body>
                <div id='root'>${toDoItemString}</div>
                <script src='/index.js'></script>
            </body>
        </html>
    `)
})

client/index.js

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ToDoList } from '../src/ToDoList';

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

此时,我们重新执行npm start命令,刷新页面会发现报错

error.png

为什么todoList会是undefined(当然我们代码也要对空逻辑进行处理,增强代码鲁棒性)还记得第四步给页面绑定点击事件而增加了client/index.js文件吗?在这里我们将点击事件移到了ToDoList组件中,因此client/index.js也做了相应的更改,但是却忘了没有给ToDoList的props传值

数据注水与脱水

我们当然可以在client/index.js中再请求一次数据传给ToDoList组件,但那这就违背了使用ssr的初衷。我们希望能复用服务端请求的数据。在服务端请求数据后返回到客户端,这个过程即注水,客户端拿到数据并渲染这个过程即脱水。

首先在服务端注水

server/index.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
import { fetchData } from '../mock';
import { ToDoList } from '../src/ToDoLIst';

app.get('/', async (req, res) => {
    const result = await fetchData()
    const toDoItemString = renderToString(<ToDoList defaultToDoList={result} />);
    res.send(`
        <html>
            <head>
                <title>hello world</title>
            </head>
            <body>
                <div id='root'>${toDoItemString}</div>
                <script>window.data=${JSON.stringify(result)}</script> // 注水
                <script src='/index.js'></script>
            </body>
        </html>
    `)
})

然后在客户端拿到数据,完成脱水

client/index.js

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ToDoList } from '../src/ToDoList';

const defaultToDoList = window.data // 脱水
hydrateRoot(document.getElementById('root'), <ToDoList defaultToDoList={defaultToDoList} />);

最后,咱们这个待办事项还是丑了点儿,需要添加点样式美观一下。服务端我们只能用style标签添加样式。

server/index.js

import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
import { fetchData } from '../mock';
import { ToDoList } from '../src/ToDoLIst';

app.get('/', async (req, res) => {
    const result = await fetchData()
    const toDoItemString = renderToString(<ToDoList defaultToDoList={result} />);
    res.send(`
        <html>
            <head>
                <title>hello world</title>
            </head>
            <style type='text/css'>
                li {
                   list-style: none;
                   border-bottom: 1px solid #f5f5f5;
                   font-size: 12px;
                   padding: 5px 0;
                   height: 28px;
                }
                button {
                    display: flex;
                    align-item: center;
                    background: #f5f5f5;
                    padding: 2px 5px;
                    margin-top: 20px;
                    svg {
                        margin-right: 2px;
                    }
                }
            </style>
            <body>
                <div id='root'>${toDoItemString}</div>
                <script>window.data=${JSON.stringify(result)}</script>
                <script src='/index.js'></script>
            </body>
        </html>
    `)
})

最终效果图如下:


效果.gif

代码git地址:https://github.com/vciscoding/ssr-demo/tree/main

相关文章

网友评论

      本文标题:服务端渲染SSR及React实现

      本文链接:https://www.haomeiwen.com/subject/zcnrzrtx.html