吃透微前端 - 关键技术解析

1,930 阅读11分钟

问题

你是否遇到过这样的问题:

  • 业务的不断发展,系统堆积的功能越来越多,难以维护。
  • 前端新技术框架层出不穷,很多老的工程就成了所谓的历史包袱,开发效率慢,开发没有成就感,完全重构掉却又耗时耗力,还没有产出。
  • 组织架构的调整,导致多个团队在同一个系统里开发。大家代码的冲突、复用,发布版本的协调,都存在问题。

要求

如果有一种方案能解决上面的问题,那么他至少应该能做到:

  • 能支持多个子工程独立开发,独立部署
  • 能支持多种不同的技术栈共存
  • 各个子工程之间js, css 互不影响
  • 这些子工程组合起来看上去像是一个工程

什么是微前端

微前端的概念最早在2016年由thoughtworks在它的“技术雷达”里提出,正是为了解决上面说所的问题:

20200420193223.jpg

人肉翻译如下:

我们已经见识到了后端微服务所带来的巨大收益,让后端可以规模化的交付独立部署、独立维护的服务。然而,我们往往难以避免变成一个前端巨石应用,这样的应用难以维护,就像已经被抛弃的后端巨石应用。一种被我们的团队称为微前端的方法正在出现,在这种方法中,web 应用程序根据其页面和特性被分解,每个特性由一个团队端到端地拥有。新的和旧的多种技术共存,看起来是一致的用户体验,目标仍然是各个应用能独立部署、测试、开发。在这个方案中,BFF能很好的共存,每个团队提供各自的BFF层为其提供服务。

这里拿了微前端跟微服务来对比,后端已经有了微服务这个武器来解耦大型应用,而前端却还没有。

实现微前端的方式有哪些

iframe嵌入的方式

<html>
  <head>
  	<title>index.html</title>
  </head>
<body>
  <iframe id="main"></iframe>
  <script type="text/javascript">
    const subApps = {
      '/appa': 'https://appa.kaola.com/index.html',
      '/appb': 'https://appb.kaola.com/index.html',
      '/appc': 'https://appc.kaola.com/index.html',
  	};

    const iframe = document.getElementById('main');
    iframe.src = subApps[window.location.pathname];
		...
    
  </script>
</body>
</html>

iframe嵌入的方式大家应该都很熟悉,也是实现微前端成本最小的方式了。它有着接入成本小,天然支持js/css隔离的优点。而缺点也是很明显,不能实现全局modal, 内部滚动条,切换页面重新加载iframe、loading时间长等。iframe的这些缺点决定它不会成为广泛应用的方案。

构建时集成

通过git sub module或者npm package在构建阶段集成在主应用仓库中,团队A,B,C独立开发,优点是实施简单,依赖也可以共享,缺点是A,B,C无法独立更新,其中一个发生更新,都需要主应用进行构建部署来更新完整应用。如下:

image.png
这种方式更适合于小团队,因为当package越来越多的时候,会导致主应用频繁发布更新,此外还会让主应用的构建速度增长,代码维护成本越来越高,所以大多数选择微前端架构的,都是希望能够在运行时集成。

运行时集成

这种方式就是将应用A, B,C打包成为不同的bundle,然后通过loader加载不同bundle,动态运行bundle的逻辑,渲染页面,如下:

image.png

这个时候应用A, B,C完全是互相无感知的,可以采用任何框架开发,结合单页面路由切换的方式,让整个系统给用户一致性的体验。在运行时如果A, B, C想进行通信使用CustomEvent或者自定义EventBus都可以。这种方式看上去很不错。真正的满足了子应用独立开发、独立部署,支持多种技术栈共存,组合起来看上去是一个工程,js/css的隔离也是可以借助一些沙箱机制来实现。

single-spa

基于single-spa 4.x版本

现如今实现微前端不用一点一滴的从头实现,微前端框架已经层出不穷。但是说到微前端框架,就不得不说这里面的鼻祖。single-spa是基于主从模式下微前端解决方案的最早实现,同时也被后来的各种解决方案所借鉴(如qiankun,mooa等)

