從零開始寫一個微前端框架-沙箱篇

cangdu發表於2021-08-04

前言

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

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

相關推薦

開始

前一篇文章中,我們已經完成了微前端的渲染工作,雖然頁面已經正常渲染,但是此時基座應用和子應用是在同一個window下執行的,這有可能產生一些問題,如全域性變數衝突、全域性事件監聽和解綁。

下面我們列出了兩個具體的問題,然後通過建立沙箱來解決。

問題示例

1、子應用向window上新增一個全域性變數:globalStr='child',如果此時基座應用也有一個相同的全域性變數:globalStr='parent',此時就產生了變數衝突,基座應用的變數會被覆蓋。

2、子應用渲染後通過監聽scroll新增了一個全域性監聽事件

window.addEventListener('scroll', () => {
  console.log('scroll')
})

當子應用被解除安裝時,監聽函式卻沒有解除繫結,對頁面滾動的監聽一直存在。如果子應用二次渲染,監聽函式會繫結兩次,這顯然是錯誤的。

接下來我們就通過給微前端建立一個JS沙箱環境,隔離基座應用和子應用的JS,從而解決這兩個典型的問題,

建立沙箱

由於每個子應用都需要一個獨立的沙箱,所以我們通過class建立一個類:SandBox,當一個新的子應用被建立時,就建立一個新的沙箱與其繫結。

// /src/sandbox.js
export default class SandBox {
  active = false // 沙箱是否在執行
  microWindow = {} // // 代理的物件
  injectedKeys = new Set() // 新新增的屬性,在解除安裝時清空

  constructor () {}

  // 啟動
  start () {}

  // 停止
  stop () {}
}

我們使用Proxy進行代理操作,代理物件為空物件microWindow,得益於Proxy強大的功能,實現沙箱變得簡單且高效。

constructor中進行代理相關操作,通過Proxy代理microWindow,設定getsetdeleteProperty三個攔截器,此時子應用對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
      },
    })
  }

  ...
}

建立完代理後,我們接著完善startstop兩個方法,實現方式也非常簡單,具體如下:

// /src/sandbox.js
export default class SandBox {
  ...
  // 啟動
  start () {
    if (!this.active) {
      this.active = true
    }
  }

  // 停止
  stop () {
    if (this.active) {
      this.active = false

      // 清空變數
      this.injectedKeys.forEach((key) => {
        Reflect.deleteProperty(this.microWindow, key)
      })
      this.injectedKeys.clear()
    }
  }
}

上面一個沙箱的雛形就完成了,我們嘗試一下,看看是否有效。

使用沙箱

src/app.js中引入沙箱,在CreateApp的建構函式中建立沙箱例項,並在mount方法中執行沙箱的start方法,在unmount方法中執行沙箱的stop方法。

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

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

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

  /**
   * 解除安裝應用
   * @param destory 是否完全銷燬,刪除快取資源
   */
  unmount (destory) {
    ...
+    this.sandbox.stop()
    // destory為true,則刪除應用
    if (destory) {
      appInstanceMap.delete(this.name)
    }
  }
}

我們在上面建立了沙箱例項並啟動沙箱,這樣沙箱就生效了嗎?

顯然是不行的,我們還需要將子應用的js通過一個with函式包裹,修改js作用域,將子應用的window指向代理的物件。形式如:

(function(window, self) {
  with(window) {
    子應用的js程式碼
  }
}).call(代理物件, 代理物件, 代理物件)

