微前端——qiankun 主要特点

2,302 阅读12分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

qiankun 是基于 single-spa 提出的微前端框架, 提供了更加开箱即用的 API(single-spa + sandbox + import-html-entry)。

Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。

sandbox(沙箱)是将您的应用程序运行在一个隔离的空间里,以避免它们对其他程序和数据造成永久性的更改。

import-html-entry 用于获取子应用的 HTML 和 JS,同时对 HTML 和 JS 进行了各自的处理,以便于子应用在父应用中加载。

主要特点

1、HTML Entry 加载子应用

首先是子应用的加载方式与 single-spa 有明显的不同,single-spa 注册子应用本质上是 JS Entry,即通过从某一地址引入 js 文件来加载整个子应用。

singleSpa.registerApplication({
    'appName',        
    () => System.import('appName'),
    location => location.pathname.startsWith('appName'), 
});

但是 qiankun 注册子应用的方式是通过一个 url,即使用 HTML Entry 的方式来引入子应用。

registerMicroApps([       
    { 
        name: 'react app',             
        entry: '//localhost:7100',             
        container: '#yourContainer',             
        activeRule: '/yourActiveRule'        
    },        
]);        
start();

qiankun-子应用的加载

Html Entry 方法的主要步骤如下:首先通过 url 获取到整个 Html 文件,从 html 中解析出 html,js 和 css 文本,在主应用中创建容器,把 html 更新到容器中,然后动态创建 style 和 script 标签,把子应用的 css 和 js 赋值在其中,最后把容器放置在主应用中。

// 解析 HTML,获取 html,js,css 文本
const {htmlText, jsText, cssText} = importHTMLEntry('https://xxxx.com')
// 创建容器
const $= document.querySelector(container)
$container.innerHTML = htmlText
// 创建 style 和 js 标签
const $style = createElement('style', cssText)
const $script = createElement('script', jsText)
$container.appendChild([$style, $script])

如何解析html

  1. 通过 url 请求到子应用的 index.html。
  2. 用正则匹配到其中的 js/css 相关标签,进行记录,然后删去。
  3. 删去 html/head/body 等标签。
  4. 返回 html 文本。

如何解析js

  1. 使用正则匹配 <script> 标签。
  2. 对于内联 js 的内容会直接记录到一个对象中。
  3. 对于外链 js 会使用 fetch 请求到内容,然后记录到这个对象中。
  4. 最后在加载子应用时直接把内容赋值在动态构建的 script 中。

如何解析css

  1. 正则匹配 <style><link> 标签。
  2. 内联 css (<style> 标签)的内容会直接记录到一个对象中。
  3. 外链 css (<link> 标签)则会使用 fetch 请到到内容(字符串),然后记录到这个对象中,执行时内容放到<style>标签,然后插入到页面,子项目卸载移除这些<style>标签,这样会把外链的 css 变成内联 css,切换子系统,不用重复请求,直接应用 css 样式,让子项目加载得更快。

2、css 隔离

css 隔离主要分为 2 种,一种是父子之间的隔离,另一种是子子之间的隔离。

子应用之间的隔离,qiankun 中并没有特别的提出,本质上就是在子应用加载时把其相应的样式加载进来,在卸载时进行移除即可。而父子之间的隔离在 qiankun 中有两种实现方法。

  • strictStyleIsolation: Shadow DOM

image.png

第一种是严格样式隔离,核心是 Shadow DOM。它可以让一个 dom 拥有自己的“影子” DOM 树,这个 DOM 树不能在主文档中被任意访问,可以拥有局部样式规则,天然实现了样式隔离,如上图所示,被代理的 dom节点称为 shadow host,shadow tree 中的根节点称为 shadow root。

比如我们常用的<video>标签,一个标签就实现了一个简易的播放器,但其实它是由一些看不到的 dom 封装而成的,这里就是使用了shadow DOM。

image.png

现在先来模拟一下父子的样式污染问题,在下面的 demo 中子应用的样式设置成所有字体颜色为红色,使得父元素和子元素所有的文字都为红色。

<div>        
    <h5>父元素</h5>    
</div>    
<div id="app1">        
    <style>            
        *{                
            color:red;            
        }
    </style>        
    <h5>子元素</h5>        
    <p class="title">一行文字</p>    
</div>

这样的结果就是子样式污染了父样式(效果如下图)。

image.png

使用严格样式隔离解决一下这个问题:获取到子应用的根节点,然后打开影子模式,把原来的 dom 结构赋值到代理的影子根节点中,然后清空原来的 dom 结构。

