微前端架构分析

1,354 阅读11分钟

什么是微前端?

微前端首次提出是在 2016 年的一篇文章 ThoughtWorks Technology Radar,它把后端的微服务概念延伸到前端界。当前的网页开发的一个趋势是构建富交互、功能强大的 web 应用,也就是我们说的 SPA。背后支撑的是多个后端微服务,往往是不同的团队开发,这种模式被称为单体巨石应用(Frontend Monolith)。

Monolithic Frontends:

image.png

微前端背后的思想是认为:现代复杂的web app或者网站,通常由很多 相对独立的功能模块组合而成,而对这些模块负责的应该是 相互独立的多个团队。这些独立的团队由于专业分工不同,会负责着 特定的业务领域,以及完成 特定的开发任务。这样的团队,通常在人员组成方面囊括了从前端开发到服务端开发,从UI实现到数据库设计这样 端到端 的 跨职能人员 构成。

在过去,微前端之类的思路,会被称为 面向垂直划分系统的前端集成(Organisation in Verticals)。

Organisation in Verticals:

image.png

微前端核心思想

  • 技术不可知主义 每个团队应该选择自己的技术栈以及技术进化路线,而不是与其他团队步调一致。

  • 隔离团队之间的代码 即便所有团队都使用同样的框架,也不要共享同一个运行时环境。构建自包含的Apps。不要依赖共享的状态或者全局变量。

  • 建立团队自己的前缀 在还不能做到完全隔离的环境下,通过命名规约进行隔离。对于CSS, 事件,Local Storage 以及 Cookies之类的环境之下,通过命名空间进行的隔离可以避免冲突,以及所有权。

  • 原生浏览器标准优先于框架封装的API 使用 用于通信的原生浏览器事件机制 ,而不是自己构建一个PubSub系统。如果确实需要设计一个跨团队的通信API,那么也尽量让设计简单为好。

  • 构建高可用的网络应用 即便在Javascript执行失败的情况下,站点的功能也应保证可用。使用同构渲染以及渐进增强来提升体验和性能。

为什么会出现微前端?

传统Web架构:

image.png

面临问题:随着互联网行业发展,软件规模迅速扩大,传统的单体架构面临一系列问题:难以横向扩展,系统内部错综复杂,可靠性差法技术升级成本巨大。

于是出现了微服务架构。

微服务架构:

image.png

优点:各业务独立开发、独立部署,可靠性高,技术可单独升级,易于横向扩展。

新问题:SPA 技术的的兴起,导致前端开发复杂度急速上升,面临传统单体架构后端同样的问题。在这个过程中,也出现了对单页面和多页面方案的探讨。

SPA or MPA?

方案优点缺点
MPAHTML直出,各应用之间硬隔离,天生具备技术栈无关、独立开发、独立部署。页面刷新、资源重复加载、体验不好
SPA体验好,按需加载,框架支持,快速开发技术栈强耦合,SEO不友好

而微前端架构的出现,可以整合两者的优点,能够做到独立开发部署、保留局部刷新良好体验。

微前端面临的问题

应用隔离

最主要的是 JS 沙箱的实现,通常有以下几种方式:

  • 利用函数作用域,如 IIFE.
  • iframe: 天然隔离,有执行上下文隔离、路由隔离、多实例等优点。
  • Proxy代理window对象:记录每次window对象的修改,子应用加载/卸载时还原全局对象,但无法处理多实例场景。
  • Proxy代理fakeWindow对象:每个子应用都有一个模拟的window对象,同时将默认的window对象传入,优先取默认的window。

DOM和样式隔离

  • Shadow DOM
  • 命名方法论,BEM/OOCSS/SMACSS
  • CSS Modules
  • scoped (vue)

构建时整合 VS 运行时整合

构建时整合: 子应用通过 Package Registry(npm、git等等) 方式整合,与主应用一起打包发布。 运行时整合: 子应用自己构建部署,主应用运行时动态加载子应用资源。

二者也有各自的优缺点:

| 方案 | 优点 | 缺点| | ---- | ---- | ---- | ---- | | 构建时整合 | 主应用、子应用更方便构建优化,资源共享。 | 技术栈耦合,每次要全量部署。 | | 运行时整合 | 独立部署,技术栈无关 | 多出运行时的性能损耗。 |

JS Entry VS HTML Entry

JS Entry 的代表就是 single-spa,这种方式是将一个微应用完整打包成一个 js 文件,包括css、图片等资源,这样容易导致 js bundle 过大,而且有些构建优化手段无法使用,如CSS拆分。而且一旦旧的项目需要接入微前端架构,改造成本比较高。

qiankun 框架为了解决 JS Entry 的问题,于是采用了 HTML Entry. 通过 import-html-entry 实现,原理是用 http 请求加载指定地址的 html 页面,然后解析这个 html 获取相应的 js、css等资源。这样既减少了接入成本,也保持了灵活性。

微前端实现方式

基于前面的分析,微前端架构有几个实现思路可供选择:

方案描述优点缺点
nginx通过nginx配置反向代理,不同的路径转发到不同的应用。简单、快速配置切换应用时页面刷新,影响体验
iframe通过父应用嵌套iframe。实现简单、子应用天然隔离需要面对诸多问题,如全局环境完全隔离、DOM无法共享、url不同步等
Web Components浏览器原生技术,符合组件化的思想。可以隔离DOM和CSS兼容性一般,旧项目改造成本高。
组合式应用路由分发每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制子应用相互隔离,纯前端改造,体验良好需解决CSS隔离、全局对象污染、通信机制等问题。
特定中心路由基座式每个子应用独立构建和部署,但必须使用相同技术栈,统一路由。体验良好,子应用通信方便,方便做工程化。需解决CSS隔离、全局对象污染问题,技术升级成本高。

接下来就以最火的 qiankun 框架来做演示。

qiankun 框架

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

特性

image.png

  • 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 样式隔离,确保微应用之间样式互相不干扰。
  • JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
  • umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

为什么不是 iframe

  • url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  • UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。
  • 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  • 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

使用

主应用

  1. 安装 qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
  1. 在主应用中注册微应用
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);

start();

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

如果微应用不是直接跟路由关联的时候(如一个页面内同时加载多个子应用),你也可以选择手动加载微应用的方式:

import { loadMicroApp } from 'qiankun';

loadMicroApp({
  name: 'app',
  entry: '//localhost:7100',
  container: '#yourContainer',
});

微应用

微应用不需要额外安装任何其他依赖即可接入 qiankun 主应用。但是需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出 bootstrap、mount、unmount 三个生命周期钩子,以供主应用在适当的时机调用。

/**
 * 渲染函数
 * 主应用生命周期钩子中运行/子应用单独启动时运行
 */
function render(props = {}) {
  if (props) {
    // 注入 actions 实例
    actions.setActions(props);
  }

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

export async function bootstrap() {
  console.log("vue app bootstraped");
}

export async function mount(props) {
  console.log("vue mount", props);
  render(props);
}

export async function unmount() {
  console.log("vue unmount");
  instance.$destroy();
  instance = null;
  router = null;
}

除了代码中暴露出相应的生命周期钩子之外,为了让主应用能正确识别微应用暴露出来的一些信息,微应用的打包工具需要增加如下配置:

const packageName = require('./package.json').name;

module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

子应用(微应用)的接入就是这么简单!接下来看看 vue 框架的接入示例。

Vue 微应用接入 基于 vue-ci 3 和 vue 2.x 示例。

  1. src 目录新增 public-path.js, 设置运行时的 publicPath:
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 入口文件 main.js 修改,为了避免根 id #app 与其他的 DOM 冲突,需要限制查找范围。
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

let router = null;
let instance = null;
function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
  1. 打包配置修改 vue.config.js
const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

应用通信

qiankun 内部提供了 initGlobalState 方法,用来注册 MicroAppStateActions 实例用于通信,该实例有三个方法:

  • setGlobalState: 设置 globalState,如果和原有的值不同,则通知所有观察者。
  • onGlobalStateChange: 监听 globalState 的变化,变化时触发此方法的回调函数。
  • offGlobalStateChange: 取消监听,不再响应 globalState 的改变。

使用示例:

// micro-app-main/src/shared/actions.ts
import { initGlobalState, MicroAppStateActions } from "qiankun";

const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);

