简易版qiankun实现

首先我们看一下qiankun中的app注册逻辑,和vue-router有些相似,使用activeRule匹配到相关路由后,通过entry加载子应用页面

接着再来看一下qiankun运行之后的代码,分别看一下主应用和子应用是如何搭配渲染整个页面的

从上面生成之后的代码其实我们可以简单看出子应用内容嵌入了主应用的container中,这时其实就可以猜想一下,我们是不是可以把子应用的html当成一个http请求,把请求内容回填到container中,接着我们再把子应用的相关js,css一起加载过来,并且执行

qiankun原理

从上面我们可以得出,qiankun实际上做了以下操作

  1. 监听路由变化,找到配置中的路由
  2. 获取路由匹配的页面
  3. 加载页面,执行js,渲染进制定的container

监听路由变化

路由监听有hash路由和history路由,hash路由采用hashchange监听,这里就不讨论了

主要处理history路由,加载一个路由使用的方法是pushState,replaceState;除了加载路由浏览器还提供了前进,后退等方法,此时需要使用popstate进行监听

js 复制代码
// rewrite-router.js

export const rewriteRouter = function () {
    // hash路由,window.hashChange()
    // history路由
    //      history.go、history.back、history.forword 使用popstate事件,window.onpopstate
    //      pushState、replaceState需要对函数进行重写
    window.addEventListener('popstate', () => {
        // 这个地方要想清楚
        preRoute = curRoute
        curRoute = window.location.pathname
        handleRouter()
    })

    let oriPushState = window.history.pushState
    window.history.pushState = (...args) => {
        preRoute = window.location.pathname
        oriPushState.apply(window.history, args)
        curRoute = window.location.pathname
        handleRouter()
    }

    let oriReplaceState = window.history.replaceState
    window.history.replaceState = (...args) => {
        preRoute = window.location.pathname
        oriReplaceState.apply(window.history, args)
        curRoute = window.location.pathname
        handleRouter()
    }
}

获取路由匹配的页面

首先需要提供一个注册子应用的方法,在主应用中调用,收集所有的子应用信息

js 复制代码
// index.js

// registerMicroApps([
//   {
//     name: 'vue1', // app name registered
//     entry: '//localhost:9001',
//     container: '#container',
//     activeRule: '/subone',
//   },
//   {
//     name: 'vue2',
//     entry: '//localhost:9002',
//     container: '#container',
//     activeRule: '/subtwo',
//   },
// ]);

let _apps = []

// 获取微应用列表
export const getApps = () => _apps

// 注册微应用
export const registerMicroApps = function (apps) {
    console.log(apps)
    _apps = apps
}

获取路由信息

js 复制代码
// handle-router.js
export const handleRouter = async function () {
    console.log('开始处理router')

    let apps = getApps()
    
    // 获取curRoute
    let curRoute = window.location.path
    let app = apps.find(item => {
        return curRoute.startsWith(item.activeRule)
    })
}

加载页面

qiankun最复杂的逻辑就在这一步,首先我们按照最开始想的把目标路由加载过来,然后回填入container中。

实际上的确是执行了,但是页面什么也没有渲染,因为我们的入口文件本身就是一个空文件,浏览器出于安全考虑不会加载innerHTML中的script,所以实际上子应用是没有请求js文件,子应用真正的业务dom没有挂载到子应用的根节点上

js 复制代码
let html = await fetch(app.entry).then(res => res.text())
const container = document.querySelector(app.container)
container.innerHTML = html

在qiankun中引入了库文件import-html-entry,核心的方法有

  • importEntry 加载子应用入口文件
  • getExternalScripts
  • getExternalStyleSheets获取css脚本
  • execScripts执行js脚本

我们不用这么复杂的功能,可以简写一下这个库文件,满足我们需求就好了

js 复制代码
// 自定义简写一下这个库
export const importHTML = async (url) => {
  const template = document.createElement('div')
  const html = await fetchResource(url)
  template.innerHTML = html

  // 获取所有script标签
  function getExternalScripts () {
    const scripts = template.querySelectorAll('script')
    return Promise.all(Array.from(scripts).map(script => {
      let src = script.getAttribute('src')
      // 两种格式,一种是外链js文件,一种是行内js
      if(!src) {
        return script.innerHTML
      } else {
        // 获取链接时要注意是相对链接
        return fetchResource(url + src)
      }
    }))
  }

  // 执行script代码
  async function execScripts () {
    let scripts = await getExternalScripts()

    let module = {exports : {}}
    let exports = module.exports

    scripts.forEach(script => {
      eval(script)
    })

    // console.log('subone-app', module.exports)

    // 如果这样做,肯定不合理,我们需要知道每个子应用导出的模块名字
    // return window['subone-app']

    // 构造commonjs环境
    return module.exports
  }

  return {
    template,
    getExternalScripts,
    execScripts
  }
}

注意这里面有一点非常重要,当我们执行完所有js代码之后,需要调用代码中的某个方法,那么我们如何才能拿到这份方法呢

umd规范

