当 Web Component 遇到微前端,代码隔离的简单方案 🥳

代强2022/06/06

微前端解决了什么问题?

  • 情景1:小明是新来员工,刚来就肩负重任,接手了公司一个历史悠久的传家宝平台的迭代维护。其代码从刀耕火种的年代一直传承到了现在,用的各种包甚至框架都是公司前辈们的手搓轮子,文档也没有。小明一身 React Webpack 的绝技无处施展,也白蹭不了公司内外其他基于新技术的包和插件。而且加 Feature 不仅牵一发动全身,如果在原有代码上修改,听说后期公司要迁移技术栈,自己又得重写。小明工作步履维艰。
  • 情景2:小红所在的公司正在准备开发一个大型 Web 应用,交互基于桌面应用的传统设计,各种模块功能十分复杂。为了快速上线,公司好多人划分不同组同时开发不同模块。但是大家都喜欢用自己熟悉的技术栈,这个小组说 React 大厂生态,一定要上React,那个小组认为 Vue 模板简单 要用Vue,新来的小组又认为 Flutter 性能接近原生,要用 Flutter 确保体验。大家各执己见,会上吵的不可开交。
  • 情景3:小明的公司和小红的公司发现彼此业务可以互补互惠,于是两个公司签订协议,公司要让小明在其负责的平台上引入小红公司的 Web 应用。但是小红公司的 Web 应用还在开发迭代,一天更新三四个版本,小明的平台还得跟着快速更新,小明和小红接到需求后都失去了头发。

微前端的出现就是为了解决以上问题。它一个广义的概念,只要一个大型应用上的模块在工程上互相解耦,但整体又能互相通信让整个应用功能完整,都可以看做属于微前端范畴。其实早在 ie6 时代,用 iframe 在网页上嵌入第三方内容本质上也算是广义的微前端 (经典例子如企业在官网嵌百度地图来展示位置)。

在以上情景中,如果小明小红使用 iframe,可以解决很大一部分问题。

  • 小明在平台上添加新功能时可以新建工程开发,之后在旧代码上用 iframe 作为容器,将新工程嵌入页面。
  • 小红公司则可单个模块单个iframe,不同小组各自开发负责的组件,最后在应用页面上分别展现各个模块,并通过 postMessage 进行数据流通信。
  • 小明接入小红公司应用,则可利用 ifame 将整个小红公司应用嵌入到自己页面,当应用发布新版时,iframe 内容会自动更新,平台本身不需要二次部署。

从原理上,如果不考虑性能体验,iframe 可能是最完美的方案。但是 iframe 有诸多无法解决的缺陷。

  • 性能开销过大。Iframe 会在浏览器中创建一个完整的页面上下文,浏览器会为其分配与一个新标签页相同的资源。现代大型Web应用可能涉及几十个模块同时运行的场景,若均采用 iframe 来实现解耦,用户访问应用时,相当于同时打开几十个标签页,会挤占大量 CPU 和内存资源。
  • 路由过多。Iframe 要求对每个模块均设置不同路由,浏览器通过访问不同路由在 iframe 中加载不同模块。这样做会导致整个应用路由过多,开发者不仅需要维护很多路由状态,而且还要防止用户手动刷新 iframe。
  • 较大通信成本。模块间无法直接通信,需通过 contentWindow(同域) postMessage(跨域) 来中转通信。
  • 代码打包过大。不同模块之间使用相同代码,打包多次。

Iframe 虽好但有诸多不便,因此业界涌现了很多框架来模拟 iframe 的效果,我们把它们统称成微前端方案。这些方案最终都是为了:**

  • 样式隔离,每个模块样式隔离, css 不能跨模块污染页面其他部分。 主要实现方法有:
    • 编译阶段对每个模块 css 选择器进行哈希化 (类似 Vue scope,css Modules)
    • 将模块放进 ShadowDOM 中渲染
    • ...
  • 逻辑隔离,每个模块的 JS 代码不能访问其他模块 制造沙箱,通过创建新的对象承接模块对外部变量的操作,并在模块内通过函数作用域覆盖掉外部变量,防止非法访问。主要实现方法有:
    • Proxy 拦截模块对外部变量的访问,并代理全局变量的修改(例如 window)
    • 对每次外部变量的操作都进行隔离并快照,并在模块执行结束时恢复,类似差异备份。
    • ...

这些框架都能在一定程度上解决小明和小红的问题。而本文要介绍的 WebComponent,也是一种类似的页面模块化方案。但区别是,WebComponent 利用的是浏览器原生实现,不依赖框架,仅通过很少量的代码来做到完全的样式隔离和一部分 JS 隔离。

