微前端揭秘:扒一扒 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 的方式来解决应用之间的样式冲突问题。

相关推荐
孤水寒月2 小时前
基于HTML的悬窗可拖动记事本
前端·css·html
祝余呀2 小时前
html初学者第一天
前端·html
耶啵奶膘4 小时前
uniapp+firstUI——上传视频组件fui-upload-video
前端·javascript·uni-app
视频砖家5 小时前
移动端Html5播放器按钮变小的问题解决方法
前端·javascript·viewport功能
lyj1689975 小时前
vue-i18n+vscode+vue 多语言使用
前端·vue.js·vscode
小白变怪兽7 小时前
一、react18+项目初始化(vite)
前端·react.js
ai小鬼头7 小时前
AIStarter如何快速部署Stable Diffusion?**新手也能轻松上手的AI绘图
前端·后端·github
墨菲安全8 小时前
NPM组件 betsson 等窃取主机敏感信息
前端·npm·node.js·软件供应链安全·主机信息窃取·npm组件投毒
GISer_Jing8 小时前
Monorepo+Pnpm+Turborepo
前端·javascript·ecmascript
天涯学馆8 小时前
前端开发也能用 WebAssembly?这些场景超实用!
前端·javascript·面试