这里我们回到子应用看看子应用的vue.config.js,子应用会把代码打包成umd库的格式

js 复制代码
// vue.config.js
configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      chunkLoadingGlobal: `webpackJsonp_${name}`,
    },
},
 
// 打包生成的文件格式
(function (n, o) {
   // commonjs规范
  "object" === typeof exports && "object" === typeof module
    ? (module.exports = o())
    // amd规范
    : "function" === typeof define && define.amd
    ? define([], o)
    : "object" === typeof exports
    // es6规范
    ? (exports["subone-app"] = o())
    : (n["subone-app"] = o());
})(self, function () {});

正常情况下,不满足上面任意规范就会走到(n["subone-app"] = o()),也就是在window下创建一个subone-app变量,存放js内容

那么如果我们需要调用子应用方法,直接使用可以吗?答案是肯定可以的,那么这么做肯定也是不合理的,我们需要知道每个子应用导出的模块名字,这就很不合理了

js 复制代码
 window['subone-app'].bootstrap

结合上面的umd文件格式,我们想一下是否可以手动构造一种规范呢,比如构造commonjs规范(当然还可以构造其他规范)

js 复制代码
let module = {exports : {}}
// 构造commonjs环境
let exports = module.exports

scripts.forEach(script => {
  eval(script)
})

// 构造commonjs环境
return module.exports

完善逻辑

到这里实际上子应用就能真实渲染到主应用里面了,但是还有一些其他问题,比如

  • 子应用把主应用给顶走了
  • 图片加载问题
子应用把主应用给顶走了

这个是为什么呢,首先我们看一下子应用是如何渲染的。

js 复制代码
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 mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}

当我们子应用渲染时由于没有提供window.POWERED_BY_QIANKUN,所以直接就走到了render方法,并且此时没有提供container,所以直接找到了页面中的#app开始渲染,而主应用同样是以#app当根节点,所以效果就是子应用把主应用直接顶掉了

那么大家可以验证一下,如果把主应用的#app节点全部换掉还会有这个问题吗?

解决方案,提供window.POWERED_BY_QIANKUN,调用子应用的mount方法

js 复制代码
window.__POWERED_BY_QIANKUN__ = true
// 这个就是上面子应用代码执行结束之后返回的自己构造的module.exports
const appExports = await execScripts()
// 执行子应用的mount方法
await mount(app)
图片加载问题

子应用中的资源通常会存在加载不到的情况,原因在于相对资源的引用会以主应用的域名去服务器查找,当然是不存在的,因为资源存在于子应用域名下

我们看到在qiankun教程中有这段内容,添加public-path文件

js 复制代码
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

我们要了解这在干什么,才能去解决这个问题,大家可以试试自己改变__webpack_public_path__,让这个值为子应用的域名会发生什么?

webpack支持运行时的publicPath,设置这个变量__webpack_public_path__后,webpack打包的时候就会拼上这个域名,那么提供一个思路,在子应用加载的时候我们时候可以动态改变这个值呢

js 复制代码
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + '/'

其他问题

样式隔离

当你程序运行起来后,发现什么都不做,子应用样式会影响主应用,此时就需要用到样式隔离了

  • css module
  • css in js
  • BEM (Block Element Module)规范
  • shadow dom
  • experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器

主要讲一下shadow dom如何进行的隔离,可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁

js 复制代码
<p>hello</p>
<div id="subapp">
    <p>world</p>
</div>

<script>
    let subapp = document.getElementById('subapp')
    let shadow = subapp.attachShadow({mode: 'open'})
    shadow.innerHTML= `
        <p> hello world</p>
        <style>
            p {
                color: green;
            }
        </style>
    `
</script>
js隔离
快照沙箱

快照沙箱的核心逻辑非常简单,它在激活和失活时各做两件事情。

在激活时,它会记录window的状态,也就是快照,以便在失活时恢复到之前的状态。同时,它会恢复上一次失活时记录的沙箱运行过程中对window做的状态改变,保持一致。

在失活时,它会记录window上发生了哪些状态变化,并清除沙箱在激活后对window做的状态改变,以便恢复到未改变之前的状态。

从代码中大概也能看出一些问题,for in遍历window这是非常损耗性能的事情。如果有多个应用同时改写window的属性,那么状态就会混乱掉

js 复制代码
class SnapshotSandBox {
  windowSnapshot = {}
  modifyPropsMap = {}
  active() {
    // 保存window对象上所有属性的状态
    for(const prop in window) {
      this.windowSnapshot[prop] = window[prop]
    }
    // 恢复上一次在运行该微应用的时候所修改过的window上的属性
    Object.keys(this.modifyPropsMap).forEach(prop => {
      window[prop] = this.modifyPropsMap[prop]
    })
  }
  inactive() {
    for(const prop in window) {
      if(window[prop] !== this.windowSnapshot[prop]){
        // 记录修改了window上的哪些属性
        this.modifyPropsMap[prop] = window[prop]
        // 将window上的属性状态还原至微应用运行之前的状态
        window[prop] = this.windowSnapshot[prop]
      }
    }
  }
}
window.city = 'Beijing'
console.log('激活之前', window.city)
let snapshotSandBox = new SnapshotSandBox()
snapshotSandBox.active()
window.city = 'Shanghai'
console.log('激活之后',window.city)
snapshotSandBox.inactive()
console.log('失活之后',window.city)
支持单应用的代理沙箱

