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

前言

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

經過這些文章,你能夠了解微前端框架的具體原理和實現方式,這在你之後使用微前端或者本身寫一套微前端框架時會有很大的幫助。若是這篇文章對你有幫助,歡迎點贊留言。git

相關推薦

開始

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

下面咱們列出了兩個具體的問題,而後經過建立沙箱來解決。segmentfault

問題示例

一、子應用向window上添加一個全局變量:globalStr='child',若是此時基座應用也有一個相同的全局變量:globalStr='parent',此時就產生了變量衝突,基座應用的變量會被覆蓋。緩存

二、子應用渲染後經過監聽scroll添加了一個全局監聽事件前端框架

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

當子應用被卸載時,監聽函數卻沒有解除綁定,對頁面滾動的監聽一直存在。若是子應用二次渲染,監聽函數會綁定兩次,這顯然是錯誤的。架構

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

建立沙箱

因爲每一個子應用都須要一個獨立的沙箱,因此咱們經過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,它的使用場景不限於微前端,也能夠用於其它地方,好比在咱們向外部提供組件或引入第三方組件時均可以使用沙箱來避免衝突。

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

相關文章
相關標籤/搜索