微前端的概念
微前端的概念是由ThoughtWorks在2016年提出的,它借鉴了微服务的架构理念,
核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,
再将这些小型应用融合为一个完整的应用
wujie-micro.github.io/demo-main-r…
业务价值
当我们启用新技术,更多的不是因为先进和噱头,而是适合
业务背景
开发效率
假如我们每一步的基础熵值都为 1,那么每一步的熵值算法为:
(框架数 y ) + (业务方 z )
则上面传统组件接入开发模式:
整体开发接入流程熵值为: 7
组件升级流程熵值为: 7
假如我们使用微前端的方式进行开发:
具体算法为:
(业务方 z )
整体开发接入流程熵值为: 4
组件升级流程熵值为: 1
其他业务价值:
体验一致性 :因为微前端应用之前相互隔离,框架无关,所以在所有接入方都 可以体验到一致性的交互和表现。
daily release能力:因为子应用直接相互独立、解耦,所以每个子应用都可以单独部署,整体系统的稳定性不会受到影响。
信息流转统一收敛,可以把各个子应用的基础数据结构做到统一收敛,提供子应用base规范,提高开发效率
微前端的实现要素
在我们实现微前端架构的时候,有几个关键要素或者问题是需要我们解决的,一个完整的微前端框架,也必须完备以下几个功能点,每一项功能做的是否完备、易用、稳定,也是我们衡量一个微前端框架好坏的标尺。
微前端的里程碑
石器时代
iframe
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
iframe 作为微前端实现存在的问题
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
2 . UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
-
全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
-
慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
1.0 时代
从现代框架组件生命周期中获得灵感
随着前端框架的百花齐放,前端的技术趋势也隐隐受到了影响。为了解决 iframe 带来的用户体验割裂感,前端er们开始探索新的微前端使用方式。
受到 React、Vue这种带有声明周期的框架的影响,衍生出了微前端1.0时代。目前大部分的微前端框架都是基于这个思想来构建。
我们首先来看下,现在框架的一个特性:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
可以看到 html 结构里面就只剩一个容器 html 元素,所有dom结构都是由 js 动态生成,动态填充。
而 js 的 bundle 会被打包成一个立即执行函数
(function() {
var greeting = 'Hello World!';
document.getElementById('root').innerHtml(greeting)
})();
等 js 加载完毕后,会动态增加 html 结构到容器 div 中。
而 single-spa 做的就是做了一层侵入管理,原来需要立即执行的 js 文件,改造成生命周期的api
bootstrap、mount、unmount
所以,原来的 bundle 在对外的暴露,由一个立即执行函数,转变为一个具有 api 的对象:
(function() {
return {
bootstrap () {
var greeting = 'Hello World!';
document.getElementById('root').innerHtml(greeting)
},
mount () {
//...
},
unmount () {
//...
}
}
})();
这样,在获取到子应用的 bundle,我们就可以动态控制子应用的渲染和基本的生命周期。
single-spa
首先 single-spa 是最早的微前端框架,但是现在来看。也暴露了许多不足。
优点:
- 敏捷性 - 独立开发、独立部署,微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新;
- 技术栈无关,主框架不限制接入应用的技术栈,微应用具备完全自主权;
- 增量升级,在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 维护和 bugfix 非常简单,每个团队都熟悉所维护特定的区域;
缺点:
-
无通信机制
-
不支持 Javascript 沙箱
-
样式冲突
-
无法预加载
-
容器方式比较死板
-
js entry 方式比较有局限
qiankun
qiankun是阿里系的微前端框架,也是收到 single-spa 的启发,而且解决了 single-spa 的一些问题,使易用性和稳定性都得到了提升。
主要特点如下:
-
html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本;
-
完备的沙箱方案,js 沙箱做了 SnapshotSandbox、LegacySandbox、ProxySandbox 三套渐进增强方案,css 沙箱做了 strictStyleIsolation、experimentalStyleIsolation 两套适用不同场景的方案;
-
做了静态资源预加载能力;
但是qiankun也有不足的地方,其中也有延续 single-spa 架构的缺点
-
适配成本比较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作;
-
css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重;
-
无法同时激活多个子应用,也不支持子应用保活;
-
无法支持 vite 等 esmodule 脚本运行;
基础架构实现
每个接入的子应用暴露出对应的生命周期:比如qiankun的子应用需改造主生命周期为:
bootstrap、mount、unmount
提供给基座应用进行管理。进行运行时加载资源和执行。
而基座应用只需要根据url的change事件,找到对应的子应用进行渲染。
两种资源导入方式 JS Entry(single-spa) vs HTML Entry(qiankun)
在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口?
JS Entry
子应用将资源打成一个 entry script,比如 single-spa。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
HTML Entry
直接将子应用打出来 HTML 作为入口,主框架通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整
总结一下:
entry方式 | 优点 | 缺点 |
---|---|---|
HTML Entry | 1. 子应用开发、发布完全独立2.子应用具备与独立应用开发时一致的开发体验 | 1. 多一次请求,子应用资源解析消耗转移到运行时 |
-
主子应用不处于同一个构建环境,无法利用bundler 的一些构建期的优化能力,如公共依赖抽取等 |
| JS Entry | 主子应用使用同一个 bundller,可以方便做构建时优化 | 1. 子应用的发布需要主应用重新打包
- 主应用需为每个子应用预留一个容器节点,且该节点id 需与子应用的容器 id 保持一致3.子应用各类资源需要一起打成一个bundle,资源加载效率 |
简而言之:1.0 的特点就是把子应用进行改造,暴露出统一的生命周期。基础应用监听url的变化,找到对应子应用,执行对应生命周期的方法,挂载到对应的容器上。
沙箱实现
沙箱也是微前端框架实现的一个重要环节,它可以保证子应用间的数据不会相互污染,全局变量不会冲突,等。
在 qiankun 框架中为了实现 js 隔离,提供了三种不同场景使用的沙箱,分别是 snapshotSandbox、proxySandbox、legacySandbox。
- snapshotSandbox
snapshotSandbox 也叫快照沙箱,其实现原理就是把当前的 window 对象进行快照保存,然后还原 初始化 window 对象。并监听需要进行同步的字段,做diff。等应用卸载后,再还原原始 window 对象。
缺点:
- 会占用内存空间
- 会污染全局变量
优点:
兼容不能使用proxy的浏览器
proxySandbox & legacySandbox
剩下的这两种沙箱实现都是基于 es6 的 proxy 实现的。
legacySandbox(单例沙箱)
legacySandbox 设置了三个参数来记录全局变量,分别是记录沙箱新增的全局变量addedPropsMapInSandbox、记录沙箱更新的全局变量modifiedPropsOriginalValueMapInSandbox、持续记录更新的(新增和修改的)全局变量,用于在任意时刻做snapshot的currentUpdatedPropsValueMap。
legacySandbox 同样会对window造成污染,但是性能比快照沙箱好,不用遍历window对象。
proxySandbox(多例沙箱)
proxySandbox 沙箱激活后,每次对window取值的时候,先从自己沙箱环境的fakeWindow里面找,如果不存在,就从rawWindow(外部的window)里去找;当对沙箱内部的window对象赋值的时候,会直接操作fakeWindow,而不会影响到rawWindow。不会污染全局window,支持多个子应用同时加载。
大体实现如下:
/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
constructor(name: string) {
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
},
get(target: FakeWindow, p: PropertyKey): any {
}
});
this.proxy = proxy;
}
}
function bindScope (
address: string,
app: AppInterface,
code: string,
scriptInfo: ScriptSourceInfo,
): string {
if (isWrapInSandBox(app, scriptInfo)) {
return app.iframe ? `(function(window,self,global,location){;${code}\n${isInlineScript(address) ? '' : `//# sourceURL=${address}\n`}}).call(window.__MICRO_APP_SANDBOX__.proxyWindow,window.__MICRO_APP_SANDBOX__.proxyWindow,window.__MICRO_APP_SANDBOX__.proxyWindow,window.__MICRO_APP_SANDBOX__.proxyWindow,window.__MICRO_APP_SANDBOX__.proxyLocation);` : `;(function(proxyWindow){with(proxyWindow.__MICRO_APP_WINDOW__){(function(${GLOBAL_CACHED_KEY}){;${code}\n${isInlineScript(address) ? '' : `//# sourceURL=${address}\n`}}).call(proxyWindow,${GLOBAL_CACHED_KEY})}})(window.__MICRO_APP_PROXY_WINDOW__);`
}
return code
}
在下载完 bundlejs 后,在源代码上面加上对于 window 对象的代理。
样式隔离
css 样式隔离大体有两个实现方式,一是靠人为规则定义:例如BEM规范等。二是靠动态引入,根据js脚本动态插入 css 片段,三是靠 shadowDom 进行样式隔离,我们这就不展开了,感兴趣的可以Google一下。
总结
得力于现代框架的架构,single-spa 和 qiankun 开辟了一个新的微前端时代,并逐步完善了沙箱、css隔离、通信等架构,但是随着前端框架和打包工具的演进,qiankun 的一些缺点被明显放大,
-
适配成本比较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作;
-
css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重;
-
无法同时激活多个子应用,也不支持子应用保活;
-
无法支持 vite 等 esmodule 脚本运行;
其中最显著的就是接入改造成本比较高,而且无法支持 vite & esmodule 形式的文件引入,所以在接下来时代,为了解决这些问题,micro-app 这种新架构的前端框架逐渐流行起来。
2.0 时代
架构的演进
由于 Web Components 技术的演进和发展,受到启发,micro-app 这个框架被创造出来。micro-app 是基于类webcomponent + qiankun sandbox 的微前端方案。
ps:插个知识点 Web Components
由于借鉴了WebComponent的思想,以此为基础推出另一种更加组件化的实现方式:类WebComponent + HTML Entry。
HTML Entry:通过加载远程html,解析其DOM结构从而获取js、css等静态资源来实现微前端的渲染,这也是qiankun目前采用的渲染方案。
WebComponent:CustomElement用于创建自定义标签,ShadowDom用于创建阴影DOM,阴影DOM具有天然的样式隔离和元素隔离属性。由于WebComponent是原生组件,它可以在任何框架中使用,理论上是实现微前端最优的方案。但WebComponent有一个无法解决的问题 - ShadowDom的兼容性非常不好,一些前端框架在ShadowDom环境下无法正常运行,尤其是react框架。
类WebComponent:就是使用CustomElement结合自定义的ShadowDom实现WebComponent基本一致的功能。
由于ShadowDom存在的问题,我们采用自定义的样式隔离和元素隔离实现ShadowDom类似的功能,然后将微前端应用封装在一个CustomElement中,从而模拟实现了一个类WebComponent组件,它的使用方式和兼容性与WebComponent一致,同时也避开了ShadowDom的问题。并且由于自定义ShadowDom的隔离特性,Micro App不需要像single-spa和qiankun一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置。
通过上述方案封装了一个自定义标签,它的渲染机制和功能与WebComponent类似,开发者可以像使用web组件一样接入微前端。它可以兼容任何框架,在使用方式和数据通信上也更加组件化,这显著降低了基座应用的接入成本,并且由于元素隔离的属性,子应用的改动量也大大降低。
PS: 为什么 React 不太兼容 shadowDom?
由于 React 的「合成事件机制」的导致的,我们知道在 React 中「事件」并不会直接绑定到具体的 DOM 元素上,而是通过在 document 上绑定的 ReactEventListener 来管理, 当时元素被单击或触发其他事件时,事件被 dispatch 到 document 时将由 React 进行处理并触发相应合成事件的执行。在 Shadow DOM 外部捕获时浏览器会对事件进行「重定向」,也就是说在 Shadow DOM 中发生的事件在外部捕获时将会使用 host 元素作为事件源。这将让 React 在处理合成事件时,不认为 ShadowDOM 中元素基于 JSX 语法绑定的事件被触发了。
micro-app 具体初始化流程为:
具体 html 内表现为:
静态资源预加载:
MicroApp 提供了预加载子应用的功能,它是基于requestIdleCallback实现的,预加载不会对基座应用和其它子应用的渲染速度造成影响,它会在浏览器空闲时间加载应用的静态资源,在应用真正被渲染时直接从缓存中获取资源并渲染。
沙箱实现
micro-app 沿用了 qiankun 的沙箱体系,用 proxy 实现了js隔离
样式隔离
micro-app 使用了给子应用的css增加作用域的方式实现了css样式隔离,其原理为:
开启后会以标签作为样式作用域,利用标签的name属性为每个样式添加前缀,将子应用的样式影响禁锢在当前标签区域。
.test {
color: red;
}
/* 转换为 */
micro-app[name=xxx] .test {
color: red;
}
路由实现
micro-app 通过自定义location和history,实现了一套虚拟路由系统,子应用运行在这套虚拟路由系统中,和主应用的路由进行隔离,避免相互影响。
实现大概与原理如下:
子应用的路由信息会作为query参数同步到浏览器地址上,如下:
import microApp from '@micro-zoe/micro-app'
// 不带域名的地址,控制子应用my-app跳转/page1
microApp.router.push({name: 'my-app', path: '/page1'})
// 带域名的地址,控制子应用my-app跳转/page1
microApp.router.push({name: 'my-app', path: 'http://localhost:3000/page1'})
// 带查询参数,控制子应用my-app跳转/page1?id=9527
microApp.router.push({name: 'my-app', path: '/page1?id=9527'})
// 带hash,控制子应用my-app跳转/page1#hash
microApp.router.push({name: 'my-app', path: '/page1#hash'})
// 使用replace模式,等同于 router.replace({name: 'my-app', path: '/page1'})
microApp.router.push({name: 'my-app', path: '/page1', replace: true })
总结
由于 Web Component 的影响与加持,micro-app 开辟了一条不同的微前端路径。根据上面的一些技术改进,我们可以看整体的一些优势,比如:
- 使用 webcomponet 加载子应用相比 single-spa 这种注册监听方案更加优雅;
- 复用经过大量项目验证过 qiankun 的沙箱机制也使得框架更加可靠;
- 组件式的 api 更加符合使用习惯,支持子应用保活;
- 降低子应用改造的成本,提供静态资源预加载能力;
但是在使用过程中,为了一些框架兼容和妥协,我们也看到了一些缺点,比如:
- 在支持 vite 运行时,必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离;
- 对于不支持 webcompnent 的浏览器没有做降级处理;
- 加载性能还存在一些问题
那么如何再进一步去演进微前端的架构呢。
3.0 时代
架构的演进
在经过 Web Components 的启发,前端er们还在尽力的探索新的微前端实现方案,无界微前端这个框架就由此诞生,无界微前端大胆的将 web component 容器 + iframe 沙箱结合,把 js 脚本运行到 iframe 中,把dom架构放到 web components 容器,中间经过信息通信相互沟通,
能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 框架支持、应用共享等用户的核心诉求。
ps:在 micro-app 1.x 的版本,也使用了类似架构,我们只剖析 无界 来进行研究
无界的加载逻辑:
export function defineWujieWebComponent() {
const customElements = window.customElements;
if (customElements && !customElements?.get("wujie-app")) {
class WujieApp extends HTMLElement {
connectedCallback(): void {
if (this.shadowRoot) return;
const shadowRoot = this.attachShadow({ mode: "open" });
const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);
sandbox.shadowRoot = shadowRoot;
}
disconnectedCallback(): void {
const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
sandbox?.unmount();
}
}
customElements?.define("wujie-app", WujieApp);
}
}
无界框架特点
css 沙箱隔离
无界将子应用的 dom 放置在 webcomponent + shadowdom 的容器中,除了可继承的 css 属性外实现了应用之间 css 的原生隔离。
-
天然 css 沙箱 直接物理隔离,样式隔离子应用不用做任何修改
-
天然适配弹窗问题 document.body的appendChild或者insertBefore会代理直接插入到webcomponent,子应用不用做任何改造
-
子应用保活 子应用保留iframe和webcomponent,应用内部的state不会丢失
-
完整的 DOM 结构 webcomponent保留了子应用完整的html结构,样式和结构完全对应,子应用不用做任何修改
js 沙箱隔离
无界将子应用的 js 放置在 iframe(js-iframe)中运行,实现了应用之间 window、document、location、history 的完全解耦和隔离。
-
浏览器刷新、前进、后退都可以作用到子应用
-
实现成本低,无需复杂的监听来处理同步问题
-
多应用同时激活时也能保持路由同步
js 沙箱和 css 沙箱连接
无界在底层采用 proxy + Object.defineproperty 的方式将 js-iframe 中对 dom 操作劫持代理到 webcomponent shadowRoot 容器中,开发者无感知也无需关心。
子应用的实例instance在iframe内运行,dom在主应用容器下的webcomponent内,通过代理 iframe的document到webcomponent,可以实现两者的互联。将document的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body全部代理到webcomponent,这样instance和webcomponent就精准的链接起来。
const proxyDocument = new Proxy(
{},
{
get: function (_, propKey) {
if (propKey === "querySelector" || propKey === "querySelectorAll") {
// 代理 shadowRoot 的 querySelector/querySelectorAll 方法
return new Proxy(shadowRoot[propKey], {
apply(target, ctx, args) {
// 相当于调用 shadowRoot.querySelector
return target.apply(shadowRoot, args);
},
});
}
},
}
);
架构总体收益
-
组件方式来使用微前端: 不用注册,不用改造路由,直接使用无界组件,化繁为简
-
一个页面可以同时激活多个子应用: 子应用采用 iframe 的路由,不用关心路由占用的问题
-
天然 js 沙箱,不会污染主应用环境: 不用修改主应用window任何属性,只在iframe内部进行修改
-
应用切换没有清理成本: 由于不污染主应用,子应用销毁也无需做任何清理工作
通信机制
承载子应用的iframe和主应用是同域的,所以可以使用 iframe 的 api 进行数据的传递和通信
预加载与预执行
前面大部分微前端只能做到静态资源预加载,但是就算子应用所有资源都预加载完毕,等到子应用打开时页面仍然有不短的白屏时间,这部分白屏时间主要是子应用 js 的解析和执行。
无界同时实现了预加载和预执行,来缩短首屏空白时间,预执行会阻塞主应用的执行线程,所以无界提供 fiber 执行模式,采取类似 react fiber 的方式间断执行 js,每个 js 文件的执行都包裹在 requestidlecallback 中,每执行一个 js 可以返回响应外部的输入,但是这个颗粒度是 js 文件,如果子应用单个 js 文件过大,可以通过拆包的方式降低体积达到 fiber 执行模式效益最大化。
ps:为了实现应用间(iframe 间)通讯,无界子应用 iframe 的 url 会设置为主应用的域名(同域)
主应用域名为 a.com
子应用域名为 b.com,但它对应的 iframe 域名为 a.com,所以要设置 b.com 的资源能够允许跨域访问
因此 iframe 的 location.href 并不是子应用的 url。
缺点
作为一个基于 Web Components 的微前端,无界也有Web Components兼容性有限的问题,在不支持Web Components的浏览器中,无界提供了降级处理,html和css的隔离会使用iframe替代,降级后的无界其实就是个iframe了。
虽然无界的设计思想更为优秀,但其设计也是有局限性的,例如必须要允许跨域、location 对象无法挟持等,这些都是开发中会遇到的问题,只有理解了无界的设计,才能更好的理解这些问题的本质原因,以及知道如何去避免它们。
番外知识点:Web Components
首先来了解下 Web Components 的基本概念, Web Component 是指一系列加入 w3c 的 HTML与DOM的特性,目的是为了从原生层面实现组件化,可以使开发者开发、复用、扩展自定义组件,实现自定义标签。
这是目前前端开发的一次重大的突破。它意味着我们前端开发人员开发组件时,不必关心那些其他MV*框架的兼容性,真正可以做到 “Write once, run anywhere”。
例如:
// 假如我已经构建好一个 Web Components 组件 <hello-world>并导出
// 在 html 页面,我们就可以直接引用组件
<script src="/my-component.js"></script>
// 而在 html 里面我们可以这样使用
<hello-world></hello-word>
而且跟任何框架无关,代表着它不需要任何外部 runtime 的支持,也不需要复杂的Vnode算法映射到实际DOM,只是浏览器api本身对标签内部逻辑进行一些编译处理,性能必定会比一些MV*框架要好一些。
三个核心API
Custom elements(自定义元素)
首先来了解下自定义元素,其实它是作为 Web Component 的基石。那么我们来看下这个基石提供了哪些方法,提供给我们进行高楼大厦的建设。
- 自定义元素挂载方法
自定义元素通过CustomElementRegistry 来自定义可以直接渲染的html元素,挂载在 window.customElements.define 来供开发者调用,demo 如下:
// 假如我已经构建好一个 Web Components 组件 <hello-world>并导出
class HelloWorld extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 10px;
background-color: #eee;
}
</style>
<h1>Hello World!</h1>
`;
}
}
// 挂载
window.customElements.define('hello-world', HelloWorld)
// 然后就可以在 html 中使用
<hello-world></hello-world>
注意:自定义元素必须用'-'连接符连接,来作为特定的区分,如果没有检测到自定义元素,则浏览器会作为空div处理。
渲染结果:
- 自定义元素的类
由上面的例子 "class HelloWorld extends HTMLElement { xxx } " 发现,自定义元素的构造都是基于 HTMLElement,所以它继承了 HTML 元素特性,当然,也可以继承 HTMLElement的派生类,如:HTMLButtonElement 等,来作为现有标签的扩展。
- 自定义元素的生命周期
类似于现有MV*框架的生命周期,自定义元素的基类里面也包含了完整的生命周期 hook 来提供给开发者实现一些业务逻辑的应用:
class HelloWorld extends HTMLElement {
constructor() {
// 1 构建组件的时候的逻辑 hook
super();
}
// 2 当自定义元素首次被渲染到文档时候调用
connectedCallback(){
}
// 3 当自定义元素在文档中被移除调用
disconnectedCallback(){
}
// 4 当自定义组件被移动到新的文档时调用
adoptedCallback(){
}
// 5 当自定义元素的属性更改时调用
attributeChangedCallback(){
}
}
- 添加自定义方法和属性
由于自定义元素由一个类来构造,所以添加自定义属性和方法就如同平常开发类的方法一致。
class HelloWorld extends HTMLElement {
constructor() {
super();
}
tag = 'hello-world'
say(something: string) {
console.log(`hello world, I want to say ${this.tag} ${something}`)
}
}
// 调用方法如下
const hw = document.querySelector('hello-world');
hw.say('good');
// 控制台打印效果如下
Shadow DOM(影子DOM)
有了自定义元素作为基石,我们想要更加顺畅的进行组件化封装,必定少不了对于DOM树的操作。那么好的,Shadow DOM(影子DOM)就应运而生了。
顾名思义,影子DOM就是用来隔离自定义元素不受到外界样式或者一些副作用的影响,或者内部的一些特性不会影响外部。使自定义元素保持一个相对独立的状态。
在我们日常开发html页面的时候也会接触到一些使用 Shadow DOM 的标签,比如:audio 和 video 等;在具体dom树中它会一一个标签存在,会隐藏内部的结构,但是其中的控件,比如:进度条、声音控制等,都会以一个Shadow DOM存在于标签内部,如果想要查看具体的DOM结构,则可以尝试在chrome的控制台 -> Preferences -> Show user agent Shadow DOM, 就可以查看到内部的结构构成。
如果组件使用Shadow host,常规document中会存在一个 Shadow host节点用来挂载 Shadow DOM,Shadow DOM内部也会存在一个DOM树:Shadow Tree,根节点为Shadow root,外部可以用伪类:host来访问,Shadow boundary其实就是Shadow DOM的边界。具体架构图如下:
下面我们通过一个简单的例子来看下Shadow DOM的实际用处:
// Shadow DOM 开启方式为
this.attachShadow( { mode: 'open' } );
- 不使用Shadow DOM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components</title>
<style>
h1 {
font-size: 20px;
color: yellow;
}
</style>
</head>
<body>
<div></div>
<hello-world></hello-world>
<h1>Hello World! 外部</h1>
<script type="module">
class HelloWorld extends HTMLElement {
constructor() {
super();
// 关闭 shadow DOM
// this.attachShadow({ mode: 'open' });
const d = document.createElement('div');
const s = document.createElement('style');
s.innerHTML = `h1 {
display: block;
padding: 10px;
background-color: #eee;
}`
d.innerHTML = `
<h1>Hello World! 自定义组件内部</h1>
`;
this.appendChild(s);
this.appendChild(d);
}
tag = 'hello-world'
say(something) {
console.log(`hello world, I want to say ${this.tag} ${something}`)
}
}
window.customElements.define('hello-world', HelloWorld);
const hw = document.querySelector('hello-world');
hw.say('good');
</script>
</body>
</html>
渲染效果为,可以看到样式已经互相污染:
- 使用 Shadow DOM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components</title>
<style>
h1 {
font-size: 20px;
color: yellow;
}
</style>
</head>
<body>
<div></div>
<hello-world></hello-world>
<h1>Hello World! 外部</h1>
<script type="module">
class HelloWorld extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
h1 {
font-size: 30px;
display: block;
padding: 10px;
background-color: #eee;
}
</style>
<h1>Hello World! 自定义组件内部</h1>
`;
}
tag = 'hello-world'
say(something) {
console.log(`hello world, I want to say ${this.tag} ${something}`)
}
}
window.customElements.define('hello-world', HelloWorld);
const hw = document.querySelector('hello-world');
hw.say('good');
</script>
</body>
</html>
渲染结果为:
可以清晰的看到样式直接互相隔离无污染,这就是Shadow DOM的好处。
HTML templates(HTML模板)
template模板可以说是大家比较熟悉的一个标签了,在Vue项目中的单页面组件中我们经常会用到,但是它也是 Web Components API 提供的一个标签,它的特性就是包裹在 template 中的 HTML 片段不会在页面加载的时候解析渲染,但是可以被 js 访问到,进行一些插入显示等操作。所以它作为自定义组件的核心内容,用来承载 HTML 模板,是不可或缺的一部分。
使用场景如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components</title>
<style>
h1 {
font-size: 20px;
color: yellow;
}
</style>
</head>
<body>
<div></div>
<hello-world></hello-world>
<template id="hw">
<style>
.box {
padding: 20px;
}
.box > .first {
font-size: 24px;
color: red;
}
.box > .second {
font-size: 14px;
color: #000;
}
</style>
<div class="box">
<p class="first">Hello</p>
<p class="second">World</p>
</div>
</template>
<script type="module">
class HelloWorld extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.appendChild(document.getElementById('hw').content.cloneNode(true));
}
}
window.customElements.define('hello-world', HelloWorld);
</script>
</body>
</html>
渲染结果为:
Slot 相当于一个连接组件内部和外部的一个占位机制,可以用来传递 HTML 代码片段
寄语:
微前端架构的演进,更加折射出的是一种工程化的思想,在利用有限的条件和环境下,我们可以通过工程化的思维来解决我们实际业务中遇到的问题,不管是对于现在框架的启发而产生的 single-spa,还是受到 Web Components 启发而产生的 micro-app,到最后结合两种架构而产生的 无界,都是为了解决问题而产生的工程化的带有设计模式的解决方案。
作为一个「前端工程师」,工程师的特性我们不能丢,在业务中我们可以去积极探索解决方案,而并不是拘泥于现代框架的束缚。为了解决问题而迈出的第一步也许是困难重重的,但是迈出第一步后面也许会带来无尽的收益。
所以,在业务中遇到问题时,我们该利用我们工程师所积累的知识,去探索、去实践、去勇敢的迈出第一步。