qiankun-微前端初探之路(一)

681 阅读8分钟

1.前文

刚开始接触微前端,这条路走得格外艰辛,这几天结合源码,官网,一些大佬的个人博客,总结出这到底是什么,下面这部分就是这几天自学的成果,逐步介绍当前最流行的微前端解决方案qiankun

2.什么是qiankun

qiankun官网

qiankun是微前端的一种解决方案。孵化自蚂蚁金融,跟umi来自同一家公司。说句实话不明白为什么叫他qiankun,明明是sing-spa的二次封装,qinkun是什么,它就是微前端的解决方案,那么微前端又是什么?我觉得用我自己的话来说,微前端首先解决了项目后期变得庞大。这个时候,我们首先会想到分模块上线,那么微前端就是将这些上线好的模块组合起来,形成一个项目。如果有一天,我发现我写的某个功能模块挺好的,我想拿下来用到我的新项目中,那么就可以采取微前端的方式。

3.qiankun的特性(官网抄的)

  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 🛡​ 样式隔离,确保微应用之间样式互相不干扰。
  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
  • 🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

4.项目搭建

项目需要一个主应用作为项目基座,至少一个子应用作为微应用嵌套在主应用中,官网使用是react作为基座。这里全都主微应用使用vue2.0语法。

4.1 安装

yarn ada qiankun --save

4.2 父应用中配置

registerMicroApps配置

  • entry: 子应用的 entry 地址,比如我们现在有两个子应用A和B,那么这里配置的就是他们的资源访问域名或ip
  • render:本质上是container的转换,container用来定义子应用的容器节点的选择器或者 Element 实例,这里使用的是实际例子
  • activeRule:子应用的激活规则,即什么路由访问才会去fetch entry配置的域名或ip,我们用了getActiveRule来完成匹配,我们看看getActiveRule的实现,该函数通过传入当前 location 作为参数,然后根据函数返回数值来看,若返回值为 true 时则表明当前子应用会被激活,则去调用entry入口配置
  1. 新建一个until文件夹下registApp.js
import { registerMicroApps, start } from 'qiankun';

function loader(){
    console.log('打印loder');
  }
  registerMicroApps([
    {
      name: 'vue1', // app name registered
      entry: '//localhost:3011',
      container: '#container',
      activeRule: '/vue1',
    //   loader,
    },
    {
      name: 'vue2',
      entry: '//localhost:3012',
      container: '#container',
      activeRule:'/vue2',
      loader,
    },
  ],
   {
    beforeLoad:()=>{
      console.log('加载前');
    },
    beforeMount:()=> {
      console.log('挂载前');
    },
    afterMount:()=>{
      console.log('挂载后');
    }
   }
  );

  start({
    sandbox:{
        // experimentalStyleIsolation:true
        strictStyleIsolation:true
    }
  });


2.mian.js中引入

import Vue from 'vue'
import App from './App.vue'
import { MessageBox } from "./components/MyUi";
import router from "./routes/routers"
import store from './store/index'
import './until/registApp'
Vue.config.productionTip = false
Vue.use(MessageBox)
new Vue({
  router,
  store,
  render: h => h(App),
}).$mount('#app')


3.使用展示

<template>
  <div id="app">
    <div>
      <home/>
      <router-link to="/vue1" tag="button">使用vue1</router-link>
      <router-link to="/vue2" tag="button">使用vue2</router-link>
      <!-- 渲染区域 -->
      <router-view />
      <div id="container"></div>
    </div>
  </div>
</template>

4.3 子应用配置

1.在main.js中配置

import Vue from 'vue'
import App from './App.vue'
import action from "@/mircros/action"
Vue.config.productionTip = false

