找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3296

积分

1

好友

453

主题
发表于 前天 20:13 | 查看: 2| 回复: 0

SSRCSR,这两个词对于前端开发者来说早已耳熟能详。它们的本质区别究竟在哪里?简单来说:

  • SSR 由服务端直接生成并返回包含首屏内容的 HTML。
  • CSR 由服务端返回一个空的根节点(如 root),浏览器需要先解析 JavaScript 再动态填充内容。

这其中的关键差异导致了渲染时间的不同:SSR 无需等待浏览器解析和执行 JS,在一次 HTTP 请求中即可获得可渲染的 HTML。

那么,业界那些优秀的、支持 SSR 的开源框架是如何实现这一机制的呢?本文将以阿里巴巴开源的 ice.js 框架为例,进行源码级的剖析,带你理解 SSR 的实现原理,以及与 CSR 的异同。

一、SSR 的核心实现

SSR 必不可少的一个环节,就是需要一台能够 “解析 SSR 脚本” 的服务器。相比之下,CSR 和 SSG 只需要静态托管即可——我们每次构建后生成的 HTML 文件是固定的,直接放在托管站点供访问就行。

那么,在 ice.js 中,解析 SSR 脚本 这件事具体做了哪些工作呢?我们可以先进行一个大致的构思:

  1. 应该会有一个 expresskoa 服务,用于接收所有请求,并根据请求的路由返回对应的组件。
  2. 应该包含一些处理 服务端 React 组件 的代码,很可能会基于 react-dom/server
  3. 最终会以 HTTP 响应或流式渲染的形式将结果返回给前端。

这大概是我们能想到的主要部分。现在,让我们直接去源码中寻找答案。

这些能力本质上属于 运行时(runtime) 范畴。在 /runtime/runServerApp.tsx 中,我找到了处理服务端渲染的核心部分:

ice.js SSR运行时核心代码结构

一个名为 renderToResponse 的函数直接映入眼帘,顾名思义,它就是负责“渲染并响应”的核心函数。

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. 调用 doRender 进行 前端组件到 HTML 的转换
  3. 根据结果类型,直接进行 HTTP 响应或流式响应。
  4. 如果渲染过程出现异常,则 降级到 CSR 渲染,返回空节点。

至此,我们之前的几个猜想都得到了印证。接下来,让我们深入看一下 doRender 函数是如何工作的。

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 });
  }
}

doRender 函数主要完成了以下几件关键事情:

  1. 解析请求与基础配置

    const location = getLocation(req.url);
    const requestContext = getRequestContext(location, serverContext);
    const appConfig = getAppConfig(app);
  2. 初始化应用上下文与运行时上下文(框架层面的设计)

    const appContext: AppContext = {
    appExport: app,
    routes,
    appConfig,
    appData,
    routesData: null,
    routesConfig: null,
    assetsManifest,
    basename,
    matches: [],
    };
    const runtime = new Runtime(appContext, runtimeOptions);
  3. Hash 路由模式直接返回 HTML(因为不支持 SSR)

    if (appConfig?.router?.type === ’hash‘) {
    return renderDocument({ matches: [], renderOptions });
    }
  4. 进行路由匹配,支持默认 404

    const matches = matchRoutes(routes, location, serverOnlyBasename || basename);
    if (!matches.length) {
    return render404();
    }
  5. 执行真正的 SSR 渲染

    return await renderServerEntry({
    runtime,
    matches,
    location,
    renderOptions,
    });

这里实际上还处理了大量框架层面的 “渲染前准备工作”,例如初始化运行时、记录 App 全局状态等,主要用于提供框架对外暴露的生命周期钩子函数。这些与 SSR 的核心渲染逻辑关系不大,我们暂且略过。

那么,最关键的一步 renderServerEntry 是如何执行渲染的呢?

/**
 * 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,
    },
  };
}

renderServerEntry 函数是 SSR 核心链路的最后一步,直接触达用户响应。它主要完成了以下几件事:

  1. 聚合上下文:将 doRender 初始化和准备的 runtimeAppDataAppContext 等全局上下文,全部注入到 React 应用的根 Provider 中,以便业务组件消费。

    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();
  2. 准备根节点 App:创建包含基础路由能力、严格模式、全局异常捕获等功能的根组件。

    const documentContext = {
    main: <App
    action={Action.Pop}
    location={location}
    navigator={staticNavigator}
    static
    RouteWrappers={RouteWrappers}
    AppRouter={AppRouter}
    />,
    };
  3. 组装完整的 React 元素树

    const element = (
    <AppDataProvider value={appData}>
    <AppRuntimeProvider>
      <AppContextProvider value={appContext}>
        <DocumentContextProvider value={documentContext}>
          <Document pagePath={routePath} />
        </DocumentContextProvider>
      </AppContextProvider>
    </AppRuntimeProvider>
    </AppDataProvider>
    );
  4. 执行流式渲染(基于 react-dom/serverrenderToPipeableStream API)

    const pipe = renderToNodeStream(element, false);

让我们看看 renderToNodeStream 的源码实现:

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,一次完整的服务端渲染流程便结束了。

/**
 * 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.js 是如何基于 webpack dev server 来实现 SSR 的呢?

锁定到 /ice/src/commands 目录,这里包含了框架的各种命令行逻辑。找到 start 命令相关的代码:

ice.js开发服务器SSR中间件配置

秘密就在这里!框架巧妙地借用了 dev server 的中间件机制,在开发服务器中拦截请求,直接触发 SSR 渲染链路,而无需启动一个独立的后端服务。

我们来细看一下 createRenderMiddleware 这个核心中间件函数:

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,
  };
}

这个中间件的核心逻辑与生产环境的 SSR 主渲染函数并无本质区别。其巧妙之处在于:它基于 appConfig 配置(判断项目是否启用了 SSR 模式),在 webpack dev server 的处理链路中插入了一层拦截。

如果是 SSR 模式,则动态引入服务端入口模块,直接走上面分析过的 renderToResponse 渲染链路;如果不是 SSR 模式,则放行请求,走常规的 webpack 构建与静态资源服务链路。这种设计保证了开发体验与生产行为的一致性,同时也充分利用了现有构建工具链。

三、总结

通过深入 ice.js 的源码,我们可以清晰地看到一个现代 React SSR 框架的核心实现路径:从接收 HTTP 请求、匹配路由、准备数据、组装 React 组件树,到最终利用 react-dom/server 进行流式渲染或字符串渲染,并妥善处理降级逻辑。

同时,其在开发阶段通过中间件巧妙集成 webpack dev server 的设计,也展示了框架如何平衡开发效率与功能完整性。这种架构模式在 Node.js 运行时上提供了强大的服务端渲染能力,是现代前端框架实现 SEO 友好、首屏性能优化的关键技术基石。希望本次源码剖析能帮助你更深入地理解 SSR 的工作原理。如果你对更多前端框架的底层实现感兴趣,欢迎在云栈社区继续交流探讨。




上一篇:避免Bank冲突:CUDA Shared Memory Swizzle技术原理与实战优化指南
下一篇:抓住OpenClaw更名SEO真空期:我的低成本快速执行实战心得
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-9 00:52 , Processed in 0.302151 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表