micro-app 原理解析

3,019 阅读11分钟

前言

回顾微前端的历史,最早的时候我们是利用 iframe 嵌入一个网页,这就是微前端的雏形。虽然接入时方便快捷,但它也存在一系列缺点,如:

  • 路由状态丢失,刷新一下,iframe 的 url 状态就丢失了
  • dom 割裂严重,弹窗只能在 iframe 内部展示,无法覆盖全局
  • 通信非常困难,只能通过 postmessage 传递序列化的消息
  • 白屏时间太长,对于有性能要求的应用来说无法接受

后来出现了 single-spa ,这是最早期的一套微前端解决方案。它规定子应用必须暴露三个方法:bootstrapmountunmount,分别对应初始化、渲染和卸载。然后监听 url change 事件,在 url 改变时执行所匹配到的子应用对应的生命周期函数。但是 single-spa 的缺点也很明显:

  • 接入子应用的入口是一个 js 而不是 html 文件,然而每次子应用打包后 js 入口文件的 hash 值是会变的,这就意味着每次子应用部署后都要去手动修改主应用设置的入口 js 地址。
  • single-spa 并没有对子应用做 js 和 css 隔离,而是留给用户自行处理。

再后来,qiankun 横空出世。它基于 single-spa 做了一层封装,并提供了 html 入口和 js、css的隔离。目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,生态较为完善。但 qiankun 继承了 single-spa 的思想,子应用仍然必须提供对应的生命周期函数,并且需要修改子应用的 webpack 配置配合使用,因此使用 qingkun 对子应用来说还是有一定的代码入侵性。

mirco-app 是京东21年开源的一款微前端框架。它借助了浏览器对 webComponent 的支持,实现了一套微前端方案体系。并且由于 Shadow Dom 对 react 这类库的兼容性较差,便自己实现了类 Shadow Dom 的效果。与 qiankun 相比,接入更加简单,但因为框架比较新,生态就没那么完善。这篇文章从源码上对 micro-app 的主流程做了以下梳理,希望对大家有所帮助。

官网地址在此 micro-app

快速开始

先从官网中的小例子看下 micro-app 是如何启动的:

主应用使用:

import microApp from '@micro-zoe/micro-app';
microApp.start();

export function MyPage () {
  return (
    <div>
      <h1>子应用</h1>
      <micro-app
        name='app1' // name(必传):应用名称
        url='http://localhost:3000/' // url(必传):应用地址会被自动补全为http://localhost:3000/index.html
        baseroute='/my-page' // baseroute(可选):基座应用分配给子应用的基础路由就是上面的 `/my-page`
      ></micro-app>
    </div>
  )
}

子应用使用:

1、设置跨域支持

使用create-react-app脚手架创建的项目,在 config/webpackDevServer.config.js 文件中添加headers。

其它项目在webpack-dev-server中添加headers。

headers: {
  'Access-Control-Allow-Origin': '*',
}

思考:为什么需要设置跨域支持?哪一步可能涉及到跨域?开发环境和生产环境有何异同?

2、设置基础路由(如果基座是history路由,子应用是hash路由,这一步可以省略)

// router.js
import { BrowserRouter, Switch, Route } from 'react-router-dom'

export default function AppRoute () {
  return (
    // 👇 设置基础路由,如果没有设置baseroute属性,则window.__MICRO_APP_BASE_ROUTE__为空字符串
    <BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
      ...
    </BrowserRouter>
  )
}

3、设置 publicPath

这一步借助了webpack的功能,避免子应用的静态资源使用相对地址时加载失败的情况,详情参考webpack文档 publicPath

步骤1: 在子应用src目录下创建名称为public-path.js的文件,并添加如下内容