function openShadow(domNode) {            
    var shadow = domNode.attachShadow({ mode: "open" });            
    shadow.innerHTML = domNode.innerHTML;            
    domNode.innerHTML = "";        
}        
var bodyNode = document.getElementById("app1");        
openShadow(bodyNode);

现在可以在 dom 树中看到,原来的子应用以及开启了影子模式,其中的子 dom 都在影子中,效果如下图所示,实现了父子之间的样式隔离。

image.png

  • experimentalStyleIsolation

第二种父子样式隔离是实验性样式隔离 ,即通过运行时修改 CSS 选择器来实现子应用间的样式隔离。

下面也是一个模拟污染的 demo,可以看到主应用和子应用有重名的选择器,子应用在后面,所以父样式被覆盖,造成了污染。

<head>    
    <style>      
        p.title {        
            color:red;      
        }
    </style>  
</head>  
<body>    
    <p class="title">父应用</p>  
    <div id="data-qiankun-A">    
        <style>        
            p.title {            
                color:blue;        
            }
        </style>    
        <p class="title">子应用</p>
    </div>
</body>

image.png

这里首先获取到子应用,然后通过正则匹配其中的所有标签,给每一个标签加上前缀,从而缩小其样式应用的范围。

function scopeCss(styleNode, prefix) {        
    const css = ruleStyle(styleNode.sheet.cssRules[0], prefix);       
    styleNode.textContent = css;    
}    

