微前端揭秘:扒一扒 qiankun 的庐山真面目!

开篇废话

滴,立冬卡❄️!

冬季伊始特奉上文章一篇,揭秘 qiankun 框架原理,以此温暖倔友们追求技术的心 🔥🔥🔥🤣。

single-spa

qiankun 实际上是对 single-spa 的二次封装;所以要了解 qiankun 的原理,还得先弄明白 single-spa 是如何工作的。

我们先来简单看一下 single-spa 的使用方式:

注册微应用

首先,需要使用 registerApplication 方法来注册一个微应用:

js 复制代码
import * as singleSpa from 'single-spa';
// 微应用的名称
const name = 'app1';

// 加载微应用的方法,需要是个返回 Promise 的加载函数
const app = () => import('./app1.js');

// 这个微应用的路由匹配规则
const activeWhen = location => location.pathname.startsWith('/app1');

// 调用 registerApplication 方法,将对应参数传入,注册微应用
singleSpa.registerApplication({ name, app, activeWhen });

在注册微应用的过程中,必须传入以下几个参数:

  • 微应用的名称;
  • 微应用的加载方法;
  • 微应用的路由匹配规则;

实现微应用生命周期

接下来,我们着手来实现一下我们刚刚注册的微应用 app1 的生命周期函数:

js 复制代码
//app1.js
let domEl;
// app1 的初始化方法,创建一个 div 元素,并为其添加属性和内容
export function bootstrap(props) {
  return Promise.resolve().then(() => {
    domEl = document.createElement('div');
    domEl.innerHTML = '我是app1的内容';
    domEl.id = 'app1';
  });
}
// 挂载方法,用于将初始化时创建的 dom 元素挂载到页面指定节点上
export function mount(props) {
  return Promise.resolve().then(() => {
    // 在这里通常使用框架将ui组件挂载到dom。
    document.querySelector('#app').appendChild(domEl);
  });
}
// 卸载方法,用于将页面上指定的 dom 元素移除。
export function unmount(props) {
  return Promise.resolve().then(() => {
    // 在这里通常是通知框架把ui组件从dom中卸载。
    const target = document.querySelector('#app1')
    target.remove()
  })
}

在上面的代码中,我们为 app1 实现了几个生命周期函数,分别是:

  • 初始化 (bootstrap):初始化方法,用于创建一个 div 元素,并为其添加属性和内容;
  • 挂载 (mount):挂载方法,用于将初始化时创建的 dom 元素挂载到页面指定节点上
  • 卸载 (unmount):卸载方法,用于将页面上指定的 dom 元素移除。

这些生命周期函数通过 export 向外暴露,single-spa 会在适当的时候去调用它们。

启动微应用

最后,我们通过 single-spastart 方法来激活这个微应用:

js 复制代码
import * as singleSpa from 'single-spa';

singleSpa.start()

一切准备就绪!

现在当我们访问 /app1 这个地址时,single-spa 会自动去做路由匹配,发现能够命中我们注册微应用时预先定义好的路由规则;

这时候,single-spa 就会通过我们在注册微应用 传入的 loadApp 方法获取到对应的资源

最后 依次调用我们导出的 bootstrapmount 这些方法 ,将我们的 dom 节点顺利挂载到页面上。

简易版 single-spa

了解了 single-spa 的基本使用方式,下面我们来试着自己动手实现一个简易版的 single-spa

实现 registerApplication 方法

首先是 registerApplication 方法,它的实现逻辑很简单,如下:

js 复制代码
let apps = [];

function registerApplication(appName, appOrLoadApp, activeWhen, customProps) {
  apps.push({
    name: appName,
    loadApp: appOrLoadApp,
    activeWhen: activeWhen,
    customProps: customProps,
    status: 'NOT_LOADED'
  });
}

single-spa 中支持注册多个微应用,所以我们用一个全局的 apps 数组来管理注册的应用

当调用 registerApplication 方法时,只要将应用信息放这个数组就行了。

同时,我们给每个应用额外增加了一个 status 字段用来 标识这个应用的状态,方便我们对应用状态进行管理。

监听路由变化

我们在前面提到 ------ 当路由地址改变时, single-spa 会比较当前地址与路由匹配规则,从而来判断是否需要加载微应用;

这实际上是通过 监听 hashchangepopstate 事件来实现的

js 复制代码
function reroute() {
  // Todo
}

// 监听路由的变化 调用对应的回调函数 reroute
window.addEventListener('hashchange', reroute);
window.addEventListener('popstate', reroute);

过滤需要激活的微应用

在对应的回调函数 reroute 中,我们遍历注册好的微应用,依次调用每个微应用的 activeWhen 方法并传入 window.location 对象,来判断是否能命中路由匹配规则;