// __MICRO_APP_ENVIRONMENT__和__MICRO_APP_PUBLIC_PATH__是由micro-app注入的全局变量
if (window.__MICRO_APP_ENVIRONMENT__) {
  // eslint-disable-next-line
  // 这里的 window.__MICRO_APP_PUBLIC_PATH__ 其实就是主应用传递的url参数,如:http://localhost:3000/
  __webpack_public_path__ = window.__MICRO_APP_PUBLIC_PATH__
}

步骤2: 在子应用入口文件的最顶部引入public-path.js

// entry
import './public-path'

思考:设置 pubilcPath 的目的是什么?如果跳过这一步会发生什么?

4、监听卸载

子应用被卸载时会接受到一个名为unmount的事件,在此可以进行卸载相关操作。

window.addEventListener('unmount', function () {
  ReactDOM.unmountComponentAtNode(document.getElementById('root'))
})

思考: 组件卸载操作可以由框架层面自动处理吗?

原理解析

当调用 microApp.start() 后,会注册一个名为 micro-app 的自定义 webComponent 标签。我们可以从 <micro-app name='app1' url='xx' baseroute='/my-page'></micro-app> 中拿到子应用的线上入口地址。有了这个地址后,micro-app 就可以做很多事情。

获取/处理子应用内容

body 和 header 的处理

首先,micro-app 可以通过 fetch 拿到 url 对应的 html 字符串,然后替换 head 和 body 标签,避免污染主应用。

export default function extractHtml (app: AppInterface): void {
  
fetchSource(app.ssrUrl || app.url, app.name, { cache: 'no-cache' }).then((htmlStr: string) => {
    if (!htmlStr) {
      const msg = 'html is empty, please check in detail'
      app.onerror(new Error(msg))
      return logError(msg, app.name)
    }
    htmlStr = htmlStr
      .replace(/<head[^>]*>[\s\S]*?<\/head>/i, (match) => {
        return match
          .replace(/<head/i, '<micro-app-head')
          .replace(/<\/head>/i, '</micro-app-head>')
      })
      .replace(/<body[^>]*>[\s\S]*?<\/body>/i, (match) => {
        return match
          .replace(/<body/i, '<micro-app-body')
          .replace(/<\/body>/i, '</micro-app-body>')
      })

    extractSourceDom(htmlStr, app)
  }).catch((e) => {
    logError(`Failed to fetch data from ${app.url}, micro-app stop rendering`, app.name, e)
    app.onLoadError(e)
  })
}

看到这里你可能会问:micro-app-head 和 micro-app-body 都是自定义标签,可以直接使用吗? 答案是可以的,自定义标签和已有的标签相比,只是缺少了默认的样式及行为,具体可以参考下面的文章。www.ruanyifeng.com/blog/2017/0…

上面处理了 head 和 body 标签,extractSourceDom 就是负责处理 header 里头的其他标签,以及加载 link 及 script 标签的内容。

function extractSourceDom (htmlStr: string, app: AppInterface) {
  const wrapElement = getWrapElement(htmlStr)
  const microAppHead = wrapElement.querySelector('micro-app-head')
  const microAppBody = wrapElement.querySelector('micro-app-body')

  if (!microAppHead || !microAppBody) {
    const msg = `element ${microAppHead ? 'body' : 'head'} is missing`
    app.onerror(new Error(msg))
    return logError(msg, app.name)
  }

  flatChildren(wrapElement, app, microAppHead)

  if (app.source.links.size) {
    fetchLinksFromHtml(wrapElement, app, microAppHead)
  } else {
    app.onLoad(wrapElement)
  }

  if (app.source.scripts.size) {
    fetchScriptsFromHtml(wrapElement, app)
  } else {
    app.onLoad(wrapElement)
  }
}

处理其他的标签前,这里创建了一个新的 div 标签,将 html 字符串的内容赋值给该 div 的 innerHTML 属性。

function getWrapElement (str: string): HTMLElement {
  const wrapDiv = pureCreateElement('div')

  wrapDiv.innerHTML = str

  return wrapDiv
}