export default actions;
// micro-app-main/src/pages/login/index.vue
import actions from "@/shared/actions";
import { ApiLoginQuickly } from "@/apis";

@Component
export default class Login extends Vue {
  $router!: VueRouter;

  // `mounted` 是 Vue 的生命周期钩子函数,在组件挂载时执行
  mounted() {
    // 注册一个观察者函数
    actions.onGlobalStateChange((state, prevState) => {
      // state: 变更后的状态; prevState: 变更前的状态
      console.log("主应用观察者:token 改变前的值为 ", prevState.token);
      console.log("主应用观察者:登录状态发生改变,改变后的 token 的值为 ", state.token);
    });
  }

  async login() {
    // ApiLoginQuickly 是一个远程登录函数,用于获取 token,详见 Demo
    const result = await ApiLoginQuickly();
    const { token } = result.data.loginQuickly;

    // 登录成功后,设置 token
    actions.setGlobalState({ token });
  }
}

部署

这里以“主应用和微应用部署在不同的服务器,使用 Nginx 代理访问”为例。

主应用的 Nginx 配置:

/app1/ {
  proxy_pass http://www.b.com/app1/;
  proxy_set_header Host $host:$server_port;
}

主应用注册微应用时,entry 可以为相对路径,activeRule 不可以和 entry 一样(否则主应用页面刷新就变成微应用):

registerMicroApps([
  {
    name: 'app1',
    entry: '/app1/', // http://localhost:8080/app1/
    container: '#container',
    activeRule: '/child-app1',
  },
],

对于 webpack 构建的微应用,微应用的 webpack 打包的 publicPath 需要配置成 /app1/,否则微应用的 index.html 能正确请求,但是微应用 index.html 里面的 js/css 路径不会带上 /app1/

module.exports = {
  output: {
    publicPath: `/app1/`,
  },
};

微应用打包的 publicPath 加上 /app1/ 之后,必须部署在 /app1 目录,否则无法独立访问。

完整的例子可以参考 qiankun官方DEMO

原理

qiankun 基于 single-spa 做了二次封装,single-spa 就做了两件事情:

  • 加载微应用(加载方法需自己实现)
  • 管理微应用的状态(初始化、挂载、卸载)

其余的 JS 沙箱、样式隔离、HTML Entry等都是 qiankun 新增的特性。下图展示了 single-spa 和qiankun 的区别:

image.png

HTML Entry

HTML Entry的加载和解析是通过 import-html-entry 包实现的,也是 qiankun 团队出品。import-html-entry 的常规用法如下:

import importHTML from 'import-html-entry';

importHTML('./subApp/index.html')
    .then(res => {
        console.log(res.template);

        res.execScripts().then(exports => {
            const mobx = exports;
            const { observable } = mobx;
            observable({
                name: 'kuitos'
            })
        })
});

importHTML() 源码如下:

export default function importHTML(url, opts = {}) {
	let fetch = defaultFetch;
	let autoDecodeResponse = false;
	let getPublicPath = defaultGetPublicPath;
	let getTemplate = defaultGetTemplate;

	// compatible with the legacy importHTML api
	if (typeof opts === 'function') {
		fetch = opts;
	} else {
		// fetch option is availble
		if (opts.fetch) {
			// fetch is a funciton
			if (typeof opts.fetch === 'function') {
				fetch = opts.fetch;
			} else { // configuration
				fetch = opts.fetch.fn || defaultFetch;
				autoDecodeResponse = !!opts.fetch.autoDecodeResponse;
			}
		}
		getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
		getTemplate = opts.getTemplate || defaultGetTemplate;
	}

	return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
		.then(response => readResAsString(response, autoDecodeResponse))
		.then(html => {

			const assetPublicPath = getPublicPath(url);
			const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);

			return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
				template: embedHTML,
				assetPublicPath,
				getExternalScripts: () => getExternalScripts(scripts, fetch),
				getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
				execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
					if (!scripts.length) {
						return Promise.resolve();
					}
					return execScripts(entry, scripts, proxy, {
						fetch,
						strictGlobal,
						beforeExec: execScriptsHooks.beforeExec,
						afterExec: execScriptsHooks.afterExec,
					});
				},
			}));
		}));
}

