浅析micro-app

1,659 阅读6分钟

一、整体流程

Micro App 的核⼼功能在CustomElement基础上进⾏构建,CustomElement⽤于创建⾃定义标签,并提供了元素的渲染、卸载、属性修改等钩⼦函数,我们通过钩⼦函数获知微应⽤的渲染时机,并将⾃定义标签作为容器,微应⽤的所有元素和样式作⽤域都⽆法逃离容器边界,从⽽形成⼀个封闭的环境。

image.png 2、使用方法

image.png

3、主体流程图

image.png

  • 初始化子应用
  • 通过fetch + url 获取子应用的html
  • 处理html文本,获取css 和js的资源地址
  • 通过fetch获取子应用的静态资源
  • 将处理过的html放入webComponent容器中
  • 给css 加上scope机制,并append到head标签中
  • 在沙箱中执行js代码
  • 完成子应用的初始化

二、生命周期

用户可在start方法中传入全局周期函数

microApp.start({ 
lifeCycles: { 
  created () { console.log('created 全局监听') }, 
  beforemount () { console.log('beforemount 全局监听') }, 
  mounted () { console.log('mounted 全局监听') }, 
  unmount () { console.log('unmount 全局监听') }, 
  }, 
 })

created

自定义元素挂载到document的时候会触发钩子函数connectedCallback, 触发自定义事件'created',并执行用户传入的全局生命周期函数

public connectedCallback (): void { 
  const cacheCount = ++this.connectedCount 
  this.connectStateMap.set(cacheCount, true) 
  const effectiveApp = this.appName && this.appUrl 
  defer(() => { 
  if (this.connectStateMap.get(cacheCount)) { 
  // 主应用第一次挂载到document的时候执行created生命周期函数 
  dispatchLifecyclesEvent( this, this.appName, lifeCycles.CREATED, ) 
  effectiveApp && this.handleConnected() 
  }
  }) 
 } 
 
 
 //dispatchLifecyclesEvent 
 dispatchLifecyclesEvent( this, this.appName, lifeCycles.CREATED, ) 
 // 触发自定义事件 
 const event = new CustomEvent(lifecycleName, { detail, }) 
 // 执行全局周期函数 
 if (isFunction(microApp.options.lifeCycles?.[lifecycleName])) { microApp.options.lifeCycles![lifecycleName]!(event) } 
 element.dispatchEvent(event)

触发created自定义事件,就会去执行created函数。其他自定义事件mounted、unmount也同理

<micro-app 
  name='vue2' 
  url={`${config.vue2}micro-app/vue2/`} 
  data={data} onCreated={created} 
  onMounted={mounted} 
  onUnmount={unmount} ></micro-app>

beforemount

dispatchLifecyclesEvent( this.container!, this.name, lifeCycles.BEFOREMOUNT, )

加载资源完成后,开始渲染之前触发行beforeMounted

mount

MicroApp 官方在子应用的处理上提供了两种模式:默认模式 和 UMD 模式。

  • 默认模式:该模式不需要修改子应用入口,但是在切换时会按顺序依次执行 所有渲染依赖 的js文件,保证每次渲染的效果是一致的
  • UMD 模式:这个模式需要子应用暴露 mount 和 unmount 方法,只需要首次渲染加载所有 js 文件,后续只执行 mount 渲染 和 unmount 卸载

官方建议频繁切换的应用使用 UMD 模式配置子应用。

js执行完以后,如果子应用是UMD格式的会去获取子应用上暴露的的生命周期mount和unmount,如果存在就去执行handleMounted函数执行子应用的mount生命周期,同时执行dispatchLifecyclesEvent。如果不存在那么直接执行dispatchLifecyclesEvent

  const { mount, unmount } = this.getUmdLibraryHooks() 
  if (isFunction(mount) && isFunction(unmount)) { 
  this.umdHookMount = mount as Func 
  this.sandBox!.markUmdMode(this.umdMode = true) 
  try { 
  this.handleMounted(this.umdHookMount(microApp.getData(this.name, true))) 
  } catch (e) { logError('An error occurred in window.mount \n', this.name, e) } 
  } else if (isFinished === true) { this.handleMounted() }

unmount

当切换子应用的时候当 custom element 从文档 DOM 中删除时,这时候会调用。disconnectedCallback,会执行handleDisconnected然后调用app.unmount函数 执行dispatchCustomEventToMicroApp触发自定义事件

