# 简介

在单页面应用程序(SPA)的上下文中,服务器端呈现(SSR)是指从Web服务器发送到浏览器的HTML页面的动态生成。在单页面应用程序中,服务器仅生成用户请求的第一页,而所有后续页面都将由浏览器呈现。

为了完成SPA的服务器端渲染,在NodeJS中执行javascript代码以生成初始HTML。在浏览器中,在“添加(注水)”过程中执行相同的javascript代码,该过程将事件侦听器附加到HTML。大多数流行的UI框架(Vue,React,Angular等)都可以在NodeJS和浏览器中执行,并提供API来生成服务器HTML并在浏览器中进行混合。此外,还有一些流行的框架(如NextJS和Nuxt)可简化开发人员对服务器端呈现的体验。

在微前端的上下文中,服务器端渲染是指从多个单独的微前端组装HTML。每个微前端控制从Web服务器发送到浏览器的HTML片段,并在浏览器中初始化它们后将其片段合并。

# 目的

服务器端渲染的主要目的是提高性能。服务器渲染的页面通常比静态页面更快地向用户显示其内容,因为在初始化javascript资源之前向用户显示了内容。SSR的其他原因包括改进的搜索引擎优化(SEO)。

服务器渲染的应用程序通常更难构建和维护,因为代码必须同时在客户端和服务器上运行。此外,SSR通常会使运行您的应用程序所需的基础架构复杂化,因为许多SPA + SSR解决方案都需要NodeJS,而纯客户端SPA的生产中并不需要NodeJS。

# 示例

isomorphic-microfrontends (opens new window) 例子显示React服务器呈现的微前端。您可以在isomorphic.microfrontends.app (opens new window)上查看代码的实时演示。

# 实施概述

服务器端呈现的最终目标是生成HTTP响应,当javascript运行时,浏览器将向用户显示该响应。大多数微前端服务器端渲染实现(包括single-spa推荐的方法)都是通过以下步骤实现的:

  • 布局-标识要为传入的HTTP请求呈现的微前端,以及将它们放置在HTML中的位置。这通常是基于路由的。
  • fetch-开始将每个微前端的HTML呈现到流中。
  • headers -从每个微前端检索HTTP响应标头。将它们合并在一起,然后将结果作为HTTP响应标头发送到浏览器。
  • body-将HTTP响应正文发送到浏览器,该浏览器是由静态和动态部分组成的HTML文档。这涉及等待每个微前端的流结束,然后再继续进行HTML的下一部分。
  • 添加(注水)-在浏览器中,下载所有需要的JavaScript,然后添加(注水)HTML。

# 1.布局

要定义用于布局页面的HTML模板,请首先选择“微前端布局中间件”:

  • single-spa-layout:单spa的官方布局引擎。
  • Tailor (opens new window):一种流行的,经过测试的布局引擎,早于 single-spa-layout ,并且未正式与 single-spa 关联。
  • TailorX:一个主动维护的Tailor分支,Namecheap在其 single-spa 网站中使用它。在编写 single-spa-layout 时,single-spa 核心团队与TailorX的创建者合作,从中汲取了一些灵感。

我们通常建议使用 single-spa-layout,尽管选择其他选项之一可能会适合您的情况,因为单spa布局较新且使用量少于Tailor / TailorX。

使用 single-spa-layout,您可以定义一个处理所有路由的模板。完整的文档。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Isomorphic Microfrontends</title>
    <meta
      name="importmap-type"
      content="systemjs-importmap"
      server-cookie
      server-only
    />
    <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.0.0/dist/import-map-overrides.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/systemjs@6.6.1/dist/system.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/systemjs@6.6.1/dist/extras/amd.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/systemjs@6.6.1/dist/extras/named-exports.min.js"></script>
  </head>
  <body>
    <template id="single-spa-layout">
      <single-spa-router>
        <nav>
          <application name="@org-name/navbar"></application>
        </nav>
        <main>
          <route path="settings">
            <application name="@org-name/settings"></application>
          </route>
          <route path="home">
            <application name="@org-name/home"></application>
          </route>
        </main>
      </single-spa-router>
    </template>
    <fragment name="importmap"></fragment>
    <script>
      System.import("@org-name/root-config");
    </script>
    <import-map-overrides-full
      show-when-local-storage="devtools"
      dev-libs
    ></import-map-overrides-full>
  </body>
</html>

# 2.fetch

您的微前端布局中间件(请参阅“布局 (opens new window)”部分)确定哪些微前端与HTTP请求的路由匹配。然后,中间件为每个微前端获取HTTP响应标头和HTML内容。

