版本 | 日期 | 修订人 | ChangeLog |
---|---|---|---|
v0.1 | 2022-4-20 | zengkunpeng | 沙箱机制文章开源 |
v0.2 | 2022-4-29 | zhouxiao | 调整 DOM 副作用 |
在微前端的场景,由于多个独立的应用被组织到了一起,在没有类似 iframe
的原生隔离下,势必会出现冲突,如全局变量冲突、样式冲突,这些冲突可能会导致应用样式异常,甚至功能不可用。所以想让微前端达到生产可用的程度,让每个子应用之间达到一定程度隔离的沙箱机制是必不可少的。
此外沙箱功能还需要满足多实例的场景,先来了解一下什么是微前端里的多实例。
常规的脚本加载,是通过 script
标签去执行的,如
要实现沙箱,因为需要控制沙箱的开启和关闭,我们就需要精确掌握脚本的执行时机,所以我们需要寻找一种合适的能手动执行代码的方法
The eval() function evaluates JavaScript code represented as a string.
首先我们想到的是 eval,由于 eval 有安全、性能等问题,同时也不利于调试,所以在以前我们听到的都是不推荐使用 eval 这个 api。
但是在微前端的沙箱场景,eval 确实是一个比较好的解决方案,比如 qiankun
就采用了 eval
作为代码执行器。
The Function constructor creates a new Function object. In JavaScript, every function is actually a Function object.
new Function
通过传入一个 string
作为函数的的主体同时返回一个新函数,可以作为 eval
的一个替代品
对比 eval
,有两点比较重要的不同:
运行结果
上面我们已经找到一种方式比较好的方式去做隔离,假设我们现在通过 new function
封装了一个 execScript
函数,能够执行传入的字符串。
那我们下一步就是实现隔离,让这个 execScript
的函数和沙箱结合起来。
结合 Garfish
的实现来具体看看,目前有两种隔离的方案,一种是快照沙箱,一种是 vm
沙箱。
顾名思义,即在某个阶段给当前的运行环境打一个快照,再在需要的时候把快照恢复,从而实现隔离。
类似玩游戏的 SL
大法,在某个时刻保存起来,操作完毕再重新 Load
,回到之前的状态。
我们假设有个 Sandbox
的类
关键的方法就是在 activate
和 deactivate
两个方法上
activate
的时候遍历 window
上的变量,存为 snapshotOriginal
deactivate
的时候再次遍历 window
上的变量,分别和 snapshotOriginal
对比,将不同的存到 snapshotMutated
里,将 window
恢复回到 snapshotOriginal
snapshotMutated
的变量恢复回 window
上,实现一次沙箱的切换。VM
沙箱使用类似于 node
的 vm
模块,通过创建一个沙箱,然后传入需要执行的代码。
在日常的编程里,会经常用到 window
、document
这类全局对象,所以我们可以去改写 new function
里的这些对象,同时收集代码对这些对象的操作,把变更放到一个局部变量,就不会影响全局的 window
。
结合 ES6
的新 API
:Proxy
,我们可以比较好的做到这点,我们来实现以下 execScript
:
这样我们就可以实现一个简单的沙盒功能。
不过 Proxy
有兼容性问题,Garfish
最初使用的是 Proxy
的 Polyfill
,虽然不能 100% Polyfill
,但是 get 和 set 能够满足我们的大多数场景。
然而 ProxyPolyfill
的方案实际让我们踩了很多坑,最终决定放弃 Polyfill
的方案,采用优先使用 Proxy
而不支持 Proxy
将降级到快照沙箱。
虽然上面已经实现了一个简单的沙箱,但是要达到生产环境可用还是远远不够的,在实际的场景里,如下面的一段 JS
,在浏览器 script
标签里执行是没问题的,但是在沙箱里就会报错
因此我们需要用到另一个之前也是被大家建议不要使用的 api:with
The with statement extends the scope chain for a statement.
来改进一下我们的沙箱,使用 with 语句包裹起来
这就可以正常运行了。
在实际环境里,还可能会有异步脚本的加载,如动态 import
,在 React
里是 Loadable
、Vue
里是动态组件,都会让 webpack
编译出单独的一个异步脚本,这种脚本是通过 script
标签去插入的,从而从沙箱里逃逸。Garfish
通过劫持 document
的 createElement
方法,判断如果是创建 script
,则阻止原生行为,通过 fetch
去拉取 script
的内容,再放到沙箱里执行。
但是这样会带来一个问题,xhr
有跨域限制,而 script
没有,有一定潜在的风险。
通过 with + proxy
可以解决这个问题,因为前面说过 with
是通过 in
来判断是否在当前作用域内的,而 Proxy
的 has
能重写 in
的返回,(而 Proxy 的 Polyfill 无法 Polyfill has 因为无法使用 Proxy 的 )我们再改写一下沙箱的代码,这段代码就运行成功了。
不过这也会带来另一个问题,任何的 'xxx'
in window
都会返回 true
,明显不符合预期,所以我们做了两个独立的 proxy
, 一个来作为 with
来解决 var
的问题,一个就是针对 window
做 proxy
。
webpack
的 output.globalObject = window
会自动隐式指向 window
的 this
构建会指向 window
,需要注意的是在 webpack
低版本中可能不支持该配置。
DOM
隔离分为两种类型:样式节点、dom
节点。
DOM
节点DOM
节点,并且提供了严格模式和非严格模式样式的隔离在微前端里也是非常重要的一个点,在两个版本的快照里,采用不太一样的处理方式
快照沙箱的样式隔离
快照沙箱对样式的隔离主要是遍历 HTML
里的 head
标签,在 activate
的时候,把 head
里的 dom
记录下来,再 deactivate
的时候再恢复。
VM 沙箱的 DOM 隔离
首先了解一个背景,webpack
在构建的时候,最终是通过 appendChild
去添加节点到 html
里的,所以我们只要通过劫持 appendChild
就可以知道有哪些节点被插入,从而实现插入节点的收集,方便进行移除。
在探索新的节点收集方案时,为了能够支持多实例,尝试了比较多的方案。
劫持原型的 appendChild 最初的版本我们通过重写 HTMLElement.prototype.appendChild,把 append 到 body 的样式放到子应用渲染的根节点里。由于劫持的是原型,这个方案无法支持多实例,如果有多个子应用同时运行,没办法区分是由哪个子应用添加的。
劫持实例的 appendChild
所以我们想到的是去劫持所有的 dom 节点,通过重写获取 dom 节点的方法,如 document.querySelector
、document.getElementByID
, 把返回的 dom
节点通过 proxy
进行包装,这样就能劫持 dom 实例的 appendChild,就可以区分是来自于哪个子应用。但是这个方案经过实践,出现的两个比较棘手的问题:
appendChild
,提供 proxy
版本的 document
,在执行 document.createElement
方法时会为创建的节点打上来源的标签,表明是哪个应用创建了这个节点, 在通过 appendChild
等原型将节点添加文档流时,对节点进行收集,在应用销毁后将收集的节点也进行销毁,由于 JavaScript
语法的动态性和灵活性,目前的沙箱方案也存在一些漏洞:parentNode
一直向上查找至 document
节点比较多的组件库中都存在这一类逻辑,从而导致逻辑异常。目前的解决逻辑是,一旦子应用内有通过 document
进行了查询或创建的行为则将 html
的 parentNode 置为 proxyDocument
在 DOM
隔离章节,我们分别探索了快照沙箱和 VM
沙箱的实现,通过 VM
沙箱的隔离机制我们能够有效的收集应用创建的 DOM
副作用,并能够有效的区分副作用的来源。
目前 VM
沙箱的能力上我们能够清除应用在运行期间创建的 DOM
和样式节点,避免应用卸载后样式和节点影响其他应用运行,但由于样式会直接对在相同文档流上的节点生效,因此在多实例场景下,样式可能会影响其他应用的正常运行,并且子应用的样式可能会影响主应用或受到主应用样式的影响,因此样式的隔离是不得不考虑解决的副作用之一。
CSS Module & CSS Namespace
通过修改基础组件样式前缀来实现框架和微应用依赖基础组件样式的隔离性(依赖于工程上 CSS
的预处理器编译和运行时基础组件库配置),同时避免全局样式的书写(依赖于约定或工程 lint
手段)。如果采用 namespace
可能需要在编译阶段做处理
CSS
(如 antd3
和 antd4
), 可以做到彻底隔离HTML
中通过 link
插入的样式CSS Scope
类似于 CSS Module
或 CSS Namespace
,通过 scope
来隔离子应用的所有样式。由于子应用有名称作为唯一标示,且挂载的容器在子应用切换时可以保证唯一性,可以通过统一加 scope
的形式处理所有的子应用样式。分为编译时和运行时两种处理方案:编译时提供 webpack
插件,对 css
编译时自动给子应用的样式添加 scope
;运行时则是加载子应用时解析,由 loader
负责处理。
body
下,需要将节点劫持添加到容器内)多实例下的样式隔离 在多实例场景下,可能会存在多份不同版本的 UI 组件库,从而导致样式冲突,目前的一种解决方案是通过构建工具给所有的样式都加上 namespace,如
由于挂载的时候会把所有的节点都挂载在#garfish_app1 上,所以样式仍然能够生效。
Shadow DOM
基于 Web Components
的 Shadow DOM
能力,将每个子应用包裹到一个 Shadow DOM
中,保证其运行时的样式的绝对隔离 WebComponents Polyfill
Shadow dom
是实现 Web Components
的主要技术之一,另外两项分别为 custom element
、HTML templates
,在 Shadow dom
用简单概括为:将 Dom
文档树中的某个节点变为隔离节点,隔离节点内的子节点样式、行为将与外界隔离(隔离节点内的样式不会受到外部影响,也不会影响外部节点,在隔离节点内的事件最终都只会冒泡到隔离节点中)
Garfish 基于 ShadowDom 实现样式隔离
React
依赖事件委托的库失效优点
缺点
上面描述的其实只是对于变量的隔离,其实除了变量之外,还会有其他的副作用是需要隔离的,包括但不限于:
setInterval
、setTimeout
addEventListener
localStorage
、sessionStorage
解决方法主要分为两类,能够通过劫持收集的:
如 setInterval
、setTimeout
、addEventListener
,通过重写这些方法,在调用的时候记录起来,放进一个队列里,在沙箱销毁的时候统一进行清除。
持久化数据,无法通过劫持进行收集的,使用命名空间来区分
如 localStorage
,sessionStorage
,重写对象和方法