最关键的是 processTpl 方法处理 template:

export default function processTpl(tpl, baseURI) {
	let scripts = [];
	const styles = [];
	let entry = null;
	const moduleSupport = isModuleScriptSupported();

	const template = tpl
		.replace(HTML_COMMENT_REGEX, '')
		.replace(LINK_TAG_REGEX, match => {
		//...
		})
		.replace(STYLE_TAG_REGEX, match => {
		//...
		})
		.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
        //...
		});
	scripts = scripts.filter(function (script) {
		// filter empty script
		return !!script;
	});

	return {
		template,
		scripts,
		styles,
		// set the last script as entry if have not set
		entry: entry || scripts[scripts.length - 1],
	};
}

JS 沙箱

使用 Proxy 实现沙箱,不支持的使用模拟的 SnapshotSandbox

  let sandbox: SandBox;
  if (window.Proxy) {
    sandbox = useLooseSandbox ? new LegacySandbox(appName) : new ProxySandbox(appName);
  } else {
    sandbox = new SnapshotSandbox(appName);
  }

ProxySandbox 核心代码: 当set的属性在FakeWindow不存在,但在原生的window中存在时,将该属性以及它的descriptor复制到FakeWindow,实现复制的副本。 get某属性时,优先在FakeWindow查找,没有再到原生window查找。

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          // We must kept its description while the property existed in rawWindow before
          if (!target.hasOwnProperty(p) && rawWindow.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
            const { writable, configurable, enumerable } = descriptor!;
            if (writable) {
              Object.defineProperty(target, p, {
                configurable,
                enumerable,
                writable,
                value,
              });
            }
          } else {
            // @ts-ignore
            target[p] = value;
          }

          if (variableWhiteList.indexOf(p) !== -1) {
            // @ts-ignore
            rawWindow[p] = value;
          }

          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
        }
        // ...
      },

      get(target: FakeWindow, p: PropertyKey): any {

        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        // ...

        // eslint-disable-next-line no-nested-ternary
        const value = propertiesWithGetter.has(p)
          ? (rawWindow as any)[p]
          : p in target
          ? (target as any)[p]
          : (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },
    });

样式隔离

如果是使用 strictStyleIsolation 模式,则用 Shadow DOM

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appName: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // createShadowRoot was proposed in initial spec, which has then been deprecated
        shadow = (appElement as any).createShadowRoot();
      }
      shadow.innerHTML = innerHTML;
    }
  }

  if (scopedCSS) {
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appName);
    }

    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appName);
    });
  }

  return appElement;
}

获取到了所有的 style 节点后遍历处理 css:

private ruleStyle(rule: CSSStyleRule, prefix: string) {
    const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
    const rootCombinationRE = /(html[^\w{[]+)/gm;

    const selector = rule.selectorText.trim();

    let { cssText } = rule;
    // handle html { ... }
    // handle body { ... }
    // handle :root { ... }
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }

    // handle html body { ... }
    // handle html > body { ... }
    if (rootCombinationRE.test(rule.selectorText)) {
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;

      // since html + body is a non-standard rule for html
      // transformer will ignore it
      if (!siblingSelectorRE.test(rule.selectorText)) {
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }

    // handle grouping selector, a,span,p,div { ... }
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
        // handle div,body,span { ... }
        // ...
      }),
    );

    return cssText;
  }

简单来说,就是利用正则表达式匹配 htmlbody:root 等特殊选择器替换为子应用的挂载点的 css selector,然后给其他的选择器加上子应用的独特前缀,这样就起到了隔离样式的效果。

其他微前端框架

  • Single-Spa:最早的微前端框架,兼容多种前端技术栈。
  • Mooa:基于Angular的微前端服务框架
  • Icestark:阿里飞冰微前端框架,兼容多种前端技术栈。
  • Ara Framework:由服务端渲染延伸出的微前端框架。