阅读视图

发现新文章,点击刷新页面。

从renderToString到hydrate,从0~1手写一个SSR框架

一、前言

上一篇文章,我们从ice.js源码学习了SSR的底层框架运行过程。

开源的SSR框架都是怎么实现的?

我们梳理了SSR从服务端渲染 -> 前端可交互的过程主要有如下几个阶段:

  1. 服务端匹配到前端请求;
  2. 基于路由匹配实际需要渲染的React组件(from cjs产物);
  3. 组装App全局上下文和前端路由(react-router-dom);
  4. 服务端执行渲染,产出html string
  5. 前端水合,执行hydrate逻辑;
  6. 用户可交互;

基于这整个过程,你有没有思考过?SSR框架是如何把我们本地的组件(页面 - pages、组件 - components等等)串联成这个渲染链路的?

本文我们基于上述的渲染流程和主流的SSR框架技术实现原理,实现一个mini版本可跑通的SSR框架,从而深入理解SSR的全链路系统原理。

二、0~1分阶段实现SSR框架

2.1、设计先行

作为框架,那必然需要前端构建,传统CSR很简单,基于webpack单入口分析所有模块,打出js、css、html

SSR构建出一个应用,最基本的需要哪些能力呢?首先最大的区别:CSR部署相对静态,而SSR部署相对动态,如:服务端执行渲染、读配置前端水合,都是框架层面的rumtime code,因此需要前端和服务端的运行时产物。

而运行时核心的做的事情和路由 -> 组件有关,而服务端node环境只能识别cjs模块;浏览器环境识别esm模块,因此需要将项目中所有的组件按统一源码,cjsesm不同模块分别打出一份供服务端前端使用。

就像这样:

image.png

2.2、项目初始化

我们新建一个项目,并初始化。

mkdir ssr-demo
cd ssr-demo
npm init -y

然后分析下需要的依赖。

  • 构建,需要webpack
  • 底层框架,需要reactreact-domreact-router-dom
  • SSR服务,需要express
  • 源码构建编译,需要@babel/core@babel/preset-reactbabel-loader

因此,执行:

npm i webpack react react-dom react-router-dom express @babel/core @babel/preset-react babel-loader

然后我们先配置下webpack基础构建能力。

核心是给前端水合的runtime、服务端渲染的runtime打包。

因此拆两个webpack配置文件。

webpack.client.config.js

const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/entry/client-entry.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "client-bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        options: {
          presets: [
            [
              "@babel/preset-react",
              {
                runtime: "automatic",
              },
            ],
          ],
        },
      },
    ],
  },
};

webpack.server.config.js

const path = require("path");

module.exports = {
  mode: "development",
  target: "node",
  entry: "./src/entry/server-entry.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "server-bundle.js",
    libraryTarget: "commonjs2",
  },
  externals: {
    react: "commonjs react",
    "react-dom/server": "commonjs react-dom/server",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        options: {
          presets: [
            [
              "@babel/preset-react",
              {
                runtime: "automatic",
              },
            ],
          ],
        },
      },
    ],
  },
};

就是两个最基础的配置,只是把代码打包了一下,没有特别复杂的能力。

后面如果要对框架做扩展,我们继续扩展就行。

然后我们再配置下打包和运行。

build就是把两个入口打一下。

start就是把服务跑起来。

package.json

{
    // ...
    "scripts": {
        "build": "npx webpack --config webpack.client.config.js && npx webpack --config webpack.server.config.js",
        "start": "node server.js"
    }
}

2.3、服务端核心

首先我们基于express跑一个服务,同时匹配路由。

const express = require("express");
const fs = require("fs");
const path = require("path");
const { render } = require("./dist/server-bundle.js");

const app = express();

// 静态文件(客户端 bundle)
app.use(express.static(path.join(__dirname, "dist")));

app.use(async (req, res) => {
  const { html, data, routePath } = await render(req.path);

  let template = fs.readFileSync(
    path.join(__dirname, "src", "template", "template.html"),
    "utf8"
  );
  template = template.replace("<!--SSR_CONTENT-->", html);
  template = template.replace("<!--INITIAL_DATA-->", JSON.stringify(data));
  template = template.replace("<!--ROUTE_PATH-->", routePath);

  res.send(template);
});

app.listen(3000, () => {
  console.log("SSR server running at http://localhost:3000");
});

这是一个基础框架,匹配到路由后做的事情很简单:

  1. 暴露dist
  2. 传入请求路径,执行render核心函数,解析对应服务端组件;
  3. 基于解析完成的html string运行时App上下文写入模板;
  4. 返回前端;