function ruleStyle(rule, prefix) {        
    const rootSelectorRE = /((?:[^\w-.#]|^)(body|html|:root))/gm;        
    let { cssText } = rule;        
    // 绑定选择器, a,span,p,div { ... }        
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>            
        selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {            
            // 绑定 div,body,span { ... }            
            if (rootSelectorRE.test(item)) {                
                return item.replace(rootSelectorRE, (m) => {                
                    // 不要丢失有效字符 如 body,html or *:not(:root)                
                    const whitePrevChars = [",", "("];                
                    if (m &amp;&amp; whitePrevChars.includes(m[0])) {                   
                        return `${m[0]}${prefix}`;                
                    }                
                    // 用前缀替换根选择器                
                    return prefix;                
                });            
            }            
            return `${p}${prefix} ${s.replace(/^ */, "")}`;            
       })        
    );        
    return cssText;        
}      
    
var container = document.getElementById("data-qiankun-A");      
var styleNode = container.getElementsByTagName("style")[0];      
scopeCss(styleNode, "#data-qiankun-A")

效果如下图所示,可以看到子应用的标签中的选择器都加上了前缀,使父应用的颜色保持原有的红色,子应用的颜色是新设置的蓝色。

image.png

3、js 隔离

js 隔离是另一个在微前端中需要关注的问题,qiankun 中有 3 种 js 隔离的做法。

  1. snapshotSandBox。快照沙箱
  2. legacySandBox (基于 Proxy API 来实现的) (仅用于 singular 单实例模式)
  3. proxySandBox (基于 Proxy API 来实现的) (多实例模式会使用 proxySandBox)
  • SnapshotSandbox 快照沙箱
let sandbox = new SnapshotSandbox();        
var a = '主应用A';        
var c = '主应用C';        
console.log('主应用原来的 Window:',a, c);        
    
function beforeMounted(){            
    sandbox.active();            
    console.log("加载子应用前");        
}        
function beforeUnMounted(){            
    sandbox.inactive();            
    console.log("卸载子应用前");        
}        
    
function app1(){            
    beforeMounted();            
    window.a = 'app1A'; // 修改            
    window.c = null; // 删除            
    window.d = 'app1D'; // 新增            
    console.log("子应用的 Window:",window.a, window.c, window.d);            
    beforeUnMounted();        
}        
    
app1();        
console.log('主应用现在的 Window:', a, c, d);

主应用中声明两个变量 a 和 c,分别赋值主应用 A 和主应用 C,然后加载子应用之后对全局变量 a c 进行修改,并且新增 d,最后卸载时再打印 a c d,可以在下图看到主应用的变量被污染了。

image.png

这时候开启沙箱再运行一遍,可以在下图看到主应用被恢复回来了,解决了变量污染的问题。

image.png

沙箱快照的核心思想如下:在子应用挂载前,对当前主应用的全局变量保存,然后恢复之前的子应用环境,在子应用运行期间则正常 get 和 set,在卸载时保存当前变量恢复主应用变量,整个过程类似于中断和中断恢复。

image.png

具体代码可参考这个 demo,但这里也有一个比较明显的缺点就是每次切换时需要去遍历 window,这种做法会有较大的时间消耗。

class SnapshotSandbox {            
    constructor() {                
        this.proxy = window; // window 属性                
        this.modifyPropsMap = {}; // 记录在 window 上的修改            
    }            
    active() { // 激活                
        this.windowSnapshot = {}; // 快照                
        for (const prop in window) {                    
            if (window.hasOwnProperty(prop)) {                        
                this.windowSnapshot[prop] = window[prop]                    
            }             
        }
        Object.keys(this.modifyPropsMap).forEach(p => {                        
            window[p] = this.modifyPropsMap[p]                    
        })   
    }            
    inactive() { // 卸载                
        for (const prop in window) {                    
            if (window.hasOwnProperty(prop)) {                        
                if (window[prop] != this.windowSnapshot[prop]) {                            
                    this.modifyPropsMap[prop] = window[prop];                            
                    window[prop] = this.windowSnapshot[prop];                        
                }                    
            }                
        }            
    }        
}

在不支持 Proxy 的场景下会降级为 snapshotSandBox,如同他的名字一样,snapshotSandBox 的原理就是在子应用激活 / 卸载时分别去通过快照的形式记录 / 还原状态来实现沙箱的。

总结起来,对当前的 window 和记录的快照做 diff 来实现沙箱。

  • legacySandBox

第二种则是 legacy 沙箱,下面的 demo 比上一个稍微复杂一点。主要是加载了两次子应用,并且每次改变的变量值不同。

let {sandbox, fakeWindow} = new legacySandBox();
var a = '主应用A';        
var c = '主应用C';              
console.log('主应用原来的Window:', a, c);        
    
function beforeMounted(){            
    sandbox.active();            
    console.log("加载子应用前");        
}        
function beforeUnMounted(){            
    sandbox.inactive();            
    console.log("卸载子应用前");        
}        
    
function app1(win = window){ // 这里使用了 fakeWindow 作为 window            
    beforeMounted();            
    if(win.a === 'app1A'){                
        win.a = 'app1A-2';                
        win.c = '2';                
        win.d = 'app1D-2';                
        console.log("子应用第二次加载 Window:", win.a, win.c, win.d);
    } else {                
        win.a = 'app1A'; // 修改                
        win.c = null; // 删除                
        win.d = 'app1D'; // 新增                
        console.log("子应用第一次加载 Window:", win.a, win.c, win.d);
    }            
    beforeUnMounted();        
}       
    
app1(fakeWindow);
console.log('主应用现在的1Window:', a, c, d);        
app1(fakeWindow);
console.log('主应用现在的2Window:', a, c, d);

下图显示的是主应用被污染的结果:

image.png

下图是打开沙箱之后解决污染的结果:

image.png

legacy 沙箱的主要原理是使用了 ES6 中的 Proxy,把原来的 window 代理到 fakeWindow 上,这样就不用遍历整个 window 去应用和恢复环境了。除此之外,它还在沙箱内部设置了三个变量池:

1. addedPropsMapinSandbox 用于存放子应用运行期间新增的全局变量,用于在卸载子应用的时候删除;
2. modifiedPropsOrginalMapInSandbox 用于存放子应用运行期间修改的全局变量,用于卸载时进行恢复;
3. currentUpdatedPropsValueMap 用于存放子应用运行期间所有变化的变量,这样可以在加载子应用时恢复其上一次的环境。

image.png

class legacySandBox {
  constructor() {
    // 记录子应用运行期间新增的 key
    this.addedPropsMapInSandbox = new Map();
    // 记录子应用运行期间修改的 key
    this.modifiedPropsOriginalValueMapInSandbox = new Map();
    // 记录子应用运行期间的值
    this.currentUpdatedPropsValueMap = new Map();
    this.sandboxRunning = false;
    const _this = this;
    const fakeWindow = new Proxy(window, {
      set(_, p, value) {
        if (_this.sandboxRunning) {
          if (!window.hasOwnProperty(p)) {
            _this.addedPropsMapInSandbox.set(p, value);
          } else if (!_this.modifiedPropsOriginalValueMapInSandbox.has(p)) {
            const originalValue = window[p];
            _this.modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }
          _this.currentUpdatedPropsValueMap.set(p, value);
          window[p] = value;
          return true;
        }
        return true;
      },
      get(_, p) {
        if (p === "top" || p === "window" || p === "self") {
          return proxy;
        }
        return window[p];
      }
    })
    return { sandbox: this, fakeWindow };
  }
  active() { // 激活
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
    }
    this.sandboxRunning = true;
  }
  inactive() { // 卸载
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((v, p) => this.setWindowProp(p, undefined, true));
    this.sandboxRunning = false;
  }
  setWindowProp(p, v) {
    window[p] = v;
  }
}