let instance = null;
function render(props = {}) {
  const { container } = props;
  //这里的目的是找到qiankun中根节点挂载以及没有qiankun就直接挂载在根节点上
  instance=new Vue({
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  //这里的props接受父应用传递的参数
  action.setActions(props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;

}

2.vue.config.js

解决跨域问题

module.exports = {
  lintOnSave: false,
  publicPath:'//localhost:3011',
  devServer:{
    port:'3011',
    //解决跨域,端口号需要与父应用中保持一致
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `vue1`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
    },
  },
}

5.沙箱

5.1 css沙箱

5.1.1 前缀式沙箱

给定义的css选择器都增加一个缀,使得css模块化

start({
    sandbox:{
      //这里使用设置这个属性可以给所有的css属性增加一个前缀
         experimentalStyleIsolation:true
    }
    })

5.1.2 影子Dom沙箱

这种方式最常见的,例如vedio标签在浏览器中,就会使用一层影子包裹

start({
    sandbox:{
        strictStyleIsolation:true
    }
    })

5.2 js沙箱

5.2.1 快照沙箱

由主应用切换a应用上的window上挂载完数据后,在由主应用切换到b应用时,此时已经清空了A应用window上的属性数据等,此时的window对象是一个全新的window,是专属于B应用的window

const window = globalThis;
class SnapshotSandbox {
    constructor(name) {
        // 当前沙箱名称
        this.name = name;
        this.windowSnapshot = {};
        this.modifyPropsMap = {};
        this.isRunning = false;
    }
    active() {
        // 1、记录快照
        this.windowSnapshot = {};
        for (const prop in window) {
            if (Object.hasOwnProperty.call(window, prop)) {
                const ele = window[prop];
                this.windowSnapshot[prop] = ele;
            }
        }
        // 2、恢复之前的变更
        for (const key of Object.keys(this.modifyPropsMap)) {
            window[key] = this.modifyPropsMap[key];
        }
        this.isRunning = true;
    }
    inactive() {
        this.modifyPropsMap = {};
        // 记录本次运行的变更,利用快照还原window
        for (const prop in window) {
            if (Object.hasOwnProperty.call(window, prop) && window[prop] !== this.windowSnapshot[prop]) {
                this.modifyPropsMap[prop] = window[prop];
                window[prop] = this.windowSnapshot[prop];
            }
        }
        this.isRunning = false;
    }
}
const sandbox1 = new SnapshotSandbox("s1");
window.city = "su zhou";
sandbox1.active();
window.city = 'hang zhou';
console.log('city1', window.city);
sandbox1.inactive();
console.log('city2', window.city);
sandbox1.active();
console.log('city3', window.city);

5.2.2 代理沙箱

通过 proxy 代理 window 对象,记录 window 对象上属性的增删改查可以做多应用沙箱。

  • 单例模式直接代理了原生 window 对象,记录原生 window 对象的增删改查,当 window 对象激活时恢复 window 对象到上次即将失活时的状态,失活时恢复 window 对象到初始初始状态
  • 多例模式代理了一个全新的对象,这个对象是复制的 window 对象的一部分不可配置属性,所有的更改都是基于这个 fakeWindow 对象,从而保证多个实例之间属性互不影响

将这个 proxy 作为微应用的全局对象,所有的操作都在这个 proxy 对象上。

// 当前活跃的沙箱数量
let activeSandbox = 0;
// 最近操作过属性的沙箱
let curSandbox = null;
// 全局上下文(在浏览器环境下是window,目前在node环境测试使用globalThis)
const globalContext = globalThis;
// 获取当前沙箱
function getCurSandbox() {
    return curSandbox;
}
// 设置当前沙箱
function setCurSandbox(sandboxIns) {
    curSandbox = sandboxIns;
}
// 沙箱构造函数
class ProxySandbox {
    registerRunningApp(name, proxy) {
        if (this.isRuning) {
            const curApp = getCurSandbox();
            if (!curApp || curApp.name !== name) {
                setCurSandbox({name:name, window:proxy})
            }
        }
    }
    constructor(name) {
        this.name = name;
        this.isRuning = false;
        const fakeWindow = Object.create({});
        const proxy = new Proxy(fakeWindow, {
            set: (target, prop, value) => {
                // 赋值时,仅当沙箱激活时才可赋值
                if (this.isRuning) {
                    target[prop] = value;
                }
                this.registerRunningApp(this.name, this.proxy);
            },
            get: (target, prop) => {
                // 取值时,先找fakeWindow再找window
                this.registerRunningApp(this.name, this.proxy);
                return prop in target ? target[prop] : globalContext[prop];
            }
        });
        this.proxy = proxy;
    }
    active() {
        // 激活沙箱当前活跃沙箱加一
        if (!this.isRuning) activeSandbox++;
        this.isRuning = true;
    }
    inactive() {
        // 让沙箱失活,当前活跃沙箱数量减一
        --activeSandbox;
        this.isRuning = false;
    }
}

const pSandbox1 = new ProxySandbox("p1");
const pSandbox2 = new ProxySandbox("p2");
globalContext.city = "changsha";
globalContext.country = "China";
pSandbox1.active()
pSandbox2.active();
console.log("当前活跃沙箱数量", activeSandbox);
pSandbox1.proxy.city = "hangzhou";
console.log("当前活跃沙箱:", curSandbox.name);
console.log("沙箱1", pSandbox1.proxy.city, pSandbox1.proxy.country);
pSandbox2.proxy.city = "fuzhou";
console.log("当前活跃沙箱:", curSandbox.name);
console.log("沙箱2",pSandbox2.proxy.city, pSandbox2.proxy.country);
console.log("全局上下文city", globalContext.city);
pSandbox1.inactive();
pSandbox2.inactive();
console.log("pSandbox1", pSandbox1.proxy.city);
console.log("pSandbox2", pSandbox2.proxy.city);
console.log("当前活跃沙箱数量", activeSandbox);

6.通信方式

6.1 action模式

6.1.2 父应用中的操作

1.父应用中在src新建micros文件夹action.js文件

import { initGlobalState } from 'qiankun'
 
// 初始化 state,这里是应用data中心
const initState = {
    msgs:""
}
const actions = initGlobalState(initState)
 
export default actions

2.在需要传递应用数据的父应用的组件中传递数据并监听数据

<template>
  <div>
    <button @click='parentClick'>父应用传递到子应用的值</button>
</div>
</template>

<script>
import actions from "../../micros/action";
export default {
  name: "Home",
  data() {
    return {
      token:'fvifvrvrionvr'
    };
  },
mounted() {
    // 注册一个观察者函数,监听传递到子应用的数据
    actions.onGlobalStateChange((state, prevState) => {
      console.log("主应用观察者:token 改变前的值为 ", prevState.token);
      console.log("主应用观察者:登录状态发生改变,改变后的 token 的值为 ", state.token);
    });
  },
  methods: {
    parentClick(){
      let token =this.token
      //设置需要传递的属性数据
    actions.setGlobalState({ token });
    }
  },
};
</script>

6.1.2.子应用的操作

1.子应用中在src新建micros文件夹action.js文件

function emptyAction() {
    // 警告:提示当前使用的是空 Action
    console.warn('Current execute action is empty!')
}

class Actions {
    // 默认值为空 Action
    actions = {
        onGlobalStateChange: emptyAction,
        setGlobalState: emptyAction
    }

    // 设置 actions
    setActions(actions) {
        this.actions = actions
    }

    // 映射监听
    onGlobalStateChange(...args) {
        return this.actions.onGlobalStateChange(...args)
    }

    // 映射设置
    setGlobalState(...args) {
        return this.actions.setGlobalState(...args)
    }
}

const actions = new Actions()
export default actions

2.在子应用的main.js组件渲染时将父应用传递过来的数据传递到reder中

import Vue from 'vue'
import App from './App.vue'
import action from "@/mircros/action"
Vue.config.productionTip = false

let instance = null;
function render(props = {}) {
  const { container } = props;
  instance=new Vue({
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  //这里是重点
  action.setActions(props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;

}

3.在需要使用父应用传递过来数据的地方使用

<template>
  <div>
    <h1>父应用传递过来的值{{ title }}</h1>
  </div>
</template>

<script>
import actions from "../mircros/action";
export default {
  name: "Home",
  data() {
    return {
      title: "",
    };
  },
  mounted() {
    // 注册观察者函数
    // onGlobalStateChange 第二个参数为 true,表示立即执行一次观察者函数
    actions.onGlobalStateChange((state) => {
      //这里可以拿到父应用传递过来的值
      console.log(state,'gg');
      //   const { token } = state;
      this.title = state.token;
    }, true);
  },
};
</script>

6.2 发布订阅模式

1.主应用构建中央数据总线

//eventbus.js
import Vue from 'vue'
Vue.prototype.$eventBus = new Vue()
export const parentEventBus = Vue.prototype.$eventBus

2.将数组总线通过props传递出去

import { registerMicroApps, start } from 'qiankun';
import { parentEventBus } from './eventBus'
function loader(){
    console.log('打印loder');
  }
  registerMicroApps([
    {
      name: 'vue1', // app name registered
      entry: '//localhost:3011',
      container: '#container',
      activeRule: '/vue1',
      props: { // 下发微应用的入口, 如果是固定确认的资源可以维护在应用注册列表
         id:'ws',
         parentEventBus :parentEventBus 
    }
    },
  ],
  );
  start({
    sandbox:{
        strictStyleIsolation:true
    }
  });

3.主应用通过bus可以接受数据也可传递数据出去

<template>
  <div>
    <div v-for='item in list' :key='item'>{{item}}</div>
</div>
</template>

<script>
  //引入bus
import { parentEventBus } from '../../until/eventBus'
export default {
  name: "Home",
  data() {
    return {
      list:[]
    };
  },
mounted() {
  //接受微应用传递的数据
  parentEventBus.$on('handleArr', data => { // 监听
  console.log('这里能够获取到arr',data);
  this.list=data
})
  
  },
 
};
</script>

4.微应用main.js中接受props里面解构出主应用传递过来的中央数据总线

import Vue from 'vue'
import App from './App.vue'
import action from "@/mircros/action"
Vue.config.productionTip = false

let instance = null;
function render(props = {}) {
  const { container,parentEventBus } = props  
  Vue.prototype.$eventBus = new Vue() // 子应用的独享的 EventBus
  //接受主应用传递过来的数据总线将它挂载到微应用的bus实例上
  Vue.prototype.$parentEventBus = parentEventBus 
  instance=new Vue({
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  // console.log(props,'gggggyy');
  action.setActions(props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;

}

5.在微应用上使用,通过bus实例传递一个数据

<template>
  <div>

    <button @click=' tranfromParent'>点击传递给父应用的数据</button>
  </div>
</template>

<script>
export default {
  name: "Home",
  data() {
    return {
      arr:[1,3,4,6,7]
    };
  },

  methods: {
    tranfromParent(){
     this.$parentEventBus.$emit('handleArr',this.arr)
    }
  },
};
</script>

7.数据共享

7.1 props

在父应用中定义一个props,子应用就可以拿到这个props了

import { registerMicroApps, start } from 'qiankun';
  registerMicroApps([
    {
      name: 'vue1', // app name registered
      entry: '//localhost:3011',
      container: '#container',
      activeRule: '/vue1',
      //这里定义
      props: { // 下发微应用的入口, 如果是固定确认的资源可以维护在应用注册列表
         id:'ws'
    }
    },
  ],
  );
  start()

在子应用的生命周期mount中可以解构出这个props里的属性

import Vue from 'vue'
import App from './App.vue'
import action from "@/mircros/action"
Vue.config.productionTip = false

let instance = null;
function render(props = {}) {
  //这里可以拿到id
  console.log(props.id,'gggid')
  const { container } = props;
  instance=new Vue({
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  action.setActions(props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;

}

7.2 window

主应用中的组件需要被微应用引用可以把该组件通过window的方式进行挂载

主应用进行挂载

import About from "./components/About/About.vue"
window.commonComponent = { About }

微应用组件注册使用

<template>
  <div>
    <about/>
  </div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
  name: "App",
  components: {
    Home,
About: window.__POWERED_BY_QIANKUN__ ? window.commonComponent.About : import('@/components/HelloWorld.vue')
  },
};
</script>

8.手动添加微应用

由于registerMicroApps的特性,会导致路由的keep alive 失效,故使用 loadMicroAp 进行来达到自动注册的目的。如果微应用不是直接跟路由关联的时候,选择手动加载微应用的方式会更加灵活

<template>
  <div>
    <button @click="mout">挂载子应用</button>
  </div>
</template>

<script>
import { loadMicroApp } from "qiankun";
export default {
  name: "About",
  methods: {
    mout() {
      this.microsApp = loadMicroApp({
        name: "vue2",
        entry: "//localhost:3012",
        container: "#container",
        activeRule: "/vue2",
        props: {
          token: "chedehkeh",
        },
      });
    },
  },
  beforeDestroy() {
    this.microsApp.unmount()
  },
};
</script>

9.应用预加载

可以预先请求子应用的 HTML、JS、CSS 等静态资源,等切换子应用时,可以直接从缓存中读取这些静态资源,从而加快渲染子应用。

9.1registerMicroApps 模式

registerMicroApps 模式下,在start函数中配置prefetch即可

  • 配置为 all 则主应用 start 后即开始预加载所有微应用静态资源
  • 配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源
  • 配置为 function 则可完全自定义应用的资源加载时机 (首屏应用及次屏应用
 
  start({
    sandbox:{
        strictStyleIsolation:true,
      //这里进行预加载
        prefetch: "all"
    }
  });

9.2 LoadMicroApps模式

手动预加载指定的微应用静态资源,仅手动加载微应用场景需要

import { prefetchApps } from 'qiankun';
prefetchApps([
  { name: 'app1', entry: '//localhost:7001' },
  { name: 'app2', entry: '//localhost:7002' },
]);