render核心函数的处理呢?

server-entry.js

import React from "react";
import ReactDOMServer from "react-dom/server";
import { Route, Routes } from "react-router-dom";
import AppProviders from "../context/AppProviders.jsx";
import routes from "../routes/routes.js";

export async function render(url) {
  let matchedRoute = routes.find((r) => r.path === url);

  let routeData = {};
  let appData = { appName: "SSR Demo" };
  let Component = matchedRoute?.element?.type;
  if (Component && Component.getServerData) {
    routeData = await Component.getServerData();
  }
  const appContext = { appData, routeData, routePath: url };

  const element = (
    <AppProviders appContext={appContext} location={url} isServer>
      <Routes>
        {routes.map((r, idx) => {
          // 只给匹配到的那个路由传数据
          if (r.path === url) {
            return (
              <Route
                key={idx}
                path={r.path}
                element={React.cloneElement(r.element, { data: routeData })}
              />
            );
          }
          // 其它路由照常渲染,data 可以传 undefined 或保持原样
          return <Route key={idx} path={r.path} element={r.element} />;
        })}
      </Routes>
    </AppProviders>
  );

  const html = ReactDOMServer.renderToString(element);

  return { html, data: appContext, routePath: url };
}

服务端渲染核心函数做了这些事情:

  1. 基于前端请求路径匹配路由组件;
  2. 读取组件服务端请求函数,用于在服务端初始化首屏动态数据;
  3. 创建App全局上下文
  4. 创建路由

那继续逐个来看,基于前端请求路由,我们先看下routes文件,看完你就明白了。

// 用 React Router v6 的形式配置路由
import About from "../components/About.jsx";
import Home from "../components/Home.jsx";
import NotFound from "../components/NotFound.jsx";

export default [
  { path: "/", element: <Home /> },
  { path: "/about", element: <About /> },
  { path: "*", element: <NotFound /> },
];

这里实际就是拿express req url来约定式路由中匹配,找到对应的组件。

ssr框架都支持在组件中暴露页面数据请求函数,用于初始化首屏数据,从props中传入。

因此Home组件会是这样的:

import { Link } from "react-router-dom";

function Home({ data }) {
  return (
    <div>
      <h1>Home Page</h1>
      <p>Data: {data?.message}</p>
      <Link to="/about">Go to About</Link>
    </div>
  );
}

Home.getServerData = async () => {
  const data = { message: "Hello from Home Page API" };
  return data;
};

export default Home;

拿到请求函数在服务端执行下,最后传入路由去就行。

服务端的工作就完成了。

AppProvider组件是干啥的?

通常一些服务端、前端的共用数据、逻辑都会在这里。

比如路由嵌套,因为两端的组件源码是一致的,项目也是同一份,只需要区分Router类型即可。

import { BrowserRouter, StaticRouter } from "react-router-dom";
import { createContext, useContext } from "react";

const AppContext = createContext(null);

export const AppContextProvider = ({ value, children }) => {
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};

export const useAppContext = () => {
  return useContext(AppContext);
};

export default function AppProviders({
  appContext,
  children,
  location,
  isServer,
}) {
  const Router = isServer ? StaticRouter : BrowserRouter;

  return (
    <AppContextProvider value={appContext}>
      <Router location={location}>{children}</Router>
    </AppContextProvider>
  );
}

同时支持了isServer参数,这样组件在服务端、前端运行时都可以用。

统一了全局数据。

服务端在生成代码的时候将appContext赋值。

然后将appContext注入到html window中。

ssr 前端运行时再将appContext透传中应用中。

这样业务组件也可以获取到ssr的配置信息。

这样流程就串起来了。

OK,至此,服务端渲染部分讲完了,最后server.js再将DOM、appContext注入到模板中,返回给前端。

  const { html, data, routePath } = await render(req.path);

  let template = fs.readFileSync(
    path.join(__dirname, "src", "template", "template.html"),
    "utf8"
  );
  template = template.replace("<!--SSR_CONTENT-->", html);
  template = template.replace("<!--INITIAL_DATA-->", JSON.stringify(data));
  template = template.replace("<!--ROUTE_PATH-->", routePath);

  res.send(template);

至此,服务端部分就讲完了。

2.4 前端核心

前端部分比较简单。

回顾一下:前端在ssr中的角色核心是水合hydrate

然后服务端返回的DOM可交互。

CSR中,我们基于react renderRoot来渲染组件。