single-spa特点简介

  • 在同一个系统里使用多个前端框架,而不刷新页面
  • 独立部署每一个单页面应用
  • 旧的单页应用不用重写可以共存
  • 按需记载子应用,加快初始化时间

single-spa使用介绍

主应用:

import * as singleSpa from 'single-spa';

// 注册一个子应用需要三个东西:一个名字,一个加载方法,一个激活函数
// app名称
const appName = 'app1';
// app的加载方法, 真实情况中需要借助systemjs等工具在运行时去拉取子工程js
const loadingFunction = () => import('./app1/app1.js');
// 激活条件,根据入参的location来判断
const activityFunction = location => location.pathname.startsWith('/app1');

// 注册应用
singleSpa.registerApplication(appName, loadingFunction, activityFunction);

// 启动
singleSpa.start();

子应用: 子应用需要在其入口js暴露出相应的钩子(全部的生命周期不止下面的3个):

// app1.js
let domEl;

// bootstrap阶段创建挂载点
export function bootstrap(props) {
    return Promise
        .resolve()
        .then(() => {
            domEl = document.createElement('div');
            domEl.id = 'app1';
            document.body.appendChild(domEl);
        });
}

// mount阶段插入内容
export function mount(props) {
    return Promise
        .resolve()
        .then(() => {
            domEl.textContent = 'App 1 is mounted!'
        });
}

// unmount阶段清除内容
export function unmount(props) {
    return Promise
        .resolve()
        .then(() => {
            domEl.textContent = '';
        })
}

single-spa的使用就是这么简单,下面来看看它的工作原理。

single-spa原理分析

image.png

上图是single-spa的流程逻辑。具体的源码分析这里就不展开了。

有几个值得注意的细节:

  • 已经显示过的子app, 再次激活的时候,不会执行bootstrap, 而是直接去mount。也就是说bootstrap只会执行一次,除非unload掉(unload要手动触发,上图里没有体现这部分)
  • load function拿到子应用返回的lifeCycle后,如果lifecycle(例如mount)是数组,则会处理成为串行执行的promise。qiankun传给single-spa的生命周期钩子就是数组。

qiankun

基于qiankun 1.x版本

single-spa是最早的微前端框架,但是它所实现的功能也是比较单薄的,在实际的开发场景中会有很多不方便的地方,如只支持js entry, umount动作完全交给开发者去处理,全局变量的污染,监听器的污染等这些问题。这才有了后续基于它开发的一些框架。这里着重介绍下qiankun,它主要有如下特点:

  • 基于single-spa封装,提供了开箱即用的api
  • 技术栈无关,无论是react/vue/angular/jquery还是其他等框架
  • HTML Entry 接入方式,让应用接入的改造改造更小
  • 样式隔离,确保子应用之间样式互不干扰
  • JS 沙箱,确保微应用之间 全局变量/事件 不冲突
  • 资源预加载,浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度

qiankun使用介绍

先来看一下qiankun的使用方式:

主应用:

// 主应用
import { registerMicroApps, start } from 'qiankun';
import render from './render/VueRender'

render({ loading: true });

function genActiveRule(routerPrefix) {
  return location => location.pathname.startsWith(routerPrefix);
}

registerMicroApps([
  { 
    name: 'react app', 
    entry: '//localhost:7100', 
    render, 
    activeRule: genActiveRule('/react') 
  },
  {
    name: 'react15 app', 
    entry: '//localhost:7102', 
    render, 
    activeRule: genActiveRule('/15react15') 
  },
  { 
    name: 'vue app', 
    entry: '//localhost:7101', 
    render, 
    activeRule: genActiveRule('/vue') 
  },
]);

start({ prefetch: true });

子应用:

// 子应用
// 跟icestark等微前端框架不同的是,子应用无需引入qiankun,只需要暴露出这3个生命周期钩子
import Vue from 'vue'
import store from './store'
import App from './App.vue'
import router from './router'

let instance = null
const appInit = () => {
    instance = new Vue({
        el: '#app',
        router,
        store,
        components: { App },
        template: '<App/>'
    })
}

