Web Components详解-组件通信

本文最后更新于:5 个月前

前言

我们常说到程序的运行和代码的实现遵循高内聚和低耦合,理解一下这句话,模块中的功能在逻辑上是有关联的,模块之间依赖关系较弱。前端的组件同样遵循这套原则,单个组件的功能逻辑是完整的,组件与组件之间也没有强关联,那么如何保证组件之间的联系呢?在Vue和React中一般使用props响应式通信、bus事件总线、Pinia,Vuex,Mobx全局状态等等方式进行数据传递,类似的本篇文章也将介绍Web组件的通信方式

插槽(Slots)

插槽的使用在之前的文章介绍过,通过自定义标签中其他标签的slot属性与影子DOM的slot标签绑定达到传递组件的效果,本文就不做介绍

属性(Attributes)

在介绍创建自定义标签时,我们曾经接触过属性监听器函数attributeChangedCallback,通过在静态方法observedAttributes中声明属性的列表,来监听某个或者某些属性的变化,在标签使用setAttribute函数时会触发属性监听回调。借助这个特点,我们结合代理(proxy)或者存取器(set,get)将element.attrs = value的操作也代理到标签中,思考下面的代码

// 组件工厂
const createCustomElement = (config) => {
  const {
    name,
    attrs = [],
    mode = "open",
    temp,
    parent = document.body,
    BaseClass = HTMLElement,
  } = config;
  let elem = document.createElement(name);
  customElements.define(
    name,
    // @ts-ignore
    class extends BaseClass {
      constructor() {
        super();
        this.initProxy();
        this.attachShadow({ mode });
        this.shadowRoot?.appendChild(temp.content);
      }
      initProxy() {
        // 监听设置标签属性操作
        elem = new Proxy(elem, {
          set: (target, property, value) => {
            target.setAttribute(property, value);
            return Reflect.set(target, property, value);
          },
          get: (target, property) => {
            const obj = Reflect.get(target, property);
            // 事件函数优化,取this指向问题
            if (typeof obj === "function") return obj.bind(this);
            return obj;
          },
        });
      }
      attributeChangedCallback(attrName, oldValue, newValue) {
        console.log(`${attrName}属性的旧值:${oldValue},新值:${newValue}`);
      }
      static observedAttributes = attrs;
    }
  );
  parent.appendChild(elem);
  return elem;
};

我们实现一个组件的批量创建的函数,无需每次都重新使用自定义标签创建组件,只需要调用createCustomElement函数就可以创建一个组件。创建组件操作如下

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Attributes</title>
</head>

<body>
    <template id="temp1">
        <div>
            123
        </div>
    </template>
    <template id="temp2">
        <div>
            456
        </div>
    </template>
    <script src="./helpers.js"></script>
    <script>
        const attrs = ["attrs"]
        const elemName1 = "custom-element1"
        const elemName2 = "custom-element2"
        const elem1 = createCustomElement({
            name: elemName1,
            attrs,
            temp: temp1
        })
        const elem2 = createCustomElement({
            name: elemName2,
            attrs,
            temp: temp2
        })
        elem1.attrs = 111

    </script>
</body>

</html>

当我们使用elem1.attrs = 111给elem1的属性赋值时,会调用自定义标签中的attributeChangedCallback函数,达到组件的信息传递的效果

事件(Events)

讲完了基础的属性传递,我们来试试通过标签自定义事件(CustomEvent)的方式给组件添加信息传递的途径。

自定义事件有三个知识点,分别是CustomEvent类,dispatchEvent函数以及addEventListener函数,后两者不难理解,它们是element上的方法,用于触发和监听标签的事件。而CustomEvent类的作用则是承载数据的传递及事件的配置。

CustomEvent构造函数接收两个参数,第一个是string类型的type,表示事件类型;第二个是eventInitDict,表示事件配置,其中包含bubbles(允许冒泡),cancelable(允许取消,通过event.preventDefault()可以取消该方法调用),composed(允许穿越Shadow DOM的边界),detail(自定义事件传递的额外数据)。

其中composed在实现自定义组件时尤为重要,将其设置为true时才能在外界和影子DOM中传递数据,参考下面的一段关于自定义事件的代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Events</title>
</head>

<body>
    <template id="temp1">
        <button id="btn1">
            组件1
        </button>
        <script type="module">
            const root = elem1.shadowRoot
            root.querySelector("#btn1").addEventListener('click', () => {
                // 在按钮点击时触发自定义事件
                root.dispatchEvent(new CustomEvent('customClick', {
                    bubbles: true, // 允许事件冒泡
                    composed: true, // 允许事件穿越Shadow DOM边界
                    detail: { message: '点击' } // 传递的数据
                }));
            });
        </script>
    </template>
    <template id="temp2">
        <button>
            组件2
        </button>
    </template>
    <script src="./helpers.js"></script>
    <script>
        const elemName1 = "custom-element1"
        const elemName2 = "custom-element2"
        const elem1 = createCustomElement({
            name: elemName1,
            temp: temp1
        })
        const elem2 = createCustomElement({
            name: elemName2,
            temp: temp2,
            BaseClass: class extends HTMLElement {
                connectedCallback() {
                    this.addEventListener('customClick:elem1', (e) => {
                        // 监听、接收来自elem1的customClick:elem1事件消息
                        console.log("收到elem1的消息:", e.detail);
                    });
                }
            }
        })
        elem1.addEventListener('customClick', (e) => {
            // 监听、接收来自elem1的customClick事件消息,并发送customClick:elem1消息给elem2
            elem2.shadowRoot.dispatchEvent(new CustomEvent('customClick:elem1', e));
        });
    </script>
