9.9. single-spa VS qiankun

748 阅读7分钟

1,主流的微前端方案

  1. iframe
  2. 基座模式,主要基于路由分发,qiankunsingle-spa就是基于这种模式
  3. 组合式集成,即单独构建组件,按需加载,类似npm包的形式
  4. EMP,主要基于Webpack5 Module Federation
  5. Web Components

2,single-spa 与 qiankun

  • single-spa很好地解决了路由和应用入口两个问题,但并没有解决应用加载问题,而是将该问题暴露出来由使用者实现(一般可以用system.js或原生script标签来实现);
  • qiankun在此基础上封装了一个应用加载方案(即import-html-entry),并给出了js隔离、css样式隔离和应用间通信三个问题的解决方案,同时提供了预加载功能。

image.png

(1). 路由问题

single-spa是通过监听hashChangepopState这两个原生事件来检测路由变化的,它会根据路由的变化来加载对应的应用,相关的代码可以在single-spa的 src/navigation/navigation-events.js 中找到:

...
// 139行
if (isInBrowser) {
  // We will trigger an app change for any routing events.
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);
...
// 174行,劫持pushState和replaceState
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );

(2). 应用入口

single-spa采用的是协议入口,即只要实现了single-spa的入口协议规范,它就是可加载的应用。single-spa的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa可以注册回调函数

(3). 应用加载

实际上single-spa并没有提供自己的解决方案,而是将它开放出来,由开发者提供。

我们看一下基于system.js如何启动single-spa

<script type="systemjs-importmap">
  {
    "imports": {
      "app1": "http://localhost:8080/app1.js",
      "app2": "http://localhost:8081/app2.js",
      "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js"
    }
  }
</script>
... // system.js的相关依赖文件
<script>
(function(){
  // 加载single-spa
  System.import('single-spa').then((res)=>{
    var singleSpa = res;
    // 注册子应用
    singleSpa.registerApplication('app1',
      () => System.import('app1'),
      location => location.hash.startsWith(`#/app1`);
    );
    singleSpa.registerApplication('app2',
      () => System.import('app2'),
      location => location.hash.startsWith(`#/app2`);
    );
    // 启动single-spa
    singleSpa.start();
  })
})()
</script>

3,qiankun 原理

使用方式

//----主应用----
registerApplication({
    name: 'vue',
    pageEntry: 'http://localhost:8001',
    activeRule: pathPrefix('/vue'),
    container: $('#subapp-viewport')
})
registerApplication({
    name: 'react',
    pageEntry: 'http://localhost:8002',
    activeRule:pathPrefix('/react'),
    container: $('#subapp-viewport')
})
start()
//----子应用----

(1). 应用路由

每次 URL 改变时,都会调用 loadApps() 方法

import { loadApps } from '../application/apps'

const originalPushState = window.history.pushState
const originalReplaceState = window.history.replaceState

export default function overwriteEventsAndHistory() {
    window.history.pushState = function (state: any, title: string, url: string) {
        const result = originalPushState.call(this, state, title, url)
        // 根据当前 url 加载或卸载 app
        loadApps()
        return result
    }
    
    window.history.replaceState = function (state: any, title: string, url: string) {
        const result = originalReplaceState.call(this, state, title, url)
        loadApps()
        return result
    }
    
    window.addEventListener('popstate', () => {
        loadApps()
    }, true)
    
    window.addEventListener('hashchange', () => {
        loadApps()
    }, true)
}

(2). 应用入口

  1. 卸载所有已失活的子应用
  2. 初始化所有刚注册的子应用
  3. 加载所有符合条件的子应用
export async function loadApps() {
	// 先卸载所有失活的子应用
    const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED)
    await Promise.all(toUnMountApp.map(unMountApp))
    
    // 初始化所有刚注册的子应用
    const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)
    await Promise.all(toLoadApp.map(bootstrapApp))

    const toMountApp = [
        ...getAppsWithStatus(AppStatus.BOOTSTRAPPED),
        ...getAppsWithStatus(AppStatus.UNMOUNTED),
    ]
    // 加载所有符合条件的子应用
    await toMountApp.map(mountApp)
}

