MicroApp 京东微前端框架源码详细解读

1,676 阅读8分钟

前言

大家好这里是阳九,一个中途转行的野路子码农,热衷于研究和手写前端工具.

我的宗旨就是 万物皆可手写

新手创作不易,有问题欢迎指出和轻喷,谢谢

本文章适合使用过微前端技术的开发人员,如果没有请查阅microApp官方文档

本人也有TS手写的简易版microApp框架,注释齐全可供学习.

官网链接: micro-zoe.github.io/micro-app/

手写简易microApp视频展示:原生TS手写京东microApp微前端框架 + microApp源码导读_哔哩哔哩_bilibili

手写简易microApp仓库地址:lzy19926/lzy-microApp: 简易microApp微前端框架实现(github.com)


前置知识 webComponent

webComponent

microApp是京东推出的一款轻量级微前端框架(解决方案),使用webComponent的思想去实现。

我们知道,html中有许多标签,div,p,span等等,这些标签渲染出的都是html元素。

我更愿意将webComponent叫做自定义html元素, 他的实现思路很简单,也就是让用户通过js代码自定义一个htmlElement,并注册到document中, 之后便可使用标签。

创建一个自定义元素

    class CustomEle extends HTMLElement {
        constructor() {
            super();
            console.log('创建了自定义标签')
        }
    }

    // 注册自定义元素为标签
    customElements.define('custom-ele', CustomEle);
    
    // 在html中使用
    <body>
        <custom-ele/>
    </body>
    // createElement时会执行new CustomEle
    const customEle = document.createElement('custom-ele');

对自定义元素进行操作

比如我们想将name属性用p标签渲染到标签内 我们可以这样做

class CustomEle extends HTMLElement {
        constructor() {
            super();
            const name = this.getAttribute('name') || '';
            this.innerHTML=`<p>{name}</p>` // 或者其他操作dom的方法
        }
    }
    
// 在html中使用
    <body>
        <custom-ele name="张三"/>
    </body>

之后我们就可以在页面中渲染出一个张三

通过这种方式,我们可以使用一个类轻松定制出一个即插即用的组件,跨平台,跨框架。

webComponent的生命周期

由于CustomEle的构造函数只会在其创建时执行一次, 我们需要他的生命周期以及钩子函数来帮助我们完成其他的操作

 class CustomEle extends HTMLElement {
        constructor() {
            super();
            console.log('创建了自定义标签')
        }
        
        connectedCallback() {}// 组件被成功添加到主文档时触发
        disconnectedCallback() {} //组件从主文档移除时触发
        adoptedCallback() {}      // 元素被移动到新的文档时调用,(不常用)
        
         // 监听组件属性,用于触发attributeChangedCallback
        static get observedAttributes() { return ['img', 'text']; }
        attributeChangedCallback() {} // 增删改被监听的组件属性时触发
    }

沙箱与shadowDom

可参考我的文章 微前端乾坤框架 CSS JS沙箱隔离环境原理 - 掘金 (juejin.cn)


MicroApp类

MicroApp的外层类, 使用者通过start方法启动microApp

microApp = new MicroApp()
microApp.start()

START方法中主要做了三件事

  • 挂载定义好的app操作函数
  • initGlobalEnv() 初始化全局变量
  • defineElement(this.tagName) 定义custom-element

源码摘要:

export class MicroApp extends EventCenterForBaseApp implements MicroAppBaseType {
  tagName = 'micro-app'
  options: OptionsType = {}
  // 挂载定义好的app操作函数
  preFetch = preFetch
  unmountApp = unmountApp
  unmountAllApps = unmountAllApps
  getActiveApps = getActiveApps
  getAllApps = getAllApps
  reload = reload
  renderApp = renderApp
  // start方法
  start (options?: OptionsType): void {
      ...
      initGlobalEnv()
      ...
      defineElement(this.tagName)
      }
  }

initGlobalEnv 初始化全局变量