public disconnectedCallback (): void { 
  this.connectStateMap.set(this.connectedCount, false)
  this.handleDisconnected() 
 } 
 
 export function dispatchCustomEventToMicroApp ( app: AppInterface, eventName: string, detail: Record<string, any> = {}, ): void { 
 const event = new CustomEvent(formatEventName(eventName, app.name), { detail, }) 
 const target = app.iframe ? app.sandBox.microAppWindow : window target.dispatchEvent(event) }

三、micro-app的能力

js沙箱

解决状态互相污染的问题。比如现在有一个vue子应用和react子应用都使用了window上面的count属性,并在各自应用中对这个值进行加减操作,无论哪个应用操作了count,另一个应用的count也会随着变化。就造成了全局变量的污染。目前js沙箱有3种方案,micro-app采用的就是with()+Proxy的方式。

image.png

with()+Proxy

原理就是通过proxy把window对象代理到其他对象上proxyWindow,获取属性的时候优先从proxyWindow获取,如果没有才从window上去获取。设置属性的时候只在proxyWindow上设置。然后通过with把js执行的作用域重置到proxyWindow。每一个子应用都有一个sandbox的属性,因为子应用是通过createAppInstance构造函数实例化来的,所以它的sandbox都是唯一的。拿到script代码后调用app.sandbox.run(scriptCode),在沙盒中去跑script代码。在创建沙箱的时候代理对象也是每个字应用独有的。所以可以避免了不同应用间变量的污染。

image.png

const rawWindow = {} 
function CreateSandbox() { 
  const microWindow = {} 
  const sandbox = { 
    global, // proxy window 
    run, // script run window inject 
    stop, 
    isRun: false 
   } 
  return sandbox 
 } 
 function execScript(code, sandbox) {
   window.__MICRO__APP = sandbox.global 
   const _code = `;(function(window, self) { with(window) { ${code} } }).call(window.__MICRO__APP,window.__MICRO__APP)` // 规避with严格模式的问题 
   try { (0,eval(_code)) sandbox.isRun = true }catch (error) { } } 
   function run (code) { 
   // 访问fakeWindow的属性,没有的话,从全局window获取 
   // 设置fakeWindow的属性,设置到fakeWindow上 
   sandbox.global = new Proxy(fakeWindow, { get(target, key) {   if(Reflect.has(target,key)) { 
   return Reflect.get(target,key) } 
   const rawValue = Reflect(rawWindow,key) 
   if(typeof rawValue === 'function') { 
   const valueStr = rawValue.toString() // 检查是否是构造函数 alert console if(!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) { 
   return rawValue.bind(rawWindow) } 
  } 
  return rawValue 
 }, 
 set(target,key, value) { 
   target[key] = value 
   return true 
  }
  }
 ) 
execScript(code,sandbox) } function stop() { sandbox.isRun = false }

快照沙箱

export class SnapShot { 
proxy: Window & typeof globalThis 
constructor () { 
this.proxy = window 
} 
// 沙箱激活 
active () { 
// 创建一个沙箱快照 
this.snapshot = new Map() 
// 遍历全局环境 
for (const key in window) { 
this.snapshot[key] = window[key] 
} } 
// 沙箱销毁 
inactive () { 
for (const key in window) { 
 if (window[key] !== this.snapshot[key]) { 
 // 还原操作 
 window[key] = this.snapshot[key] 
 } 
} } }

在子应用挂载时执行沙箱激活,它会记录window的状态,也就是快照,以便在失活时恢复到之前的状态。在子应用卸载时执行沙箱销毁,恢复到未改变之前的状态。

缺点:(1)window上属性特别多,快照性能消耗很大

(2) 无法支持多个微应用同时运行,多个应用同时改写window上的属性,就会出现状态混乱。这也是为什么快照沙箱无法支持多个微应用同时运行的原因

应用场景:比较老版本的浏览器

样式隔离

micro-app有两种隔离方式:

(1)shadowDOM,会将自定义元素里面的内容用shadowDom包裹起来,内部的样式不会影响其他外面的元素样式。优先级高于cssScope,开启shadowDOM后css scope会失效

image.png

(2) css scope,如果用户传入了useScopecss会在样式前面加上前缀micro-app[name=vue3] vu3是用户传入的子应用的名称,起到子应用之间样式隔离的作用。类似于vue scoped的机制。这是micro-app默认的css 隔离方法

image.png

此外,还有一种样式隔离方式css modules,CSS的规则都是全局的,任何一个组件的样式规则,都对整个页面有效。

产生局部作用域的唯一方法,就是使用一个独一无二的class的名字,不会与其他选择器重名。这就是 CSS Modules 的做法。

通过在webpack中做以下设置。optios里设置module: true

image.png

import React from 'react'; 
import style from './App.css'; 
export default () => { 
return ( 
<h1 className={style.title}> Hello World </h1> 
); 
}; 