其他标签处理

flatChildren 函数是处理 header 里的其他标签的具体操作。注意这里用了递归,以确保每个标签都能处理到。

function flatChildren (
  parent: HTMLElement,
  app: AppInterface,
  microAppHead: Element,
): void {
  const children = Array.from(parent.children)

  children.length && children.forEach((child) => {
    flatChildren(child as HTMLElement, app, microAppHead)
  })

  for (const dom of children) {
	// 处理 link 标签
    if (dom instanceof HTMLLinkElement) {
      if (dom.hasAttribute('exclude')) {
        parent.replaceChild(document.createComment('link element with exclude attribute ignored by micro-app'), dom)
      } else if (!dom.hasAttribute('ignore')) {
        extractLinkFromHtml(dom, parent, app)
      } else if (dom.hasAttribute('href')) {
        dom.setAttribute('href', CompletionPath(dom.getAttribute('href')!, app.url))
      }
	// 处理 style 标签
    } else if (dom instanceof HTMLStyleElement) {
      if (dom.hasAttribute('exclude')) {
        parent.replaceChild(document.createComment('style element with exclude attribute ignored by micro-app'), dom)
      } else if (app.scopecss && !dom.hasAttribute('ignore')) {
        scopedCSS(dom, app)
      }
	// 处理 script 标签
    } else if (dom instanceof HTMLScriptElement) {
      extractScriptElement(dom, parent, app)
	// 移除 meta 和 title 标签
    } else if (dom instanceof HTMLMetaElement || dom instanceof HTMLTitleElement) {
      parent.removeChild(dom)
	// 处理 img 标签
    } else if (dom instanceof HTMLImageElement && dom.hasAttribute('src')) {
      dom.setAttribute('src', CompletionPath(dom.getAttribute('src')!, app.url))
    }
  }
}

下面是对具体各标签处理流程的总结,源码过多,就不一一列举了。

link 标签处理流程:

  • 若包含 exclude/ignore 属性,主应用会删除/跳过该标签
  • 处理 href 属性,在原本的 href 的前面拼接上 app.url,使得主应用能正确访问子应用的资源。
  • ref 的属性是 stylesheet,则会删除该 link 标签,将处理后的 href 记录在一个 map中,后面再调用fetchLinksFromHtml 方法,加载资源内容并将创建 style 标签插入之前定义的 div 下的 <micro-app-head> 中。最后的 html 内容大概如下:
<micro-app name="appname-sidebar" url="http://www.micro-zoe.com/child/sidebar/">
	<micro-app-head>
		<style data-origin-href="http://www.micro-zoe.com/child/sidebar/css/chunk-vendors.d2ab7433.css">...</style>
		<style data-origin-href="http://www.micro-zoe.com/child/sidebar/css/app.708cd7c5.css">...</style>
	</micro-app-head>
	<micro-app-body>...</micro-app-body>
</micro-app>

Q: 为什么需要删除 link 标签,自己去请求内容并创建 style 标签呢?如果保留 link 标签会有什么问题?

A: 在创建 style 标签时会调用 scopedCSS 方法,给子应用的 style 标签加上作用域,实现父子应用样式的隔离。

  • ref 的属性包含 ['prefetch', 'preload', 'prerender', 'icon', 'apple-touch-icon'] 中的任意一项,则会移除 link 标签。

style 标签处理流程:

  • 若包含 exclude/ignore 属性,主应用会删除/跳过该标签
  • 调用 scopedCSS 方法, 给子应用的 style 标签加上作用域,前缀是 ${microApp.tagName}[name=${app.name}]。大致原理是当 style 元素被插入到文档中时,浏览器会自动为 style 元素创建 CSSStyleSheet 样式表,样式表包含了一组表示规则的 CSSRule 对象。我们再遍历这个对象给各个 css 选择器加上作用域。参考segmentfault.com/a/119000004…