首先这里定义了原始window,document等, 并将一些原始方法从Element中取出并保存,以便以后可以直接从globalENV中获取到原始方法

其作用是为了服务沙箱, 沙箱内需要修改一些方法,比如window.getElementById

export function initGlobalEnv (): void {
  if (isBrowser) {
    const rawWindow = Function('return window')()
    const rawDocument = Function('return document')()
    const rawRootDocument = Function('return Document')()
    /**
     * save patch raw methods
     * pay attention to this binding
     */
    // 将一些Element上的原始方法取出保存
    const rawSetAttribute = Element.prototype.setAttribute
    const rawAppendChild = Element.prototype.appendChild
    const rawRemoveChild = Element.prototype.removeChild
    ... 
    // 将一些document上的原始方法取出保存
    const rawCreateElement = rawRootDocument.prototype.createElement
    const rawQuerySelector = rawRootDocument.prototype.querySelector
    const rawGetElementById = rawRootDocument.prototype.getElementById
    
    // 代理Image元素
    const ImageProxy = new Proxy(Image, {...})

    /**
     * save effect raw methods
     * pay attention to this binding, especially setInterval, setTimeout, clearInterval, clearTimeout
     */
    // 将window原始方法拿出来保存 比如addEventListener  setInterval  setTimeout等
    const rawWindowAddEventListener = rawWindow.addEventListener
    const rawSetInterval = rawWindow.setInterval
    const rawSetTimeout = rawWindow.setTimeout
    ... 
    // 将document原始方法拿出来保存 addEventListener removeEventListener
    const rawDocumentAddEventListener = rawDocument.addEventListener
    const rawDocumentRemoveEventListener = rawDocument.removeEventListener

    // 修改全局变量,表示baseApp运行
    window.__MICRO_APP_BASE_APPLICATION__ = true

    // 把以上方法用Object.assign合并到globalEnv对象中
    assign(globalEnv, {...})

    // 给baseApp设置一个初始head body样式 
    // micro-app-body { display: block; } ; micro-app-head { display: none; }
    rejectMicroAppStyle()
  }
}

MicroAppElement类

我们知道 start中主要会defineElement() 定义并创建一个MicroAppElement实例,也就是前文所说的webComponent,自定义元素

export function defineElement (tagName: string): void {
   // 定义自定义元素
   class MicroAppElement extends HTMLElement implements MicroAppElementType {
    // 监视标签中的'name', 'url'属性 改变时触发回调,进行connect
    static get observedAttributes (): string[] {
      return ['name', 'url']
    }
       ......
   }
   // 注册元素(这里tagName初始就是"micro-app")
  globalEnv.rawWindow.customElements.define(tagName, MicroAppElement)
}

handleConnect链接app 当我们设置元素的name和url后, 元素会首先触发attributeChangedCallback,执行handleInitialNameAndUrl方法 而后执行handleConnect

  • handleConnect中会初始化shadowDOM,updateSsrUrl,对KeepAliveApp做处理等等
  • 最终会执行handleCreateApp

handleCreateApp创建App实例

    // create app instance
    private handleCreateApp (): void {
      // 如果有app存在先销毁app
      if (appInstanceMap.has(this.appName)) {
        appInstanceMap.get(this.appName)!.actionsForCompletelyDestroy()
      }
      new CreateApp({
        name: this.appName,
        url: this.appUrl,
        scopecss: this.isScopecss(),
        useSandbox: this.isSandbox(),
        inline: this.getDisposeResult('inline'),
        esmodule: this.getDisposeResult('esmodule'),
        container: this.shadowRoot ?? this,
        ssrUrl: this.ssrUrl,
      })
    }

CreateApp类

App核心类 用于创建一个App实例

获取app资源

跟single-SPA一样 首先我们应该获取一个微前端应用的三大资源 js css html

class CreateApp implements AppInterface {
    constructor(){
        ...
        this.loadSourceCode()
    }
    