(3). 应用加载

qiankun的作者将其封装成了npm插件import-html-entry

export default function parseHTMLandLoadSources(app: Application) {
    return new Promise<void>(async (resolve, reject) => {
        const pageEntry = app.pageEntry    
        // load html        
        const html = await loadSourceText(pageEntry)
        const domparser = new DOMParser()
        const doc = domparser.parseFromString(html, 'text/html')
        const { scripts, styles } = extractScriptsAndStyles(doc as unknown as Element, app)
        
        // 提取了 script style 后剩下的 body 部分的 html 内容
        app.pageBody = doc.body.innerHTML

        let isStylesDone = false, isScriptsDone = false
        // 加载 style script 的内容
        Promise.all(loadStyles(styles))
        .then(data => {
            isStylesDone = true
            // 将 style 样式添加到 document.head 标签
            addStyles(data as string[])
            if (isScriptsDone && isStylesDone) resolve()
        })
        .catch(err => reject(err))

        Promise.all(loadScripts(scripts))
        .then(data => {
            isScriptsDone = true
            // 执行 script 内容
            executeScripts(data as string[])
            if (isScriptsDone && isStylesDone) resolve()
        })
        .catch(err => reject(err))
    })
}
  • 利用 ajax 请求子应用入口 URL 的内容,得到子应用的 HTML
  • 提取 HTML 中 script style 的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的 script style 的内容
  • 将所有 style 添加到 document.head 下,script 代码直接执行
  • 将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下

(4). js隔离

qiankun通过import-html-entry,可以对html入口进行解析,并获得一个可以执行脚本的方法execScripts。

app.window = new Proxy({}, {
    get(target, key) {
        if (Reflect.has(target, key)) {
            return Reflect.get(target, key)
        }
        
        const result = originalWindow[key]
        return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result
    },

    set: (target, key, value) => {
    	this.injectKeySet.add(key)
        return Reflect.set(target, key, value)
    }
})

export function executeScripts(scripts: string[], app: Application) {
    try {
        scripts.forEach(code => {            
            // ts 使用 with 会报错,所以需要这样包一下
            // 将子应用的 js 代码全局 window 环境指向代理环境 proxyWindow
            const warpCode = `
                ;(function(proxyWindow){
                    with (proxyWindow) {
                        (function(window){${code}\n}).call(proxyWindow, proxyWindow)
                    }
                })(this);
            `

            new Function(warpCode).call(app.sandbox.proxyWindow)
        })
    } catch (error) {
        throw error
    }
}

(5). css隔离

  • 给子应用添加 id = 'single-spa-id-' + count++
  • 子应用css增加作用域 items[0] = ${items[0]}[single-spa-name=${app.name}]
function handleCSSRules(cssRules: CSSRuleList, app: Application) {
    let result = ''
    Array.from(cssRules).forEach(cssRule => {
        const cssText = cssRule.cssText
        const selectorText = (cssRule as CSSStyleRule).selectorText
        result += cssRule.cssText.replace(
            selectorText, 
            getNewSelectorText(selectorText, app),
        )
    })

    return result
}

let count = 0
const re = /^(\s|,)?(body|html)\b/g
function getNewSelectorText(selectorText: string, app: Application) {
    const arr = selectorText.split(',').map(text => {
        const items = text.trim().split(' ')
        items[0] = `${items[0]}[single-spa-name=${app.name}]`
        return items.join(' ')
    })

    // 如果子应用挂载的容器没有 id,则随机生成一个 id
    let id = app.container.id
    if (!id) {
        id = 'single-spa-id-' + count++
        app.container.id = id
    }

    // 将 body html 标签替换为子应用挂载容器的 id
    return arr.join(',').replace(re, `#${id}`)
}

(6). 应用通信.

qiankun官方提供了一个简要的方案,思路是基于一个全局的globalState对象。
这个对象由基座应用负责创建,内部包含一组用于通信的变量,以及两个分别用于修改变量值和监听变量变化的方法:setGlobalState和onGlobalStateChange。

