初探微前端框架

date
Dec 5, 2021
slug
discover-about-micro-frontend
status
Published
tags
微前端
Web
summary
微前端是一种新兴的架构风格,将单体的应用划分为一个个相互独立的更小模块,在前端开发过程中做到技术栈无关、降低各个功能之间的耦合度、提高开发效率。在项目庞大、多人员协作的场景下,采用微前端架构有较高的收益,常见适合微前端方案部署的有系统的控制台、多个独立应用杂糅运行等场景
type
Post

一、背景

微前端是一种新兴的架构风格,将单体的应用划分为一个个相互独立的更小模块,在前端开发过程中做到技术栈无关、降低各个功能之间的耦合度、提高开发效率。在项目庞大、多人员协作的场景下,采用微前端架构有较高的收益,常见适合微前端方案部署的有系统的控制台、多个独立应用杂糅运行等场景。
实现微前端的方案有很多。iframe 作为 HTML 标准的组件,自带支持独立的 CSS 样式和 JS 运行环境隔离,用来实现微前端有着天然的优势。与此同时,强隔离的属性也带来一些弊端:独立的两个页面在相互进行控件布局的时候存在着一定的区域限制,在实现如模态弹框的场景中灵活性非常低;无法保存子应用路由状态,重新载入后可能存在状态丢失的问题;cookie 之间相互隔离,子父应用之间不能直接保存和传递;应用间的通信只能依赖 postMessage 机制,十分不便。如果这些缺点都可以接受或者影响不大的情况下, iframe 可以说是接入微前端架构最快的方案了。鉴于此,业界产生了诸如 single-spa、qiankun、micro-app 等框架提供微前端接入方案,通过在同一页面中载入子、主应用的方式来规避上述的问题、提升用户体验。

二、框架方案对比

在微前端的实现上诞生了许多优秀的框架,对微前端方案中会遇到的问题都提供了不同的解决方案。上述的三个分别是不同时期较为有代表性的框架,它们互相的现原理和特性都有自己的特点。先进行一个简单的横向的对比。
框架
载入方式
CSS样式隔离
JS运行隔离
子应用接入成本
single-spa
JS Entry
/
/
单独打包
qiankun
HTML Entry
Scoped / shadowDOM
Proxy / Snapshot 机制
根据 window.__POWERED_BY_QIANKUN__ 字段执行不同初始化方法
micro-app
HTML Entry(Custom Elements
Scoped / shadowDOM
Proxy
无需修改
*表格中粗体内容为该框架相较于其他框架独有的特性
下面就几个常见的问题方向进行分析。

1. 载入渲染方式

(1)JS Entry

在主应用的同一页面载入子应用,可以通过将子应用的所有资源(包括布局、功能逻辑与素材)打包进一个js文件里,并将这个文件作为资源的入口在主应用引入,参考 single-spa 的这个实现。这种方式增加了主子应用的耦合性,且加载子应用的过程中无法并行加载,存在单个资源过大的问题,较难提升整体的效率;但该方案对应用的部署流程较为便捷,且要求较低。

(2)HTML Entry

主应用在运行时通过提供单独打包好的子应用 URL 地址,以 fetch 方式获取子应用的 HTML 文件,经过处理后在指定的容器中插入 HTML 内容,这样的方式称为 HTML Entry。相较于 JS Entry 方式,html Entry有着更低的耦合性和更大的灵活度,支持主应用在 fetch 后进行二次处理,通过 CSS 增加前缀、JS sandbox 等方式实现样式隔离和 JS 沙箱隔离,且能对静态资源采用并行加载,子应用的更新可以单独发布;但这种方式必须需要子应用支持跨域。
qiankun 框架采用的就是 HTML Entry 的形式进行子应用的载入,参考官网的 demo 注册环节:
import { registerMicroApps } from 'qiankun';

registerMicroApps(
  [
    {
      name: 'app1',
      entry: '//localhost:8080',
      container: '#container',
      activeRule: '/react',
      props: {
        name: 'kuitos',
      },
    },
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)],
  },
);
可以发现,只需要提供子应用对应的 entry URL 路径,指定子应用的 name(提供给 qiankun 框架用以在多个子应用之间作为唯一标识进行区分),设置对应的 container 目标容器框架,并提供激活该子应用的路由前缀 activeRule,qiankun就可以帮助我们在现有的框架下载入子应用。