srcipt 标签处理流程:

  • 若包含 exclude/ignore 属性,主应用会删除/跳过该标签
  • 若包含 src 属性,在原本的 src 的前面拼接上 app.url,将处理后的 href 记录在一个 map中,后面再调用fetchScriptsFromHtml(wrapElement, app),加载 srcipt 标签,再将其内容保存起来(此时仍然只是记录在map)。
  • 若是行内 script,和上一步同理,只是缺少数据请求这一步。

到这一步为止,这只是在内存中创建了新的 div 标签,并将处理好的 html 内容赋值给 innerHtml 属性,并没有真正渲染到页面上。

挂载子应用

当对 html 做了处理后,下一步就是挂载到 micro-app 自定义的 webComponent 中

  /**
   * When resource is loaded, mount app if it is not prefetch or unmount
   */
  onLoad (html: HTMLElement): void {
    if (++this.loadSourceLevel === 2) {
      this.source.html = html

      if (this.isPrefetch) {
        this.prefetchResolve?.()
        this.prefetchResolve = null
      } else if (appStates.UNMOUNT !== this.state) {
        this.state = appStates.LOAD_SOURCE_FINISHED
        this.mount()
      }
    }
  }
  /**
   * mount app
   * @param container app container
   * @param inline js runs in inline mode
   * @param baseroute route prefix, default is ''
   */
  mount (
    container?: HTMLElement | ShadowRoot,
    inline?: boolean,
    baseroute?: string,
  ): void {
   ...
   // 触发生命周期 beforemount
    dispatchLifecyclesEvent(
      this.container,
      this.name,
      lifeCycles.BEFOREMOUNT,
    )

    this.state = appStates.MOUNTING

	// 将之前处理过的 html 内容放入 webComponent 容器 (<micro-app/>) 中
    cloneContainer(this.source.html as Element, this.container as Element, !this.umdMode)

	// 创建 js 沙箱环境
    this.sandBox?.start(this.baseroute)

    let umdHookMountResult: any // result of mount function

    let hasDispatchMountedEvent = false
      // if all js are executed, param isFinished will be true
      // 在沙箱环境中执行子应用的所有 srcipt
      execScripts(this.source.scripts, this, (isFinished: boolean) => {
        if (!this.umdMode) {
          const { mount, unmount } = this.getUmdLibraryHooks()
          // if mount & unmount is function, the sub app is umd mode
          if (isFunction(mount) && isFunction(unmount)) {
            this.umdHookMount = mount as Func
            this.umdHookUnmount = unmount as Func
            this.umdMode = true
            this.sandBox?.recordUmdSnapshot()
            try {
              umdHookMountResult = this.umdHookMount()
            } catch (e) {
              logError('an error occurred in the mount function \n', this.name, e)
            }
          }
        }

        if (!hasDispatchMountedEvent && (isFinished === true || this.umdMode)) {
          hasDispatchMountedEvent = true
          this.handleMounted(umdHookMountResult)
        }
      })
  }

绑定沙箱

下面来看下 micro-app 是如何绑定沙箱环境的

/**
 * bind js scope
 * @param url script address
 * @param app app
 * @param code code
 * @param module type='module' of script
 */
function bindScope (
  url: string,
  app: AppInterface,
  // 传入的 js 脚本
  code: string,
  module: boolean,
): string {
  if (isPlainObject(microApp.plugins)) {
    code = usePlugins(url, code, app.name, microApp.plugins!)
  }

  if (app.sandBox && !module) {
    globalEnv.rawWindow.__MICRO_APP_PROXY_WINDOW__ = app.sandBox.proxyWindow
    return `;(function(proxyWindow){with(proxyWindow.__MICRO_APP_WINDOW__){(function(${globalKeyToBeCached}){;${code}\n}).call(proxyWindow,${globalKeyToBeCached})}})(window.__MICRO_APP_PROXY_WINDOW__);`
  }

  return code
}