4,micro-app

使用方式

// vue2/src/main.js

import SimpleMicroApp from 'simple-micro-app'

SimpleMicroApp.start()

然后就可以在vue2项目中的任何位置使用micro-app标签。

<!-- page1.vue -->
<template>
  <div>
    <micro-app name='app' url='http://localhost:3001/'></micro-app>
  </div>
</template>

(1). 利用custom-component引入

元素被插入到DOM时执行,此时去加载子应用的静态资源并渲染

// /src/element.js
// 自定义元素
class MyElement extends HTMLElement {
  // 声明需要监听的属性名,只有这些属性变化时才会触发attributeChangedCallback
  static get observedAttributes () {
    return ['name', 'url']
  }

  constructor() {
    super();
  }

  connectedCallback() {
   // 创建微应用实例 
   const app = new CreateApp({ name: this.name, url: this.url, container: this, }) 
   // 记入缓存,用于后续功能 
   appInstanceMap.set(this.name, app) 

  }

  disconnectedCallback () {
    // 元素从DOM中删除时执行,此时进行一些卸载操作
    console.log('micro-app has disconnected')
  }

  attributeChangedCallback (attr, oldVal, newVal) {
    // 元素属性发生变化时执行,可以获取name、url等属性的值
    console.log(`attribute ${attrName}: ${newVal}`)
  }
}

/**
 * 注册元素
 * 注册后,就可以像普通元素一样使用micro-app,当micro-app元素被插入或删除DOM时即可触发相应的生命周期函数。
 */
window.customElements.define('micro-app', MyElement)

(2). 加载入口.

//---- /src/app.js
import loadHtml from './source'
import Sandbox from './sandbox'

export default class CreateApp {
  constructor ({ name, url, container }) {
    loadHtml(this)
    this.sandbox = new Sandbox(name)
  }


  mount () {
    // 克隆DOM节点 
    const cloneHtml = this.source.html.cloneNode(true) 
    // 创建一个fragment节点作为模版,这样不会产生冗余的元素 
    const fragment = document.createDocumentFragment() 
    Array.from(cloneHtml.childNodes).forEach((node) => { 
        fragment.appendChild(node) 
    }) 
    // 将格式化后的DOM结构插入到容器中 
    this.container.appendChild(fragment)

    this.sandbox.start()
    // 执行js
    this.source.scripts.forEach((info) => {
      (0, eval)(info.code)
    })
  }


  unmount (destory) {
  
    this.sandbox.stop()
    // destory为true,则删除应用
    if (destory) {
      appInstanceMap.delete(this.name)
    }
  }
}

//--- src/source.js
import { fetchSource } from './utils'
export default function loadHtml (app) {
  fetchSource(app.url).then((html) => {
    html = html
      .replace(/<head[^>]*>[\s\S]*?<\/head>/i, (match) => {
        // 将head标签替换为micro-app-head,因为web页面只允许有一个head标签
        return match
          .replace(/<head/i, '<micro-app-head')
          .replace(/<\/head>/i, '</micro-app-head>')
      })
      .replace(/<body[^>]*>[\s\S]*?<\/body>/i, (match) => {
        // 将body标签替换为micro-app-body,防止与基座应用的body标签重复导致的问题。
        return match
          .replace(/<body/i, '<micro-app-body')
          .replace(/<\/body>/i, '</micro-app-body>')
      })

    // 将html字符串转化为DOM结构
    const htmlDom = document.createElement('div')
    htmlDom.innerHTML = html
    console.log('html:', htmlDom)

    // 进一步提取和处理js、css等静态资源
    extractSourceDom(htmlDom, app)
  }).catch((e) => {
    console.error('加载html出错', e)
  })
}

(3). 创建沙箱.

创建Window的代理

// /src/sandbox.js
export default class SandBox {
  active = false // 沙箱是否在运行
  microWindow = {} // // 代理的对象
  injectedKeys = new Set() // 新添加的属性,在卸载时清空