    public loadSourceCode (): void {
        this.state = appStates.LOADING
        HTMLLoader.getInstance().run(this, extractSourceDom) // run获取资源
  }
}

我们创建一个htmlLoader并执行run方法

提一嘴htmlLoader的单例模式,这里使用HTMLLoader.getInstance()方法获取单例,保证获取对象的唯一性

export class HTMLLoader implements IHTMLLoader {
  private static instance: HTMLLoader;
  public static getInstance (): HTMLLoader {
    if (!this.instance) {
      this.instance = new HTMLLoader()
    }
    return this.instance
  }
  ...
}

通过简单的fetch方法即可通过url "localhost:3000" 获取到html字符串

  window.fetch(url, options).then((res: Response) => {
    return res.text()
  })

传入的extractSourceDom方法作为html字符串的回调,获取对应的script和links

export function extractSourceDom (htmlStr: string, app: AppInterface): void {
    if (app.source.links.size) {
      fetchLinksFromHtml(wrapElement, app, microAppHead, fiberStyleResult) // fetchLinks
      } 
      ...
    if (app.source.scripts.size) {
      fetchScriptsFromHtml(wrapElement, app)    // fetchScripts
      }
      ...
}

检测资源是否获取完毕

团队封装了PromiseStream方法来获取scripts和links(一个html中往往有多个脚本和样式)

这是一个用于瀑布流式执行(one by one)多个Promise的函数

export function promiseStream <T> (
  promiseList: Array<Promise<T> | T>,
  successCb: CallableFunction,
  errorCb: CallableFunction,
  finallyCb?: CallableFunction,
): void {
    ...
}

我们可以看到 调用次函数传入的finallyCb中 一定会执行app.onload方法

promiseStream<string>(
    promiseList,
    successCb,
    errorCb,
    () => {
      if (fiberLinkTasks) {
        fiberStyleResult!.then(() => {
          fiberLinkTasks.push(() => Promise.resolve(app.onLoad(wrapElement))) // resolve执行onload
          serialExecFiberTasks(fiberLinkTasks)
        })
      } else {
        app.onLoad(wrapElement)  // 直接执行onload
      }
    })
)

将fetch来的资源保存到sourceCenter中

使用sourceCenter来缓存获取的资源,以便复用。 在extractLinkFromHtml中我们可以看到,将link代码包装为linkInfo后存入sourceCenter, 当然script同理

export function extractLinkFromHtml(){
    ...
   sourceCenter.link.setInfo(href, linkInfo)
}

App.onload

我们可以看到,只有第三次触发onload方法,才会真正开始执行, 也就是当links和scripts成功获取并执行对应finally回调后,才会执行。

public onLoad (
    html: HTMLElement,
    defaultPage?: string,
    disablePatchRequest?: boolean,
  ): void {
     if(++this.loadSourceLevel === 2){// 每次执行onload++
     
      // 非preFetch时,直接获取container,执行mount方法
       if (!this.isPrefetch && appStates.UNMOUNT !== this.state) {
        getRootContainer(this.container!).mount(this)
      } 
      
      // preFetch时, 创建一个div作为container,执行mount方法
      else if (this.isPrerender) {
        const container = pureCreateElement('div')
        container.setAttribute('prerender', 'true')
        this.sandBox?.setPreRenderState(true)
        this.mount({
          container,
          inline: this.inline,
          useMemoryRouter: true,
          baseroute: '',
          fiber: true,
          esmodule: this.esmodule,
          defaultPage: defaultPage ?? '',
          disablePatchRequest: disablePatchRequest ?? false,
          
        })
      }
      }
  }

mount

mount中我们会

  • 测试shadowDom
  • 开启沙箱 this.sandBox?.start
  • 执行脚本 execScripts -->runScript

execScripts中执行的代码从sourceCenter中获取到的资源,需要在之前提供的沙箱中执行。