.title { color: red; }

上面代码中,我们将样式文件App.css输入到style对象,然后引用style.title代表一个class。构建工具会将类名style.title编译成一个哈希字符串。

<h1 class="_3zyde4l1yATCOkgn-DBWEL"> Hello World </h1>

App.css也会同时被编译。

._3zyde4l1yATCOkgn-DBWEL { color: red; }

这样一来,这个类名就变成独一无二了,只对App组件有效。

预加载

image.png 预加载是指在应用尚未渲染时提前加载资源并缓存,从而提升首屏渲染速度。预加载并不是同步执行的,它会在浏览器空闲时间,依照开发者传入的顺序,依次加载每个应用的静态资源,以确保不会影响基座应用的性能。在应⽤真正被渲染时直接从缓存中获取资源并渲染。通过 microApp.start 配置项的 preFetchApps 属性设置子应用的预加载,或者通过 microApp.preFetch 方法设置。

** 子应用列表 */ 
const apps = [{ name: 'child', url: 'http://localhost:3000' }] 
// 方式一 
microApp.start({ preFetchApps: apps }) 
// 方式二 
microApp.preFetch(apps)

会去执行preFetch(options.preFetchApps),会调用requestIdleCallback去执行资源的预加载,即在浏览器空闲的时候去执行。首先回去判断appInstanceMap对象里面是否存在当前子应用实例。不存在才去执行预加载。

if (options.name && options.url && !appInstanceMap.has(options.name)) { 
const app = new CreateApp({ 
name: options.name, 
url: options.url, 
isPrefetch: true, 
scopecss: !(options['disable-scopecss'] ?? options.disableScopecss ?? microApp.options['disable-scopecss']), 
useSandbox: !(options['disable-sandbox'] ?? options.disableSandbox ?? microApp.options['disable-sandbox']), i
nline: options.inline ?? microApp.options.inline, 
iframe: options.iframe ?? microApp.options.iframe, 
prefetchLevel: options.level && PREFETCH_LEVEL.includes(options.level) ? options.level : microApp.options.prefetchLevel && PREFETCH_LEVEL.includes(microApp.options.prefetchLevel) ? microApp.options.prefetchLevel : 2, }) }

此时会执行创建应用实例new CreateApp,并且此时isPrefetch被设置为true。此时appInstanceMap中就有两个子应用。会去执行loadSourceCode加载html 资源。并执行css 和js的加载工作,并将结果缓存下来。下次渲染child子应用的时候直接从缓存中获取css和js。这样就可以省去发送请求的耗时,提升渲染速度。

image.png

数据通信

image.png

<micro-app 
  name='vue3' 
  url='http://localhost:5008' 
  isPrefetch="true" 
  :data="data" 
  @datachange='handleDataChange' > 
 </micro-app> 
 const data = ref({from: '来自基座的初始化数据'})

自己实现简易版数据通信:

class EventCenter { 
  // 缓存数据和绑定函数 
  eventList = new Map() 
  on(name, f) { 
    let eventInfo = this.eventList.get(name) 
    // 如果没有缓存就初始化 
    if(!eventInfo) { 
      eventInfo = { data: {}, callbacks: new Set() } 
     } 
     // 放入缓存 
     this.eventList.set(name, eventInfo) 
     // 记录绑定函数 
     eventInfo.callbacks.add(f) } 
     // 解除绑定 
     off(name, f) { 
      const eventInfo = this.eventList.get(name) 
      if(eventInfo && typeof f === 'function') { 
       eventInfo.callbacks.delete(f) } 
      } 
      // 发送数据 
      dispatch(name,data) { 
       const eventInfo = this.eventList.get(name) 
       // 当数据不相等时才更新 
       if(eventInfo && eventInfo.data !== data) { 
       eventInfo.data = data 
       // 遍历执行所有绑定函数 
       for(const f of eventInfo.callbacks) { 
        f(data) 
        } 
       } } } 
       // 创建发布订阅对象 
       const eventCenter = new EventCenter() 
       
       
       // 基座应用的数据通信方法集合 
       export class EventCenterForBaseApp { 
       // 向指定子应用发送数据 
       setData(appName, data) { 
        eventCenter.dispatch(formatEventName(appName, true), data) } 
        // 清空某个子应用的监听函数 
        clearDataListener(appName) { eventCenter.off(formatEventName(appName, false)) } } 
        // 子应用的数据通信方法集合 
        export class EventCenterForMicroApp { 
        constructor(appName) { this.appName = appName }
        // 监听基座发送的数据 
        addDataListener(cb) { eventCenter.on(formatEventName(this.appName, true), cb) }       // 解除监听函数 
        removeDataListener(cb) { if(typeof cb === 'function') {    eventCenter.off(formatEventName(this.appName, true), cb) } } 
        // 向基座发送数据 dispatch(data) { 
        const app = appInstanceMap.get(this.appName) 
        if(app?.container) { 
        // 自定义事件 
        const event = new CustomEvent('datachange', { detail: { data } }) app.container.dispatchEvent(event) } } 
        /** * 清空当前子应用绑定的所有监听函数 */ 
        clearDataListener () { 
        eventCenter.off(formatEventName(this.appName, true)) } } 
        // 格式化订阅名称来进行数据的绑定通信 
        function formatEventName(appName, fromBaseApp) { 
        if(typeof appName !== 'string' || !appName) return 
        fromBaseApp ? `__from_base_app_${appName}__`: `__from_micro_app_${appName}__` }