WebComponent 让页面在 DOM 结构上被切割成不同的几个部分,代码互相隔离,但 UI 观感仍旧保持整体,用户无感知。另外,得益于浏览器的原生实现,利用WebComponent 来实践微前端的 UI 模块化的思想除了能够解决上述问题,还可以实现一些特殊的功能。例如 Semi 的 (主题切换器)[https://semi.design/dsm/install_switcher] 就得益于 WebComponent和书签注入,可以在不安装浏览器插件的情况下在第三方网站唤起 Semi 的弹窗UI,设计师可在线上页面直接修改页面主题调试效果。

WebComponent 是什么,又怎么解决的问题?

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。—— MDN

组件是前端的发展方向,现在流行的 React 和 Vue 都是组件框架。 谷歌公司由于掌握了 Chrome 浏览器,一直在推动浏览器的原生组件,即 Web Components API。相比第三方框架,原生组件简单直接,符合直觉,不用加载任何外部模块,代码量小。目前,它还在不断发展,但已经可用于生产环境。 —— 阮一峰

简单来说,WebComponent 可以看做是 React 和 Vue 这样组件化 UI 框架 + CSS Modules 的原生版本。目的是将页面切割成模块便于封装和开发,而且WebComponent 更进一步,每个组件甚至都渲染在单独的 DOM 树中。

浏览器支持

上述情景中小明和小红遇到的问题其实可以直观的分为四个子问题 :

1. 如何做到动态加载页面上的组件?需要的时候再去加载子业务的模块?

简单来说,一个 WebComponent 组件能显示在页面上,需要两个前提

  • 该 WebComponent 的 js 类已经被在页面上被定义。
  • 该 WebComponent 的自定义 tag 的 DOM 节点已经在页面上存在

上图高亮部分即是一个已经渲染的 Web Component。 如果 webComponent 的 tag 已经出现在页面上但没有定义,浏览器仍然会在页面上保留此 tag,并不会报错。但是因为该 tag 并没有被定义,所以内部的子节点是空的。但一旦某一时刻,该WebComponent 在 js 中被定义,页面上所有的该自定义tag 都会被真实 DOM 填充,并在UI视觉上显示出来。 因此,动态加载子业务模块有两种类似的操作。可以预先在主要页面框架子业务模块的位置填写好对应的自定义tag,需要加载子业务模块时再声明该 WebComponent。也可以两步都在需要加载子业务模块时去做。当子模块在 DOM 树中位置固定时第一种比较方便,子模块渲染位置经常变化时第二种较方便。

2. 如何做到样式隔离,让页面不同模块 CSS 互不影响?

WebComponent 内部节点均在 shadowDOM 内渲染,和 window.document 是两棵 DOM 树,样式是天然隔离的。

3. 如何做到代码隔离,让页面模块间使用的不同框架互不影响(例如不同模块的事件绑定,不同React版本虚拟 dom 树的 diff,甚至嵌入 Flutter 子模块)

同上,不同 DOM 树的事件传播也是隔离的,因此不同模块不同版本的 React 和 Vue 因为本身的 render 根节点不在同一棵 DOM 树上,模块内的框架的所有 DOM 操作都是天然隔离的。但注意的是,子模块的 JavaScript 仍然可以访问父页面元素,(与同源 iframe 类似)。

4. 如何和现有业务代码整合?不至于大动干戈?

WebComponent 是浏览器规范,无需额外操作,主流框架都默认支持。React 和 Vue 都可以在 JSX 或模板中直接嵌入 WebComponent。另外,如果想要对外隐藏细节,可将 WebComponent 组件包裹一层空的 React 或 Vue组件,作用是接收外部或父级组件传入的 Props 并通过 attribute 或其他方式传入 WebComponent。框架使用已包裹组件时,对内部的 WebComponent 是无感知的。

好在哪?

  • 易加载,很轻量

    • 虽然已经有很多成熟的微前端方案,但是都几乎需要额外引入一些运行时逻辑,而WebComponent 被浏览器原生支持,天然适合一些需要解决特定问题的 case。
  • 易部署,可热更

    • WebComponent 逻辑可打包成单独的 JavaScript 文件,随用随加载。如果将子模块 chunk 部署到 cdn 上,可以通过切不同版本的 chunk 来加载不同版本的子模块。
  • 易整合,能省事

    • WebComponent 已被各大框架支持,React 和 Vue 都在自己的官方文档中着重介绍了与WebComponent 的互相调用。使用时,可像普通组件一样引入 WebComponent 模块,轻松和已有框架整合。
  • 易维护,节成本

    • 如果使用其他方案或者微前端框架,所有的子模块开发者都需要付出一定的学习和理解成本,但 WebComponent 是 Web 规范的一部分,未来会默认开发者已经有相当的了解。当项目某模块更换维护人员,也不需要专门学习理解,节约人力成本。
  • 易学习,无后忧

    • WebComponent 作为 Web W3C 规范 的一部分,已经属于互联网基础设施的一部分,并且也已经被各大浏览器落实。作为互联网基础设施,学习 WebComponent 永远不会像学习一个框架一样,需要担心框架本身的兼容性,框架是否能稳定迭代,其他开发人员是否认同,学了会不会没用等问题。并且 W3C 规范本身设计时就会考虑到了不同人群的学习成本,本身学习难度并不高。作为规范的一部分,未来一个前端开发者理解 WebComponent 会像理解 JS 的 this 指向一样,属于基础技能。另外,进入规范后,就永远是规范的一部分。例如最新的 chromium 浏览器内核 v100 版本甚至还支持着上世纪 W3C 制定网页标准前网景的 Navigator 浏览器和 IE 所使用的怪异模式

但我不会,可以手把手教我吗?

当然可以。接下来我们以 React 框架为例,来实现一个 WebComponent。 最终目的是实现一个 WebComponent, 接收一个 text 的prop,渲染出 text 的内容并在其后加上 is Great!, 并且当传入 WebComponent 的 props 改变时,UI 会自动更新。 最终打包产物是一个纯 js 文件,任何 html 页面中使用 script 标签引入此 js 文件都可以直接显示我们的组件。

最终效果和 DOM 结构

编写 React 组件代码:

代码非常简单,不多赘述。

App.tsx

import React  from 'react';
import './app.css'

const App = ({text}: { text?:string }) => {

    return <>
        <span className="custom-text">{text || 'Everything'}</span> is Great!
    </>
};

export default App;

app.css

.custom-text{
    color: salmon;
}

index.html

<!DOCTYPE html>
<html>
    <head/>
    <body>
        <my-component text="Semi"/>
    </body>
</html>

创建一个 WebComponent :

我们需要两步

  • 定义我们的 WebComponet 类,起名 my-component
  • 在页面中注册,即调用 customElements.define

1. 定义 WebComponent

我们需要了解几个会用到的 webComponent 的 生命周期钩子

  • connectedCallback: 当 WebComponent 第一次被连接到文档时被调用。
  • attributeChangedCallback (可选): 当 WebComponent 的一个属性被增加、移除或更改时被调用。
  • observedAttributes: (可选) get 属性,返回一个字符串数组,只有数组中的 attribute 的 key 的 value 被改变时,才会触发 attributeChangedCallback

具体步骤: 在 connectedCallback 生命周期:

  1. 创建空 div 作为 React 的挂载点 创建一个 div 作为 ReactDOM.render 的挂载点,React 组件的 dom 和事件监听都会被自动挂载到此 div 下。
  2. (可选) 获取 attribute 作为 默认 props 传入 React 组件 WebComponent 的特性允许页面 html 预先存在 的 空 DOM,因此定义时需要获取 attribute 作为 React 组件的默认 props。
  3. 渲染 React 组件

有两种方法可以允许外部向 WebComponent 传入 prop,进而透传给内部的 React 组件,并且当外部的 props 更新时,会触发 React 组件的更新来更新 UI。

** 方法一: 通过 WebComponent 实例内的自定义方法 (推荐)**

  1. 在 WebComponent 类中定义一个方法, 例如 setProps, 入参为新 props,功能是执行 ReactDOM.render 并传入新 props 来触发 React 组件的更新。
  2. 页面中需要向 WebComponent 传入 props 时,可直接调用 实例.setProps({...}) 来触发 UI 更新。例如 document.querySelector('my-component').setProps({text:'新内容'});

index.tsx

//index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

class MyComponent extends HTMLElement {

    private mountPoint: HTMLElement | null = null;
    private static attributeForWebComponentProps = ['text'];

    connectedCallback() {
        const mountPoint = document.createElement('div');
        this.mountPoint = mountPoint;
        this.attachShadow({mode: 'open'}).appendChild(mountPoint);
        // (可选)获取 attribute 上的数据作为默认值
        const props: Record<string, string | number | boolean> = {};
        MyComponent.attributeForWebComponentProps.forEach(prop => {
            const defaultValue = this.getAttribute(prop);
            defaultValue && (props[prop] = defaultValue);
        })
        
        //渲染 React 组件
        this.renderReactElement(props);
    }

    renderReactElement = (props: any) => {
        if (this.mountPoint) {
            ReactDOM.render(<App {...props}/>, this.mountPoint as HTMLElement);
        }
    }

    setProps = (props: any) => {
        this.renderReactElement(props);
    }
}


customElements.define('my-component', MyComponent);

方法二: 利用 attribute (只能传入可被序列化的值)

在 attributeChangedCallback生命周期:

  1. 注册 webComponent 的 attribute 监听属性。 当 attribute 的值被改变时候,更新 React 组件的 prop 值,同时设置 observedAttributes get 属性表明需要监听哪些 attribute。
  2. 当 attribute 被改变时,调用 ReactDOM.render,并修改 props, 触发 React 组件的更新

index.tsx

//index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

class MyComponent extends HTMLElement {

    private mountPoint: HTMLElement | null = null;
    private static attributeForWebComponentProps = ['text'];

    //与上文相同, 下同
    connectedCallback() {...}

    mountReactElement = (props: any) => {...}

    attributeChangedCallback(name: string, oldValue: string, newValue: string) {
        if (MyComponent.attributeForWebComponentProps.includes(name) && newValue !== oldValue) {
            this.renderReactElement({text: newValue})
        }
    }

    static get observedAttributes() {
        return MyComponent.attributeForWebComponentProps;
    }
}


customElements.define('my-component', MyComponent);

2. 注册 WebComponent

调用 customElements.define 将定义好的 WebComponent 注册到页面中, 其中 customElements 为浏览器原生对象。

注入额外样式(可选):

我们的最终目的是编译出一份纯 js,任何页面使用 script 标签直接引入产物的 js 文件即可使用组件。因此我们需要将 css 样式也打包进最终产物 js 中。你可以直接在 js 中硬编码 css,或者直接在 shadowDOM 中插入外部的 css link,都能达到类似的效果。但是如果我们想要和往常开发一样,直接在 JS 里 import 样式文件就能在 WebComponent 中生效的话,就需要在编译时做一些额外干预。下文介绍了如何让 webpack 的 style-loader 替我们做这些工作。 利用 style-loader 的 insert 配置,我们可以自定义 style-loader 插入 css 样式到页面中的时机和位置。我们需要将 style-loader 生成的 style 标签的 dom ( insert 函数入参) ,插入到页面中的 WebComponent 中。(下方示例代码直接使用了 querySelector 来确定插入的 WebComponent 的位置,并使用 setTimeout 在下一个宏任务执行,确保在 shadowRoot 创建后才插入样式,实际业务应当使用事件模型来通信,这里为了剔除细节简化表述)。

// 下方的配置基于 webpack 5.67.0   style-loader 3.3.1  css-loader 6.5.1
{
    test: /\.css$/i,
    use: [
        {
            loader: 'style-loader',
            options: {
                insert: (element) => {
                // 确保插入时  webComponent 的 shadowRoot 已被创建,业务中应当使用事件通信,这里为了简化
                    setTimeout(()=>{
                        const webComponentName = 'my-component'
                        const webComponent = document.querySelector(webComponentName);
                        if (webComponent) {
                            // 获取 WebComponent 实例的 shadowRoot
                            const shadowRoot = webComponent.shadowRoot;
                            if (shadowRoot) {
                                shadowRoot.prepend(element.cloneNode(true));
                            }
                        }
                    })
                }
            }
        },
        {
            loader: 'css-loader',
            options: {
                modules: {
                    auto: true,
                    localIdentName: isDev ? '[path][name]__[local]--[hash:base64:5]' : '[hash:base64:5]',
                    localIdentContext: path.resolve(__dirname, 'src')
                }

            }
        }
    ]
}

经过上述步骤,将 index.tsx 作为打包入口,即可打包出一份 js 文件,任何 html 页面使用 script 标签引用该 js 文件,并且页面中存在 的 tag,你的组件就会被渲染在页面上,并且 props 变化会触发 UI 的更新。

缺点

甘瓜苦蒂,WebComponent 并不能处理一些特殊的 case,不是万能的。

  • WebComponent 并不能帮你实现 JS 沙箱,如果你特别需要沙箱化模块逻辑代码,可能需要手动实现或者为此额外引入一些库。
  • WebComponent 的 DOM 树隔离是双刃剑,如果你需要一些所有模块通用的样式,你可能需要将对应的 CSS 的 link 或者 style 标签的 dom 节点 clone 到每个子模块来使其生效。
  • 主页面和子模块,子模块和子模块之间是不同的 dom 树,因此每个模块 querySelectorAll 之类方法的上下文也是单独的,模块之间不能互相 select 到对方的 dom,需要先获取其他模块的 WebComponent 节点,再在其上使用 querySelectorAll 方法。

Semi 与 WebComponent

Semi Design 作为广泛使用的专业前端组件库,长久保持优秀质量的同时也一直致力于关注最新前端动态。WebComponent 作为网页规范也是我们重点关注的方向之一。未来如果时机合适,我们会进行更多的尝试并可能提供原生 WebComponent 版本的 Semi 组件库,让我们一起推动前端行业的蓬勃发展。