JavaScript事件通信研究之自定义事件

1,067 阅读7分钟

前言

公司前段时间开发A项目的时候,因为A项目涉及的模块比较多,而且好多又是具有相当独立性等特点的,比如编辑器模块可视化编辑模块动态表单自定义配置模块导航栏及权限页面配置模块等。

而且,部分功能模块在B项目C项目等中也有使用到,所以,综合考虑,这些模块还是作为单独组件模块开发比较合适,通过统一的包管理,如npm,可以方便其他人员和项目快速添加使用,避免重复劳动,也避免复制粘贴这种不易维护和稳定以及同步更新的麻烦~

其中,有不少场景是需要跨组件模块传值的。而我们使用组件的常规方式基本是通过 import 这种引入一个组件模块,然后通过props传值的方式进行接收,类似antd这种。通过父组件 Parent结合props 也可以实现 Child1Child2的通信,但是这样对于组件内部分模块嵌套又比较深的,操作起来耦合性比较大,也比较乱,所以,我就在想如何降低耦合性并实现组件间的传值和通信呢~

简单看下代码和逻辑结构

代码大致结构

import React from 'react'
import Child1 from 'M' // npm包 M
import Child2 from 'N' // npm包 N

const Parent:React.FC=()=>{

    return <div>
        <Child1 />
        <Child2 />

        {/* 其他业务组件等 */}
        {/* ... */}
    </div>
}

export default  Parent

组件之间的关系

event-1

如图所示,Child1组件内部又分为很多父子小模块,这时我们需要和Child1中嵌套比较深的E模块进行通信,propsContext方式都有一定的成本和耦合性,联想到VueEvent事件通信传值,我觉得或许可以换个思路试一试~

emoji

事件通信的几种方式

✨Event Bus

主要就是通过发布订阅模式实现,这个我在另一篇文章里有写过 发布订阅模式vs观察者模式

具体实现和原理就不说了,简单看下使用方式

yarn add events

创建 src/utils/eventBus.ts,进行实例化,确保实例唯一,之后使用的时候,都应该引用这个

import { EventEmitter } from 'events'

// 保证唯一实例
const EventBus = new EventEmitter()

export default EventBus

简单写个demo测试下

目录结构如下

components-eventBus 
  ├── Parent 
  ├── Child1
  ├── Child2
utils
  ├── eventBus

Parent组件

import React from "react"
import Child1 from "./Child1"
import Child2 from "./Child2"

const Parent: React.FC = () => {
  return (
    <div className="parent-container">
      <div className="content-box">
        <Child1 />
        <Child2 />
      </div>
    </div>
  )
}

export default Parent

Child1组件1

import React, { useEffect, useState } from "react"
import EventBus from "../utils/eventBus"

interface StateProps {
  name: string
  age: number
  count: number
}

const PERSON_INIT: StateProps = {
  name: "小明",
  age: 18,
  count: 0,
}

const Child1: React.FC = () => {
  const [state, setState] = useState<StateProps>(PERSON_INIT) // 本组件的 state
  const [recevie, setRecevie] = useState() // 接收来自其他组件的 state

  const eventRegister = (args: any) => {
    console.log("我是Child1,我接收到了来自Child2的消息:", args)
    setRecevie(args)
  }

  useEffect(() => {
    // mount
    EventBus.on("msgTochild1", eventRegister)

    // unmount
    return () => {
      EventBus.off("msgTochild1", eventRegister)
    }
  }, [])

  const sendMsgToChild2 = () => {
    EventBus.emit("msgTochild2", state)
  }

  return (
    <div>
      <h2>Child1</h2>
      <article>
        <p>state:</p>
        <pre>{JSON.stringify(state, null, 2)}</pre>
        <br />
        <p>event recevie:</p>
        <pre>{JSON.stringify(recevie, null, 2)}</pre>
        <br />
        <button onClick={() => setState((prev) => ({ ...prev, count: prev.count + 1 }))}>
          count + 1
        </button>
        <button onClick={sendMsgToChild2}>sendMsgToChild2</button>
      </article>
    </div>
  )
}

export default Child1

Child2组件2

import React, { useEffect, useState } from "react"
import EventBus from "../utils/eventBus"

interface ListProps {
  name: string
  age: number
  count: number
}

const LIST_INIT: ListProps[] = [
  {
    name: "张三",
    age: 10,
    count: 0,
  },
]