至此,js代码执行完毕。 页面上就能正常的渲染出一个页面

沙箱 patch与releasePatch

微应用作用与沙箱环境中,在进入沙箱时,我们需要修改document,Element上的诸多dom操作方法。

还记得之前在init时保存到globalEnv中的原始方法吗?现在起作用了

  • patch: 可以理解为:修改方法
  • releasePatch: 将修改的原生方法还原

为什么要修改原生方法? 这里主要做两件事

  • 修改this指向 : 如果当前document是Proxy代理后的document,则this指向原始document,如果不是则this指向当前document

  • 给创建的元素做标记

我们以microApp中的patchDocument方法举例

function patchDocument () {
    // 从globalEnv中获取原始document
      const rawDocument = globalEnv.rawDocument
      const rawRootDocument = globalEnv.rawRootDocument
    // 获取需要指向的this
     function getBindTarget (target: Document): Document {
       return isProxyDocument(target) ? rawDocument : target
     }
    // 给创建的element打上标记
    function markElement <T extends { __MICRO_APP_NAME__: string }> (element: T): T {
      const currentAppName = getCurrentAppName()
      if (currentAppName) element.__MICRO_APP_NAME__ = currentAppName
      return element
    }
    // 修改rawRootDocument.prototype.createElement上的createElement方法
   rawRootDocument.prototype.createElement = function createElement (
      tagName: string,
      options?: ElementCreationOptions
    ): HTMLElement
    {
     const element = globalEnv.rawCreateElement.call(getBindTarget(this), tagName, options)
     return markElement(element)
   }
  
  // 后面还修改了很多dom操作方法 如
  rawRootDocument.prototype.createElementNS = function createElementNS(){...}
  rawRootDocument.prototype.createDocumentFragment = function createDocumentFragment(){...}
  rawRootDocument.prototype.querySelector = function querySelector(){...}
  rawRootDocument.prototype.querySelectorAll = function querySelectorAll(){...}
  rawRootDocument.prototype.getElementById = function getElementById(){...}
  rawRootDocument.prototype.getElementsByClassName = function getElementsByClassName(){...}
  ...
}

同样的 对于Element对象上操作dom方法也进行了修改,并将对应的操作封装到了commonElementHandler方法里

release patch

将修改过的方法还原
(当然我觉得这里应该抽出patchElement和releasePatchElement函数,感觉层级不太对劲) patchAttrbuilt需要在MicroAppElement创建时执行,做特殊处理


// release patch
export function releasePatches (): void {
  removeDomScope()
  releasePatchDocument() // 还原document方法
   
  // 还原Element方法
  Element.prototype.appendChild = globalEnv.rawAppendChild
  Element.prototype.insertBefore = globalEnv.rawInsertBefore
  Element.prototype.replaceChild = globalEnv.rawReplaceChild
  Element.prototype.removeChild = globalEnv.rawRemoveChild
  Element.prototype.append = globalEnv.rawAppend
  Element.prototype.prepend = globalEnv.rawPrepend
  Element.prototype.cloneNode = globalEnv.rawCloneNode
  Element.prototype.querySelector = globalEnv.rawElementQuerySelector
  Element.prototype.querySelectorAll = globalEnv.rawElementQuerySelectorAll
  //DefineProperty 方法特殊处理
  rawDefineProperty(Element.prototype, 'innerHTML', globalEnv.rawInnerHTMLDesc)   
}

后记

微前端的其他原理比如qiankun实现原理,沙箱的实现,等可以参考我的文章

手把手带你手写一个qiankun 微前端核心原理 - 掘金 (juejin.cn)

微前端乾坤框架 CSS JS沙箱隔离环境原理 - 掘金 (juejin.cn)

本人也有简易版microApp框架的实现,代码可用,注释详细,

lzy19926/lzy-microApp: 简易microApp微前端框架实现(github.com) 需要学习的小伙伴可以給个star嘛,bug很多 凑合看把