微前端由ThoughtWorks 2016年提出,将后端微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。
iframe嵌入模式
iframe
在现在的微前端方案(single-spa
、qiankun
)流行前,是比较主流的集合不同系统的做法。像重构老东家的hr新系统,就是外部是react
,内部套了一个iframe
支持老jsp
实现的旧hr系统。至于为什么要如此设计,其实跟美团这篇文章有些类似,🔗用微前端的方式搭建类单页应用
。
那么我们客观分析一波iframe
的优势和缺陷:
优势
- 提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。
缺陷
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中…(通过
postMessage
告知parent
去实现确实可行,但这样就需要额外的重构成本,并且对子应用开发者来讲负担较大) - 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
single-spa&qiankun
基于iframe的问题,社区中目前已经给出了更好的方案:single-spa
&qiankun
。后者是对前者的二次封装,主要差异点在于:JS Entry vs HTML Entry。
JS Entry
single-spa
的JS Entry方式会将子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
HTML Entry
qiankun
采用的是HTML Entry的方案,它直接将子应用打出来 HTML
作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。
打包构建角度
从打包构建角度来说,HTML Entry的开发和发布可以完全解耦,但是我们页面会多一次拉取HTML的请求,之后将资源解析的时长转接到运行时,另外无法像JS Entry那样共用同一个构建环境,抽离公共依赖等。并且对于JS Entry来说,主应用还需要为加载的子应用预留容器节点,如id对应。
JS和CSS如何避免不同子应用之间的污染
js层面,qiankun
通过自己构造了一个js运行沙盒避免冲突污染,本质上是对挂载前的全局状态进行快照。便于在之后的重载或者卸载进行覆盖或者恢复。
以上图片via 知乎,《可能是你见过最完善的微前端解决方案》。
CSS层面则通过HTML的结构进行嵌套控制。浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。
1 | <html> |
根据维护人员在2.0版本后的介绍,实际上完全要隔离样式冲突还是需要Shadow DOM参与(类似如下的用法):
1 | const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'}); |
类似如下的结构:
但是开启 Shadow DOM 也会引发一些别的问题:
一个典型的问题是,一些组件可能会越过 Shadow Boundary 到外部 Document Tree 插入节点,而这部分节点的样式就会丢失;比如 antd 的 Modal 就会渲染节点至 ducument.body ,引发样式丢失;针对刚才的 antd 场景你可以通过他们提供的 ConfigProvider.getPopupContainer API 来指定在 Shadow Tree 内部的节点为挂载节点,但另外一些其他的组件库,或者你的一些代码也会遇到同样的问题,需要你额外留心。此外 Shadow DOM 场景下还会有一些额外的事件处理、边界处理等问题.
什么是Shadow DOM
Web components 的一个重要属性是封装——可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。其中,Shadow DOM 接口是关键所在,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。
Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。
Shadow DOM 的一些常规定义:
- Shadow host:一个常规 DOM节点,Shadow DOM 会被附加到这个节点上。
- Shadow tree:Shadow DOM内部的DOM树。
- Shadow boundary:Shadow DOM结束的地方,也是常规 DOM开始的地方。
- Shadow root: Shadow tree的根节点。
我们可以像正常操作DOM元素般操作Shadow DOM(添加子节点,增加样式等),不同的是,Shadow DOM 内部的元素始终不会影响到它外部的元素(除了 :focus-within
),这为封装提供了便利。
实际上,我们使用的原生标签video
内部就是一套Shadow DOM,它包含了一系列的按钮和其他控制器。
使用
我们可以使用Element.attachShadow
将一个Shadow root附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode
属性,值可以是 open
或者 closed
:
1 | let shadow = elementRef.attachShadow({mode: 'open'}); |
open
表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM,例如使用 Element.shadowRoot
属性:
1 | let myShadowDom = myCustomElem.shadowRoot; |
如果你将一个 Shadow root 附加到一个 Custom element 上,并且将 mode 设置为 closed,那么就不可以从外部获取 Shadow DOM 了——myCustomElem.shadowRoot 将会返回 null。浏览器中的某些内置元素就是如此,例如<video>
,包含了不可访问的 Shadow DOM。
MDN上给出了一个在Shadow DOM上加载样式的DEMO:
1 | // Create a class for the element |
其实这里面还涉及到了一个我们浏览器自定义标签组件的对象: Window.customElements
,根据MDN解释,该只读属性返回了一个CustomElementRegistry
对象的引用,该对象允许我们注册一个自定义元素,返回已注册自定义元素的信息,等等。
再说CustomElementRegistry.define()
方法用作注册一个自定义元素,该方法接受以下参数:
- 所表示的创建名称元素的符合
DOMString
标准的字符串。注意,定制元素的名称不能是单个单词,其中必须要有短横线。 - 用于定义元素行为的类。
- 可选参数,一个包含
extends
属性的配置对象,是可选参数。它指定了所创建的元素继承自其中内置元素,可以继承任何内置元素。
就拿前文DEMO中最后一行定义来说,我们在html
中就可以直接使用<popup-info></popup-info>
标签了。但是这个DEMO有个样式闪烁的现象,因为Shadow DOM中的<link>
元素不会打断 Shadow root 的绘制, 因此在加载样式表时可能会出现未添加样式内容(FOUC),导致闪烁。
关于SystemJS
在阅读微前端的文章中,我发现其中依赖了SystemJS这种模块化方案。实际上目前个人了解的模块化方案主要有cjs、amd、cmd、umd、esm,这里对SystemJS进行一个知识的补充。
实际上当我们看到像single-spa
、qiankun
这些微前端方案都使用了SystemJS进行模块化处理,便能说明其在这种场景下有其独到的地方。
Universal dynamic module loader - loads ES6 modules, AMD, CommonJS and global scripts in the browser and NodeJS.
根据官方对自身的定义,我们可以发现SystemJS,是一个通用的动态(按需)模块加载方案。同时,它可以将我们代码中不同模块编织方式的代码,转换成System.register
方式,以支持旧版本浏览器(运行在浏览器侧)。另外,SystemJS也是Angular常用的模块记载器之一。
根据Youtube上的老哥分享,SystemJS其实就是js在浏览器侧的polyfill
,除了不同模块化方案的转化外,还有新的一些能力如importmap
的兼容。
importmap
当前只有chrome及一些较新的浏览器支持,通过SystemJS我们可以实现该功能的全浏览器兼容。另外这种用法其实就是类似我们webpack中的externals
配置。
小结
关于前文视频中对SystemJS的介绍总结如下:
- 对浏览器的
importmap
的polyfill
支持 - 一个文件包含所有模块引入,意味着一次web请求(通过自身的
register
api,) - 内置的读取模块权限
import.meta.resolve(moduleName)
返回加载的模块url
;import.meta.url
返回当前js文件的路径url
- 支持除了js模块外的模块处理(
json
、css
、html
、wasm
…) - 在浏览器端使用js module处理的时候才考虑使用SystemJS
script
标签中的module
及importmap
转化成systemjs-module
,systemjs-importmap
- SystemJS中不能使用
export
和import
关键词,因为在支持的浏览器侧会被浏览器正常识别,即使用SystemJS自己的方式去进行模块导入导出 - 基于9中的问题,外链的线上代码模块,需要注意格式,不要使用
esm
格式 - 通过Webpack的
output
配置,可以输出SystemJS格式的模块代码
1 | { |