2. 环境隔离

(1)CSS样式隔离

除了 single-spa 默认不提供样式隔离功能外,其余主流的微前端框架都实现了基于子应用作用域下的 CSS 样式隔离(原理类似 Vue 的 style scpoed 实现方式)。核心步骤代码如下:

1)遍历 HTML 文档中 style 标签下的 CSS 文件

// loader.js
var styleNodes = appElement.querySelectorAll('style') || [];

_forEach(styleNodes, function (stylesheetElement) {
  css.process(appElement, stylesheetElement, appName);
});

// css.js
export var process = function process(appWrapper, stylesheetElement, appName) {
  // ...
  // 不处理外部样式表
  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }

  // ...

  var tag = (mountDOM.tagName || '').toLowerCase();
  // 对内联样式进行进一步的处理
  if (tag && stylesheetElement.tagName === 'STYLE') {
    var prefix = "".concat(tag, "[").concat(QiankunCSSRewriteAttr, "=\\"").concat(appName, "\\"]");
    processor.process(stylesheetElement, prefix);
  }
};

2)解析 CSS 文件内容

function process(styleNode) {
  var _this = this;

  // 获取函数的第二个参数作为前缀
  var prefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';

  var _a;

  // 当该style标签内有内容时进行操作
  if (styleNode.textContent !== '') {
    var textNode = document.createTextNode(styleNode.textContent || '');
    // 这里的swapNode是之前创建好的空style,起到工具人的作用,完事后会把里面的内容清空
    this.swapNode.appendChild(textNode);
    // 通过StyleElement.sheet API获取样式表对象CSSStyleSheet
    var sheet = this.swapNode.sheet;
    // 从CSSStyleSheet的rules或cssRules字段获取所有样式规则,并将这个伪数组转为数组
    var rules = arrayify((_a = sheet === null || sheet === void 0 ? void 0 : sheet.cssRules) !== null && _a !== void 0 ? _a : []);

    // 给每条css规则的选择器增加前缀后返回修改后的css
    var css = this.rewrite(rules, prefix); // eslint-disable-next-line no-param-reassign
    // 将处理后的css放到传入的style元素中
    styleNode.textContent = css; // cleanup
    this.swapNode.removeChild(textNode);
    return;
  }
}

3)为每个样式添加前缀