legacySandBox 的本质上还是操作 window 对象,但是他会存在三个状态池,分别用于子应用卸载时还原主应用的状态和子应用加载时还原子应用的状态:

  • addedPropsMapInSandbox: 存储在子应用运行时期间新增的全局变量,用于卸载子应用时还原主应用全局变量;
  • modifiedPropsOriginalValueMapInSandbox:存储在子应用运行期间更新的全局变量,用于卸载子应用时还原主应用全局变量;
  • currentUpdatedPropsValueMap:存储子应用全局变量的更新,用于运行时切换后还原子应用的状态;

所以,总结起来,legacySandBox 还是会操作 window 对象,但是他通过激活沙箱时还原子应用的状态,卸载时还原主应用的状态来实现沙箱隔离的。

  • ProxySandbox

第二种沙箱的实现对于单例模式来说已经比较完善了,但是不适用于多例模式,即同时有多个子应用在运行期间的时候,qiankun 针对这个问题提出来 proxySandbox。

proxySandbox 依然是使用 proxy 代理 window,但不同的是对于每个子应用都代理了一个 fakeWindow,这样在查找变量的时候在本地的 fakeWindow 上查找,如果没有找到就到主应用的 window 上查找,而修改时只修改本地的 fakeWindow,不会影响到其他的应用,在最终卸载时把 fakeWindow 删除即可。

image.png

在 qiankun 中,proxySandBox 用于多实例场景。什么是多实例场景,这里我简单提下,一般我们的中后台系统同一时间只会加载一个子应用的运行时。但是也存在这样的场景,某一个子应用聚合了多个业务域,这样的子应用往往会经历多个团队的多个同学共同维护自己的业务模块,这时候便可以采用多实例的模式聚合子模块(这种模式也可以叫微前端模块)。

和 legacySandBox 最直接的不同点就是,为了支持多实例的场景,proxySandBox 不会直接操作 window 对象。并且为了避免子应用操作或者修改主应用上诸如 window、document、location 这些重要的属性,会遍历这些属性到子应用 window 副本(fakeWindow)上.

因为 proxySandBox 不直接操作 window,所以在激活和卸载的时候也不需要操作状态池更新 / 还原主子应用的状态了。相比较看来,proxySandBox 是现阶段 qiankun 中最完备的沙箱模式,完全隔离了主子应用的状态,不会像 legacySandBox 模式下在运行时期间仍然会污染 window。

4、生命周期

项目在迁移成子应用时,需要在入口的 JS 配合 qiankun 来做一些改动,而这些改动有可能影响子应用的独立运行。为了解决子应用也能独立运行的问题,qiankun 注入了一些变量:

window.__POWERED_BY_QIANKUN__,这样就可以判断当前应用是否在独立运行。

但是变量需要在运行时动态的注入,那么该变量设置的位置就需要考虑清楚,qiankun 选择在 single-spa 提供的生命周期前进行变量的注入,在 beforeLoad 和 beforeMount 中把变量置为 true,在 beforeUnmount 中把变量置为 false, 最后 qiankun 暴露了五个生命周期钩子:

beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount,

这五个钩子可以在主应用中注册子应用时使用。

和 single-spa 一样的是子应用的接入必须暴露三个生命周期(毕竟是基于 single-spa 实现的):

  • Bootstrap: 在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存。
  • Mount:触发应用的渲染方法。
  • Unmount:卸载微应用的应用实例。

5、子应用预加载

子应用预加载是一种优化策略,使用 requestIdleCallback 通过时间切片的方式去加载静态资源,在浏览器空闲时间去执行回调函数,避免浏览器卡顿,qiankun 有四种预加载策略:

  • 主应用执行 start 以后就直接开始预加载所有微应用的静态资源。
  • 在第一个微应用挂载以后加载指定的微应用的静态资源。
  • 第一个微应用挂载以后加载其它微应用的静态资源,利用 single-spa 提供的 single-spa:first-mount 事件来实现。
  • 自定义函数,返回两个微应用组成的数组,criticalAppNames 是关键微应用组成的数组,需要马上就执行预加载的微应用,minorAppsName 是普通的微应用组成的数组,在第一个微应用挂载以后预加载这些微应用的静态资源。

6、全局状态管理

在微前端中各个子应用需要和主应用进行通信,以获得必要的信息,子应用之间也可能会有少量的通信需要,在 qiankun 中使用的是一种订阅发布模式的通信方法。

