微前端:ESM模块+shadowDOM+iframe 理论上最优的微前端架构

8 阅读7分钟

前言

在写这篇博客之前,我们已经介绍过了 如何手搓一个 JS沙箱代理+子模块UMD打包模式 的微前端架构

链接:微前端:从原理到实战搭建一个框架本博客将带你系统地理解什么是微前端,以及微前端背后的核心原理。在理论讲解的基础上,本文会 - 掘金



当时说过,这个微前端的架构实现,必须要让子模块的打包方式是UMD

但是UMD打包模式,没办法做 静态分析 和TreeShaking


如果子模块ESM模块打包,那么微前端架构就会失效


原理:

1.import 无法当成动态代码块执行,它必须声明在模块的最上方

2.ESM模块的作用域是本模块下 moduleEnv环境下,你无法通过代理window的方式,劫持全局window,实现JS沙箱隔离



那么有没有能够接受子模块使用ESM模块的微前端架构呢?

这样子模块能够使用ESM的静态分析 做TreeShaking优化


那么我们必须介绍我们的今天的微前端架构

ESM模块+shadowDOM+iframe

它是理论上隔离效果最强的微前端架构



在介绍这个方案实现之前

我们先要介绍一个 WebComponent

ShadowDOM



ShadowDOM是什么?

Shadow DOM(影子 DOM) 是 Web Components 标准中最核心的三大技术之一

它的核心目的是封装


简单的说,shadowDOM允许你把一棵隐藏的、独立的DOM子树

附加到一个普通的DOM元素上


也就是说,它允许你在一个DOM上渲染完整的一个DOM结构

而且 这棵子树从 shadow root 开始,和外面的主文档是隔离的。


内部的样式不会影响外部的样式

外部的样式同样也无法渗透到shadowDOM里面


它在F12中结构类似于

<my-component>
  #shadow-root (open)
    <style>...</style>
    <div class="wrapper">
      <slot></slot>
    </div>
</my-component>

外部只能看到<my-component>,内部的结构和样式都被隐藏起来了


它对于我们当前这个方案的微前端架构的实现的最大意义就是

为我们提供了DOM/CSS隔离的基础



下面开始正式介绍我们这个【理论隔离最优】的微前端架构

架构流程

1.创建shadowDOM容器

首先我们给子应用提供一个shadowDOM的挂载点

实现DOM和CSS隔离


    1.首先提供寄生容器
   <div id="micro-container">  </div>
   
<script>
    
    2.创建shadow容器
    const container =document.getElementById('micro-container')
    
    
    const shadowRoot =container.attachShadow({mode: "open"})
    
    
    最后子应用的全部DOM都会挂载到这个 shadowRoot下
    
</script>

2.创建JS沙箱环境

这个方案最大的特点之一,JS沙箱隔离实现是通过iframe

  • 1.创建iframe元素,在这里执行子应用的JS逻辑

  • 2.创建shadowDOM元素,在这里实现子应用DOM/CSS生效


这样就实现了

1.JS沙箱隔离 (避免全局变量污染)

2.CSS样式隔离


创建iframe隔离环境


1.创建iframe元素
const iframe =document.createElement('iframe')


2.隐藏元素
iframe.style.display='none'


3.加入到文档对象
document.body.appendChild(iframe)

4.拿到iframe的全局变量window
const microWindow= iframe.contentWindow



3.创建拦截

我们要通过Object.defineProperty 或者Proxy 去拦截/劫持 ,

iframe下全部的document相关的API


因我们子模块的运行是在iframe,但是我们必须做重定向拦截

让它的DOM操作/样式 都重定向到我们之前创建的ShadowDOM




Object.defineProperty(microWindow ,'document',{

    get(){
    
    
        1.返回一个代理的document 指向shadowDOM
        return new Proxy(document,{
            
            get(target,prop){
            
                2.把全部的操作都锁定到shadowDOM内部
                
                if(prop==="querySelector"){

                return shadowRoot.querySelector.bind(shadowRoot)
                }
            
            
                if(prop==="getElementById"){

                return shadowRoot.getElementById.bind(shadowRoot)

                }
            
            
            
                if(prop==="body"){
                
                return shadowRoot
                }
                
                
                3.其他属性
                return Reflect.get(target,prop)
            
            }
        })
    }
}  )

你发现很有趣的是,UMD模块的微前端是代理JS环境,劫持window

这里的是代理document,劫持全部的document操作


4.iframe中加载子模块的ESM模块

