wujie & qiankun 原理浅析

微前端的描述

微前端是一种类似于微服务的架构,将前端应用分解成多个部分,每个部分都能够独立运行、独立测试、独立交付。

微前端主要解决两个问题:

  • 跨团队协作
  • 项目新老迭代

1、iframe

优点:

  • 浏览器原生支持
  • 接入简单
  • 完美隔离,js、css、dom 完全隔离
  • 多应用激活,可以在页面上显示多个 iframe

缺点(引用):

  • url 不同步,刷新一下,ifram e的 url 状态就丢失了,前进后退按钮无法使用。
  • dom 割裂严重。iframe 里的弹窗无法覆盖全局。
  • 通信困难。只能通过 postmessage 传递序列化的消息。
  • 慢。白屏时间太长。子应用每次进入都需要浏览器上下文重建、资源重新加载。

2、iframe + Web Component ------ 腾讯:无界

wujie 的方案是利用 iframe 的优势,解决 iframe 的缺点。

2.1 wujie 的使用

2.2 无界的方案

  1. 应用加载机制和 js 沙箱机制

利用 iframe 实现沙箱,让子应用脚本在 iframe 里运行,利用 Web component 的 custom element 和 shadow dom 实现样式隔离。通过代理 iframe 的 document 的查询类接口(getElementByTagName,getElementById等)到 Web component 上,实现两者的关联。

  1. 路由同步机制

    在 iframe 内部进行 history.pushState,浏览器会自动在 joint seesion history 中添加 iframe 的 session-history,浏览器的前进、后退在不做任何处理的情况下就可以直接作用于子应用。

    劫持 iframe 的 history.pushState 和 history.replaceState,就可以将子应用的 url 同步到主应用的 query 参数上,当刷新浏览器初始化 iframe 时,读回子应用的 url 并使用 iframe 的 history.replaceState 进行同步。

  2. 通信机制

    承载子应用的 iframe 和主应用是同域的,所以可以进行通信。通信方式:

    • Props 注入

      子应用通过 $wujie.props 可以拿到主应用注入的数据。

    • window.parent 通信

      子应用和主应用同源,可以通过 window.parent 和主应用通信。

      主应用调用子应用的全局数据:

      dart 复制代码
      window.document.querySelector("iframe[name=子应用id]").contentWindow.xxx

      子应用调用主应用的全局数据:

      javascript 复制代码
      window.parent.xxx
    • 去中心化的通信

      通过 EventBus 事件总线实例,注入到主应用和子应用,实现去中心化通信。

dart 复制代码
具体实现:
主应用里注册微应用
1、setupApp ------ 主应用
注册并通过name做缓存
​
渲染时(以react为例):
2、startApp ------ 在子应用componentDidMount中执行
    1. new Wujie({})  
    - 创建iframe,将sandbox放到iframe._WUJIE上,代理window, document, location(劫持location, 将doument的查询类接口代理到shadowRoot)
    - 创建bus
    2.importHTML
    - 解析html 创建dom template 获取script和styleSheets
    3. active
    - 路由同步,window.history.replaceState
    - 准备shadow自定义wujie-app element,renderTemplateToShadowRoot
    4. start
    - 将script插入到iframe里
     insertScriptToIframe函数开始执行。fiber ?? requestIdleCallback
    - 执行js     fiber ?? requestIdleCallback
      区分before,sync,defer,async
      除了async不放入execQueue外,其他都放到execQueue里串行执行。因为async不需要保证执行顺序。
      最后触发load事件
​
通信:
3、EventBus
一个存储对象 cbs 
订阅 $on(name, fn)   cbs[name] = [fn]
发布 $emit(name, ...args) 遍历cbs[name]并执行fn

3、single-spa ------ 阿里:qiankun

qiankun 是基于 single-spa 的微前端框架,那为什么需要再包裹一层呢? qiankun 解决了 single-spa 的哪些问题呢?