使用 single-spa-layout 时,通过renderApplication提供给的功能来处理每个微前端renderServerResponseBody

提取标题和HTML内容的方法可能会有所不同,因为 single-spa-layout 允许任何任意的自定义提取方法。但是,实际上,有两种流行的方法,如下所述。通常,我们建议将动态模块加载作为主要方法,因为动态加载模块所需的基础设施较少,并且可以(稍微)有更好的性能。但是,HTTP请求也具有一些优点,并且还可以使用不同的提取方法来实现不同的微前端。

# A.模块加载

模块加载是指使用import和加载javascript代码import()。使用模块加载,获取每个微前端的标头和内容的实现完全在单个Web服务器和操作系统过程中完成:

import('@org-name/navbar/server.js').then(navbar => {
  const headers = navbar.getResponseHeaders(props);
  const htmlStream = navbar.serverRender(props);
})

在 single-spa-layout 的情况下,这是在renderApplication函数内部完成的:

import {
  constructServerLayout,
  sendLayoutHTTPResponse
} from "single-spa-layout/server";
import http from 'http';
const serverLayout = constructServerLayout({
  filePath: "server/views/index.html",
});
http.createServer((req, res) => {
  const { bodyStream } = sendLayoutHTTPResponse({
    res,
    serverLayout,
    urlPath: req.path,
    async renderApplication({ appName, propsPromise }) {
      const [app, props] = await Promise.all([
        import(`${props.name}/server.mjs`,
        propsPromise
      )])
      return app.serverRender(props);
    },
    async retrieveApplicationHeaders({ appName, propsPromise }) {
      const [app, props] = await Promise.all([
        import(`${props.name}/server.mjs`,
        propsPromise
      )])
      return app.getResponseHeaders(props);
    },
    async retrieveProp(propName) {
      return "prop value"
    },
    assembleFinalHeaders(appHeaders) {
      return Object.assign({}, ...Object.values(allHeaders).map(a => a.appHeaders));
    },
    renderFragment(name) {
      // not relevant to the docs here
    }
  });
  bodyStream.pipe(res);
}).listen(9000)

为了促进我们的微前端的独立部署,以使Web服务器不必在每次更新每个微前端时都重新启动/重新部署,我们可以使用动态模块加载。动态模块加载是指从动态位置加载模块-通常是从磁盘上的某个位置或通过网络加载。默认情况下,NodeJS仅从相对URL或node_modules目录中加载模块,但是动态模块加载允许您从任意文件路径或URL加载模块。

通过动态模块加载来促进独立部署的一种模式是,每个微前端的部署都将一个或多个javascript文件上传到受信任的CDN,然后使用动态模块加载在CDN上加载特定版本的代码。Web服务器将轮询每个微前端的新版本,并在部署时下载新版本。

为了完成动态模块加载,我们可以使用NodeJS模块加载器。具体来说,@node-loader/ import-maps@node-loader/http 允许我们控制模块的位置以及如何通过网络下载它。下面的代码说明了服务器端导入映射如何促进动态模块加载

在部署navbar之前:

{
  "imports": {
    "@org-name/navbar/": "https://cdn.example.com/navbar/v1/"
  }
}

部署navbar之后:

{
  "imports": {
    "@org-name/navbar/": "https://cdn.example.com/navbar/v2/"
  }
}

导入映射本身托管在CDN上,因此可以在不重新启动Web服务器的情况下进行部署。这里显示了此设置的示例。

# B. HTTP请求

还可以使用HTTP请求来实现从微前端获取HTML内容和HTTP标头。在此设置中,每个微前端必须作为已部署的Web服务器运行。根部Web服务器(负责响应浏览器)对每个微前端的Web服务器进行HTTP调用。每个微前端Web服务器都会以HTML页面作为响应正文以及其HTTP响应标头进行响应。响应主体将流式传输到根Web服务器,以便它可以将字节尽快发送到浏览器。

在 single-spa-layout 的情况下,这可以通过以下renderApplication功能完成:

import {
  constructServerLayout,
  sendLayoutHTTPResponse,
} from "single-spa-layout/server";
import http from 'http';
import fetch from 'node-fetch';
const serverLayout = constructServerLayout({
  filePath: "server/views/index.html",
});
http.createServer((req, res) => {
  const fetchPromises = {}
  sendLayoutHTTPResponse(serverLayout, {
    res,
    serverLayout,
    urlPath: req.path,
    async renderApplication({ appName, propsPromise }) {
      const props = await propsPromise
      const fetchPromise = fetchPromises[appName] || (fetchPromises[appName] = fetchMicrofrontend(props))
      const response = await fetchPromise;
      // r.body is a Readable stream when you use node-fetch,
      // which is best for performance when using single-spa-layout
      return response.body;
    },
    async retrieveApplicationHeaders({ appName, propsPromise }) {
      const props = await propsPromise
      const fetchPromise = fetchPromises[appName] || (fetchPromises[appName] = fetchMicrofrontend(props))
      const response = await fetchPromise;
      return response.headers;
    },
    async retrieveProp(propName) {
      return "prop value"
    },
    assembleFinalHeaders(allHeaders) {
      return Object.assign({}, ...Object.values(allHeaders))
    },
    renderFragment(name) {
      // not relevant to the docs here
    }
  });
  bodyStream.pipe(res);
}).listen(9000)
async function fetchMicrofrontend(props) {
  fetch(`http://${props.name}`, {
    headers: props
  }).then(r => {
    if (r.ok) {
      return r;
    } else {
      throw Error(`Received http response ${r.status} from microfrontend ${appName}`);
    }
  })
}

# 3. HTTP响应头

发送到浏览器的HTTP响应标头是默认标头和从每个微前端检索的 headers 的组合。您获取微前端的方法不会更改浏览器最终 headers 的合并和组装方式。

Tailor 和 TailorX 具有合并 headers 的内置方法。Single-spa-layout 允许通过以下assembleFinalHeaders 选项进行自定义合并:

import {
  constructServerLayout,
  sendLayoutHTTPResponse
} from "single-spa-layout/server";
import http from 'http';
const serverLayout = constructServerLayout({
  filePath: "server/views/index.html",
});
http.createServer((req, res) => {
  const { bodyStream } = sendLayoutHTTPResponse({
    res,
    serverLayout,
    urlPath: req.path,
    async renderApplication({ appName, propsPromise }) {
      const [app, props] = await Promise.all([
        import(`${props.name}/server.mjs`,
        propsPromise
      )])
      return app.serverRender(props);
    },
    async retrieveApplicationHeaders({ appName, propsPromise }) {
      const [app, props] = await Promise.all([
        import(`${props.name}/server.mjs`,
        propsPromise
      )])
      return app.getResponseHeaders(props);
    },
    async retrieveProp(propName) {
      return "prop value"
    },
    assembleFinalHeaders(allHeaders) {
      // appHeaders contains all the application names, props, and headers for
      return Object.assign({}, ...Object.values(allHeaders).map(a => a.appHeaders));
    },
    renderFragment(name) {
      // not relevant to the docs here
    }
  });
  bodyStream.pipe(res);
}).listen(9000)

# 4. HTTP响应主体

从Web服务器发送到浏览器的HTTP响应正文必须逐字节进行流处理,以使性能最大化。NodeJS可读流通过充当缓冲区来实现这一目的,该缓冲区发送接收到的每个字节,而不是一次发送所有字节。

本文档中提到的所有微前端布局中间件都将HTML响应主体流式传输到浏览器。在 single-spa-layout 的情况下,这可以通过调用 sendLayoutHTTPResponse

import {
  sendLayoutHTTPResponse,
} from "single-spa-layout/server";
import http from 'http';
const serverLayout = constructServerLayout({
  filePath: "server/views/index.html",
});
http.createServer((req, res) => {
  sendLayoutHTTPResponse({
    res,
    // Add all other needed options here, too
  })
}).listen(9000)

# 5.hydrate

hydrate(或rehydration)是指浏览器Javascript初始化事件侦听器并将其附加到服务器发送的HTML。有几种变体,包括进行性rehydration和部分rehydration。

另请参阅Google的“网络渲染 (opens new window)”。

在微前端的情况下,rehydration 是通过微前端的底层UI框架(React,Vue,Angular等)完成的。例如,在React中,这是通过调用ReactDOM.hydrate (opens new window)完成的。通过单spa适配器库,您可以指定是初次 hydrating 还是安装(请参见single-spa-react的renderType选项 (opens new window))。

单spa布局的作用是确定哪些微前端应 hydrating DOM的哪些部分。当您调用constructLayoutEngine (opens new window)和singleSpa.start()时,将自动完成此操作。如果使用TailorX而不是 single-spa-layout ,则同构布局编辑器项目 (opens new window)的作用与相似constructLayoutEngine。

阅读全文