类似于快照沙箱的功能,即记录window对象的状态,并在沙箱失活时恢复window对象的状态。不同之处在于,LegacySandbox使用了三个变量来记录沙箱激活后window发生变化过的所有属性,避免了遍历window的所有属性来进行对比,提高了程序运行的性能。但是,这种机制仍然会改变window的状态,因此无法承担同时支持多个微应用运行的任务。

js 复制代码
class LegacySandBox {
    currentUpdatedPropsValueMap = new Map()
    modifiedPropsOriginalValueMapInSandbox = new Map();
    addedPropsMapInSandbox = new Map();
    proxyWindow = {}
    constructor() {
        const fakeWindow = Object.create(null)
        this.proxyWindow = new Proxy(fakeWindow, {
            set: (target, prop, value, receiver) => {
                const originalVal = window[prop]
                if (!window.hasOwnProperty(prop)) {
                    this.addedPropsMapInSandbox.set(prop, value)
                } else if (!this.modifiedPropsOriginalValueMapInSandbox.hasOwnProperty(prop)) {
                    this.modifiedPropsOriginalValueMapInSandbox.set(prop, originalVal)
                }
                this.currentUpdatedPropsValueMap.set(prop, value)
                window[prop] = value
            },
            get: (target, prop, receiver) => {
                return window[prop]
            }
        })
    }
    setWindowProp(prop, value, isToDelete = false) {
        //有可能是新增的属性,后面不需要了
        if (value === undefined && isToDelete) {
            delete window[prop]
        } else {
            window[prop] = value
        }
    }
    active() {
        // 恢复上一次微应用处于运行状态时,对window上做的所有修改
        this.currentUpdatedPropsValueMap.forEach((value, prop) => {
            this.setWindowProp(prop, value)
        })
    }
    inactive() {
        // 还原window上原有的属性
        this.modifiedPropsOriginalValueMapInSandbox.forEach((value, prop) => {
            this.setWindowProp(prop, value)
        })
        // 删除在微应用运行期间,window上新增的属性
        this.addedPropsMapInSandbox.forEach((_, prop) => {
            this.setWindowProp(prop, undefined, true)
        })
    }
}
window.city = 'Beijing'
let legacySandBox = new LegacySandBox();
console.log('激活之前', window.city)
legacySandBox.active();
legacySandBox.proxyWindow.city = 'Shanghai';
console.log('激活之后', window.city)
legacySandBox.inactive();
console.log('失活之后', window.city)
支持多应用的代理沙箱
  • 在沙箱激活后,每次获取window属性时,会先从当前沙箱环境的fakeWindow里面查找,如果不存在,就从外部的window里面去查找。这样做可以保证沙箱内部的操作不会影响到全局的window对象。
  • 同时,当window对象发生修改时,使用代理的set方法进行拦截,直接操作代理对象fakeWindow,而不是全局的window对象,从而实现真正的隔离。这种机制可以避免不同微应用之间的状态混乱问题,保证微应用之间的独立性。
js 复制代码
class ProxySandBox {
  proxyWindow = {};
  isRunning = false;
  active() {
    this.isRunning = true;
  }
  inactive() {
    this.isRunning = false;
  }
  constructor() {
    const fakeWindow = Object.create(null);
    this.proxyWindow = new Proxy(fakeWindow, {
      set: (target, prop, value, receiver) => {
        // 设置时只操作fakeWindow
        if (this.isRunning) {
          target[prop] = value;
        }
      },
      get: (target, prop, receiver) => {
        return prop in target ? target[prop] : window[prop];
      },
    });
  }
}
window.city = "Beijing";
let proxySandBox1 = new ProxySandBox();
let proxySandBox2 = new ProxySandBox();
proxySandBox1.active();
proxySandBox2.active();
proxySandBox1.proxyWindow.city = "Shanghai";
proxySandBox2.proxyWindow.city = "Chengdu";
console.log(
  "active:proxySandBox1:window.city:",
  proxySandBox1.proxyWindow.city
);
console.log(
  "active:proxySandBox2:window.city:",
  proxySandBox2.proxyWindow.city
);
console.log("window:window.city:", window.city);
proxySandBox1.inactive();
proxySandBox2.inactive();
console.log(
  "inactive:proxySandBox1:window.city:",
  proxySandBox1.proxyWindow.city
);
console.log(
  "inactive:proxySandBox2:window.city:",
  proxySandBox2.proxyWindow.city
);
console.log("window:window.city:", window.city);
相关推荐
前端大卫22 分钟前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘38 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare39 分钟前
浅浅看一下设计模式
前端
Lee川42 分钟前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix1 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人1 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端