// 非受控状态,正常创建实例
// 受控状态下,qiankun会注入一个 window.__POWERED_BY_QIANKUN__ 全局变量
if(!window.__POWERED_BY_QIANKUN__) {
    appInit()
}

export async function bootstrap() {
    console.log('pop-coupon-fed bootstrap')
}

export async function mount(props) {
    console.log('props from main framework', props)
    appInit()
}

export async function unmount() {
    instance.$destory()
    instance = null
}

qiankun关键代码分析

qiankun跟single-spa是什么关系
import { registerApplication, start as startSpa } from 'single-spa';

export function registerMicroApps(pps, ifeCycles, ts) {
  window.__POWERED_BY_QIANKUN__ = true;

  apps.forEach(app => {
    const { name, entry, render, activeRule, props = {} } = app;

    registerApplication(
      name,
      // single-spa的 load 方法
      async ({ name: appName }) => {
        ...
        
        // 返回包装之后的mount, unmount方法
        return {
          bootstrap: [bootstrapApp],
          mount: [
            ...
          ],
          unmount: [
            ...
          ],
        };
      },
      activeRule,
      props,
    );
  });
}

可以看到qiankun的registerMicroApps方法,就是自定义了load function,其他的参数如name, activeRule, props都是原封不动的传到了single-spa的registerApplicaiton方法。qiankun的核心逻辑就是从这个自定义的load function开始的。

再来看看qiankun的start方法。处理了qiankun独有的prefetch,jsSandbox, singular这三个参入,然后就调用了single-spa的start方法:

import { registerApplication, start as startSpa } from 'single-spa';

export function start(opts: StartOpts = {}) {
  const { prefetch = true, jsSandbox = true, singular = true, fetch } = opts;

  switch (prefetch) {
    case true:
      prefetchAfterFirstMounted(microApps, fetch);
      break;

    case 'all':
      prefetchAll(microApps, fetch);
      break;

    default:
      break;
  }

  if (jsSandbox) {
    useJsSandbox = jsSandbox;
  }

  if (singular) {
    singularMode = singular;
  }

  startSpa();

  frameworkStartedDefer.resolve();
}

所以,可以看出来,qiankun就是在最大程度复用single-spa的情况下,加入了定制的几个特性。

HTML Entry

image.png

例如,处理前的html如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>vue app</title>
  <style>
    .main {
      font-size: 14px;
    }
  </style>
<link rel="stylesheet" href="/vue.e31bb0bc.css"></head>
<body>

<div id="vueRoot"></div>
<script>console.log('hello world');</script>
<script src="/vue.e31bb0bc.js"></script>
</body>
</html>

processTpl处理完之后的是:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>vue app</title>
  <style>
    .main {
      font-size: 14px;
    }
  </style>
<!-- link http://localhost:7101/vue.e31bb0bc.css replaced by import-html-entry --></head>
<body>

<div id="vueRoot"></div>
<!-- inline scripts replaced by import-html-entry -->
<!-- script http://localhost:7101/vue.e31bb0bc.js replaced by import-html-entry -->
</body>
</html>

PS:外链的css现在还是一个注释占位,fetch外链css并替换是在getEmbedHTML里完成的。

原始html经过一番处理之后,外链、内联的css都变为内联css了,外链、内联的js都被替换成占位注释,然后整个html作为content返回给render函数,render函数将它作为props去渲染要特定位置

其中是否entry的判断, 要么是script标签上加了enrty属性,要么是scripts的最后一个。

模块导入打标记

微前端架构下,我们需要获取到子应用暴露出的一些钩子引用,如 bootstrap、mount、unmout 等(参考 single-spa),从而能对接入应用有一个完整的生命周期控制。而由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个需要解决的问题。 通常我们第一反应的解法,也是最简单的解法就是与子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,然后主应用从这里面取生命周期函数。 这个方案很好用,但是最大的问题是,主应用与子应用之间存在一种强约定的打包协议。那我们是否能找出一种松耦合的解决方案呢? 很简单,我们只需要走 umd 包格式中的 global export 方式获取子应用的导出即可,大体的思路是通过给 window 变量打标记,记住每次最后添加的全局变量,这个变量一般就是应用 export 后挂载到 global 上的变量。实现方式可以参考 systemjs global import,这里不再赘述。(乾坤作者的一段原话)