function ruleStyle(rule, prefix) {
  var rootSelectorRE = /((?:[^\\w\\-.#]|^)(body|html|:root))/gm;
  var rootCombinationRE = /(html[^\\w{[]+)/gm;
  var selector = rule.selectorText.trim();
  var cssText = rule.cssText;

  // 下面两个判断是针对根选择器(body、html、:root)的判断的,处理逻辑是会将根选择器替换为prefix,也就是当前子应用挂载容器的选择器
  if (selector === 'html' || selector === 'body' || selector === ':root') {
    return cssText.replace(rootSelectorRE, prefix);
  }
  // 如果选择器是html开头并且带有后代元素
  if (rootCombinationRE.test(rule.selectorText)) {
    // 如果是不标准的用法,如html + body就不会对根选择器做处理,否则会把html删除
    var siblingSelectorRE = /(html[^\\w{]+)(\\+|~)/gm;
    if (!siblingSelectorRE.test(rule.selectorText)) {
      cssText = cssText.replace(rootCombinationRE, '');
    }
  }

  cssText = cssText.replace(/^[\\s\\S]+{/, function (selectors) {
    // 以字符串或,开始,到下一个逗号之前结束,第一个参数是匹配到的整个字符串
    // 第二个参数是空或者, 第三个参数是,之后到下一个逗号或结尾的内容
    return selectors.replace(/(^|,\\n?)([^,]+)/g, function (item, p, s) {
      // 处理带有根选择器的分组选择器 如:div,body,span { ... }
      if (rootSelectorRE.test(item)) {
        return item.replace(rootSelectorRE, function (m) {
          // 处理body、html 及 *:not(:root) 这样的情况,将根选择器直接转为前缀
          var whitePrevChars = [',', '('];

          if (m && whitePrevChars.includes(m[0])) {
            return "".concat(m[0]).concat(prefix);
          }

          return prefix;
        });
      }
      // 在选择器之前加上前缀,并且删除之前选择器前面的所有空格
      return "".concat(p).concat(prefix, " ").concat(s.replace(/^ */, ''));
    });
  });
  return cssText;
}
通过上述的三个步骤,为每个选择器加上子应用框架的名称,从而实现子应用样式的独立。但在这个情况下,主应用的样式仍然会影响到子应用,在使用时候应当注意规避样式污染。
此外,基于 Web Components 方式实现的微前端框架还可以通过 shadowDOM 的实现方式达到更好的样式隔离效果,但随之带来用户使用过程中(仅现代浏览器支持)、开发过程中(React、Vue3)的兼容性问题也需要注意。

(2)JS 运行环境隔离

现有的一众微前端框架在运行时的状态下要做到模拟多个环境的方案,采用的大多是 sandbox 的思想,不同框架间的区别就是怎么去给子应用构造这套环境。拿 qiankun 来举例,核心代码参考如下:
// 通过 createFakeWindow 方法复制当前状态的 window 对象
function createFakeWindow(global: Window) {
  // 这里qiankun给我们了一个知识点:在has和check的场景下,map有着更好的性能 :)
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;
  // 从window对象拷贝不可配置的属性
  // 举个例子:window、document、location这些都是挂在Window上的属性,他们都是不可配置的
  // 拷贝出来到fakeWindow上,就间接避免了子应用直接操作全局对象上的这些属性方法
  Object.getOwnPropertyNames(global).filter((p) => {
    const descriptor = Object.getOwnPropertyDescriptor(global, p);
    // 如果属性不存在或者属性描述符的configurable的话
    return !descriptor?.configurable;
  }).forEach((p) => {
    const descriptor = Object.getOwnPropertyDescriptor(global, p);
    if (descriptor) {
      // 判断当前的属性是否有getter
      const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, "get");
      // 为有getter的属性设置查询索引
      if (hasGetter) propertiesWithGetter.set(p, true);
      // freeze the descriptor to avoid being modified by zone.js
      // zone.js will overwrite Object.defineProperty
      // const rawObjectDefineProperty = Object.defineProperty;
      // 拷贝属性到fakeWindow对象上
      rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
    }
  });
  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

// proxySandBox 的实现
const rawWindow = window;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow); // window副本和上面说的有getter的属性的索引
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
const proxy = new Proxy(fakeWindow, {
  set(target: FakeWindow, p: PropertyKey, value: any): boolean {
    if (sandboxRunning) {
      // 在fakeWindow上设置属性值
      target[p] = value;
      // 记录属性值的变更
      updatedValueSet.add(p);
      // SystemJS属性拦截器
      interceptSystemJsProps(p, value);
      return true;
    }
    // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
    return true;
  },
  get(target: FakeWindow, p: PropertyKey): any {
    if (p === Symbol.unscopables) return unscopables;
    // 避免window.window 或 window.self 或window.top 穿透sandbox
    if (p === "top" || p === "window" || p === "self") {
      return proxy;
    }
    if (p === "hasOwnProperty") {
      return hasOwnProperty;
    }
    // 批处理场景下会有场景使用,这里就不多赘述了
    const proxyPropertyGetter = getProxyPropertyGetter(proxy, p);
    if (proxyPropertyGetter) {
      return getProxyPropertyValue(proxyPropertyGetter);
    }
    // 取值
    const value = propertiesWithGetter.has(p)
      ? (rawWindow as any)[p]
      : (target as any)[p] || (rawWindow as any)[p];
    return getTargetValue(rawWindow, value);
  },
  // ...
});
在支持 JS Proxy 的环境下,通过复制 window 下的属性,并通过 Proxy 进行代理,达到单独为子应用构造一个独立的 window 对象环境的目的。
而在不支持 Proxy 的浏览器环境中,qiankun 也提供了一套降级方案:snapshotSandBox。核心的思想是对 window 对象在子应用激活的时候进行遍历并快照备份,并在子应用销毁的时候进行 diff ,将修改后的属性进行还原。核心代码如下所示:
 active() {
   if (this.sandboxRunning) {
     return;
   }

   this.windowSnapshot = {} as Window;
   // iter方法就是遍历目标对象的属性然后分别执行回调函数
   // 记录当前快照
   iter(window, prop => {
     this.windowSnapshot[prop] = window[prop];
   });

   // 恢复之前运行时状态的变更
   Object.keys(this.modifyPropsMap).forEach((p: any) => {
     window[p] = this.modifyPropsMap[p];
   });

   this.sandboxRunning = true;
 }

inactive() {
  this.modifyPropsMap = {};

  iter(window, prop => {
    if (window[prop] !== this.windowSnapshot[prop]) {
      // 记录变更,恢复环境
      this.modifyPropsMap[prop] = window[prop];
      window[prop] = this.windowSnapshot[prop];
    }
  });

  this.sandboxRunning = false;
}
对于 micro-app 实现的方法也类似,并对全局方法进行了重写处理。详细可以参考 https://github.com/micro-zoe/micro-app/issues/19 的具体实现。

3. 应用通信

参考 qiankun 的 initGlobalState API(文档),大多数的微前端框架都会通过 props 的形式由主应用向子应用传递数据。
// 主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();

// 微应用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });
  props.setGlobalState(state);
}
对于主应用获取子应用的数据(或子应用向主应用传递数据),qiankun 在框架层面没有提供一个现成的方法,但我们可以简单通过在子应用注册时的 props 参数中传递一个钩子函数,子应用在需要向上传递数据的时候调用即可实现这个功能,思路和常见的 Vue 子组件类似。而 micro-app 在框架层提供了 dispatch 函数供实现子应用向基座应用发送数据的功能,方法大同小异,子应用可以通过名称为 microApp 的被注入的全局对象与基座应用进行数据交互,核心代码如下。
// event_center.ts
dispatch (name: string, data: Record<PropertyKey, unknown>): void {
  //...
  // 先从事件列表中取出同名事件
  let eventInfo = this.eventList.get(name)
  if (eventInfo) {
    if (eventInfo.data !== data) {
      // 更新 data 数据
      eventInfo.data = data
      // 循环遍历 callback 函数并执行
      for (const f of eventInfo.callbacks) {
        f(data)
      }
    }
  } else {
    // 储存对应 data
    // ...
  }
}

三、demo 实践

1. qiankun

根据官方文档的接入手册,我们只需要在现有的应用上稍加改造即可。由于用于 demo 的主应用是基于 Vue 创建的,抽取一个统一的 js 文件用于注册框架,只需要在框架层的 mounted 函数中执行注册即可,以防止在注册时候子应用的容器还未创建导致报错。
// AppWrapper.vue
import startQiankun from '@/qiankun/index';
// ...
mounted() {
  if (!window.hasQiankunStarted) {
    window.hasQiankunStarted = true;
    startQiankun();
  }
},
// ...

// qiankun.js
import { registerMicroApps, start, runAfterFirstMounted, addGlobalUncaughtErrorHandler } from 'qiankun';
const startQiankun = () => {
  const loadedApps = filterApps();  // 构造应用列表,下文详细介绍

  registerMicroApps(loadedApps, {
    // 事件监听
  });
  // 第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本。
  runAfterFirstMounted(() => {
    console.log('[main] first App mounted callback');
  });
  // 添加全局的未捕获异常处理器。
  addGlobalUncaughtErrorHandler((event) => console.log(event));
  // 定义全局状态
  const actions = initGlobalState();
  // 启动
  start();
  return actions;
};
为了便于应用的管理,我们可以专门创建一个类似 apps.js 的文件存放子应用信息,并将其 export 供注册流程中使用
// apps.js
// 正式环境配置
const prodAppConfig = [
  {
    name: 'child1',
    entry: '/subapp/c1/',
    container: '#content',
    activeRule: '/c1',
  },
  {
    name: 'child2',
    entry: '/subapp/c2/',
    container: '#content',
    activeRule: '/c2',
  },
];
// 开发环境配置
const devAppConfig = [
  // 参考上方
];
const apps = () => {
  switch (process.env.NODE_ENV) {
    case 'production':
      return prodAppConfig;
    case 'development':
    // dev 与 default 合并处理,默认返回dev
    default:
      return devAppConfig;
  }
};
export default apps();

// qiankun.js
import apps from './apps';
// 读取App的信息并构造数组
const filterApps = () => {
  const resApps = apps.map((item) => ({
    ...item,
    // props: props,
    activeRule: item.activeRule,
  }));
  return resApps;
};

注册好了 qiankun 内部的处理逻辑,还需要在主应用中将对应的路由指向 AppWrapper 组件,故在 main.js 中加入如下代码进行动态路由注册。
// main.js
import qiankunApps from './qiankun/apps';
// 注册子应用路由
qiankunApps.forEach((appItem) => {
  router.addRoute({
    path: `${appItem.activeRule}*`,
    name: appItem.name,
    component: () => import(/* webpackChunkName: "appWrapper" */ './views/AppWrapper.vue'),
  });
});
至此,就完成了qiankun 的主应用注册逻辑。在实际打包运行过程中,还需要对 webpack 进行改造,以供导出所需的生命周期。

2. micro-app

得益于 micro-app 借鉴 Custom Elements 实现的类 Web Components 的设计思想,我们不需要对子应用做特殊的入侵性改造(仅需要修改子应用 router 层的base URL)即可实现接入,而主应用的改造也非常简单,在主应用(基于 Vue)的 main.js 文件中进行注册后即可完成应用的配置。
// micro-app engine
import microApp from '@micro-zoe/micro-app';
// ...
microApp.start()
在需要引入子应用的地方,通过 micro-app 标签即可将框架提供的自定义标签引入,完成对应的设置后,框架将自动处理内部的 fetch 及样式隔离逻辑。
<!-- my-page.vue -->
<template>
  <div>
    <h1>子应用</h1>
    <!--
      name(必传):应用名称,每个`name`都对应一个应用,必须以字母开头,且不可以带有 `.``#` 等特殊符号
      url(必传):页面html的地址
      baseroute(可选):基座应用分配给子应用的路由前缀,就是上面的my-page
     -->
    <micro-app name='app1' url='<http://localhost:3000/>' baseroute='/my-page'></micro-app>
  </div>
</template>
在实测的时候,micro-app 在特定情况下存在 fetch 方式不够灵活、对于不支持 Proxy 的场景未做降级方案等问题,且根据 issue 看到仍然存在一些体验上的缺陷,故在技术选型的时候没有打算在生产环境用这个框架。但是这种设计思路做到了对子应用较少的入侵改动,还是可圈可点的,希望以后有场景能用上。

四、参考

  1. https://juejin.cn/post/6888695499793268744
  1. https://juejin.cn/post/6896643767353212935
  1. https://github.com/micro-zoe/micro-app/issues/8

© Krist 2016 - 2024

|