  constructor () {
    this.proxyWindow的代理 = new Proxy(this.microWindow, {
      // 取值
      get: (target, key) => {
        // 优先从代理对象上取值
        if (Reflect.has(target, key)) {
          return Reflect.get(target, key)
        }

        // 否则兜底到window对象上取值
        const rawValue = Reflect.get(window, key)

        // 如果兜底的值为函数,则需要绑定window对象,如:console、alert等
        if (typeof rawValue === 'function') {
          const valueStr = rawValue.toString()
          // 排除构造函数
          if (!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) {
            return rawValue.bind(window)
          }
        }

        // 其它情况直接返回
        return rawValue
      },
      // 设置变量
      set: (target, key, value) => {
        // 沙箱只有在运行时可以设置变量
        if (this.active) {
          Reflect.set(target, key, value)

          // 记录添加的变量,用于后续清空操作
          this.injectedKeys.add(key)
        }

        return true
      },
      deleteProperty: (target, key) => {
        // 当前key存在于代理对象上时才满足删除条件
        if (target.hasOwnProperty(key)) {
          return Reflect.deleteProperty(target, key)
        }
        return true
      },
    })
  }

  ...
}

(4). js隔离.

// /src/sandbox.js

export default class SandBox {
  ...
  // 修改js作用域
  bindScope (code) {
    window.proxyWindow = this.proxyWindow
    return `;(function(window, self){with(window){;${code}\n}}).call(window.proxyWindow, window.proxyWindow, window.proxyWindow);`
  }
}
// /src/app.js

export default class CreateApp {
  mount () {
    ...
    // 执行js
    this.source.scripts.forEach((info) => {
      (0, eval)(this.sandbox.bindScope(info.code))
    })
  }
}

(5).css隔离

const prefix = `micro-app[${appName}]`
//给css加上作用域,在选择器前面加前缀
dom.textContext = scopedRule(Array.from(dom.sheet.cssRules),prefix)

5,react MicroFrontend

100行代码写一个微前端组件

config ={
  app:{
    port:3000,
    publicPath:'/app/',
    renderkey:'App'
  },
  ...
}


class MicroFrontend extend React.Component {
  async componentDidMount(){
    const {document,module} = this.props
    const scriptId = `micro-frontend-script-${module}`
    const linkId  = `micro-frontend-link-${module}`
    if(document.getElementById(scriptId) || document.getElementById(linkId)){
        this.renderMicroFrontend()
        return
    }
    
    const {port,publicPath}= config[module]
    const host = process.env.NODE_ENV === 'development'?`localohost:${port}`:''
    this.loadPage(host+publicPath,scriptId,linkId)
  }
  
  loadPage = async (path,scriptId,linkId){
    const {data} = await axios.get(`${path}asset-mainfest.json`,{baseURL:'/'})
    this.appendChild('link',{
      rel:'stylesheet',
      id:linkId,
      type:'text/css',
      href:data['main.css']
    })
    this.appendChild('script',{
      id:scriptId,
      type:'text/javascript',
      href:data['main.js'],
      onload:this.renderMicroFrontend
    })
  } 
  
  appendChild = (element,obj)=>{
    let ele = document.createElement(element)
    for (let k in obj) {
        ele[k] = obj[k]  
    }
    document.head.appendChild(ele)
  }
  renderMicroFrontend =() =>{
    const {module,props} = this.props
    let domId = `${module}-container`
    const {renderkey}  = config[module]
    window[`render${renderkey}`]&& window[`render${renderkey}`](domId,props)
  }
  
  componentWillUnmount(){
    const {module,props} = this.props
    let domId = `${module}-container`
    const {renderkey}  = config[module]
    window[`unmount${renderkey}`]&& window[`unmount${renderkey}`](domId)
  }
  
  render(){
    return <div id={`this.props.module`}-container />
  }
}
MicroFrontend.defaultProps = {
 document,
 module
}

参考

qiankun之原理与实战
手写qiankun
手写MicroApp