從零開始寫一個微前端框架-資料通訊篇

cangdu發表於2021-08-06

前言

自從微前端框架micro-app開源後,很多小夥伴都非常感興趣,問我是如何實現的,但這並不是幾句話可以說明白的。為了講清楚其中的原理,我會從零開始實現一個簡易的微前端框架,它的核心功能包括:渲染、JS沙箱、樣式隔離、資料通訊。由於內容太多,會根據功能分成四篇文章進行講解,這是系列文章的最終篇:資料通訊篇。

通過這些文章,你可以瞭解微前端框架的具體原理和實現方式,這在你以後使用微前端或者自己寫一套微前端框架時會有很大的幫助。如果這篇文章對你有幫助,歡迎點贊留言。

相關推薦

開始

架構設計

微前端各個應用本身是獨立執行的,通訊系統不應該對應用侵入太深,所以我們採用釋出訂閱系統。但是由於子應用封裝在micro-app標籤內,作為一個類webComponents的元件,釋出訂閱系統的弱繫結和它格格不入。

最好的方式是像普通屬性一樣通過micro-app元素傳遞資料。但自定義元素無法支援物件型別的屬性,只能傳遞字串,例如<micro-app data={x: 1}></micro-app> 會轉換為 <micro-app data='[object Object]'></micro-app>,想要以元件化形式進行資料通訊必須讓元素支援物件型別屬性,為此我們需要重寫micro-app原型鏈上setAttribute方法處理物件型別屬性。

流程圖

程式碼實現

建立檔案data.js,資料通訊的功能主要在這裡實現。

釋出訂閱系統

實現釋出訂閱系統的方式很多,我們簡單寫一個,滿足基本的需求即可。

// /src/data.js

// 釋出訂閱系統
class EventCenter {
  // 快取資料和繫結函式
  eventList = new Map()
  /**
   * 繫結監聽函式
   * @param name 事件名稱
   * @param f 繫結函式
   */
  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)
    // eventInfo存在且f為函式則解除安裝指定函式
    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()

釋出訂閱系統很靈活,但太過於靈活可能會導致資料傳輸的混亂,必須定義一套清晰的資料流。所以我們要進行資料繫結,基座應用一次只能向指定的子應用傳送資料,子應用只能傳送資料到基座應用,至於子應用之間的資料通訊則通過基座應用進行控制,這樣資料流就會變得清晰

通過格式化訂閱名稱來進行資料的繫結通訊。

// /src/data.js
/**
 * 格式化事件名稱,保證基座應用和子應用的繫結通訊
 * @param appName 應用名稱
 * @param fromBaseApp 是否從基座應用傳送資料
 */
 function formatEventName (appName, fromBaseApp) {
  if (typeof appName !== 'string' || !appName) return ''
  return fromBaseApp ? `__from_base_app_${appName}__` : `__from_micro_app_${appName}__`
}

由於基座應用和子應用的資料通訊方式不同,我們分開定義。

// /src/data.js

// 基座應用的資料通訊方法集合
export class EventCenterForBaseApp {
  /**
   * 向指定子應用傳送資料
   * @param appName 子應用名稱
   * @param data 物件資料
   */
  setData (appName, data) {
    eventCenter.dispatch(formatEventName(appName, true), data)
  }

  /**
   * 清空某個應用的監聽函式
   * @param appName 子應用名稱
   */
  clearDataListener (appName) {
    eventCenter.off(formatEventName(appName, false))
  }
}

// 子應用的資料通訊方法集合
export class EventCenterForMicroApp {
  constructor (appName) {
    this.appName = appName
  }

  /**
   * 監聽基座應用傳送的資料
   * @param cb 繫結函式
   */
  addDataListener (cb) {
    eventCenter.on(formatEventName(this.appName, true), cb)
  }

  /**
   * 解除監聽函式
   * @param cb 繫結函式
   */
  removeDataListener (cb) {
    if (typeof cb === 'function') {
      eventCenter.off(formatEventName(this.appName, true), cb)
    }
  }

  /**
   * 向基座應用傳送資料
   * @param data 物件資料
   */
  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))
  }
}

在入口檔案中建立基座應用通訊物件。

// /src/index.js

+ import { EventCenterForBaseApp } from './data'
+ const BaseAppData = new EventCenterForBaseApp()

在沙箱中建立子應用的通訊物件,並在沙箱關閉時清空所有繫結的事件。

// /src/sandbox.js

import { EventCenterForMicroApp } from './data'

export default class SandBox {
  constructor (appName) {
    // 建立資料通訊物件
    this.microWindow.microApp = new EventCenterForMicroApp(appName)
    ...
  }

  stop () {
    ...
    // 清空所有繫結函式
    this.microWindow.microApp.clearDataListener()
  }
}

到這裡,資料通訊大部分功能都完成了,但還缺少一點,就是對micro-app元素物件型別屬性的支援。

我們重寫Element原型鏈上setAttribute方法,當micro-app元素設定data屬性時進行特殊處理。

// /src/index.js

// 記錄原生方法
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)
  }
}

大功告成,我們驗證一下是否可以正常執行,在vue2專案中向子應用傳送資料,並接受來自子應用的資料。

// vue2/pages/page1.vue
<template>
  ...
  <micro-app
    name='app'
    url='http://localhost:3001/'
    v-if='showapp'
    id='micro-app-app1'
    :data='data'
    @datachange='handleDataChange'
  ></micro-app>
</template>

<script>
export default {
  ...
  mounted () {
    setTimeout(() => {
      this.data = {
        name: '來自基座應用的資料'
      }
    }, 2000)
  },
  methods: {
    handleDataChange (e) {
      console.log('接受資料:', e.detail.data)
    }
  }
}
</script>

在react17專案中監聽來自基座應用的資料並向基座應用傳送資料。

// react17/index.js

// 資料監聽
window.microApp?.addDataListener((data) => {
  console.log("接受資料:", data)
})

setTimeout(() => {
  window.microApp?.dispatch({ name: '來自子應用的資料' })
}, 3000);

檢視控制抬的列印資訊:

資料正常列印,資料通訊功能生效。

結語

從這些文章中可以看出,微前端的實現並不難,真正難的是開發、生產環境中遇到的各種問題,沒有完美的微前端框架,無論是Module Federation、qiankun。micro-app以及其它微前端解決方案,都會在某些場景下出現問題,瞭解微前端原理才能快速定位和處理問題,讓自己立於不敗之地。

相關文章