Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从零开始写一个微前端框架-沙箱篇 #19

Closed
bailicangdu opened this issue Aug 4, 2021 · 5 comments
Closed

从零开始写一个微前端框架-沙箱篇 #19

bailicangdu opened this issue Aug 4, 2021 · 5 comments
Labels
docs Improvements or additions to documentation

Comments

@bailicangdu
Copy link
Member

bailicangdu commented Aug 4, 2021

前言

自从微前端框架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,它的使用场景不限于微前端,也可以用于其它地方,比如在我们向外部提供组件或引入第三方组件时都可以使用沙箱来避免冲突。

下一篇文章我们会完成微前端的样式隔离。

@xuya227939
Copy link

很强

@BARMPlus
Copy link

BARMPlus commented Jan 5, 2023

m

@pengtianyua
Copy link

amazing

@yangxin9003
Copy link

和乾坤的js sandbox有什么区别?
是否无界的方案更优秀呢?

@nameRoy
Copy link

nameRoy commented Nov 13, 2023

对于沙箱的实现上,用了 with 太妙了,但是这个语法已经不建议使用了,如果未来浏览器去掉了,那沙箱部分应该怎么改一下呢?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Improvements or additions to documentation
Projects
None yet
Development

No branches or pull requests

6 participants