featured-image
SSR同构和框架原理
发表于 2024-11-12
更新于 2024-11-12
编程
阅读时长:20分钟
阅读量:69

一、概述

在现代web开发中,我们往往采用spa的开发方式。即服务端只返回一个根节点,剩下的DOM由浏览器根据js生成。大多数对SEO不敏感的中后台如此。但是一旦涉及到项目面向C端用户,例如官网,spa的方式就有点捉襟见肘。

spa的弊端

首先是白屏的问题,初次渲染时由于会加载较多的JavaScript文件,首屏渲染时间可能较长,影响用户的初次体验。其次是SEO,由于内容是由客户端渲染的,搜索引擎爬虫通常无法抓取动态内容,除非使用特殊的SSR/预渲染解决方案来补充。再者SPA通常会进行大量的前端渲染和数据处理,尤其是复杂应用,容易造成前端性能瓶颈。

ssr的优势

于是许多的希望提升用户体验与网站搜索排名的网站采用了SSR,即服务端的渲染方式。页面的渲染由服务器直接返回完整的HTML,加速了首屏的加载,可以被搜索引擎爬取,利于搜索引擎优化(SEO)

本文将结合React + Node,搭建一个简易的SSR框架,本文作为个人学习笔记,如有错误的地方,烦请指出~

二、思路整理

服务器渲染结合spa和ssr两者的优势,首次进入页面由服务器端渲染,后续在该页面的交互就是客户端接管。

流程:

1、用户输入url访问页面,服务器接收到请求并根据path匹配到对应的组件。

2、服务器获取所需的数据后,将数据传递给组件进行渲染

3、服务端将组件渲染为html字符串,与数据一起输送给浏览器(注水),浏览器拿到数据注入组件(脱水)。

4、浏览器开始进行渲染和节点对比,完成组件内事件绑定和交互

思考:为什么不在服务端进行事件绑定

服务器端渲染生成的是纯静态 HTML,无法处理用户的动态交互。事件处理通常依赖于浏览器环境中的 DOM、事件模型等,而这些在服务器端是不可用的。

思考:为什么服务端要将数据传输给浏览器?

为了实现客户端的无缝接管,服务端通常会在 HTML 中注入一段 JSON 格式的“数据状态”,通常嵌入在 <script> 标签中,便于客户端 JavaScript 读取。当 HTML 页面加载到浏览器后,客户端的 JavaScript 会读取这份注入的数据,并使用它来初始化客户端的状态管理(如 Redux 或 Zustand),这样客户端代码就能继续在已有数据的基础上执行,而不必重新请求数据。

这里有一个很重要的概念:同构

同构的目的在于让代码在服务器和客户端都能顺利运行,以实现高效的渲染和一致的用户体验。大致由三个关键点

  • 渲染同构

渲染同构是指服务器端和客户端使用相同的渲染逻辑和组件,以便在任意端渲染的页面都保持一致

  • 路由同构

指服务器和客户端使用同样的路由配置,以确保访问同一路径时返回的页面内容保持一致

  • 数据同构

数据同构是指服务器和客户端之间保持一致的数据状态

三、架构搭建

最基本的结构:服务端返回html代码,并在浏览器进行渲染

安装express npm install express

// src/server/index.js

const express = require("express");
const childProcess = require("child_process");

const app = express();
app.get("*", (req, res) => {
  res.send(`
    <html
      <body>
        <div>server-ssr-render</div>
      </body>
    </html>
  `);
});

app.listen(3333, () => {
  console.log("ssr-server listen on 3333");
});

childProcess.exec("start http://127.0.0.1:3333");

打开浏览器控制台,发现html代码直接输出到了浏览器,实现了简单的服务端渲染。

为了使项目更符合真实的ssr架构,我们需要进行以下设计:

1、使用ts代替js

2、利用 Webpack 打包并运行项目

安装webpack以及babel-loader、ts-loader分别构建js和ts代码

npm install @babel/preset-env babel-loader ts-loader webpack webpack-merge webpack-cli @types/express --save-dev

新增通用的 Webpack 配置

// webpack.base.js
const path = require("path");

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-env"],
        },
      },
      {
        test: /.(ts|tsx)?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
    alias: {
      "@": path.resolve(process.cwd(), "./src"),
    },
  },
};
// webpack.server.js
const path = require("path");
const { merge } = require("webpack-merge");
const baseConfig = require("./webpack.base");

module.exports = merge(baseConfig, {
  mode: "development",
  entry: "./src/server/index.tsx",
  target: "node",
  output: {
    filename: "bundle.js",
    path: path.resolve(process.cwd(), "server_build"),
  },
});

初始化ts代码配置

// tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS",
    "types": ["node"], 
    "jsx": "react-jsx",
    "target": "es6",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "baseUrl": "./",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"]
}

更改服务端代码

// src/server/index.tsx
import express from "express";
import childProcess from "child_process";

