服务端渲染SSR及React实现

前言

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();
    res.send(`
        
            
                hello world
            
            
                
${toDoItemString}
`) })

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

src/ToDoItem/index.js

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

    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();
        res.send(`
            
                
                    hello world
                
                
                    
    ${toDoItemString}
    `)})

    我们需要增加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 
  • {!isFinished ? <> { setInputVal(e.target.value) }} /> : { setIsFinished(false) }}>{inputVal}}
  • ; };

    事件绑定只能在浏览器端执行,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();
            res.send(`
                
                    
                        hello world
                    
                    
                        
    ${toDoItemString}
    // 增加id,方便获取需要hydrate的DOM节点 `)})

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

    import React from 'react';
    import { hydrateRoot } from 'react-dom/client';
    import { ToDoItem } from '../src/ToDoItem';
    
    hydrateRoot(document.getElementById('root'), );
    

    增加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();
        res.send(`
            
                
                    hello world
                
                
                    
    ${toDoItemString}
    // 引入client/index.js打包后的静态文件 `) })

    第五步:支持数据请求

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

    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 (
            
      { todoList.map(todo => ) }
    ); };

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

    src/ToDoItem/index.js

    import React, { useState } from 'react';
    
    export const ToDoItem = ({defaultToDo}) => {
        const [inputVal, setInputVal] = useState(defaultToDo)
    
        return 
  • { setInputVal(e.target.value) }}>{inputVal}
  • ; };

    这边为了方便直接使用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();
        res.send(`
            
                
                    hello world
                
                
                    
    ${toDoItemString}
    `) })

    client/index.js

    import React from 'react';
    import { hydrateRoot } from 'react-dom/client';
    import { ToDoList } from '../src/ToDoList';
    
    hydrateRoot(document.getElementById('root'), );
    

    此时,我们重新执行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();
        res.send(`
            
                
                    hello world
                
                
                    
    ${toDoItemString}
    // 注水 `) })

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

    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'), );
    

    最后,咱们这个待办事项还是丑了点儿,需要添加点样式美观一下。服务端我们只能用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();
        res.send(`
            
                
                    hello world
                
                
                
                    
    ${toDoItemString}
    `) })

    最终效果图如下:


    效果.gif

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

    你可能感兴趣的:(服务端渲染SSR及React实现)