其中 globalKeyToBeCached 的值是 window,self,globalThis,Array,Object,String...,这里巧妙用了with关键字,将子应用语句的作用域替换成了 proxyWindow.__MICRO_APP_WINDOW__,从而绑定了沙箱环境。可以参考一下这里,了解一下with 的用法。

沙箱实现原理

实现元素隔离

什么是元素隔离?举个例子,基座应用和子应用都有一个元素<div id='root'></div>,此时子应用通过document.querySelector('#root')获取到的是自己内部的#root元素,而不是基座应用的。

下面我们来看下源码是怎么实现的:

  // query element👇
  function querySelector (this: Document, selectors: string): any {
    const appName = getCurrentAppName()
    if (
      !appName ||
      !selectors ||
      isUniqueElement(selectors) ||
      // see https://github.com/micro-zoe/micro-app/issues/56
      rawDocument !== this
    ) {
      return globalEnv.rawQuerySelector.call(this, selectors)
    }
    return appInstanceMap.get(appName)?.container?.querySelector(selectors) ?? null
  }
  // 修改了 Document 原型链上的方法
  Document.prototype.querySelector = querySelector

我们可以看到 micro-app 是修改了 Document 原型链上的方法,通过判断 appName,如果 appName 非空,则说明是子应用调用的 querySelector,这时候我们就可以直接使用 appInstanceMap.get(appName)?.container?.querySelector(selectors) 方法,从而做到元素的隔离。

思考一下,主应用和子应用得到的 appName 是一样的吗?如果是一样的,那么主应用和子应用的 querySelector 方法就是一样的,肯定不合理。我们来看下源码

子应用在访问 document 对象时实际上还做了一层拦截

    rawDefineProperties(microAppWindow, {
      document: {
        get () {
          throttleDeferForSetAppName(appName)
          return globalEnv.rawDocument
        },
        configurable: false,
        enumerable: true,
      }
	})

throttleDeferForSetAppName 方法作用是修改 appName,并创建一个微任务,执行微任务将appName置空。

export function defer (fn: Func, ...args: any[]): void {
  Promise.resolve().then(fn.bind(null, ...args))
}

export function throttleDeferForSetAppName (appName: string) {
  if (currentMicroAppName !== appName) {
    setCurrentAppName(appName)
    defer(() => {
      setCurrentAppName(null)
    })
  }
}

所以 appName 仅在子应用访问 document 对象时才会存在,当主应用访问 document 时,appName 被清空了,这种设计还是挺巧妙的。

实现 js 隔离

为什么要做 js 隔离,我们可以从下面两个问题说起:

  • 假设主应用上有个全局变量 window.ThemeColor = 'blue',而恰巧子应用也设置了这个变量,那么 window 对象中的变量就会发生冲突。
  • 假设子应用注册了一个全局监听事件,如果子应用卸载时没有对其进行处理,那么每次切换或加载子应用时都会重新注册一个这样的全局事件,显示是不合理的。