single-spa

  • 需要主应用指定加载哪些 js、css,如果子应用打包逻辑发生变化,主应用也要跟着修改
  • 一个页面加载多个子应用时,之间可能会存在样式冲突,js冲突
  • 多个子应用之间的通信问题

qiankun

1、html 自动加载:import-html-entry

根据 ur l入口 html 文件,解析出 scripts、styles 去单独加载,其余部分做转换后放到 dom 里。

head 部分转换成 qiankun-head,把 script 部分提取出来单独加载。

支持预加载,在空闲时(requestIdleCallback)解析 script 和 style。

js 复制代码
const fetch = window.fetch.bind(window)

let embedHTMLCache = {}

function importHTML(url) {
  const assetPublicPath = getPublicPath(url)

  return (embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url))).then(response => {
    // 解析html文件
    const { template, scripts, entry, styles } = processTpl(response.text(), assetPublicPath)
    
    return {
      template,
      assetPublicPath,
      getExternalScripts: () => getExternalScripts(scripts, fetch),
      getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
      execScripts: (proxy) => execScripts(scripts[scripts.length - 1], scripts, proxy, { fetch })
    }

  })
                                 
}

// 获取资源路径
function getPublicPath(entry) {
  const { origin, pathname } = new URL(entry, location.href);
  const paths = pathname.split('/');
  // 移除最后一个元素
  paths.pop();
  return `${origin}${paths.join('/')}/`;
}

// 执行script脚本
function execScripts(entry, scripts, proxy = window, opts = {}) {
  return getExternalScripts(scripts, fetch).then(scriptsText => {
    
    function schedule(i, resolvePromise) {
      if(i < scripts.length) {
		const scriptSrc = scripts[i]
        const inlineScript = scriptsText[i]
        evalCode(scriptSrc, inlineScript)
        
        if(!entry && i === scripts.length - 1) {
          resolvePromise()
        } else {
          schedule(i + 1, resolvePromise)
        }
      }
    }
    
    return new Promise(resolve => schedule(0, resolve))
  })
}

// eval执行脚本
const evalCache = {};
function evalCode(scriptSrc, code) {
	const key = scriptSrc;
	if (!evalCache[key]) {
		const functionWrappedCode = `(function(){${code}})`;
		evalCache[key] = (0, eval)(functionWrappedCode);
	}
	const evalFunc = evalCache[key];
	evalFunc.call(window);
}

