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

一、前言

SSRCSR是每一个前端开发者耳闻熟知的词。

两者本质区别:

  • SSR由服务端直接返回首屏内容(html)给前端;
  • CSR由服务端返回空根节点(root),浏览器解析JS再填充内容(html);

两者的渲染时间差也差在其中,SSR不需要解析完JS再渲染,等一次请求就行。

那业界优秀的支持SSR的框架都是怎么实现的呢?

本文以Alibaba开源框架ice.js来举例,源码级剖析SSR是如何实现的、与CSR的共性、不同点在哪里。

二、SSR的实现

SSR必备的就是一台"解析SSR脚本"的服务器。

而CSR只需要静态托管即可,我们每次发布后所生成的html文件是固定的,直接托管在站点访问即可(SSG也是如此)。

解析SSR脚本 这件事,在ice里主要做了哪些呢?

我们先大概猜想构思下:

  1. 可能有一个express/koa服务,用于接收所有的请求,基于请求路由来返回路由组件;
  2. 可能有一些处理服务端React组件的代码,应该会基于react-dom/server
  3. 最后会以http或者流式渲染的形式返回给前端;

能想到的主要是这些,那我们直接去扒源码吧。

这些能力本质应该是属于运行时runtime,因此在/runtime/runServerApp.tsx中我找到了比较核心专门处理服务端渲染的部分内容:

这个renderToResponse直接映入眼帘,非常言简意赅,就是处理服务端渲染 -> 响应的函数。

tsx 复制代码
export async function renderToResponse(requestContext: ServerContext, renderOptions: RenderOptions) {
  const { res } = requestContext;
  const result = await doRender(requestContext, renderOptions);

  const { value } = result;

  if (typeof value === 'string') {
    sendResult(res, result);
  } else {
    const { pipe, fallback } = value;

    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html; charset=utf-8');

    try {
      await pipeToResponse(res, pipe);
    } catch (error) {
      if (renderOptions.disableFallback) {
        throw error;
      }
      console.error('PiperToResponse error, downgrade to CSR.', error);
      // downgrade to CSR.
      const result = await fallback();
      sendResult(res, result);
    }
  }
}

核心是做了这些事情:

  1. 读取网络response对象;
  2. 处理前端组件 -> html的转换;
  3. 直接进行http响应或者流式响应;
  4. 出现异常降级到CSR渲染,返回空节点;

一下子我们的多个猜想都石锤了。网络请求响应(http/steam)都找到了。

再看下doRender是如何处理的。

tsx 复制代码
async function doRender(serverContext: ServerContext, renderOptions: RenderOptions): Promise<RenderResult> {
  const { req } = serverContext;
  const {
    app,
    basename,
    serverOnlyBasename,
    routes,
    documentOnly,
    disableFallback,
    assetsManifest,
    runtimeModules,
    renderMode,
    runtimeOptions,
  } = renderOptions;

  const location = getLocation(req.url);

  const requestContext = getRequestContext(location, serverContext);
  const appConfig = getAppConfig(app);
  let appData: any;
  const appContext: AppContext = {
    appExport: app,
    routes,
    appConfig,
    appData,
    routesData: null,
    routesConfig: null,
    assetsManifest,
    basename,
    matches: [],
  };
  const runtime = new Runtime(appContext, runtimeOptions);
  runtime.setAppRouter(DefaultAppRouter);
  // Load static module before getAppData.
  if (runtimeModules.statics) {
    await Promise.all(runtimeModules.statics.map(m => runtime.loadModule(m)).filter(Boolean));
  }
  // don't need to execute getAppData in CSR
  if (!documentOnly) {
    try {
      appData = await getAppData(app, requestContext);
    } catch (err) {
      console.error('Error: get app data error when SSR.', err);
    }
  }
  // HashRouter loads route modules by the CSR.
  if (appConfig?.router?.type === 'hash') {
    return renderDocument({ matches: [], renderOptions });
  }

  const matches = matchRoutes(routes, location, serverOnlyBasename || basename);
  if (!matches.length) {
    return render404();
  }

  const routePath = getCurrentRoutePath(matches);

  if (documentOnly) {
    return renderDocument({ matches, routePath, renderOptions });
  }
  try {
    const routeModules = await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
    const routesData = await loadRoutesData(matches, requestContext, routeModules, renderMode);
    const routesConfig = getRoutesConfig(matches, routesData, routeModules);
    runtime.setAppContext({ ...appContext, routeModules, routesData, routesConfig, routePath, matches, appData });
    if (runtimeModules.commons) {
      await Promise.all(runtimeModules.commons.map(m => runtime.loadModule(m)).filter(Boolean));
    }
    return await renderServerEntry({
      runtime,
      matches,
      location,
      renderOptions,
    });
  } catch (err) {
    if (disableFallback) {
      throw err;
    }
    console.error('Warning: render server entry error, downgrade to csr.', err);
    return renderDocument({ matches, routePath, renderOptions, downgrade: true });
  }
}