下面来看看实际是怎么做的。

if (scriptSrc === entry) {
  noteGlobalProps();

  try {
    // bind window.proxy to change `this` reference in script
    geval(`;(function(window){;${inlineScript}\n}).bind(window.proxy)(window.proxy);`);
  } catch (e) {
    console.error(`error occurs while executing the entry ${scriptSrc}`);
    throw e;
  }

  const exports = proxy[getGlobalProp()] || {};

  // 之后执行到是entry的时候,才会获取entry的exports
  resolve(exports);

}

// 标记全局属性,记住第一个,第二个,以及最后一个
export function noteGlobalProps() {
	// alternatively Object.keys(global).pop()
	// but this may be faster (pending benchmarks)
	firstGlobalProp = secondGlobalProp = undefined;
	for (let p in global) {
		if (!global.hasOwnProperty(p))
			continue;

		if (!firstGlobalProp)
			firstGlobalProp = p;
		else if (!secondGlobalProp)
			secondGlobalProp = p;
		lastGlobalProp = p;
	}
	return lastGlobalProp;
}

// 目的找出新添加的那个全局属性
export function getGlobalProp() {
	let cnt = 0;
	let lastProp;
	let hasIframe = false;
	for (let p in global) {
		// 不是自身属性,跳过
		if (!global.hasOwnProperty(p))
			continue;

		// 遍历 iframe,检查 window 上的属性值是否是 iframe,是则跳过后面的 first 和 second 判断
		for (let i = 0; i < window.frames.length; i++) {
			const frame = window.frames[i];
			if (frame === global[p]) {
				hasIframe = true;
				break;
			}
		}
		// 不存在iframe
		if (
			!hasIframe && 
			(cnt === 0 && p !== firstGlobalProp || cnt === 1 && p !== secondGlobalProp)
		) return p;
			
		cnt++;
		lastProp = p;
	}
	if (lastProp !== lastGlobalProp)
		return lastProp;
}



image.png

CSS隔离 - Dynamic Stylesheet

在qiankun的方案里CSS隔离是天然支持的,因为css都被fetch回来,改写成内联样式,并作为content插入到页面中,当切换应用的时候,整个content都会被替换调,所以当然样式之间不会有影响。

JS沙箱

针对 JS 隔离的问题,我们独创了一个运行时的 JS 沙箱。简单画了个架构图:

image.png

即在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后**当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,**确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。 当然沙箱里做的事情还远不止这些,其他的还包括一些对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载,同时也会在 remount 时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。

如上是qiankun作者对其框架沙箱的一段解释。下面从源代码的角度来复原这个流程:

image.png

在某商家系统的实践

我所维护的某商家系统是一个集合了商家的商品、订单、促销、物流、售后等等,几乎商家全部业务活动的系统。从15年发展至今,包含的页面上百个,技术栈从regularjs 到 regularjs+vue的混合工程,历史包袱在这个工程里的体现可谓淋漓尽致。 到后来组织架构的调整,商品、促销、价格、市场等业务拆分到各基础服务组,我们对接的产品、后端分散在各个小组。还有少量页面由其他组的前端维护。开发风格的一致性、公共代码的复用性、发布时间的协调等等都是问题。用微前端来拆分这个系统再合适不过了。

系统的拆分不是一蹴而就的,必然有一个新旧工程共存的阶段。于是我们新弄了一个域名作为基座。老工程、基座、子工程的关系如下:

image.png

除了完成框架要求的改造外,其他要注意的点还有:

  • 子应用支持cors
  • 子应用打包output umd
  • dll打包output umd
  • 子应用静态资源publicPath
  • 子应用异步请求publicPath
  • 子应用href跳转的改造

整体的感受是,qiankun的使用是很方便的,确实如其所说的开箱即用。改造的主要工作都在业务的改造上。如果子系统本身即是webpack的单页应用,接入的成本是很小的。如果子应用都有自己的node层,后续有横向升级的需求,那需要改动的工程就多了,所以微前端结合 静态部署 + faas 会成为一个更好的选择。

参考文档