微前端

53 阅读26分钟

微前端的概念

微前端的概念是由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 作为微前端实现存在的问题

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。

2 . UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..

  1. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。

  2. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

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 的一些问题,使易用性和稳定性都得到了提升。

主要特点如下:

  1. html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本;

  2. 完备的沙箱方案,js 沙箱做了 SnapshotSandbox、LegacySandbox、ProxySandbox 三套渐进增强方案,css 沙箱做了 strictStyleIsolation、experimentalStyleIsolation 两套适用不同场景的方案;

  3. 做了静态资源预加载能力;

但是qiankun也有不足的地方,其中也有延续 single-spa 架构的缺点

  1. 适配成本比较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作;

  2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重;

  3. 无法同时激活多个子应用,也不支持子应用保活;

  4. 无法支持 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 Entry1. 子应用开发、发布完全独立2.子应用具备与独立应用开发时一致的开发体验1. 多一次请求,子应用资源解析消耗转移到运行时
  1. 主子应用不处于同一个构建环境,无法利用bundler 的一些构建期的优化能力,如公共依赖抽取等                    |
    

| JS Entry | 主子应用使用同一个 bundller,可以方便做构建时优化 | 1. 子应用的发布需要主应用重新打包

  1. 主应用需为每个子应用预留一个容器节点,且该节点id 需与子应用的容器 id 保持一致3.子应用各类资源需要一起打成一个bundle,资源加载效率 |

简而言之:1.0 的特点就是把子应用进行改造,暴露出统一的生命周期。基础应用监听url的变化,找到对应子应用,执行对应生命周期的方法,挂载到对应的容器上。

沙箱实现

沙箱也是微前端框架实现的一个重要环节,它可以保证子应用间的数据不会相互污染,全局变量不会冲突,等。

在 qiankun 框架中为了实现 js 隔离,提供了三种不同场景使用的沙箱,分别是 snapshotSandbox、proxySandbox、legacySandbox。

  1. snapshotSandbox

snapshotSandbox 也叫快照沙箱,其实现原理就是把当前的 window 对象进行快照保存,然后还原 初始化 window 对象。并监听需要进行同步的字段,做diff。等应用卸载后,再还原原始 window 对象。

缺点:

  1. 会占用内存空间
  2. 会污染全局变量

优点:

兼容不能使用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 的一些缺点被明显放大,

  1. 适配成本比较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作;

  2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重;

  3. 无法同时激活多个子应用,也不支持子应用保活;

  4. 无法支持 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参数同步到浏览器地址上,如下:

alt

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 开辟了一条不同的微前端路径。根据上面的一些技术改进,我们可以看整体的一些优势,比如:

  1. 使用 webcomponet 加载子应用相比 single-spa 这种注册监听方案更加优雅;
  2. 复用经过大量项目验证过 qiankun 的沙箱机制也使得框架更加可靠;
  3. 组件式的 api 更加符合使用习惯,支持子应用保活;
  4. 降低子应用改造的成本,提供静态资源预加载能力;

但是在使用过程中,为了一些框架兼容和妥协,我们也看到了一些缺点,比如:

  1. 在支持 vite 运行时,必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离;
  2. 对于不支持 webcompnent 的浏览器没有做降级处理;
  3. 加载性能还存在一些问题

那么如何再进一步去演进微前端的架构呢。

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 的基石。那么我们来看下这个基石提供了哪些方法,提供给我们进行高楼大厦的建设。

  1. 自定义元素挂载方法

自定义元素通过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处理。

渲染结果:

  1. 自定义元素的类

由上面的例子 "class HelloWorld extends HTMLElement { xxx } " 发现,自定义元素的构造都是基于 HTMLElement,所以它继承了 HTML 元素特性,当然,也可以继承 HTMLElement的派生类,如:HTMLButtonElement 等,来作为现有标签的扩展。

  1. 自定义元素的生命周期

类似于现有MV*框架的生命周期,自定义元素的基类里面也包含了完整的生命周期 hook 来提供给开发者实现一些业务逻辑的应用:

class HelloWorld extends HTMLElement {
    constructor() {
        // 1 构建组件的时候的逻辑 hook
        super();
    }
  // 2 当自定义元素首次被渲染到文档时候调用 
  connectedCallback(){
  } 
  // 3 当自定义元素在文档中被移除调用 
  disconnectedCallback(){ 
  } 
  // 4 当自定义组件被移动到新的文档时调用
  adoptedCallback(){ 
  } 
  // 5 当自定义元素的属性更改时调用
  attributeChangedCallback(){  
  }
}
  1. 添加自定义方法和属性

由于自定义元素由一个类来构造,所以添加自定义属性和方法就如同平常开发类的方法一致。

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,到最后结合两种架构而产生的 无界,都是为了解决问题而产生的工程化的带有设计模式的解决方案。

作为一个「前端工程师」,工程师的特性我们不能丢,在业务中我们可以去积极探索解决方案,而并不是拘泥于现代框架的束缚。为了解决问题而迈出的第一步也许是困难重重的,但是迈出第一步后面也许会带来无尽的收益。

所以,在业务中遇到问题时,我们该利用我们工程师所积累的知识,去探索、去实践、去勇敢的迈出第一步。