软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

微前端与SSR微服务

随着业务的发展,单页面应用(SPA)开发的前端系统也越来越复杂,这时需要多个团队并行开发。为了提高生产效率,每个团队需要能够独立开发、部署与维护,于是引入了与后端微服务相似的“微”架构:微前端与SSR微服务。即使一个团队,为了新特性不影响其它部分、能够更快速的上线,也引入了“微”架构。无逻辑的前端静态资源服务,就不在讨论之列。

本文主要探讨React与Vue的微前端 + SSR微服务

1. 微架构特点

前端微架构

微前端,重点是在前端浏览器中组装各个小部分构成整个页面。各小部分的渲染方式可以是CSR或SSR,也可以是混合的方式。为了兼容更多的浏览器与良好的用户体验,一般选择React/Vue/Angular组件化开发各个小部分,然后在前端浏览器中组装出一个单页面应用(SPA)。

SSR微服务,重点是在服务端通过多个前端技术栈的微服务组装成一个页面或片段,然后返回给用户。请求返回即可显示,首屏显示很快,这点比CSR的体验要好。交互不多时,对低功耗、低性能的设备支持良好。但是,当在线用户量较大时,服务器压力也比较大,扩容不及时时可能出现大面积用户都无法访问,这时需要考虑缓存、服务降级等措施。同时,作为微服务,像其它后端微服务一样需要考虑很多问题:负载均衡、链路追踪、遥测(日志、指标、性能)等。服务划分颗粒越小,这些非功能需求就越重要,可以考虑上Service Mesh。

用户导航浏览时,访问入口APP服务,APP服务再请求其它服务、组装整页HTML并返回,首屏显示加速。部分内容需要更新时,直接向特定的微服务请求到HTML片段。因HTML都是后端渲染,前端挂载HTML片段之后需要使用Hydrate添加事件响应处理。

在大型复杂的系统中,我们可以考虑上微前端 + SSR微服务,各业务模块之间代码库隔离,同时考虑前端代码复用,技术难度也最大。在中小型系统中,可以选择CSR/SSR代码拆分、CSR微前端中合适的架构。

2. 接口数据

2.1. 首屏HTML

首屏HTML需包含以下内容:嵌入CSS内容、入口JS URL、已渲染的微APP数据、JS/CSS Preload URLs。

2.2. 微APP接口

微APP接口返回的数据主要是给SSR与CSR使用的, 包含以下内容:

{
    "html": "<div>....</div>",
    "state": { "a": 10 },
    "componentId": "default",
    "resource": {
        "notModified": false,
        "appId": "my-app-id",
        "revision": "23334dfe",
        "styles": [{
            "url": "/abc.2332aafea.css",
            "content": ".my-class {}",
        }],
        "entryScripts": [{
            "url": "/abc.23234de.js"
        }],
        "preloadScripts": [{
            "url": "/efg.aw1234.js"
        }],
    }
}

微前端请求时,可以携带已缓存的appIdrevision给SSR微服务,避免重复传输resource其它字段,节省流量。

2.3. 服务降级

服务降级之后,SSR微服务只提供数据服务,不再渲染html字段,导航浏览返回固定的HTML,所有的微APP由客户端渲染。

3. 微前端组装

SSR时,微前端组装CSS内容与HTML片段,再加载JS,最终Hydrate。CSR时,客户端请求到数据之后再自己渲染。我们的代码需要满足在SSR与CSR中复用,只能设计成微APP的形式,而不是微组件的形式。

微APP一个功能区块,不是可独立运行的页面,只是其创建方式像一个页面应用。

微APP需要自己维护生命周期,上下文中也没有parent组件,如需透传数据,需要额外的代码提供数据。在浏览器中,我们整个页面还是一个SPA,所以,数据与JS等都可以复用。

3.1. React微APP

当启用SSR渲染时,我们使用<div dangerouslySetInnerHTML={this.ssrHtml}></div>渲染。在客户端时,在componentDidMount()中使用doRender(h(App, {}), this.el);挂接一个独立的微APP。

要透传全局store,我们需要使用<ReactReduxContext.Consumer>取到全局store。在创建微APP时,再使用<Provider>透传。

以下是一个简单的样例代码:

// LazyApp.tsx

import {
  h, Component, render, hydrate,
} from 'preact'; /** @jsx h */
import { unmountComponentAtNode } from 'preact/compat';
import { Provider, ReactReduxContext } from 'react-redux';

