开源的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的原理。

相关推荐
前端架构师-老李4 小时前
npm、yarn、pnpm的对比和优略
前端·npm·node.js·pnpm·yarn
fox_4 小时前
别再混淆 call/apply/bind 了!一篇讲透用法、场景与手写逻辑(二)
前端·javascript
潜心编码5 小时前
基于vue的停车场管理系统
前端·javascript·vue.js
神奇的小猴程序员5 小时前
Mutantcat Web Pdf Reader —— 开源、轻量、安全的网页 PDF 阅读器
前端·pdf
三小河5 小时前
React Vite 中动态批量导入路由
前端·vue.js
Qinana5 小时前
📚 论如何用代码谈一场不露脸的恋爱
前端·前端框架·html
自由的疯5 小时前
Java 如何学习 Jenkins?
java·架构
自由的疯5 小时前
Java ‌认识Docker
java·架构
Forfun_tt5 小时前
xss-labs pass-10
java·前端·xss