const Child2: React.FC = () => {
  const [list, setlist] = useState<ListProps[]>(LIST_INIT) // 本组件的 state
  const [recevie, setRecevie] = useState() // 接收来自其他组件的 state

  const eventRegister = (args: any) => {
    console.log("我是Child2,我接收到了来自Child1的消息:", args)
    setRecevie(args)
  }

  useEffect(() => {
    // mount
    EventBus.on("msgTochild2", eventRegister)

    // unmount
    return () => {
      EventBus.off("msgTochild2", eventRegister)
    }
  }, [])

  const sendMsgToChild1 = () => {
    EventBus.emit("msgTochild1", list)
  }

  return (
    <div>
      <h2>Child2</h2>
      <article>
        <p>list:</p>
        <pre>{JSON.stringify(list, null, 2)}</pre>
        <br />
        <p>event recevie:</p>
        <pre>{JSON.stringify(recevie, null, 2)}</pre>
        <br />
        <button onClick={() => setlist((prev) => [...prev, { name: "小李", age: 11, count: 0 }])}>
          count + 1
        </button>
        <button onClick={sendMsgToChild1}>sendMsgToChild1</button>
      </article>
    </div>
  )
}

export default Child2

看下效果

event-2

传值没啥问题,配合 callback 即可完成状态的更新,不过,这里有个问题

首先,我们的需求是和Child1组件E模块进行通信,目前这个简易demo只是验证了eventBus通信传值的可行性,即Child1Child2通信

但这里的Child1其实是我们抽象出来的的演示组件,实际场景中它应该是我们通过npm包-M引入的组件,因此,这样看来我们还是要通过propsM组件进行传值通信,毕竟我们只能直接接触到M组件,而无法接触到M组件内的E模块

因为eventBus要确保实例唯一,而我们的eventBus实例是放在父组件的项目内的,所以,M组件也是无法直接获取到实例化的eventBus,我们不可能把eventBus通过Props传递下去,感觉这样又绕回来了

目前可行的方案,就是把实例化后的eventBus挂载到组件不通过props就可以访问到的地方

  • window 全局属性
// Parent组件 挂载 EventBus 到 window
import EventBus from "../utils/eventBus"
window._MY_EVENTBUS_ = EventBus

// M组件
const EventBus = window._MY_EVENTBUS_

当前项目下window是全局唯一,任意组件都可访问,不受局限,看起来似乎没问题

不过这里存在一些风险

  • 变量污染
  • 安全性

因此,这种方式也不建议使用~

✨JavaScript 自定义事件

直接使用JavaScript原生自定义事件 EventCustomEvent,先看下用法

1. 🤓使用JavaScript内置的 Event 构造函数

const myEvent = new Event(typeName, option)

  • typeName :DOMString 类型,表示创建事件的名称;
  • option : 可选配置项
    • bubbles:表示该事件是否冒泡,默认 null
    • cancelable:表示该事件能否被取消,默认 false
    • composed:指示事件是否会在影子DOM根节点之外触发侦听器(影子DOM:Shadow DOM),默认 false

示例

// 创建一个支持冒泡的 event-A 事件
const myEvent = new Event("event-A", { bubbles: true })

// 触发事件
document.dispatchEvent(myEvent);

事件通信没啥问题,不过这个方式不支持直接传递参数值,该方式有待考虑

2. 🤓使用JavaScript内置的 CustomEvent 构造函数

const myEvent = new CustomEvent(typeName, option)

  • typeName :DOMString 类型,表示创建事件的名称;
  • option : 可选配置项
    • detail:表示该事件中需要被传递的数据,在 EventListener 获取,默认 null
    • bubbles:表示该事件是否冒泡,默认 false
    • cancelable:表示该事件能否被取消。(影子DOM:Shadow DOM),默认 false

示例

// 创建事件
const myEvent = new CustomEvent("eventName", { detail: list })
// 添加事件监听
window.addEventListener("eventName", e=> console.log(e))
// 派发事件
window.dispatchEvent(myEvent)

同样的简单写个demo测试下

目录结构如下

components-customEvent 
  ├── Parent 
  ├── Child1
  ├── Child2

Parent组件和上面一样,没变

Child1组件1

import React, { useEffect, useState } from "react"

interface StateProps {
  name: string
  dec: string
  count: number
}

const PERSON_INIT: StateProps = {
  name: "小明",
  dec: "Child1的CustomEvent自定义事件event-A",
  count: 0,
}