那么如何做到 js 隔离?我们来看下源码

  // create proxyWindow with Proxy(microAppWindow)
  private createProxyWindow (appName: string) {
    const rawWindow = globalEnv.rawWindow
    const descriptorTargetMap = new Map<PropertyKey, 'target' | 'rawWindow'>()
    // window.xxx will trigger proxy
    return new Proxy(this.microAppWindow, {
      get: (target: microAppWindowType, key: PropertyKey): unknown => {
        throttleDeferForSetAppName(appName)
		// 如果代理对象中存在该属性,直接返回代理对象的属性
        if (
          Reflect.has(target, key) ||
          (isString(key) && /^__MICRO_APP_/.test(key)) ||
          this.scopeProperties.includes(key)
        ) return Reflect.get(target, key)

        const rawValue = Reflect.get(rawWindow, key)
        
		// 代理对象不存在该属性时,从原生的 windows 对象中返回。但是需要检查一下属性是否是构造函数,如果是构造函数,还需要给函数绑定 window 对象,例如 `console`,`alert` 属性。
        return isFunction(rawValue) ? bindFunctionToRawWindow(rawWindow, rawValue) : rawValue
      },
      set: (target: microAppWindowType, key: PropertyKey, value: unknown): boolean => {
        if (this.active) {
         // 允许逃逸的属性直接写入原生 window 对象
          if (escapeSetterKeyList.includes(key)) {
            Reflect.set(rawWindow, key, value)
          } else if (
            // target.hasOwnProperty has been rewritten
            !rawHasOwnProperty.call(target, key) &&
            rawHasOwnProperty.call(rawWindow, key) &&
            !this.scopeProperties.includes(key)
          ) {
            const descriptor = Object.getOwnPropertyDescriptor(rawWindow, key)
            const { configurable, enumerable, writable, set } = descriptor!
            // set value because it can be set
            rawDefineProperty(target, key, {
              value,
              configurable,
              enumerable,
              writable: writable ?? !!set,
            })
			// 收集有 set 操作的 key 
            this.injectedKeys.add(key)
          } else {
            Reflect.set(target, key, value)
            this.injectedKeys.add(key)
          }

          if (
            (
              this.escapeProperties.includes(key) ||
              (staticEscapeProperties.includes(key) && !Reflect.has(rawWindow, key))
            ) &&
            !this.scopeProperties.includes(key)
          ) {
            Reflect.set(rawWindow, key, value)
            this.escapeKeys.add(key)
          }
        }

        return true
      },
	// 只贴出了 get 和 set 的源码,其他属性可以自行去阅读源码 
	}
}

主要是利用了强大的 Proxy,下面是对于 get 和 set 拦截器的简要分析:

get 拦截器主要做的事情是

  • 如果代理对象中存在该属性,直接返回代理对象的属性
  • 代理对象不存在该属性时,从原生的 windows 对象中返回。但是需要检查一下属性是否是构造函数,如果是构造函数,还需要给函数绑定 window 对象,例如 consolealert 属性。

set 拦截器主要做的事情是

  • 当沙箱处于 active 状态才会处理
  • 使用 injectedKeys 将 key 记录下来,方便子应用在频繁切换应用时恢复现场。

事件处理

接下来看下如何对事件做处理: 首先是改写原来的 addEventListener 方法,将监听的事件名和事件句柄记录在一个 map 中

  microAppWindow.addEventListener = function (
    type: string,
    listener: MicroEventListener,
    options?: boolean | AddEventListenerOptions,
  ): void {
    type = formatEventType(type, microAppWindow)
    const listenerList = eventListenerMap.get(type)
    if (listenerList) {
      listenerList.add(listener)
    } else {
      eventListenerMap.set(type, new Set([listener]))
    }
    listener && (listener.__MICRO_APP_MARK_OPTIONS__ = options)
    rawWindowAddEventListener.call(rawWindow, type, listener, options)
  }

在子应用卸载的时候会触发 releaseEffect 方法,将之前监听的事件全部移除。

  // release all event listener & interval & timeout when unmount app
  const releaseEffect = () => {
    // Clear window binding events
    if (eventListenerMap.size) {
      eventListenerMap.forEach((listenerList, type) => {
        for (const listener of listenerList) {
          rawWindowRemoveEventListener.call(rawWindow, type, listener)
        }
      })
      eventListenerMap.clear()
    }
  }

整体流程

micro-app.png

待探究

应用之间如何共享依赖? issue 地址

参考

micro-app

segmentfault.com/a/119000004…
segmentfault.com/a/119000004…

qiankun

zhuanlan.zhihu.com/p/463905990
blog.csdn.net/qq_41694291…

其他

zhuanlan.zhihu.com/p/415900889
mp.weixin.qq.com/s/Mg3fU0WvZ…