</body>

</html>

当点击组件1时,控制台会打印相关信息,达到组件通信的效果

消息中心(MessageCenter)

消息中心类似vue的$emit,它是一种发布订阅的写法,在异步,解耦的实践中发挥重要的作用,在早期的文章中,我曾经介绍过这种设计模式的实现:消息中心发布者/订阅者模式。本文就不做详细的介绍,有兴趣的朋友可以看看链接文章,下面给出消息中心通信方式的使用示例:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Events</title>
</head>

<body>
    <template id="temp1">
        <button id="btn1">
            组件1
        </button>
        <script type="module">
            const root1 = elem1.shadowRoot
            bus.on(`${elemName2}:msg`, (e) => {
                console.log(e);
            })
            root1.querySelector("#btn1").addEventListener('click', () => {
                bus.emit(`${elemName1}:msg`, { msg: "hello this is elem1" })
            });
        </script>
    </template>
    <template id="temp2">
        <button id="btn2">
            组件2
        </button>
        <script type="module">
            const root2 = elem2.shadowRoot
            bus.on(`${elemName1}:msg`, (e) => {
                console.log(e);
            })
            root2.querySelector("#btn2").addEventListener('click', () => {
                bus.emit(`${elemName2}:msg`, { msg: "hello this is elem2" })
            });
        </script>
    </template>
    <script src="./helpers.js"></script>
    <script src="./node_modules/event-message-center/dist/umd/index.js"></script>
    <script>
        const elemName1 = "custom-element1"
        const elemName2 = "custom-element2"
        const bus = MessageCenter.messageCenter
        const elem1 = createCustomElement({
            name: elemName1,
            temp: temp1
        })
        const elem2 = createCustomElement({
            name: elemName2,
            temp: temp2
        })

    </script>
</body>

</html>

上述代码中,当我们点击组件1时,控制台打印hello this is elem1;点击组件2时提示hello this is elem2,由此达到组件的消息通信

全局状态(GloalState)

我要介绍的全局状态可能与框架中常用的全局状态管理工具不太一样,这里我使用代理实现了一个简单的状态管理类,以供组件可以访问

// 简单的响应式全局状态
class GlobalState {
  constructor(state, action) {
    this.state = this.initState(state);
    this.action = action;
  }
  initState(state) {
    return new Proxy(state, {
      set: (target, key, val) => {
        Reflect.set(target, key, val);
        this.action(target);
        return true;
      },
    });
  }
}

在组件中,通过修改state的状态触发钩子函数,达到响应的效果

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GloalState</title>
</head>

<body>
    <template id="temp1">
        <button id="btn1">
            组件1
        </button>
        <script type="module">
            const root1 = elem1.shadowRoot
            root1.querySelector("#btn1").addEventListener('click', () => {
                state.name = "elem1"// 组件1中点击,name转换成elem1
            });
        </script>
    </template>
    <template id="temp2">
        <button id="btn2">
            组件2
        </button>
        <script type="module">
            const root2 = elem2.shadowRoot
            root2.querySelector("#btn2").addEventListener('click', () => {
                state.name = "elem2"// 组件2中点击,name转换成elem2
            });
        </script>
    </template>
    <script src="./helpers.js"></script>
    <script src="./node_modules/event-message-center/dist/umd/index.js"></script>
    <script>
        const elemName1 = "custom-element1"
        const elemName2 = "custom-element2"
        const { state } = new GlobalState({
            name: "elem"
        }, (state) => {
            console.log(state);
        })
        console.log(state);// 初始化name是elem
        const elem1 = createCustomElement({
            name: elemName1,
            temp: temp1
        })
        const elem2 = createCustomElement({
            name: elemName2,
            temp: temp2
        })

    </script>
</body>

</html>

总结

在前端开发中,遵循高内聚低耦合的原则,确保组件之间的功能逻辑关联性较强,同时组件之间的耦合性较低,是设计和构建可维护性高的应用的重要指导原则。

Web 组件作为可重用的组件化技术,也需要有效的通信机制来实现组件之间的数据传递和交互。文章中的每种通信方式都有其适用的场景,根据项目需求和设计原则来选择合适的通信方式是非常重要的。

以上就是文章全部内容了,如果觉得文章不错的话,还望三连支持一下,感谢!

相关代码

myCode: 基于js的一些小案例或者项目 - Gitee.com

MessageCenter: 基于发布订阅模式实现的一个事件消息中心

参考文章

Shadow DOM 和事件(events)