基座向子应用传值

当在上传入data时就会触发重写后的setAttribute, 就会执行BaseAppData.setData(this.getAttribute('name'), cloneValue)会调用EventCenter上的dispatch向对应的子应用发送数据。子应用通过addDataListener监听父应用发出的事件拿到数据。

    window.microApp.addDataListener(this.handleDataChange, true)

子应用向基座传值

子应用向父应用传递数据,可以通过

    window.microApp.dispatch({text: 'hhh'})

会调用EventCenterForMicroApp上的dispatch一个自定义事件datachange,主应用就可以通过handleDataChange拿到子应用传递的数据

重写后的setAttribute方法

// 记录原生方法 
const rawSetAttribute = Element.prototype.setAttribute 
// 重写setAttribute 
Element.prototype.setAttribute = function setAttribute (key, value) { 
// 目标为micro-app标签且属性名称为data时进行处理 
if (/^micro-app/i.test(this.tagName) && key === 'data') { if (toString.call(value) === '[object Object]') { 
// 克隆一个新的对象 
const cloneValue = {} 
Object.getOwnPropertyNames(value).forEach((propertyKey) => { 
// 过滤vue框架注入的数据 
if (!(typeof propertyKey === 'string' && propertyKey.indexOf('__') === 0)) { cloneValue[propertyKey] = value[propertyKey] } }) 
// 发送数据 
BaseAppData.setData(this.getAttribute('name'), cloneValue) } 
} else { rawSetAttribute.call(this, key, value) } }

插件系统

    plugins: {
    modules: {
      react16: [{
        loader(code: string, url: string) {
          if (code.indexOf('sockjs-node') > -1) {
            console.log('react16插件', url)
            code = code.replace('window.location.port', '3001')
          }
          return code
        }
      }],
     
    }
  },

插件可以理解为符合特定规则的对象,对象中提供⼀个函数⽤于对资源进⾏处理,插件通常由开发者⾃定义。

插件系统的作⽤是对传⼊的静态资源进⾏初步处理,并依次调⽤符合条件的插件,将初步处理后的静态资源作为参数传⼊插件,由插件对资源内容进⼀步的修改,并将修改后的内容返回。插件系统赋予开发者灵活处理静态资源的能⼒,对有问题的资源⽂件进⾏修改。

image.png

元素隔离

元素隔离可以有效的防⽌⼦应⽤对基座应⽤和其它⼦应⽤元素的误操作,常⻅的场景是多个应⽤的根元素都使⽤相同的id,元素隔离可以保证⼦应⽤的渲染框架能够正确找到⾃⼰的根元素。

image.png 如上图所示, micro-app 元素内部渲染的就是⼀个⼦应⽤,它还有两个⾃定义元素micro-app-head 、micro-app-body ,这两个元素的作⽤分别对应html中的head和body元素。⼦应⽤在原head元素中的内容和⼀些动态创建并插⼊head的link、script元素都会移动到micro-app-head 中,在原body元素中的内容和⼀些动态创建并插⼊body的元素都会移动到micro-app-body 中。这样可以防⽌⼦应⽤的元素泄漏到全局,在进⾏元素查询、删除等操作时,只需要在micro-app 内部进⾏处理,是实现元素隔离的重要基础。

image.png 举个栗子🌰 :

基座应用和子应用都有一个元素,此时子应用通过document.querySelector('#root')获取到的是自己内部的#root元素,而不是基座应用的。这是因为改写了document querySelector 方法。子应用有appName所以执行querySelector函数的时候会执行appInstanceMap.get(appName)?.container?.querySelector(selectors)??null。如果是基座的话没有appName 就会执行globalEnv.rawQuerySelector.call(this,selectors)。