// 获取可执行的script脚本
function getExecutableScript(scriptText) {
  return `;(function(window, self, globalThis){with(window){;${scriptText}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
}
2、js、css沙箱:

js 隔离:隔离 window 全局变量

  • 快照,加载子应用前先记录下 window 的属性,卸载后恢复之前的快照
  • diff,加载子应用后记录对 window 属性的修改,卸载之后恢复回去
  • Proxy,创建一个代理对象,每个子应用访问到的都是这个代理对象

快照和diff都不能同时存在多个子应用,一般使用Proxy

css隔离:shadow dom 和 scoped css

js 复制代码
import { registerApplication, start } from 'single-spa';

// 注册子应用
function registerMicroApps(apps, lifeCycles) {
  apps.forEach((app) => {
    const { name, activeRule, props, ...appConfig } = app
    registerApplication({
      name,
      app: async () => {
        const microAppConfigs = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )()

        return microAppConfigs
      },
      activeWhen: activeRule,
      customProps: props,
    })
  })
}

// 根据entry url做处理,并返回single-spa中app需要的字段
async function loadApp(app, configuration = {}, lifeCycles) {
  const { entry, name: appName } = app
  const {
    singular = false,
    sandbox = true,
    globalContext = window,
    ...importEntryOpts
  } = configuration

  // 根据name获取缓存里的实例id
  const appInstanceId = genAppInstanceIdByName(appName)

  // 解析html
  const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts)

  // 用<div id="xx" data-name="name" data-version="xx"></div>包裹。将<head></head>替换成<qiankun-head></qiankun-head>
  const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template)

  // 创建element
  let initialAppWrapperElement = createElement(
    appContent,
    strictStyleIsolation = false,
    scopedCSS = true,
    appInstanceId,
  )

  render({ element: initialAppWrapperElement })

  let sandboxContainer;
  if (sandbox) {
    sandboxContainer = createSandboxContainer(
      appInstanceId,
      useLooseSandbox = false,
      global,
    )
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxContainer.instance.proxy
    mountSandbox = sandboxContainer.mount
    unmountSandbox = sandboxContainer.unmount
  }

  const scriptExports = await execScripts(global, sandbox && !useLooseSandbox)

  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global,
  )
  
  return {
    name: appInstanceId,
    bootstrap,
    mount: [..., mount],
    unmount: [...,unmount],
    update,
  }
}

// 创建element,创建shadowdom,给css添加scope
function createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId) {
  const containerElement = document.createElement('div')
  containerElement.innerHTML = appContent
  const appElement = containerElement.firstChild

  // shadowDom
  if(strictStyleIsolation) {
    const { innerHTML } = appElement;
    appElement.innerHTML = '';
    let shadow

    if (appElement.attachShadow) {
      shadow = appElement.attachShadow({ mode: 'open' });
    } else {
      shadow = appElement.createShadowRoot()
    }
    
    shadow.innerHTML = innerHTML;
  }

  if (scopedCSS) {
    appElement.setAttribute('data-qiankun', appInstanceId)
    const styleNodes = appElement.querySelectorAll('style') || []

    // 遍历styleNodes,给每个styleNode都添加前缀
    forEach(styleNodes, (stylesheetElement) => {
      css.process(appElement, stylesheetElement, appInstanceId)
    })
  }

  return appElement
}

// 创建沙箱
function createSandboxContainer(appName, useLooseSandbox, globalContext) {
  let sandbox
  if(window.Proxy) {
    sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext)
  } else {
    sandbox = new SnapshotSandbox(appName)
  }

  return {
    instance: sandbox,
    async mount() { sandbox.active() },
    async unmount() { sandbox.inactive() },
  }
}

/**
 * 子应用记住改动,卸载时还原
 */
class LegacySandbox {
  /** 沙箱期间新增的全局变量 */
  addedPropsMapInSandbox = new Map()

  /** 沙箱期间更新的全局变量 */
  modifiedPropsOriginalValueMapInSandbox = new Map()

  /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
  currentUpdatedPropsValueMap = new Ma()

  setWindowProp(prop, value, toDelete) {
    if (value === undefined && toDelete) {
      delete this.globalContext[prop]
    } else if (typeof prop !== 'symbol') {
      Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true })
      this.globalContext[prop] = value
    }
  }

  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((_, p) => this.setWindowProp(p, undefined, true))

    this.sandboxRunning = false
  }

  name;
  proxy;
  globalContext;
  type;
  sandboxRunning = true;

  constructor(name, globalContext = window) {
    this.name = name
    this.globalContext = globalContext
    this.type = 'LegacyProxy'
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this
    const rawWindow = globalContext
    const fakeWindow = Object.create(null)

    const setTrap = (p, value, originalValue) => {
      if (this.sandboxRunning) {
        if (!rawWindow.hasOwnProperty(p)) {
          addedPropsMapInSandbox.set(p, value)
        } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
          modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
        }
        currentUpdatedPropsValueMap.set(p, value)
      }
      return true
    }

    const proxy = new Proxy(fakeWindow, {
      set: (_, p, value) => {
        const originalValue = rawWindow[p]
        return setTrap(p, value, originalValue)
      },
      get() {return rawWindow[p]}
    })
    this.proxy = proxy
  }
}

/**
 * 基于 Proxy 实现的沙箱
 */
let activeSandboxCount = 0
class ProxySandbox {
  updatedValueSet = new Set()
  document = document;
  name;
  type;
  proxy;
  sandboxRunning = true;

  active() {
      if (!this.sandboxRunning) activeSandboxCount++;
      this.sandboxRunning = true;
  }

  inactive() {
    if (--activeSandboxCount === 0) {
      Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {
        const descriptor = this.globalWhitelistPrevDescriptor[p]
        if (descriptor) {
          Property(this.globalContext, p, descriptor)
        } else {
          delete this.globalContext[p]
        }
      })
    }

    this.sandboxRunning = false;
  }

  globalWhitelistPrevDescriptor = {}
  globalContext

  constructor(name, globalContext = window) {
    this.name = name;
    this.globalContext = globalContext;
    this.type = SandBoxType.Proxy;
    const { updatedValueSet } = this;

    const { fakeWindow } = createFakeWindow(globalContext)

    const proxy = new Proxy(fakeWindow, {
      set: (target, p, value) => {
        if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
          this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p)

          globalContext[p] = value
        } else {
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p)
            Object.defineProperty(target, p, descriptor)
          } else {
            target[p] = value
          }
        }

        updatedValueSet.add(p)
      },
      get: (target, p) => {return target[p]},
    })

    this.proxy = proxy
  }
}

// copy Window对象
function createFakeWindow(globalContext) {
  const fakeWindow = {}

  Object.getOwnPropertyNames(globalContext).forEach((p) => {
    const descriptor = Object.getOwnPropertyDescriptor(globalContext, p)
    Object.defineProperty(fakeWindow, p, descriptor)
  })

  return { fakeWindow }
}
3、应用状态管理
  • props
  • globalState

主应用里做全局状态初始化,子应用获取全局状态 getGlobalState 和状态变化时的处理: onGlobalStateChange

发布订阅的模式

  • globalState:全局变量对象

  • deps:保存订阅方法 onGlobalStateChangeCallback = (state, prevState)

  • emitGlobal:触发 state 全局监听

    forEach遍历dep并执行callback

  • initGlobalState

    return一个对象:{

    onGlobalStateChange(callback: onGlobalStateChangeCallback),并触发emitGlobal,

    setGlobalState(state),改变全局变量,并触发emitGlobal,

    offGlobalStateChange,移除监听

    }

    主应用将return的对象通过props传给子应用,子应用可监听和修改 globalState

4、路由监听

当子应用是通过路由(activeRule)切换加载时,single-spa 需要监听路由变化,来加载子应用。加载的方法为 reroute,该方法会根据当前路由改变所有子应用的状态(挂载,卸载等)。

按路由加载的使用:

浏览器的路由模式有hash 路由, history 路由,如何监听路由变化?

hash路由的监听可以通过 onhashchange 事件

history 路由通过监听 popState 事件?

以下方法是可以触发 popState 事件的,因为以下方法都会重新加载页面:

history.back() 返回浏览器会话历史中的上一页,跟浏览器的回退按钮功能相同

history.foward() 指向浏览器会话历史中的下一页,跟浏览器的前进按钮功能相同

history.go() 跳转到浏览器会话历史中指定的一个记录页

但 history.replaceState() 将当前的 url 替换成指定的数据 和 history.pushState() 进入到指定的url,history历史+1,这两个方法都不会刷新页面,也不会触发 popState 方法。ps:react-router 中的路由跳转使用的 history.pushState

所以要监听 history 路由的变化需要重写 history.replaceState 和 history.pushState 方法:

4、附录:

参考链接:

wujie-micro.github.io/doc/guide/

github.com/Tencent/wuj...

github.com/umijs/qiank...

qiankun.umijs.org/zh/api

github.com/single-spa/...

相关推荐
m0_748247551 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255022 小时前
前端常用算法集合
前端·算法
真的很上进2 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203982 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2343 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1233 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~4 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语4 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport4 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg4 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全