const Child1: React.FC = () => {
  const [state, setState] = useState<StateProps>(PERSON_INIT)
  const [recevie, setRecevie] = useState()

  const eventRegister = (args: any) => {
    console.log("我是Child1,我接收到了来自Child2的消息:", args)
    setRecevie(args.detail)
  }

  useEffect(() => {
    // mount
    window.addEventListener("event-B", eventRegister)

    // unmount
    return () => {
      window.removeEventListener("event-B", eventRegister)
    }
  }, [])

  const sendMsgToChild2 = () => {
    // 创建事件
    const myEvent = new CustomEvent("event-A", { detail: state })
    // 派发事件
    window.dispatchEvent(myEvent)
  }

  return (
    <div>
      <h2>Child1</h2>
      <article>
        <p>state:</p>
        <pre>{JSON.stringify(state, null, 2)}</pre>
        <br />
        <p>event recevie:</p>
        <pre>{JSON.stringify(recevie, null, 2)}</pre>
        <br />
        <button onClick={() => setState((prev) => ({ ...prev, count: prev.count + 1 }))}>
          count + 1
        </button>
        <button onClick={sendMsgToChild2}>sendMsgToChild2</button>
      </article>
    </div>
  )
}

export default Child1

Child2组件2

import React, { useEffect, useState } from "react"

interface ListProps {
  name: string
  dec: string
  count: number
}

const LIST_INIT: ListProps[] = [
  {
    name: "小李",
    dec: "Child2的CustomEvent自定义事件event-B",
    count: 0,
  },
]

const Child2: React.FC = () => {
  const [list, setlist] = useState<ListProps[]>(LIST_INIT)
  const [recevie, setRecevie] = useState()

  const eventRegister = (args: any) => {
    console.log("我是Child2,我接收到了来自Child1的消息:", args)
    setRecevie(args.detail)
  }

  useEffect(() => {
    // mount
    window.addEventListener("event-A", eventRegister)

    // unmount
    return () => {
      window.removeEventListener("event-A", eventRegister)
    }
  }, [])

  const sendMsgToChild1 = () => {
    // 创建事件
    const myEvent = new CustomEvent("event-B", { detail: list })
    // 派发事件
    window.dispatchEvent(myEvent)
  }

  return (
    <div>
      <h2>Child2</h2>
      <article>
        <p>list:</p>
        <pre>{JSON.stringify(list, null, 2)}</pre>
        <br />
        <p>event recevie:</p>
        <pre>{JSON.stringify(recevie, null, 2)}</pre>
        <br />
        <button
          onClick={() =>
            setlist((prev) => [
              ...prev,
              { name: "小李", dec: "Child2的CustomEvent自定义事件event-B", count: 0 },
            ])
          }
        >
          person list + 1
        </button>
        <button onClick={sendMsgToChild1}>sendMsgToChild1</button>
      </article>
    </div>
  )
}

export default Child2

看下效果

event-3

可以看到该方式是支持事件通信参数传值的,基本可以满足我们的需求

而对于使用方式和普通的事件 addEventListener 也差不多,添加订阅后,通过 callback 接收事件的传值和状态更新操作,但是我们也可以发现有些不一样的地方

即,它不像 EventBus 那样必须使用单一实例,我们只要指定自定义事件的 typeName,就可以在任意地方触发使用,似乎用起来更方便简单~

话说不知道有没有坑~

看了下兼容性,如下

event-4

IE还是稳啊😆,IE浏览器是不支持CustomEvent.detail的,似乎Edge 14+才开始支持,怎么办,难道IE浏览器就不能使用的吗?

这里贴一份张鑫旭博客提供的Polyfill方案,cv~

/**
 * CustomEvent constructor polyfill for IE
 */
(function () {
    if (typeof window.CustomEvent === 'function') {
        // 如果不是IE
        return false;
    }

    var CustomEvent = function (event, params) {
        params = params || {
            bubbles: false,
            cancelable: false,
            detail: undefined
        };
        var evt = document.createEvent('CustomEvent');
        evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
        return evt;
    };

    CustomEvent.prototype = window.Event.prototype;

    window.CustomEvent = CustomEvent;
})();

结语

OK,到此结束

事件本质是一种消息,事件模式本质上是观察者模式的实现,即能用观察者模式的地方,自然也能用事件模式

又回到了观察者模式了呀~

emoji

参考