如果能够命中,说明这是个 即将要激活的应用,我们将其保存在一个数组中:

js 复制代码
function reroute() {
  // 储存需要激活的应用
  const activeApps = [];

  apps.forEach(app => {
    // 判断当前地址是否能命中微应用的路由匹配规则
    if (app.activeWhen(window.location)) {
      activeApps.push(app);
    }
  });
}

加载对应的微应用

通过上一步,我们已经知道了哪些是需要激活的微应用;

接下来就需要 获取这些微应用的资源,拿到它暴露出的生命周期钩子并按顺序调用它们,同时更新应用状态

js 复制代码
function reroute() {
  const activeApps = [];

  apps.forEach(app => {
    if (app.activeWhen(window.location)) {
      activeApps.push(app);
    }
  });
  
  // +++ 新增代码 +++
  // 遍历 activeApps 数组
  activeApps.forEach(async app => {
    // 判断应用状态 防止重复执行
    if (app.status === 'NOT_LOADED') {
   
      // 通过传入的 loadApp 方法拿到对应的资源
      app.loadApp().then(result => {
      
        // 修改应用状态
        app.status = 'NOT_BOOTSTRAPPED';
        
        // 获取暴露的生命周期狗子
        app.bootstrap = result.bootstrap;
        app.mount = result.mount;
        app.unmount = result.unmount;
      }).then(() => {
      
        // 按顺序执行生命周期钩子 并修改应用状态
        app.bootstrap().then(() => {
          app.status = 'NOT_MOUNTED';
        }).then(() => {
          app.mount();
          app.status = 'NOT_MOUNTED';
        }).then(() => {
          app.status = 'MOUNTED';
        });
      })
    }
  });
}

这样一来微应用 app1 对应的内容就被成功挂载到页面上了!

这时候,如果路由发生了改变与当前激活的微应用不再匹配,那么就需要 卸载对应的微应用

对应的逻辑也很简单,直接上代码:

js 复制代码
function reroute() {
  // +++ 新增代码 +++
  // 声明一个数组 用于储存失活的 app
  const inactiveApps = [];

  apps.forEach(app => {
    if (app.activeWhen(window.location)) {
    // ...省略应用激活逻辑...
    } else {
      // +++ 新增代码 +++
      // 如果没有命中路由匹配规则,将其加入 inactiveApps 数组中
      inactiveApps.push(app);
    }
  });

  // ...省略应用激活逻辑...
  
  // +++ 新增代码 +++
  // 应用失活时的逻辑
  inactiveApps.forEach(async app => {
    if (app.status === 'MOUNTED') {
      app.status = 'UNMOUNTING';
      
      // 调用预先定义好的 unmount 方法卸载应用;
      app.unmount().then(app => {
        // 成功卸载后 修改应用的状态;
        app.status = 'NOT_MOUNTED';
      });
    }
  });
}

实现 start 方法

最后,我们来实现一下 start 方法;当调用这个方法时,只需要去调用 reroute 函数就可以啦:

js 复制代码
export function start() {
  reroute();
}

so easy~

完整代码

完整代码如下:

js 复制代码
// my-single-spa
let apps = [];

// 注册微应用
export function registerApplication(options) {
  const {
    name,
    app,
    activeWhen
  } = options
  apps.push({
    name: name,
    loadApp: app,
    activeWhen,
    status: 'NOT_LOADED'
  });
}

// 加载微应用
export function start() {
  started = true;
  reroute();
}

// 路由变化回调
export function reroute() {
  const activeApps = [];
  const inactiveApps = [];

  apps.forEach(app => {
    if (app.activeWhen(window.location.href)) {
      activeApps.push(app);
    } else {
      inactiveApps.push(app);
    }
  });

  activeApps.forEach(async app => {
    if (app.status === 'NOT_LOADED') {
      app.loadApp().then(result => {
        app.status = 'NOT_BOOTSTRAPPED';
        app.bootstrap = result.bootstrap;
        app.mount = result.mount;
        app.unmount = result.unmount;
      }).then(() => {
        app.bootstrap().then(() => {
          app.status = 'NOT_MOUNTED';
        }).then(() => {
          app.mount();
          app.status = 'NOT_MOUNTED';
        }).then(() => {
          app.status = 'MOUNTED';
        });
      })
    }
  });

  inactiveApps.forEach(async app => {
    if (app.status === 'MOUNTED') {
      app.status = 'UNMOUNTING';
      app.unmount().then(() => {
        app.status = 'NOT_LOADED';
      });
    }
  });
}

window.addEventListener('hashchange', reroute);
window.addEventListener('popstate', reroute);

singla-spa 痛点

至此,我们已经实现了一个简易的 single-spa

