Fork me on GitHub

什么是微前端

 微前端由ThoughtWorks 2016年提出,将后端微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。

iframe嵌入模式

  iframe在现在的微前端方案(single-spaqiankun)流行前,是比较主流的集合不同系统的做法。像重构老东家的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
2
3
4
5
6
7
8
9
10
11
<html>

<body>
<main id="subApp">
// 子应用完整的 html 结构
<link rel="stylesheet" href="//alipay.com/subapp.css">
<div id="root">....</div>
</main>
</body>

</html>

  根据维护人员在2.0版本后的介绍,实际上完全要隔离样式冲突还是需要Shadow DOM参与(类似如下的用法):

1
2
const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
shadow.innerHTML = '<sub-app>Here is some new text</sub-app><link rel="stylesheet" href="//unpkg.com/antd/antd.min.css">';

  类似如下的结构:

  但是开启 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
2
3
4
5
6
let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

var para = document.createElement('p');
shadow.appendChild(para);
// etc.

  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Create a class for the element
class PopUpInfo extends HTMLElement {
constructor() {
// Always call super first in constructor
super();

// Create a shadow root
const shadow = this.attachShadow({mode: 'open'});

// Create spans
const wrapper = document.createElement('span');
wrapper.setAttribute('class', 'wrapper');

const icon = document.createElement('span');
icon.setAttribute('class', 'icon');
icon.setAttribute('tabindex', 0);

const info = document.createElement('span');
info.setAttribute('class', 'info');

// Take attribute content and put it inside the info span
const text = this.getAttribute('data-text');
info.textContent = text;

// Insert icon
let imgUrl;
if(this.hasAttribute('img')) {
imgUrl = this.getAttribute('img');
} else {
imgUrl = 'img/default.png';
}

const img = document.createElement('img');
img.src = imgUrl;
icon.appendChild(img);

// Apply external styles to the shadow dom
const linkElem = document.createElement('link');
linkElem.setAttribute('rel', 'stylesheet');
linkElem.setAttribute('href', 'style.css');

// Attach the created elements to the shadow dom
shadow.appendChild(linkElem);
shadow.appendChild(wrapper);
wrapper.appendChild(icon);
wrapper.appendChild(info);
}
}

// Define the new element
customElements.define('popup-info', PopUpInfo);

  其实这里面还涉及到了一个我们浏览器自定义标签组件的对象: 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-spaqiankun这些微前端方案都使用了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的介绍总结如下:

  1. 对浏览器的importmappolyfill支持
  2. 一个文件包含所有模块引入,意味着一次web请求(通过自身的register api,)
  3. 内置的读取模块权限
  4. import.meta.resolve(moduleName)返回加载的模块url;
  5. import.meta.url返回当前js文件的路径url
  6. 支持除了js模块外的模块处理(jsoncsshtmlwasm…)
  7. 在浏览器端使用js module处理的时候才考虑使用SystemJS
  8. script标签中的moduleimportmap转化成systemjs-modulesystemjs-importmap
  9. SystemJS中不能使用exportimport关键词,因为在支持的浏览器侧会被浏览器正常识别,即使用SystemJS自己的方式去进行模块导入导出
  10. 基于9中的问题,外链的线上代码模块,需要注意格式,不要使用esm格式
  11. 通过Webpack的output配置,可以输出SystemJS格式的模块代码
1
2
3
4
5
{
output: {
libraryTarget: 'system',
}
}

umi微前端demo试玩

  参见文章