SSR中,服务端已经返回了当前页面所有的DOM,因此我们基于react hydrateRoot来水合(复用不渲染)组件。

前端运行时代码如下:

import React from "react";
import { hydrateRoot } from "react-dom/client";
import { Route, Routes } from "react-router-dom";
import AppProviders from "../context/AppProviders.jsx";
import routes from "../routes/routes.js";

function RootApp({ appContext }) {
  return (
    <Routes>
      {routes.map((r, idx) => (
        <Route
          key={idx}
          path={r.path}
          element={React.cloneElement(r.element, {
            data: appContext.routeData,
          })}
        />
      ))}
    </Routes>
  );
}

async function run() {
  const appContext = window.__INITIAL_DATA__;
  const element = (
    <AppProviders appContext={appContext} isServer={false}>
      <RootApp appContext={appContext} />
    </AppProviders>
  );
  hydrateRoot(document.getElementById("root"), element);
}

run();

将这段代码注入到html中,就会将服务端返回的DOM开始水合。

SSR有个非常关键的点,如果前端和服务端的dom不同,则会水合失败,执行渲染流程。

在服务端设计的部分,我们实现了通用的AppProviders,在这里就派上用处了。

import AppProviders from "../context/AppProviders.jsx";

前端运行时沿用这个组件。

并且将window.__INITIAL_DATA__继续作为上下文透传到前端所有组件中。

这样既保持了组件双端统一性。

也保证了数据统一性(框架数据从后端流到了前端)。

2.5 打包 -> 运行 -> 验证

至此框架的所有代码都编写完了。

我们跑下框架。

npm run build
npm run start

image.png

先后成功打包了服务端代码和前端代码。

最后把ssr服务跑起来了,运行在3000端口。

我们访问下localhost:3000

image.png

请求直接返回了首屏DOM元素。

有动态数据直接渲染。

ssr client运行时脚本执行。

符合预期。

我们再测试下应用是否可以正常用,点击Link执行下路由跳转。

image.png

可以看到About组件的动态数据没有渲染,原因很简单。

因为目前的设计是首屏的服务端组件,会在express执行getServerData注入动态数据。

而后续跳转时,组件没有在服务端执行,这时候就需要在前端执行一遍了。

怎么设计呢?

我们在框架层前端runtime加一段逻辑即可。

给非双屏的<Route />包装一层,如果是次屏组件,则请求一次数据再传入就行。

就像这样:

import React, { useEffect, useState } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { Routes, Route, useLocation } from 'react-router-dom';
import AppProviders from '../context/AppProviders.jsx';
import routes from '../routes/routes.js';

// 页面容器组件:处理首次加载的数据和路由切换时的数据获取
function DataLoader({ route }) {
  const location = useLocation();
  const [data, setData] = useState(() => window.__INITIAL_DATA__?.routeData);

  useEffect(() => {
    let active = true;

    async function fetchData() {
      const Component = route.element.type;
      if (Component.getServerData) {
        const newData = await Component.getServerData();
        if (active) {
          setData(newData);
        }
      }
    }

    // 首屏不请求(数据由 SSR 注入),后续路由切换才请求
    if (location.pathname !== window.__ROUTE_PATH__) {
      fetchData();
    }

    return () => { active = false; };
  }, [location.pathname, route.element.type]);

  const ElementWithData = React.cloneElement(route.element, { data });
  return ElementWithData;
}

function RootApp({ appContext }) {
  return (
    <Routes>
      {routes.map((route, idx) => (
        <Route
          key={idx}
          path={route.path}
          element={<DataLoader route={route} />}
        />
      ))}
    </Routes>
  );
}

export default function run() {
  const appContext = window.__INITIAL_DATA__ || { routeData: {} };

  const element = (
    <AppProviders appContext={appContext} isServer={false}>
      <RootApp appContext={appContext} />
    </AppProviders>
  );

  hydrateRoot(document.getElementById('root'), element);
}

这样一个可用、具备基础功能的SSR就完成了。

三、结尾

至此,从0~1手写一个ssr框架,不就搞定了么?

基于这个思路再去看Next.jsIce.js,你会发现实现原理都很类似。

都是服务端渲染 -> 前端水合,结合双端运行时代码和约定式路由。

为什么现代ssr框架这么热门?

因为react支持了水合,让更完美的渲染方案问世了,即首屏SSR+前端接管

如果你以前对于ssr的理解只停留在后端返回页面,页面跳转不好处理的阶段。