在sandbox中新增方法bindScope,修改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);`
  }
}

然後在mount方法中新增對bindScope的使用

// /src/app.js

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

到此沙箱才真正起作用,我們驗證一下問題示例中的第一個問題。

先關閉沙箱,由於子應用覆蓋了基座應用的全域性變數globalStr,當我們在基座中訪問這個變數時,得到的值為:child,說明變數產生了衝突。

開啟沙箱後,重新在基座應用中列印globalStr的值,得到的值為:parent,說明變數衝突的問題已經解決,沙箱正確執行。

第一個問題已經解決,我們開始解決第二個問題:全域性監聽事件。

重寫全域性事件

再來回顧一下第二個問題,錯誤的原因是在子應用解除安裝時沒有清空事件監聽,如果子應用知道自己將要被解除安裝,主動清空事件監聽,這個問題可以避免,但這是理想情況,一是子應用不知道自己何時被解除安裝,二是很多第三方庫也有一些全域性的監聽事件,子應用無法全部控制。所以我們需要在子應用解除安裝時,自動將子應用殘餘的全域性監聽事件進行清空。

我們在沙箱中重寫window.addEventListenerwindow.removeEventListener,記錄所有全域性監聽事件,在應用解除安裝時如果有殘餘的全域性監聽事件則進行清空。

建立一個effect函式,在這裡執行具體的操作

// /src/sandbox.js

// 記錄addEventListener、removeEventListener原生方法
const rawWindowAddEventListener = window.addEventListener
const rawWindowRemoveEventListener = window.removeEventListener

/**
 * 重寫全域性事件的監聽和解綁
 * @param microWindow 原型物件
 */
 function effect (microWindow) {
  // 使用Map記錄全域性事件
  const eventListenerMap = new Map()

  // 重寫addEventListener
  microWindow.addEventListener = function (type, listener, options) {
    const listenerList = eventListenerMap.get(type)
    // 當前事件非第一次監聽,則新增快取
    if (listenerList) {
      listenerList.add(listener)
    } else {
      // 當前事件第一次監聽,則初始化資料
      eventListenerMap.set(type, new Set([listener]))
    }
    // 執行原生監聽函式
    return rawWindowAddEventListener.call(window, type, listener, options)
  }

  // 重寫removeEventListener
  microWindow.removeEventListener = function (type, listener, options) {
    const listenerList = eventListenerMap.get(type)
    // 從快取中刪除監聽函式
    if (listenerList?.size && listenerList.has(listener)) {
      listenerList.delete(listener)
    }
    // 執行原生解綁函式
    return rawWindowRemoveEventListener.call(window, type, listener, options)
  }

  // 清空殘餘事件
  return () => {
    console.log('需要解除安裝的全域性事件', eventListenerMap)
    // 清空window繫結事件
    if (eventListenerMap.size) {
      // 將殘餘的沒有解綁的函式依次解綁
      eventListenerMap.forEach((listenerList, type) => {
        if (listenerList.size) {
          for (const listener of listenerList) {
            rawWindowRemoveEventListener.call(window, type, listener)
          }
        }
      })
      eventListenerMap.clear()
    }
  }
}

在沙箱的建構函式中執行effect方法,得到解除安裝的鉤子函式releaseEffect,在沙箱關閉時執行解除安裝操作,也就是在stop方法中執行releaseEffect函式

// /src/sandbox.js

export default class SandBox {
  ...
  // 修改js作用域
  constructor () {
    // 解除安裝鉤子
+   this.releaseEffect = effect(this.microWindow)
    ...
  }

  stop () {
    if (this.active) {
      this.active = false

      // 清空變數
      this.injectedKeys.forEach((key) => {
        Reflect.deleteProperty(this.microWindow, key)
      })
      this.injectedKeys.clear()
      
      // 解除安裝全域性事件
+      this.releaseEffect()
    }
  }
}

這樣重寫全域性事件及解除安裝的操作基本完成,我們驗證一下是否正常執行。

首先關閉沙箱,驗證問題二的存在:解除安裝子應用後滾動頁面,依然在列印scroll,說明事件沒有被解除安裝。

開啟沙箱後,解除安裝子應用,滾動頁面,此時scroll不再列印,說明事件已經被解除安裝。

從截圖中可以看出,除了我們主動監聽的scroll事件,還有errorunhandledrejection等其它全域性事件,這些事件都是由框架、構建工具等第三方繫結的,如果不進行清空,會導致記憶體無法回收,造成記憶體洩漏。

沙箱功能到此就基本完成了,兩個問題都已經解決。當然沙箱需要解決的問題遠不止這些,但基本架構思路是不變的。

結語

JS沙箱的核心在於修改js作用域和重寫window,它的使用場景不限於微前端,也可以用於其它地方,比如在我們向外部提供元件或引入第三方元件時都可以使用沙箱來避免衝突。

下一篇文章我們會完成微前端的樣式隔離。

相關文章