// 触发全局监听,执行所有应用注册的回调函数
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
  // 循环遍历,执行所有应用注册的回调函数
  Object.keys(deps).forEach((id: string) => {
    if (deps[id] instanceof Function) {
      deps[id](cloneDeep(state), cloneDeep(prevState));
    }
  });
}
    
/**
 * 定义全局状态,并返回通信方法,一般由主应用调用,微应用通过 props 获取通信方法。
 * @param state 全局状态,{ key: value }
 */
export function initGlobalState(state: Record<string, any> = {}) {
  if (state === globalState) {
    console.warn('[qiankun] state has not changed!');
  } else {
    // 方法有可能被重复调用,将已有的全局状态克隆一份,
    // 为空则是第一次调用 initGlobalState 方法
    // 不为空则非第一次次调用
    const prevGlobalState = cloneDeep(globalState);
    // 将传递的状态克隆一份赋值为 globalState
    globalState = cloneDeep(state);
    // 触发全局监听
    emitGlobal(globalState, prevGlobalState);
  }
  // 返回通信方法,参数表示应用 id,true 表示自己是主应用调用
  return getMicroAppStateActions(`global-${+new Date()}`, true);
}
    
/**
 * 返回通信方法 
 * @param id 应用 id
 * @param isMaster 表明调用的应用是否为主应用,
 * 在主应用初始化全局状态时,initGlobalState 内部调用该方法时会传递 true,其它都为 false
 */
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
  return {
    /**
     * 全局依赖监听,为指定应用(id = 应用id)注册回调函数
     * 依赖数据结构为:
     * {
     *   {id}: callback
     * }
     *
     * @param callback 注册的回调函数
     * @param fireImmediately 是否立即执行回调
     */
    onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      // 回调函数必须为 function
      if (!(callback instanceof Function)) {
        console.error('[qiankun] callback must be function!');
        return;
      }
      // 如果回调函数已经存在,重复注册时给出覆盖提示信息
      if (deps[id]) {
        console.warn(`[qiankun] '${id}' global listener already exists before this`);
      }
      // id 为一个应用 id,一个应用对应一个回调
      deps[id] = callback;
      // 克隆全局状态
      const cloneState = cloneDeep(globalState);
      // 如果需要,立即出发回调执行
      if (fireImmediately) {
        callback(cloneState, cloneState);
      }
    },
    
    /**
     * setGlobalState 更新 store 数据
     *
     * 1. 对新输入 state 的第一层属性做校验,如果是主应用则可以添加新的一级属性进来,
 * 也可以更新已存在的一级属性,
     *    如果是微应用,则只能更新已存在的一级属性,不可以新增一级属性
     * 2. 触发全局监听,执行所有应用注册的回调函数,以达到应用间通信的目的
     *
     * @param state 新的全局状态
     */
    setGlobalState(state: Record<string, any> = {}) {
      if (state === globalState) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      // 记录旧的全局状态中被改变的 key
      const changeKeys: string[] = [];
      // 旧的全局状态
      const prevGlobalState = cloneDeep(globalState);
      globalState = cloneDeep(
        // 循环遍历新状态中的所有 key
        Object.keys(state).reduce((_globalState, changeKey) => {
          if (isMaster || _globalState.hasOwnProperty(changeKey)) {
            // 主应用 或者 旧的全局状态存在该 key 时才进来
            // 说明只有主应用才可以新增属性,
            // 微应用只可以更新已存在的属性值
            // 记录被改变的key
            changeKeys.push(changeKey);
            // 更新旧状态中对应的 key value
            return Object.assign(_globalState, { [changeKey]: state[changeKey] });
          }
          console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
          return _globalState;
        }, globalState),
      );
      if (changeKeys.length === 0) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      // 触发全局监听
      emitGlobal(globalState, prevGlobalState);
      return true;
    },
    // 注销该应用下的依赖
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

所需要的状态都存在 window 的 gloablState 全局对象里,使用 onGlobalStateChange 添加监听器。

当调用 setGlobalState 更新值后,会触发 emitGlobal,执行所有对应的监听器。

调用 offGlobalStateChange 删掉监听器。

7、全局错误管理

当运行中发生错误时,需要对其进行捕获,这里主要监听了 error 和 unhandledrejection 两个错误事件。

window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);

最后

了解到 qiankun 的这些特点之后,我们就能很好的运用 qiankun 帮助我们做微前端服务。整合我们项目。特别适合一个部门把自己一系列的产品都集合在一起,也特别适合项目分模块重构等等。