那对你的帮助应该很大!

如何设计一个架构良好的前端请求库?

一、写在前面(抛出思考题)

过去的时间,你刷遍了面试题,在公司中工作了很多年,基于axios二次封装单项目级别的请求文件手到擒来。

你有没有想过?你是一个前端团队的资深老人,随着公司业务不断发展,各种各样的前端项目用着不同的请求技术方案。

A项目比较老,用的xhr

B项目用了axios,拥抱开源;

C项目因为小王拥抱原生的理念,使用了fetch

现在团队需要技术标准化,统一前端请求方案,将所有的请求集成到一个包里,如何设计技术方案,可以让这个包很健壮,未来很好维护?

这其实和大公司业务面广的背景很相似,比如美团外卖业务,在微信小程序端,你需要使用wx.request;在其他小程序,你需要使用不同的DSL去请求;在H5,你需要用到fetch/xhr/axios;在APP,你需要用到端上bridge能力,发出请求是计算机网络统一标准成熟的事情,如何基于不同的客户端环境来解耦并集成统一的前端解决方案?

二、方案设计

比较快速地实现是拆两层,直接在请求库中判断传入的配置,如axiosfetchxhr,去执行不同的函数。

image.png

这样很简单,也能实现,但是会有很严重的问题:

“请求”这件事不变的点和变的点,耦合在了一起,比如埋点、异常上报、拦截器等共性的功能全部都耦合在了请求库本身中,随着接入能力越来越多,代码会变得更加混乱。

那不耦合在一起呢?放在对应的执行请求文件里?重复代码会很多,相同的“事情”会在多个文件中重复多次。

那有什么比较好的方案呢?

有,开源项目中比较多的技术方案,如axios、umi、openai,通常会引入client请求器+adapter适配器概念,什么意思呢?

  1. 首先整个请求库需要设定标准统一的入参和出参,如入参就是url、options.....,出参就是success、data、code......
  2. client负责处理所有的通用逻辑,如埋点、异常上报、拦截器;
  3. adapter负责保存特定的请求能力,如wx.request、fetch、xhr,并且基于透传过来的标准化入参先做一层transform,比如微信小程序的入参命名叫params,那这里就需要将标准化的入参转成这个命名,响应也同理,将不同请求方案返回的结构体标准化处理再回传给client,最后client进行埋点、响应拦截、异常上报等处理,再透传给业务侧;

用图来说即为三层架构:

image.png

一句话总结:

在用户和发出请求之间增加一层适配层,负责对用户定制一套标准统一的入参类型、一套统一标准的出参类型,但只需要这一套参数类型标准即可满足所有请求方案。

而前面提到的“耦合”问题,直接在client层执行即可,因此client层做的是 执行请求 + 执行通用事件;adapter层做的是参数转换 + 实际请求逻辑处理。

说到这里光看两张图的前后对比,可能感知没那么明显,接下来我基于xhr + fetch适配器+client来手写一个请求库感受一下这个架构设计吧。

三、代码实现

3.1、类型设计

入参标准化:

{
  url: string,
  method: 'GET' | 'POST' | ...,
  headers: Record<string,string>,
  body?: any, // 自动 JSON.stringify 或 FormData 等
}

出参标准化:

{
  status: number,
  statusText: string,
  headers: Record<string,string>,
  data: any, // 已解析完成的响应数据
  rawResponse: any // 原始 Response 或 XHR 对象
}

这样在用户调用层 -> 请求库Client层核心的类型就设计好了。

3.2、核心代码设计

// Adapter 抽象基类
class Adapter {
  request(config) {
    throw new Error("Adapter.request must be implemented");
  }

  // 入参标准化(可在基类内定义通用处理)
  normalizeConfig(config) {
    return {
      url: config.url,
      method: (config.method || 'GET').toUpperCase(),
      headers: config.headers || {},
      body: config.body || null
    };
  }

  // 出参标准化
  normalizeResponse({ status, statusText, headers, data, rawResponse }) {
    return {
      status,
      statusText,
      headers,
      data,
      rawResponse
    };
  }
}

// Fetch Adapter
class FetchAdapter extends Adapter {
  async request(config) {
    const cfg = this.normalizeConfig(config);

    const response = await fetch(cfg.url, {
      method: cfg.method,
      headers: cfg.headers,
      body: cfg.body ? JSON.stringify(cfg.body) : null
    });

    // 解析 headers
    const headersObj = {};
    response.headers.forEach((v, k) => { headersObj[k] = v; });

    // 自动解析 json / text
    let data;
    const contentType = response.headers.get('content-type') || '';
    if (contentType.includes('application/json')) {
      data = await response.json();
    } else {
      data = await response.text();
    }

    return this.normalizeResponse({
      status: response.status,
      statusText: response.statusText,
      headers: headersObj,
      data,
      rawResponse: response
    });
  }
}