const app = express();
app.get("*", (req, res) => {
  res.send(`
    <html
      <body>
        <div>server-ssr-render</div>
      </body>
    </html>
  `);
});

app.listen(3333, () => {
  console.log("ssr-server listen on 3333");
});

childProcess.exec("start http://127.0.0.1:3333");

安装react,后续将通过react的hook组件编写组件

npm install react react-dom --save

npm install @types/react @types/react-dom --save-dev

// src/pages/Home/index.tsx
const Home = () => {
  return (
    <div>
      <h1>Home Page</h1>
      <button onClick={() => alert("hello-ssr")}>btn</button>
    </div>
  );
};

export default Home;

添加脚本命令进行打包与执行,其中利用nodemon运行打包后的代码

// package.json
"scripts": {
    "start": "npx nodemon --watch src server_build/bundle.js",
    "build:server": "npx webpack build --config ./webpack.server.js --watch",
 }

1、渲染同构

渲染同构是指服务器端和客户端使用相同的渲染逻辑和组件,以便在任意端渲染的页面都保持一致

1.1 renderToString

基于上面的思路,现在已经实现服务端返回html结构,简单实现了服务端渲染。但是在真实的项目中,我们不可能将所有的代码都写成字符串形式,需要用到组件化的开发方式。那么如何将组件转变为html呢

这里用到了 React 提供的renderToString方法,它可以将 React 组件转换成 HTML 字符串

将上方的代码进行简单改造

import express from "express";
import childProcess from "child_process";
import Home from "@/pages/Home";
import { renderToString } from "react-dom/server";

const app = express();
const content = renderToString(<Home />); // 将组件渲染为HTML

app.get("*", (req, res) => {
  res.send(`
    <html
      <body>
        ${content}
      </body>
    </html>
  `);
});

app.listen(3333, () => {
  console.log("ssr-server listen on 3333");
});

childProcess.exec("start http://127.0.0.1:3333");

可以看到,组件渲染成功了。但是这个时候点击按钮是没有反应的。因为服务端是绑定不了事件的

1.2 ReactDom.hydrateRoot

既然要实现事件绑定等客户端才能实现的交互,那么就需要将组件也在客户端调用一次,由浏览器处理好交互。

解决方案就是另写一份代码专门运行在客户端,并且最好能够最大程度地复用服务端中的代码

ReactDOM.hydrateRoot 是 React 18 引入的新方法,用于在客户端复用服务端渲染的 HTML,并将事件绑定到服务端生成的静态标记上,这一过程称为注水、水合

创建一个专用于客户端渲染的入口

// src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import Home from "@/pages/Home";

hydrateRoot(document.getElementById("root") as Document | Element, <Home />);

注意:ReactDom.hydrateRoot 需要指定一个绑定的真实 dom,记得给 server 入口页面加一个id

// src/server/index.tsx
<div id="root">${content}</div>

将客户端代码进行打包成js文件,并在服务端组件中引入

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

module.exports = merge(baseConfig, {
  mode: "development",
  entry: "./src/client/index.tsx",
  output: {
    filename: "index.js",
    path: path.resolve(process.cwd(), "client_build"),
  },
});
"scripts": {
    "build:client": "npx webpack build --config ./webpack.client.js --watch",
},
// src/server/index.tsx
app.use(express.static(path.resolve(process.cwd(), "client_build")));

app.get("*", (req, res) => {
  res.send(`
    <html
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
});

此时再次构建并运行项目,就可以看到事件绑定成功了

2、路由同构

路由同构是指服务器端和客户端使用相同的路由配置,以确保无论在服务器还是客户端渲染,访问同一路径时返回的页面内容保持一致。

安装一下路由相关的依赖npm install react-router-dom --save,并配置一个新的路由页面About

// src/router.tsx
import Home from "@/pages/Home";
import About from "@/pages/About";

interface Router {
  path: string;
  element: JSX.Element;
}

const router: Array<Router> = [
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/about",
    element: <About />,
  },
];
export default router;
// src/pages/About/index.tsx
import { FC } from "react";

const About: FC = (data) => {
  return (
    <div>About Page</div>
  );
};

export default About;

重构客户端和服务端的路由

// src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/routes";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";

const app = express();

app.use(express.static(path.resolve(process.cwd(), "client_build")));

app.get("*", (req, res) => {
  // 服务端渲染HTML
  const content = renderToString(
    <StaticRouter location={req.path}>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />;
        })}
      </Routes>
    </StaticRouter>
  );

  res.send(`
    <html
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
});

app.listen(3333, () => {
  console.log("ssr-server listen on 3333");
});

childProcess.exec("start http://127.0.0.1:3333");
// src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/routes";

const Client = (): JSX.Element => {
  return (
    <BrowserRouter>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />;
        })}
      </Routes>
    </BrowserRouter>
  );
};

hydrateRoot(document.getElementById("root") as Document | Element, <Client />);

