关于javascript:从零开始写一个微前端框架沙箱篇

43次阅读

共计 5934 个字符,预计需要花费 15 分钟才能阅读完成。

前言

自从微前端框架 micro-app 开源后,很多小伙伴都十分感兴趣,问我是如何实现的,但这并不是几句话能够说明确的。为了讲清楚其中的原理,我会从零开始实现一个繁难的微前端框架,它的外围性能包含:渲染、JS 沙箱、款式隔离、数据通信。因为内容太多,会依据性能分成四篇文章进行解说,这是系列文章的第二篇:沙箱篇。

通过这些文章,你能够理解微前端框架的具体原理和实现形式,这在你当前应用微前端或者本人写一套微前端框架时会有很大的帮忙。如果这篇文章对你有帮忙,欢送点赞留言。

相干举荐

  • micro-app 仓库地址
  • simple-micro-app 仓库地址
  • 从零开始写一个微前端框架 - 渲染篇
  • 从零开始写一个微前端框架 - 沙箱篇
  • 从零开始写一个微前端框架 - 款式隔离篇
  • 从零开始写一个微前端框架 - 数据通信篇
  • micro-app 介绍

开始

前一篇文章中,咱们曾经实现了微前端的渲染工作,尽管页面曾经失常渲染,然而此时基座利用和子利用是在同一个 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,它的应用场景不限于微前端,也能够用于其它中央,比方在咱们向内部提供组件或引入第三方组件时都能够应用沙箱来防止抵触。

下一篇文章咱们会实现微前端的款式隔离。

正文完
 0