interface LazyAppProps {
  appId: string;
  [key: string]: any;
}

class LazyApp extends Component<LazyAppProps> {
  el = null;

  store = null;

  lazyInst = {} as {
    state: any,
    app: any,
  };

  ssrHtml = {
    __html: '<span data-server-rendered="true">Some lazy app (SSR).</span>',
  };

  componentDidMount() {
    this.mountLazyApp();
  }

  componentWillUnmount() {
    unmountComponentAtNode(this.el);
  }

  setRef = (el) => {
    this.el = el;
  }

  async mountLazyApp() {
    // Load styles and scripts
    const info = await Promise.resolve({
      styles: [],
      scripts: [],
    });

    // find the lazy component
    const { component: LazyComponent, globalStore } = await Promise.resolve({
      component: ({ state }) => (<span>Some lazy app ...</span>),
      globalStore: false,
    });

    // mount the lazy micro frontend app
    let App;

    if (globalStore) {
      App = (
        <Provider store={this.store}>
          <LazyComponent state={this.lazyInst.state} />
        </Provider>
      );
    } else {
      App = <LazyComponent state={this.lazyInst.state} />;
    }

    const canHydrate = !!this.ssrHtml;
    const doRender = canHydrate ? hydrate : render;
    doRender(h(App, {}), this.el);
  }

  render() {
    // eslint-disable-next-line react/no-danger
    return (
      <ReactReduxContext.Consumer>
        {(store) => {
          this.store = store;
          return (<div ref={this.setRef} dangerouslySetInnerHTML={this.ssrHtml} />);
        }}
      </ReactReduxContext.Consumer>
    );
  }
}

export default LazyApp;

3.2. Vue微APP

在Vue中,我们使用render()函数渲染SSR HTML,见参数domProps.innerHTML。在客户端中,在生命周期函数mounted()中创建微APP并挂接到DOM中,其中也透传了router与store。Vue中的router与store,都不支持多实例。

样例代码如下:

<script lang="ts">
import { CreateElement, RenderContext } from 'vue';
import { Component, Vue } from 'vue-property-decorator';

const ssrPlaceholder = '<div></div>';

@Component
export default class LazyApp extends Vue {
  lazyInst = {} as {
    state: any,
    app: Vue,
  };

  // ssrHtml = '';
  ssrHtml = '<span data-server-rendered="true">Some lazy app (SSR).</span>';

  render(createElement: CreateElement, context: RenderContext) {
    return createElement('div', {
      domProps: {
        innerHTML: this.ssrHtml || ssrPlaceholder,
      },
    });
  }

  mounted() {
    this.mountLazyApp();
  }

  beforeDestroy() {
    this.lazyInst.app.$destroy();
  }

  async mountLazyApp() {
    // Load styles and scripts
    const info = await Promise.resolve({
      styles: [],
      scripts: [],
    });

    // find the lazy component
    const { component } = await Promise.resolve({
      component: {
        render: (h: CreateElement) => h('span', {}, ['Some lazy app ...']),
      },
    });

    // mount the lazy micro frontend app
    const app = new Vue({
      router: this.$router,
      store: this.$store,
      render: (h: CreateElement) => h(component, { props: { state: this.lazyInst.state } }),
    });

    this.lazyInst.app = app;

    this.$nextTick(() => {
      app.$mount(this.$el.firstChild as Element);
    });
  }
}
</script>

4. 有坑吗

要上微前端 + SSR微服务,还有以下问题要解决:

  1. React SSR,官方库未支持异步数据加载与渲染,需要提前请求其它SSR微服务的数据与HTML。但是,这种写法的代码比较难以维护。我们还是另外写一个支持异步的SSR库比较好,让我们可以在组件中准备异步数据,最后再渲染VNode成HTML。这种异步渲染,需要两次遍历树,在高并发量时性能可能不佳。也许需要考虑其它的扁平化数据加载方案。
  2. Vue Router需要Hack才能支持懒加载路由,不支持嵌套的懒加载子路由。Vue Router也仅能预先取到匹配的路由定义中的组件。

5. 总结

相对而言,目前Vue中实现微前端 + SSR微服务比React中更成熟,也有开源的样例:Vue Genesis

不管Vue还是React,上SSR微服务的技术成本、运营成本都是比较高的。在追求极致体验与开发效率时,团队有能力再上。否则,可能出现服务不稳定、出现问题时不易定位等情况,出现问题时都不知道是前端团队还是后端团队的问题,开发效率低下,绩效也不会好。