核心做了这几件事:

  1. 解析请求体、基础配置
ts 复制代码
  const location = getLocation(req.url);
  const requestContext = getRequestContext(location, serverContext);
  const appConfig = getAppConfig(app);
  1. 初始化App上下文+运行时上下文(框架层面设计)
ts 复制代码
  const appContext: AppContext = {
    appExport: app,
    routes,
    appConfig,
    appData,
    routesData: null,
    routesConfig: null,
    assetsManifest,
    basename,
    matches: [],
  };
  const runtime = new Runtime(appContext, runtimeOptions);
  1. hash模式直接返回html,因为不支持ssr
ts 复制代码
  if (appConfig?.router?.type === 'hash') {
    return renderDocument({ matches: [], renderOptions });
  }
  1. 路由匹配,支持默认404
ts 复制代码
  const matches = matchRoutes(routes, location, serverOnlyBasename || basename);
  if (!matches.length) {
    return render404();
  }
  1. 执行SSR真实渲染
ts 复制代码
return await renderServerEntry({
  runtime,
  matches,
  location,
  renderOptions,
});

OK,这里实际上还处理框架层面的渲染前准备工作 ,做了一些框架上的运行时、App全局处理记录,可能是用于提供框架对外透出的生命周期钩子函数,与SSR本身关系不大,先不管。

我们看下renderServerEntry是如何执行渲染的?

tsx 复制代码
/**
 * Render App by SSR.
 */
async function renderServerEntry(
  {
    runtime,
    matches,
    location,
    renderOptions,
  }: RenderServerEntry,
): Promise<RenderResult> {
  const { Document } = renderOptions;
  const appContext = runtime.getAppContext();
  const { appData, routePath } = appContext;
  const staticNavigator = createStaticNavigator();
  const AppRuntimeProvider = runtime.composeAppProvider() || React.Fragment;
  const RouteWrappers = runtime.getWrappers();
  const AppRouter = runtime.getAppRouter();

  const documentContext = {
    main: <App
      action={Action.Pop}
      location={location}
      navigator={staticNavigator}
      static
      RouteWrappers={RouteWrappers}
      AppRouter={AppRouter}
    />,
  };

  const element = (
    <AppDataProvider value={appData}>
      <AppRuntimeProvider>
        <AppContextProvider value={appContext}>
          <DocumentContextProvider value={documentContext}>
            <Document pagePath={routePath} />
          </DocumentContextProvider>
        </AppContextProvider>
      </AppRuntimeProvider>
    </AppDataProvider>
  );

  const pipe = renderToNodeStream(element, false);

  const fallback = () => {
    return renderDocument({ matches, routePath, renderOptions, downgrade: true });
  };

  return {
    value: {
      pipe,
      fallback,
    },
  };
}

这个函数实际上也是SSR核心的最后一步,触达到用户响应。

核心做了这几件事情:

  1. doRender所初始化、准备的runTimeAppDataAppContext等全局上下文,全部聚合在React应用根节点,用户可以基于透传的数据在业务组件中消费。
tsx 复制代码
  const { Document } = renderOptions;
  const appContext = runtime.getAppContext();
  const { appData, routePath } = appContext;
  const staticNavigator = createStaticNavigator();
  const AppRuntimeProvider = runtime.composeAppProvider() || React.Fragment;
  const RouteWrappers = runtime.getWrappers();
  const AppRouter = runtime.getAppRouter();
  1. 准备根节点App,包括最基本的React严格模式、全局异常捕获,提供路由能力。
tsx 复制代码
const documentContext = {
  main: <App
    action={Action.Pop}
    location={location}
    navigator={staticNavigator}
    static
    RouteWrappers={RouteWrappers}
    AppRouter={AppRouter}
  />,
};
  1. 组件整颗html树
tsx 复制代码
const element = (
  <AppDataProvider value={appData}>
    <AppRuntimeProvider>
      <AppContextProvider value={appContext}>
        <DocumentContextProvider value={documentContext}>
          <Document pagePath={routePath} />
        </DocumentContextProvider>
      </AppContextProvider>
    </AppRuntimeProvider>
  </AppDataProvider>
);
  1. 流式渲染(基于react/dom renderToPipeableStream API)
tsx 复制代码
const pipe = renderToNodeStream(element, false);

renderToNodeStream源码:

tsx 复制代码
import * as Stream from 'stream';
import type * as StreamType from 'stream';
import * as ReactDOMServer from 'react-dom/server';

const { Writable } = Stream;