构建并运行项目,发现就可以访问/about页面了,并且也是由服务端进行的渲染

注意:当使用 React 内置的路由跳转的时候,会进行客户端路由的跳转,采用客户端渲染。而使用a标签或者新开一个页面时,走的是服务端渲染

3、数据同构

数据同构是指服务器和客户端之间保持一致的数据状态

我们试着在About页面中编写一个接口看看效果

npm install axios body-parser --save

创建接口

//src/server/index.tsx
const bodyParser = require("body-parser");

// 请求body解析
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.post("/api/getAboutData", (req, res) => {
  res.send({
    data: req.body,
    status_code: 0,
  });
});

调用

//src/pages/About/index.tsx
import { FC, useState, useEffect } from "react";
import axios from "axios";

const About: FC = (data) => {
  const [content, setContent] = useState("");

  useEffect(() => {
    axios
      .post("/api/getAboutData", {
        content: "这是一个demo页面",
      })
      .then((res: any) => {
        setContent(res.data?.data?.content);
      });
  }, []);

  return <div>{content}</div>;
};

export default About;

但是返回的html字符串却不包含数据,因为接口是在客户端发起的

需要的效果是服务端根据路径匹配到页面组件中所有的请求,发送请求获取数据并拼接成HTML字符串返回给前端

我们可以将数据请求的方法关联到路由中,然后在服务端查找到对应的方法调用获取数据

这里使用了zustand来进行全局数据管理

npm install zustand

//src/store.ts
import { create } from "zustand";

interface State {
  data: any;
}
interface Actions {
  setData: (data: any) => void;
}

export const useStore = create<State & Actions>((set) => {
  return {
    data: "",
    setData: (data: string) => set({ data }),
  };
});

关联网络请求

//src/pages/About/index.tsx
import { useStore } from "@/store";
import axios from "axios";

const About = () => {
  const content = useStore.getState().data;

  return (
    <div>
      <h1>About Page</h1>
      {JSON.stringify(content)}
    </div>
  );
};

About.getInitData = () => {
  return axios
    .post("http://127.0.0.1:3333/api/getAboutData", {
      content: "网络请求的数据:123321",
    })
    .then((res: any) => {
      return {
        content: res.data?.data?.content,
      };
    });
};

export default About;
//src/routes.tsx
interface Router {
  path: string;
  element: JSX.Element;
  loadData?: () => Promise<any>;
}

const router: Array<Router> = [
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/about",
    element: <About />,
    loadData: About.getInitData, //网络请求
  },
];

在服务端匹配到路由的网络请求,获取数据并存储到store中

//src/server/index.tsx
app.get("*", async (req, res) => {
  // 服务端渲染HTML
  const content = renderToString(
    <StaticRouter location={req.path}>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />;
        })}
      </Routes>
    </StaticRouter>
  );

  const routeMap = new Map<string, () => Promise<any>>();
  router.forEach((item) => {
    if (item.path && item.loadData) {
      routeMap.set(item.path, item.loadData);
    }
  });

  const matchedRoutes = matchRoutes(router, req.path);
  const promises: Array<Promise<any>> = [];
  matchedRoutes?.forEach((item) => {
    if (routeMap.has(item.pathname)) {
      promises.push(routeMap.get(item.pathname)!());
    }
  });

  // 获取数据
  Promise.all(promises).then((data) => {
    console.log("data", data);
    // 数据注入Store
    useStore.getState().setData(data);
  });

  res.send(`
    <html
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
});

重新构建项目,并启动

这时候你会发现确实网络数据由服务端渲染进了html中,但是页面闪烁了一下数据又消失了,为什么呢

因为客户端的store与服务端的store数据不一致。当客户端js执行时,由于其store是空的,结果就是把服务端的网咯请求数据给覆盖了。

所以,需要同时将服务端的store,传递给客户端,实现数据同构

// src/server/index.tsx
// 获取完数据,讲数据通过script传递到客户端的window中
  Promise.all(promises).then((data) => {
    useStore.getState().setData(data);

    res.send(`
      <html
        <body>
          <div id="root">${content}</div>
          <script>
            window.__INITIAL_DATA__  = {
              state: ${JSON.stringify(useStore.getState())}
            }
          </script>
          <script src="/index.js"></script>
        </body>
      </html>
    `);
  });
});

客户端接收数据,并初始化store

//src/client/index.tsx
// 获取服务端注入的数据
const initialData = window.__INITIAL_DATA__?.state || {};
// 初始化 Zustand 状态
useStore.setState(initialData);

hydrateRoot(document.getElementById("root") as Document | Element, <Client />);

服务端和客户端共享同一个store,解决了闪烁的问题

到此,项目的架构基本实现了渲染同构、路由同构、数据同构。

评论
  • 支持 Markdown 格式
  • 评论需要登录
  • 邮箱会回复提醒(也许会在垃圾箱内)

0 /400 字