1.创建Script元素
const Script = iframe.contentDocument.createElement("script")

Script.type="module"

2.拿到子模块的入口文件执行
Script.textContent = `

    3.渲染挂载函数
    import { mount } from 'https://micro-app.com/main.js';
    
    4.这里的 window 已经是 iframe 的独立 window
    
    
    5.这里的 document 已经被我们拦截,实际上指向了 Shadow DOM
    mount({ container: document.body })`



6.加入到iframe里面
iframe.contentDocument.head.appendChild(script)


总结流程

1.主应用中创建一个ShadowDOM (mode:open) 作为子容器的渲染容器

  • 实现DOM和CSS的物理隔离

2.创建一个隐藏的、同源的iframe,作为子应用的JS沙箱提供独立的Window和全新的JS执行上下文

3.iframe中创建script标签 (type="module") ,以ESM的方式加载并且执行子 应用的入口模块,让子应用运行在iframe的独立环境中

4.通过proxy或者Object.defineProperty 截至iframe中的document对象,讲子应用DOM操作重写到shadowDOM里面

5.最终实现 JS运行在iframe 、DOM渲染在ShadowDOM里面



这个方案最大的特点是

  • 1.JS执行在iframe

  • 2.DOM渲染在shadowDOM

  • 3.通过劫持document,把DOM操作重定向到shadowDOM


1.Shadow DOM 负责“渲染隔离”

2.iframe 负责“JS 世界隔离”

3.ESM 负责“模块加载与依赖管理”

4.Proxy / defineProperty 负责“欺骗子应用,让它以为自己在操作 document”



优点

1.彻底解决 Window 污染

子应用在iframe中运行,它定义的任何全局变量,都只存在iframe内部,不会干扰主应用


2.原生支持ESM

iframe 中使用 <script type="module"> 。浏览器会自动处理依赖加载,不需要基座去做复杂的源码解析或 eval


3.样式和DOM完美的物理隔离

由于DOM渲染在ShadowDOM中,子应用的CSS无法影响主应用

同样主应用的CSS无法渗入影响子应用


4.性能比纯粹的iframe优

传统的 iframe 渲染会导致弹窗遮罩受限、URL 同步困难

而这种方案 JS 与渲染分离,渲染依然在主文档流中,解决了 UI 展示的限制。



缺点

iframe + Shadow DOM + ESM 是一种在理论上隔离最彻底、性能也非常优秀的微前端方


但是如果这个方案这么好,实现这么简单,那么市面上早就用了


这套方案最大的问题是

  • JavaScript 在 iframe 的独立执行上下文中运行,而 DOM 实际渲染在主应用的 Shadow DOM 中

这是一个 “JS 世界与渲染世界分离” 的架构。


1.子应用的行为不可控

  • 这套方案隐含前提是: 子应用是 可被约束、可被改造的

  • 但是在实际开发中,你无法保证子应用只使用你“代理过的 API”

  • 任意一次绕过 document Proxy 都可能导致渲染错位或内存泄漏

  • 微前端框架需要为 所有不规范用法兜底


工程量太大了,基本上说document相关的API和属性 你都要全部做捕获/重写



2.document Proxy更接近在模拟浏览器行为


Shadow DOM + iframe 方案的关键难点在于:

必须 Proxy document,让 iframe 内的 JS 操作主文档中的 Shadow DOM

这意味着你不是在简单的 Proxy 变量,而是在 重定义浏览器行为

你并不是和UMD打包模式一样,做一个Proxy代理全局变量

而且在尝试模拟整个浏览器的行为


某种意义上来说,这违反了设计规范

因为你真的做不到保证,绝对安全,绝对兜底

况且不同的浏览器环境,浏览器的行为还不同


只能说

理论上 性能最好、隔离最优

不过只是理论,实际项目无法落地

因为违反了设计规范,让开发者去模拟浏览器的行为


而且如果出现逃逸,会导致整个业务页面不可用

(假如,这个行为(API/属性)我们没有去写捕获/兜底)


而且我们使用了shadowDOM,这个隔离黑箱,所以业务崩溃了 也做不到定位具体问题



总结

iframe + Shadow DOM + ESM

这套微前端设计方案,是理论上隔离最优的

但是实际上实现的成本,维护的心智成本,其实是非常高的


这套方案更多的是,给大伙一种可行的设计思路

一种架构设计

JS 和 DOM的分离


这篇博客,对我来说更像是微前端架构博客的延申和拓展