export type NodeWritablePiper = (
  res: StreamType.Writable,
  next?: (err?: Error) => void
) => void;

export function renderToNodeStream(
  element: React.ReactElement,
  generateStaticHTML: boolean,
): NodeWritablePiper {
  return (res, next) => {
    const { pipe } = ReactDOMServer.renderToPipeableStream(
      element,
      {
        onShellReady() {
          if (!generateStaticHTML) {
            pipe(res);
          }
        },
        onAllReady() {
          if (generateStaticHTML) {
            pipe(res);
          }
          next();
        },
        onError(error: Error) {
          next(error);
        },
      },
    );
  };
}

至此,最后到入口函数renderToResponse,一次完整的服务端渲染结束。

ts 复制代码
/**
 * Render and send the result to ServerResponse.
 */
export async function renderToResponse(requestContext: ServerContext, renderOptions: RenderOptions) {
  const { res } = requestContext;
  const result = await doRender(requestContext, renderOptions);

  const { value } = result;

  if (typeof value === 'string') {
    sendResult(res, result);
  } else {
    const { pipe, fallback } = value;

    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html; charset=utf-8');

    try {
      await pipeToResponse(res, pipe);
    } catch (error) {
      if (renderOptions.disableFallback) {
        throw error;
      }
      console.error('PiperToResponse error, downgrade to CSR.', error);
      // downgrade to CSR.
      const result = await fallback();
      sendResult(res, result);
    }
  }
}

三、开发阶段怎么实现?

聊到另一个很巧妙的点,我们都知道SSR依托于服务,在开发阶段,ice是如何基于webpack dev server来实现SSR的?

锁定到/ice/src/commands文件,这里包含了命令行。

找到start命令:

一眼就发现了秘密,这里核心是借用了dev server的中间件,拦截构建直接触发渲染。

我们细看一下createRenderMiddleware函数:

tsx 复制代码
export default function createRenderMiddleware(options: Options): Middleware {
  const {
    documentOnly,
    renderMode,
    serverCompileTask,
    routeManifestPath,
    getAppConfig,
    taskConfig,
    userConfig,
  } = options;
  const middleware: ExpressRequestHandler = async function (req, res, next) {
    const routes = JSON.parse(fse.readFileSync(routeManifestPath, 'utf-8'));
    const appConfig = (await getAppConfig()).default;
    if (appConfig?.router?.type === 'hash') {
      warnOnHashRouterEnabled(userConfig);
    }
    const basename = getRouterBasename(taskConfig, appConfig);
    const matches = matchRoutes(routes, req.path, basename);
    // When documentOnly is true, it means that the app is CSR and it should return the html.
    if (matches.length || documentOnly) {
      // Wait for the server compilation to finish
      const { serverEntry, error } = await serverCompileTask.get();
      if (error) {
        consola.error('Server compile error in render middleware.');
        return;
      }
      let serverModule;
      try {
        delete require.cache[serverEntry];
        serverModule = await dynamicImport(serverEntry, true);
      } catch (err) {
        // make error clearly, notice typeof err === 'string'
        consola.error(`import ${serverEntry} error: ${err}`);
        return;
      }
      const requestContext: ServerContext = {
        req,
        res,
      };
      serverModule.renderToResponse(requestContext, {
        renderMode,
        documentOnly,
      });
    } else {
      next();
    }
  };

  return {
    name: 'server-render',
    middleware,
  };
}

核心和服务端主渲染函数没什么区别。

比较巧妙的点是基于appConfig配置(判断是否是ssr模式),在dev server加了一层,如果是ssr,则动态引入服务端渲染函数,直接走渲染链路(即上节链路),否则就走常规webpack构建链路。

四、结尾

希望文章让你有所帮助和收获,让你更加了解SSR的原理。

相关推荐
LabVIEW开发1 天前
LabVIEW QMH 队列消息处理架构
架构·labview·labview知识·labview功能·labview程序
代码搬运媛1 天前
Jest 测试框架详解与实现指南
前端
counterxing1 天前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
wangqiaowq1 天前
windows下nginx的安装
linux·服务器·前端
rising start1 天前
二、全面理解MySQL架构
mysql·架构
之歆1 天前
DAY_12JavaScript DOM 完全指南(二):实战与性能篇
开发语言·前端·javascript·ecmascript
发现一只大呆瓜1 天前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite
麦客奥德彪1 天前
Android Skills
架构·ai编程
Maimai108081 天前
React如何用 @microsoft/fetch-event-source 落地 SSE:比原生 EventSource 更灵活的实时推送方案
前端·javascript·react.js·microsoft·前端框架·reactjs·webassembly
candyTong1 天前
Claude Code 的 Edit 工具是怎么工作的
javascript·后端·架构