// XHR Adapter
class XHRAdapter extends Adapter {
  request(config) {
    const cfg = this.normalizeConfig(config);

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open(cfg.method, cfg.url, true);

      // 设置 headers
      Object.entries(cfg.headers).forEach(([key, value]) => {
        xhr.setRequestHeader(key, value);
      });

      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          const headersObj = this.parseHeaders(xhr.getAllResponseHeaders());
          let data;
          try {
            const contentType = headersObj['content-type'] || '';
            if (contentType.includes('application/json')) {
              data = JSON.parse(xhr.responseText);
            } else {
              data = xhr.responseText;
            }
          } catch (err) {
            reject(err);
            return;
          }

          resolve(this.normalizeResponse({
            status: xhr.status,
            statusText: xhr.statusText,
            headers: headersObj,
            data,
            rawResponse: xhr
          }));
        }
      };

      xhr.onerror = () => {
        reject(new Error('XHR network error'));
      };

      xhr.send(cfg.body ? JSON.stringify(cfg.body) : null);
    });
  }

  // 辅助方法解析 headers 字符串
  parseHeaders(rawHeaders) {
    const headers = {};
    rawHeaders.trim().split(/[\r\n]+/).forEach(line => {
      const parts = line.split(': ');
      const header = parts.shift();
      const value = parts.join(': ');
      if (header) headers[header.toLowerCase()] = value;
    });
    return headers;
  }
}

// Client 类
class Client {
  constructor(adapter, options = {}) {
    this.adapter = adapter;
    this.baseURL = options.baseURL || '';
    this.defaultHeaders = options.defaultHeaders || {};
  }

  async request(config) {
    try {
      const finalConfig = {
        ...config,
        url: this.baseURL + config.url,
        headers: { ...this.defaultHeaders, ...(config.headers || {}) }
      };
      console.log(`-----请求开始${finalConfig}-----`);
      const res = await this.adapter.request(finalConfig);
      console.log(`-----请求成功${res}-----`);
    } catch(e) {
      console.log(`-----请求失败${e}-----`);
      throw(e);
    }
  }

  get(url, headers) {
    return this.request({ url, method: 'GET', headers });
  }

  post(url, body, headers) {
    return this.request({ url, method: 'POST', body, headers });
  }
}

// 使用示例
const fetchClient = new Client(new FetchAdapter(), {
  baseURL: 'https://jsonplaceholder.typicode.com',
  defaultHeaders: { 'Content-Type': 'application/json' }
});

const xhrClient = new Client(new XHRAdapter(), {
  baseURL: 'https://jsonplaceholder.typicode.com',
  defaultHeaders: { 'Content-Type': 'application/json' }
});

fetchClient.get('/todos/1').then(res => console.log('FetchAdapter normalized:', res));
xhrClient.get('/todos/2').then(res => console.log('XHRAdapter normalized:', res));

162行代码,核心的代码就实现完毕了(实际开发可以拆分文件处理)

主要干了这几件事情:

  1. 定义BaseAdapter适配器抽象类,负责初始化请求、入参统一、出参统一三个核心能力;
  2. 定义XHRAdapterFetchAdapter,实现各自的请求函数,基于原生不同的参数标准化处理响应结构,返回给Client层;
  3. 定义Client请求实例,调用传入的Adapter,透传对应配置,中转请求发出和响应;

这样 在每个 Adapter 内部,对 入参 和 出参 做一层 标准化处理,这样无论使用什么请求方式,Client 都能拿到统一结构的返回值,同时向 Adapter 传入的 config 也会有统一的字段,这也是很多通用库(axios 等)会做的事。

结尾

基于这个思路和模板,你非常容易的扩展很多能力,当你的公司业务需要接入京东小程序,请求相关的迭代很简单,新建一个适配器,把京东相关的DSL开发一下,把请求库更新个版本就搞定了。

请求监控、稳定性,你都不用管,因为这些早就成熟稳定地在多个项目跑了很久了。(自从你的请求库投产后)。

很多开源项目都是基于这个思路来工作的,包括你最熟悉的axios

如果这篇文章对你有帮助,欢迎和我一起讨论。

❌