但是,不知道各位小伙伴有没有发现,在使用 single-spa 的过程中有几个痛点:

  • 首先,single-spa 加载的微应用时 需要以 JS 文件作为入口 ,这会把所有的资源文件打包在一个 bundle 中,造成最终输出的 bundle 十分庞大

  • 其次,对于每一个微应用,我们都需要手动实现它的生命周期,并暴露给 single-spa 使用

    在上面的例子中,我们通过在生命周期钩子中操作原生 DOM 对象,从而实现应用的挂载、卸载等逻辑;而现在,大前端什么 VueReactAngular 等等各种框架五花八门,难不成针对每个框架都要调用相关的 API 封装一套对应的逻辑吗?

这可不能忍!😤😤😤

qiankun 针对上面提到的这些问题一一做出了处理,下面就来看看它是怎么做的吧。

qiankun

首先回想一下,当我们单独使用 VueReact 这些前端框架构建 SPA 时,它们最终都会打包生成一个 index.html 页面,用户操作时框架会通过执行各种脚本来动态地更新这个 index.html 页面。

如果我们能够拿到这个最终的 index.html 资源文件,并 把它作为微应用的入口 ;当微应用被 激活时,我们将这个 index.html 文件的内容挂载到页面指定的位置 ;当 微应用失活时,我们再操作 DOM 将对应的内容移除

这样一来,管你微应用使用的是什么技术栈,老夫我直接操作 DOM 一把梭哈!

并且使用 HTML 作为入口文件还有一个好处:能够利用浏览器的并行加载能力 提高资源的加载速度,可谓是一举两得!

qiankun 底层正是通过 import-html-entry 这个库来实现 JS EntryHTML Entry 的转变。

那么 import-html-entry 又是怎么运作的呢,我们接着往下看 ------

import-html-entry

假设在我们本地的 http://localhost:7104 地址上运行了一个项目,项目的 index.htmlmain.js 文件分别如下:

html 复制代码
// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <script type="text/javascript" src="main.js"></script>
  </body>
</html>
js 复制代码
// main.js
console.log('我是 7104 端口')
export function main () {
  console.log('我是 main 函数')
}

我们通过 import-html-entry 库的 importHTML 方法来加载 7104 端口这个项目的资源文件:

js 复制代码
import importHTML from 'import-html-entry';
// 调用 importHTML 方法,传入需要加载的资源地址
importHTML('//localhost:7104').then(res => {
  // 将获取到的资源结果输出
  console.log(res);
});

这里输出的 res 是个对象,内容如下:

从图中可以看出,这个项目的 index.html 文件被字符串化放在了 template 字段中,并且对应的 main.js 脚本被一段文本注释代替了

如果我们想要执行这些注释了的脚本,只需要 调用返回的这个对象上的 execScripts 方法

同时,还能 通过 getExternalScripts 方法来获取对应脚本;以及 getExternalStyleSheets 方法来获取对应的样式表文件

js 复制代码
import importHTML from 'import-html-entry';

importHTML('//localhost:7104').then(res => {
  const { execScripts, getExternalScripts, getExternalStyleSheets } = res;
  // 执行脚本,控制台输出: '我是 7104 端口'
  execScripts().then(exports => {
    // 获取导出的内容,控制台输出: function main () {console.log('我是 main 函数')}
    console.log(exports) 
  })
  
  // 获取并执行外部脚本
  // scripts 是个数组,数组中的每一项都是字符串化的脚本文件
  getExternalScripts().then(scripts => {
    // 执行脚本,控制台输出: '我是 7104 端口'
    scripts.forEach(eval);
  });

  // 获取外部样式表
  getExternalStyleSheets().then(styleSheets => {
    // styleSheets 是个数组,数组中的每一项都是字符串化的样式文件
    styleSheets.forEach(styleSheet => {});
  });
});

import-html-entry 这个库的加入,让我们能够 获取到各个项目打包后的 index.html 资源,并且自由地控制各个脚本、样式文件的加载、执行时机

沙盒

优化了微应用的加载,还有一个需要关注的问题 ------

微应用在加载的过程中,往往会声明全局的变量、设置监听器等等操作;这些副作用可能会导致 全局环境被污染,从而影响其他应用的执行。

因此,需要想办法给这些应用提供一个能够 隔离这些副作用的运行环境,保证它们能够稳定的运行彼此之间互不影响;

这就是我们常说的开启一个 沙盒 (sandbox)

隔离运行环境

一个最简单的隔离运行环境的方式,就是 复制 window 对象上的所有属性到一个新的对象中,然后使用 Proxy 来代理这个对象

我们通过几段代码来看看它的实现:

  1. 复制 window 对象:
js 复制代码
// 复制 window 对象
function createFakeWindow(globalContext) {
  // 声明一个新的变量 用于储存 window 对象上复制过来的属性
  const fakeWindow = {};
  
  // 遍历 window 对象上所有属性,过滤出哪些熟悉是可配置的
  Object.getOwnPropertyNames(globalContext).filter(function (p) {
    const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
    return !(descriptor === null || descriptor === void 0 ? void 0 : descriptor.configurable);
  }).forEach(function (p) {
    const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
    if (descriptor) {
      // 将这些可配置的属性 设置到我们新的对象中
      Object.defineProperty(fakeWindow, p, Object.freeze(descriptor));
    }
  });

  return fakeWindow;
}

const fakeWindow = createSimpleFakeWindow(window);
  1. 使用 proxy 劫持 fakeWindow
js 复制代码
class ProxySandbox {
  private proxy

  constructor() {
    const rawWindow = window;
    const fakeWindow = createFakeWindow(window)
    // 使用 proxy 劫持
    const proxy = new Proxy(fakeWindow, {
      // 这里以 get 和 set 方法为例
      get: (target: any, p: PropertyKey): any => {
        // 对一些特殊的属性做处理 返回代理对象本身
        if (p === 'window' || p === 'self') {
          return proxy
        }
        return target[p] || rawWindow[p];
      },
      set: (target: any, p: PropertyKey, value: any): boolean => {
        target[p] = value;
        return true;
      },
    });
  }
  this.proxy = proxy
}
  1. 使用代理 window 对象:
js 复制代码
// 使用示例
const sandbox = new ProxySandbox();

sandbox.a = 1;  // 在沙箱环境中设置全局变量
console.log(sandbox.a);  // 1
console.log(window.a);  // undefined,全局环境未被污染

而在 qiankun 源码中主要采用的也是这种 Proxy 代理的方式来实现运行环境的隔离。

样式隔离

我们知道,在微应用中子应用的样式和主应用的样式最后是存在同一颗 DOM 树下的,这那就有可能会造成 样式冲突 的问题

因此,除了要开辟一个稳定的运行环境外,进行样式隔离同样是个重中之重;

我们来看看 qiankun 是怎么做的。

scoped css

如果开启了 experimentalStyleIsolation 选项,qiankun 会使用 scoped css 的方式来进行样式隔离。

具体做法就是: 给包裹子应用的最外层元素加上特定的属性;然后获取到子应用下的样式表内容,修改其中的 css 规则,给其加上对应的属性选择器

html 复制代码
<!-- 添加特定属性 data-qiankun="react15" -->
<div data-qiankun="react15"></div>
css 复制代码
// 默认样式
.react15-icon { 
  height: 400px; 
}

// 加上特定属性选择器前缀后
div[data-qiankun="react15"] .react15-icon { 
  height: 400px; 
}

而这里的 react15 实际上就是我们在注册这个子应用时提供的 name 字段。

shadow dom

第二种方式是使用 shadow dom

当配置为 { strictStyleIsolation: true } 时, qiankun 会为每个微应用的容器包裹上一个 shadow dom,如下图:

shadow dom 下的样式 只会应用于其内部的元素 ,不会泄漏到外部的文档或其他 shadow dom 中的元素;从而达到样式隔离的目的。

需要强调的是,shadow dom 的 DOM 访问方法、样式选择器等许多特性与普通 DOM 都有所差异,因此在使用时会有许多限制,需要谨慎开启!

总结

以上就是关于 qiankun 框架的全部介绍啦~

总的来说, qiankun 就是对 single-spa 的二次封装,它通过 import-html-entry 库将 JS Entry 转换为 HTML Entry,抹平了各大框架之间的差异。

此外,qiankun 还引入了沙盒机制,通过复制 window 对象并使用 Proxy 来隔离子应用的运行环境,防止全局环境被污染。

最后,qiankun 通过 scoped cssshadow dom 的方式来解决应用之间的样式冲突问题。

相关推荐
M_emory_27 分钟前
解决 git clone 出现:Failed to connect to 127.0.0.1 port 1080: Connection refused 错误
前端·vue.js·git
Ciito30 分钟前
vue项目使用eslint+prettier管理项目格式化
前端·javascript·vue.js
成都被卷死的程序员1 小时前
响应式网页设计--html
前端·html
mon_star°1 小时前
将答题成绩排行榜数据通过前端生成excel的方式实现导出下载功能
前端·excel
Zrf21913184551 小时前
前端笔试中oj算法题的解法模版
前端·readline·oj算法
文军的烹饪实验室3 小时前
ValueError: Circular reference detected
开发语言·前端·javascript
Martin -Tang4 小时前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发4 小时前
解锁微前端的优秀库
前端
王解5 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁5 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis