关于javascript:JavaScript设计模式-发布订阅最佳实践

5次阅读

共计 22827 个字符,预计需要花费 58 分钟才能阅读完成。

什么是设计模式?

  • 设计模式(Design pattern)代表了最佳的实际,通常被有教训的面向对象的软件开发人员所采纳。设计模式是软件开发人员在软件开发过程中面临的个别问题的解决方案。这些解决方案是泛滥软件开发人员通过相当长的一段时间的试验和谬误总结进去的。
  • 在 1994 年,由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四人合著出版了一本名为 Design Patterns – Elements of Reusable Object-Oriented Software(中文译名:设计模式 – 可复用的面向对象软件元素) 的书,该书首次提到了软件开发中设计模式的概念。四位作者合称 GOF(四人帮,全拼 Gang of Four)
  • 设计模式其实是一种解决方案、一种编程套路、一种解决问题的思维,并不是具体的代码,代码只是它具体的实现,它跟编程语言无关,框架无关,你能够用 JavaScript 实现、也能够是 Go 实现、你能够在 Vue 里用,也能够在 React 里用。
  • 特地留神:” 应用设计模式是为了可重用代码、让代码更容易被别人了解、保障代码可靠性、程序的重用性。” 如果你不能满足这个前提,那就不要用,肯定要依据业务场景去抉择适合的设计模式,不要生吞活剥。

📢 文本所有代码都是用 node v17.8.0 版本进行开发和运行;应用 pnpm 包治理进行依赖治理和装置;

什么是公布订阅?

  • 公布订阅其实就是一个事件模型,平时咱们前端开发罕用的事件监听,其实就是一个公布订阅模式;它把一些列用户监听 (订阅) 的事件收集起来,而后当用户点击监听 (订阅) 的按钮时,就触发 (公布) 该事件的所有监听 (订阅) 函数;
  • 咱们的公众号也是公布订阅模式;关注 (订阅) 一个公众号,当这个公众号有新文章公布时,所有关注这种公众号的用户都能收到新文章公布的音讯!
  • 上面咱们来看一个平时开发中最常见的公布订阅例子:

    <!-- example/demo01-default-event.html-->
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title> 公布订阅 - 事件监听 </title>
    </head>
    <body>
    <div id="click-me" style="border: 1px solid green; width: 300px; height: 300px"> 点我 </div>
    <script>
      const div = document.querySelector('#click-me');
      // 监听(订阅)点击事件,当用户点击这个元素时调用这个函数
      div.addEventListener('click', e => {console.log('点我', e)
      });
    
      // 监听 (订阅) 点击工夫,屡次订阅,点击时,会触发这个元素所有的订阅事件
      div.addEventListener('click', e => {console.log('点我 第 2 个订阅工夫', e)
      })
    </script>
    </body>
    </html>
  • 运行后果:

  • 能够发现,公布订阅其实是咱们前端最早接触到的设计模式,也是最最罕用的设计模式;像 click 或者 keydown 这种都是浏览器内置定义好的事件,只有点击元素或者按下键盘时才会触发!
  • 如果说我想弄一个浏览器没有定义的事件行不行?当然能够,因为浏览器是反对自定义事件的,上面通过例子演示一下如何应用浏览器的自定义事件:

    <!-- example/demo02-custom-event.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title> 公布订阅 - 浏览器自定义事件 </title>
    </head>
    <body>
    <script>
      document.addEventListener('jameswain', function (e) {console.log('事件触发了', e, this);
      });
    
      document.addEventListener('jameswain', function (e) {console.log('第二次订阅,事件触发了', e, this);
      });
    
    
      // 控制台调用此函数触发
      function triggrtCustomEvent() {
        // 触发事件
        const event = new CustomEvent('jameswain', { detail: { tips: '我是事件自定义参数', age: 18} });
        document.dispatchEvent(event);
      }
    </script>
    </body>
    </html>

    运行后果:

通过上述两个例子能够发现,如果要在浏览器内应用公布订阅模式,间接应用浏览器的自定义事件即可,无需引入三方库和本人造轮子了;然而毛病就是移植性差,你只能在反对 CustomEvent 的浏览器中应用,你无奈将你的代码移植到 Node 环境中或者不反对 CustomEvent 的小程序环境中应用。

通过 caniuse 能够发现,各大浏览器厂商从 2011 年开始陆续反对 CustomEvent 到 2013 年根本过后所有支流的浏览器都反对了,前面像 Edge 这种新浏览器从 2015 年推出的第一个版本就反对了;2013~2022 至今 9 年工夫,所以大家能够放心大胆应用了。

公布订阅的利用场景

通过上述的两个小例子,咱们基本上曾经理解了什么是公布订阅模式了;咱们先忘掉那些浅近的概念,先记住公布订阅如何应用,用一句话来记住它的用法:” 先订阅 (监听) 事件,而后公布 (触发) 事件 ” 就这么简略,然而上述的两个小例子并不能齐全展现公布订阅的真正威力,上面我通过一系列的利用场景来展现一下公布订阅的真正威力。

跨 IFrame 传递数据

咱们日常工作中常常会遇到一种状况,须要在一个零碎外面通过 iframe 嵌入其余域名下的某个页面;这时候两个零碎之间的数据通讯,咱们也能够通过公布订阅的形式进行,上面通过一个简略的例子演示一下:

  • 被嵌入的 iframe 页面:

    <!-- example/demo03-iframe/iframe.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title> 我是 iframe 页面 </title>
      <style>
          body {text-align: center;}
          h4 {text-align: center;}
          #text {width: 100%;}
          #send-data {margin-top: 20px;}
      </style>
    </head>
    <body>
      <h4> 我是 iframe</h4>
      <textarea id="text" rows="4" ></textarea>
      <button id="send-data"> 发送数据到主 index.html 页面中 </button>
      <script>
          const text = document.querySelector('#text');
          const btnSendData = document.querySelector('#send-data');
          btnSendData.addEventListener('click', () => {
              const content = text.value;
              top.document.dispatchEvent(new CustomEvent('iframe-data', { detail: { content} }));
          });
      </script>
    </body>
    </html>
  • 主页面:

    <!-- example/demo03-iframe/index.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title> 主页面 </title>
      <style>
          .main {
              width: 100%;
              border: 1px solid black;
              text-align: center;
          }
    
          .iframe {
              text-align: center;
              margin-top: 100px;
              width: 100%;
              border: 1px dotted orange;
          }
          .iframe iframe {
              width: 100%;
              height: 300px;
          }
      </style>
    </head>
    <body>
      <div class="main">
          <h4> 我是主页面 index</h4>
          <p> 以下内容是从 iframe 中通过公布订阅模式传递过去的:</p>
          <div id="content"></div>
      </div>
      <div class="iframe">
          <iframe src="iframe.html"></iframe>
      </div>
      <script>
          const content = document.querySelector('#content');
          document.addEventListener('iframe-data', (e) => {content.innerHTML = e.detail.content;});
      </script>
    </body>
    </html>

    运行后果:

  • 通过上述代码能够发现,咱们通过在 index.html 中订阅一个 iframe-data 自定义事件,而后在 iframe.html 页面中公布 iframe-data 事件通过参数将数据传递给主页面上!
  • 此时有的同学会说,你通过 top.document.querySelector('#content').innerHTML 进行批改元素的内容不就能够了吗?为什么非要应用公布订阅这种模式呢?以后这个例子的场景下是能够的,而且更加简略;然而如果说 index.html 页面是应用 vue 编写的而且 div#content 渲染机会还不确定,iframe.html页面是应用 React 编写的;这个时候你用 top.document.querySelector('#content') 就会找不到元素了,上面通过例子模仿演示一下:

被嵌入的 iframe 页面:

<!-- example/demo04-iframe-async/iframe.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title> 我是 iframe 页面 </title>
    <style>
        body {text-align: center;}
        h4 {text-align: center;}
        #text {width: 100%;}
        #send-data {margin-top: 20px;}
    </style>
</head>
<body>
    <h4> 发布者:我是 iframe,用 React 编写的 </h4>
    <textarea id="text" rows="4" ></textarea>
    <button id="send-data"> 发送数据到主 index.html 页面中 </button>
    <script>
        const text = document.querySelector('#text');
        const btnSendData = document.querySelector('#send-data');
        btnSendData.addEventListener('click', () => {
            const content = text.value;
            top.document.dispatchEvent(new CustomEvent('iframe-data', { detail: { content} }));
        });
    </script>
</body>
</html>

主页面:

<!--  example/demo04-iframe-async/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title> 主页面 </title>
    <style>
        .main {
            width: 100%;
            border: 1px solid black;
            text-align: center;
        }

        .iframe {
            text-align: center;
            margin-top: 100px;
            width: 100%;
            border: 1px dotted orange;
        }
        .iframe iframe {
            width: 100%;
            height: 300px;
        }
    </style>
</head>
<body>
    <div class="main">
        <h4> 订阅者:我是主页面 index,用 Vue 写的,content 元素 10 秒后才会被插入 </h4>
        <p> 以下内容是从 iframe 中通过公布订阅模式传递过去的:</p>
    </div>
    <div class="iframe">
        <iframe src="iframe.html"></iframe>
    </div>
    <script>
        const insertReault = new Promise(resolve => {
            // 10 秒当前才会插入元素
            setTimeout(() => {const content = document.createElement('div');
                content.setAttribute('id', 'content');
                document.querySelector('.main').appendChild(content);
                resolve();}, 10 * 1000);
        });
        document.addEventListener('iframe-data', async (e) => {
            // 等 content 元素插入后,再更新内容
            await insertReault;
            const content = document.querySelector('#content');
            content.innerHTML = e.detail.content;
        });
    </script>
</body>
</html>

运行后果:


感触到公布订阅的威力了吗?发布者 不须要关怀 订阅者 的业务逻辑,老子才不论你的 dom 元素啥时候渲染,我只管触发事件把数据传给你 订阅者 ;至于 订阅者 你怎么应用或啥时候应用这个数据我不论,你什么业务逻辑我也不论;

React 跨组件之间传递数据

  • 在说跨组件传递数据之前,咱们先来看一下上述这张组织架构图,皇帝要下达一条音讯,都是通过丞相层层传递到各级官员的;亭长要上报一条音讯给皇帝,也得层层上报,能力到皇帝耳朵里。这种形式最大的问题消息传递链路很长,音讯可能会被劫持不报,传丢了,上面咱们通过代码来演示一下上述成果:
// example/demo05-passing-data-across-components/src/donot-use-publish-subscribe.jsx
// @ts-nocheck
import React from "react";
import './app.css'

function 亭长(props) {
  return <div className="col">
    亭长{props.shengzhi ? ': 卑职明确' : ''}
    {props.shengzhi ? '': <span className="shangzouze"onClick={() => props. 上奏折(' 皇上,郎中令那个王八蛋抢了我老婆!')}> 上奏折 </span> }
  </div>
}

function 乡(props) {
  return <div>
    <div className="col"> 乡{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 亭长 {...props} />
    </div>
  </div>
}

function 县令(props) {
  return <div>
    <div className="col"> 县令{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 乡 {...props} />
    </div>
  </div>
}

function 郡守(props) {
  return <div>
    <div className="col"> 郡守{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 县令 {...props} />
    </div>
  </div>
}

function 少府(props) {return <div className="col"> 少府{props.shengzhi ? ': 卑职明确' : ''}</div>
}

function 郎中令(props) {
  return <div>
    <div className="col"> 郎中令{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 郡守 {...props} />
    </div>
  </div>
}

function 宗正(props) {return <div className="col"> 宗正{props.shengzhi ? ': 卑职明确' : ''}</div>
}


function 御史大夫(props) {return <div className="col"> 御史大夫{props.shengzhi ? ': 卑职明确' : ''}</div>
}

function 丞相(props) {
  return <div>
    <div className="col"> 丞相{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 少府 {...props} />
      < 郎中令 {...props} />
      < 宗正 {...props} />
    </div>
  </div>
}

function 太尉(props) {return <div className="col"> 太尉{props.shengzhi ? ': 卑职明确' : ''}</div>
}

export default class 皇帝 extends React.Component {
  state = {
    shengzhi: '',
    zhouze: ''
  }

  render() {
    return  <>
      <div className="col huangdi">
        皇帝
        {this.state.zhouze ? ': 大胆郎中令, 你竟敢干出如此伤天害理之事!' : ''}
        <span className="shengzhi" onClick={this. 下圣旨}> 下圣旨 </span>
      </div>
      <div className="row">
        < 御史大夫 {...this.state} />
        < 丞相 {...this.state} 上奏折 ={this. 上奏折} />
        < 太尉 {...this.state} />
      </div>
    </>
  }

  下圣旨 = () => {this.setState({ shengzhi: '朕要大赦天下'});
  }

  上奏折 = (奏折内容) => {this.setState({ zhouze: 奏折内容})
  }
}

📢 留神: 上述代码是为了让大家可能更加清晰的看清楚这个组织架构,所以成心用的中文命名,理论开发中大家千万不要用中文命名挑战 JavaScript 的底线。

​ 通过上述代码咱们能够发现,每个组件之间传递数据都是通过 props 进行的,有很多的 props,凡是有一点忽略,把props 传丢了,那么咱们这个组件就接管不到数据了。

​ 而且依照这种层层上报的机制,在上述现代官员的组织架构层级中就会存在一个问题,亭长 他想跟 皇帝 举报 郎中令 抢了他老婆的这个禽兽行为,层层上报的话,它肯定会通过这个 郎中令 的,到 郎中令 这里只有他不想死,那么他肯定不会在往上传递了。如果此时亭长还想举报郎中令的禽兽行为,应该怎么办呢?还有条路径就是告御状,告御状跟咱们的公布订阅有着殊途同归之妙,相当于建设了一条亭长跟皇帝的专属通道,亭长能够直通皇帝,上面咱们来看代码:

// example/demo05-passing-data-across-components/src/use-publish-subscribe.jsx
// @ts-nocheck
import React from "react";
import './app.css'

function 亭长(props) {
  return <div className="col">
    亭长{props.shengzhi ? ': 卑职明确' : ''}
    {props.shengzhi ? '': <span className="shangzouze"onClick={() => document.dispatchEvent(new CustomEvent(' 告御状 ', { detail: { zhouze:' 皇上,郎中令那个王八蛋抢了我老婆!'}}))}> 上奏折 </span> }
  </div>
}

function 乡(props) {
  return <div>
    <div className="col"> 乡{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 亭长 {...props} />
    </div>
  </div>
}

function 县令(props) {
  return <div>
    <div className="col"> 县令{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 乡 {...props} />
    </div>
  </div>
}

function 郡守(props) {
  return <div>
    <div className="col"> 郡守{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 县令 {...props} />
    </div>
  </div>
}

function 少府(props) {return <div className="col"> 少府{props.shengzhi ? ': 卑职明确' : ''}</div>
}

function 郎中令(props) {
  return <div>
    <div className="col"> 郎中令{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 郡守 {...props} />
    </div>
  </div>
}

function 宗正(props) {return <div className="col"> 宗正{props.shengzhi ? ': 卑职明确' : ''}</div>
}


function 御史大夫(props) {return <div className="col"> 御史大夫{props.shengzhi ? ': 卑职明确' : ''}</div>
}

function 丞相(props) {
  return <div>
    <div className="col"> 丞相{props.shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 少府 {...props} />
      < 郎中令 {...props} />
      < 宗正 {...props} />
    </div>
  </div>
}

function 太尉(props) {return <div className="col"> 太尉{props.shengzhi ? ': 卑职明确' : ''}</div>
}

export default class 皇帝 extends React.Component {
  state = {
    shengzhi: '',
    zhouze: ''
  }

  componentDidMount() {
     // 接管御状音讯
     document.addEventListener('告御状', (e) => {this.setState({ zhouze: e.detail.zhouze})
    });
  }

  render() {
    return  <>
      <div className="col huangdi">
        皇帝
        {this.state.zhouze ? ': 大胆郎中令, 你竟敢干出如此伤天害理之事!' : ''}
        <span className="shengzhi" onClick={this. 下圣旨}> 下圣旨 </span>
      </div>
      <div className="row">
        < 御史大夫 {...this.state} />
        < 丞相 {...this.state} />
        < 太尉 {...this.state} />
      </div>
    </>
  }

  下圣旨 = () => {this.setState({ shengzhi: '朕要大赦天下'});
  }
}

  • 通过上述代码咱们能够发现,应用公布订阅模式亭长间接就把奏折消息传递给皇帝了,没有通过任何的其余组件,这就是公布订阅的威力;

redux 与公布订阅的区别

​ 这里有的同学会问有一个 redux 的货色也能够实现跨组件共享数据,这个跟公布订阅有什么区别呢?其实最实质的区别是 redux 是跨组件 共享数据,把各个组件须要用到的数据,对立放到 store 里,所有组件都有能获取这外面的数据,也都有资格批改这外面的数据。

​ 公布订阅只是传递数据,它并没有存储数据、批改数据的能力,它可能帮你忽视掉各个组件之间的层级关系,间接把数据传递过来;你只有晓得事件名称就能够了,真正存储数据批改数据还是在组件的自身,上面咱们通过代码来看一下用 redux 实现上述组织架构的跨组件共享数据:

// example/demo05-passing-data-across-components/src/use-react-redux.jsx
// @ts-nocheck
import React from "react";
import {Provider, useSelector, connect} from 'react-redux';
import store from './store';
import {reduxShangZhouZe, reduxXiaShengZhi} from './store/action';
import './app.css'

function 亭长() {const shengzhi = useSelector(state => state.shengzhi)
  return <div className="col">
    亭长{shengzhi ? ': 卑职明确' : ''}
    {shengzhi ? '': <span className="shangzouze"onClick={() => reduxShangZhouZe(' 皇上,郎中令那个王八蛋抢了我老婆!')}> 上奏折 </span> }
  </div>
}

function 乡() {const shengzhi = useSelector(state => state.shengzhi)
  return <div>
    <div className="col"> 乡{shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 亭长 />
    </div>
  </div>
}

function 县令() {const shengzhi = useSelector(state => state.shengzhi)
  return <div>
    <div className="col"> 县令{shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 乡 />
    </div>
  </div>
}

function 郡守() {const shengzhi = useSelector(state => state.shengzhi)
  return <div>
    <div className="col"> 郡守{shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 县令 />
    </div>
  </div>
}

function 少府() {const shengzhi = useSelector(state => state.shengzhi)
  return <div className="col"> 少府{shengzhi ? ': 卑职明确' : ''}</div>
}

function 郎中令() {const shengzhi = useSelector(state => state.shengzhi)
  return <div>
    <div className="col"> 郎中令{shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 郡守 />
    </div>
  </div>
}

function 宗正() {const shengzhi = useSelector(state => state.shengzhi)
  return <div className="col"> 宗正{shengzhi ? ': 卑职明确' : ''}</div>
}


function 御史大夫() {const shengzhi = useSelector(state => state.shengzhi)
  return <div className="col"> 御史大夫{shengzhi ? ': 卑职明确' : ''}</div>
}

function 丞相() {const shengzhi = useSelector(state => state.shengzhi)
  return <div>
    <div className="col"> 丞相{shengzhi ? ': 卑职明确' : ''}</div>
    <div className="row">
      < 少府 />
      < 郎中令 />
      < 宗正 />
    </div>
  </div>
}

function 太尉() {const shengzhi = useSelector(state => state.shengzhi)
  return <div className="col"> 太尉{shengzhi ? ': 卑职明确' : ''}</div>
}

@connect(state => state)
class 皇帝 extends React.Component {render() {
    return <>
      <div className="col huangdi">
        皇帝
        {this.props.zhouze ? ': 大胆郎中令, 你竟敢干出如此伤天害理之事!' : ''}
        <span className="shengzhi" onClick={this. 下圣旨}> 下圣旨 </span>
      </div>
      <div className="row">
        < 御史大夫 />
        < 丞相 />
        < 太尉 />
      </div>
    </>
  }

  下圣旨 = () => {reduxXiaShengZhi('朕要大赦天下');
  }
}

export default () => <Provider store={store}>
  < 皇帝 />
</Provider>

运行成果:

​ 从运行成果来看和公布订阅没有任何区别,然而给咱们最大的感触就是代码量减少了很多,尤其是初始化 redux 一下就减少了 5 个文件;如果你素来没有接触过 redux 的话,这对于你来说这确实会有一点小小的难度,更加感觉公布订阅的简略;除了这个变动以外,还有一个变动就是咱们的 state 数据都被放入到了 redux 中,组件之间也不在须要通过 props 层层传递了,要让数据变动的时候,间接批改 redux 中的数据就能够了,组件展现数据也是从 redux 中取,任何组件都能够操作批改 redux 中的数据。

CocosCreator 与 React 跨框架之间传递数据

  • 跨组件传递数据只是公布订阅模式的牛刀小试,跨框架传递数据能力真正展现它的威力,玩过 Cocos 的同学都晓得开发 Cocos 游戏必须要用 CocosCreator 这个软件进行开发,而且它跟 React 这个框架也有十分大的区别,如何做到低耦合、低侵入实现二者的互相数据通讯尤为重要;因为在理论工作中 Cocos 游戏开发和 React 前端开发通常都是两个团队,通常对彼此的框架都不太理解,所以框架之间的相互通信解耦十分重要。
  • 在我的工作生涯中就遇到过这么一种场景,有一个性能是用 cocos 编写实现的(是游戏团队负责开发的他们不懂 React),然而须要嵌入到用 React 写的前端页面(前端团队负责开发页面他们不懂 Cocos),并且须要在前端页面里操控 cocos 外面的一些行为;怎么做到我不须要理解对方的框架和逻辑细节,并且可能操控对方的框架的一些行为?用公布订阅就可能完满解决这个问题,上面我通过一个 demo 来模仿一下这个场景。

  • 上图是 CocosCreator 的开发界面,假如这是游戏团队开发的一个小游戏,他们不懂 React,就会用 CocosCreator 开发游戏,开发好当前构建打包交给前端团队,由前端团队嵌入到他们的前端页面中,然而须要前端页面操作 cocos 外面的 敞开音乐 亢龙有悔 这两个性能;而且 Cocos 游戏和 React 前端页面都是独立仓库、独立部署。

  • 上图是 CocosCreator 打包编译后的源码和资源,能够发现资源和源码都进行了拆分和压缩,可读性很低了;咱们前端要嵌入 Cocos 游戏也是一个编译后的代码,如果从这个切入点动手,显然是不可取的。
  • 如果用公布订阅就很简略了,Cocos 游戏团队开发人员只须要增加两行订阅事件的代码就能够了,代码如下:
// 订阅 亢龙有悔 事件
document.addEventListener('onKangLongYouHui', this.onKangLongYouHui.bind(this));
// 订阅 敞开音乐 事件
document.addEventListener('onCloseMusic', this.onCloseMusic.bind(this));
  • Cocos 开发人员不须要关怀前端如何嵌入他的游戏?如何触发他的事件?他只有监听到两个事件被触发时,去做相干的解决就能够了;
  • 前端开发人员也不须要关怀 Cocos 外部是怎么敞开音乐的?怎么收回 亢龙有悔 技能的;前端只须要晓得当我触发 onCloseMusic 事件就是敞开音乐,触发 onKangLongYouHui 事件就是触发 亢龙有悔 技能,至于你 cocos 外部逻辑如何实现,如何编写代码,我不须要关怀,齐全解耦,上面咱们来看一下代码实现:
  • 首先大家不须要装置 CocosCreator 相干环境和软件,你只须要进入 /blog/design-patterns/publish-subscribe/example/demo06-cocos-across-react/Kingdoms/build/web-mobile 这个目录下启动一个 http 服务即可,例如:

  • 服务端启动当前,能够在浏览器中进行拜访,运行起来的成果如下:

  • Cocos 游戏的 demo 曾经顺利运行起来了,然而咱们无奈对游戏角色进行操作;此时如果咱们我的项目触发 亢龙有悔 技能怎么办?其实能够通过控制台间接触发订阅的事件即可,例如:

  • 能够看到,咱们只有在控制台中公布 (触发)onKangLongYouHui 这个事件就能够操作 Cocos 中的角色收回 亢龙有悔 这个技能,咱们基本不须要懂 Cocos 也不须要理解这个技能是实现逻辑以及它的动画是如何管制的,是不是很解耦?然而,咱们的前端程序员能够通过控制台触发,给用户用的话,咱们就不能这么暴力了,接下来咱们须要通过一个页面将这个游戏嵌入到咱们的页面中,而后通过按钮来触发,上面咱们来看代码:
// example/demo06-cocos-across-react/react-with-cocos/src/app.jsx
import {useCallback} from 'react';
import cs from './app.module.scss';

function App() {const onCloseMusic = useCallback(() => {document.querySelector('iframe').contentWindow.document.dispatchEvent(new CustomEvent('onCloseMusic'));
  }, []);

  const onKangLongYouHui = useCallback(() => {document.querySelector('iframe').contentWindow.document.dispatchEvent(new CustomEvent('onKangLongYouHui'))
    console.log(document.querySelector('iframe').contentWindow.document)
  }, []);

  return (<div className={cs.app}>
      <iframe className={cs.iframe} src='./web-mobile/index.html'></iframe>
      <h1 className={cs.h1}> 上面是 React 框架外面的代码 </h1>
      <div className={cs.tool}>
        <div className={cs.btn} onClick={onCloseMusic}> 敞开音乐 </div>
        <div className={cs.btn} onClick={onKangLongYouHui}> 亢龙有悔 </div>
      </div>
    </div>
  )
}

export default App

  • 通过运行成果能够发现,通过公布订阅咱们曾经胜利实现了在 React 框架里管制了 Cocos 游戏外面的一些行为了,而且咱们都不须要装置 CocosCreator 相干的开发环境。当然这里只是演示 React 和 Cocos 的通信,如果你是 Vue 或者 Angular 都是能够的,这个货色与框架语言无关,外围是思维。

  • 通过公布订阅模式就可能实现 Cocos 游戏逻辑和前端 React 逻辑的解耦,单方只有约定好事件名称的性能就好了,至于对方的逻辑怎么实现,彼此都不须要关怀;Cocos 游戏团队订阅好 onKangLongYouHui 事件了,至于你前端什么时候触发我不论,你爱啥时候触发啥时候触发;我只关注当你触发的时候,我就给你放技能。

客户端(Android)与前端(JavaScript)通信

​ 开发过 hybrid 的同学应该都晓得前端和客户端有一种通信形式叫做 bridge,通过 bridge 能够实现客户端与前端的互相通信,从而扩大前端的能力。其实咱们开发的网页不论是挪动端还是 PC 端,咱们的前端代码都不只是间接运行在零碎上的,PC 端是运行在操作系统上的一个浏览器软件上,挪动端是运行在操作系统的一个 APP 里的 Webview 组件上的,所以咱们前端并没有太多可能间接去操作系统底层的 API,上面咱们通过 2 个 demo 演示如何利用公布订阅实现前端与客户的数据通讯。

​ 在说 Android 客户端与前端通信之前,咱们首先理解一下客户端调用前端的原理,其实 Android 客户端调用前端的形式很简略,是间接通过 webview.loadUrl 函数和 webview.evaluateJavascript 函数,这个跟咱们间接在浏览器控制台里间接调用window.call 函数是一样的原理:

  • 首先咱们来看第一个案例,当 Android 客户端的音量发生变化时和用户按 back 建时须要调用前端的函数告诉前端:
// example/demo08-android-call-javascript/src/case1/index.jsx
import {useState, useEffect} from 'react'
import Other from './other';
import cs from './index.module.scss';

function Case1() {const [text, setText] = useState('');

  useEffect(() => {
    /** 客户端会调用这个函数 */
    window.callJs = function({type, param}) {if (type === 'VOLUME_UP') {console.log('param:', param);
        setText('音量调大了');
      } else if (type === 'BACK') {setText('用户按返回键');
      }
    }
  }, [])

  return (<div className={cs.app}>
      <h3> 我是 app 组件 </h3>
      <div>
        app 组件的内容: {text}
      </div>
      <Other />
    </div>
  )
}

export default Case1
// example/demo08-android-call-javascript/src/case1/other.jsx
import React from 'react';
import cs from './index.module.scss';

export default class Other extends React.PureComponent {
  state = {content: ''}
  
  render() {const { content} = this.state;
    return <div className={cs.other}>
      <h3> 我是 Other 组件 </h3>
      Other 组件的内容: {content}
    </div>
  }

  componentDidMount() {
    /** 客户端会调用这个函数 */
    window.callJs = function({type, param}) {if (type === 'VOLUME_DOWN') {this.setState({ content: '音量调小了'});
      }
    }
  }
}

上述代码中咱们通过在 window 上挂载 callJs 函数给客户端调用,而后传递不同的 type 代表用户的不同操作,并且咱们在两个组件中都在 window 上挂载了 callJs 函数,这样带来的结果就是 Case1 组件里的 callJs 函数会笼罩掉 other.jsx 外面挂载的 callJs 函数,上面咱们看运行成果:

  • 既然咱们曾经晓得前面挂载的 callJs 函数会把后面组件挂载的 callJs 逻辑笼罩掉,那么咱们应该如何解决这样的问题呢?此时又到了公布订阅的表演工夫了,首先咱们利用公布订阅模式,将用户的操作逻辑通过事件收集起来,全局只挂载一次 callJs,上面咱们来看代码:
// example/demo08-android-call-javascript/src/bridge.js
const bridge = {MAP_CALLJS: {},
  /**
   * 监听事件
   */
  on(type, fn) {if (this.MAP_CALLJS[type]) {this.MAP_CALLJS[type].push(fn)
    } else {this.MAP_CALLJS[type] = [fn];
    }
    console.log('this.MAP_CALLJS:', this.MAP_CALLJS);
  },
  /**
   * 触发函数
   * @param {*} type 
   */
  emit(type, param) {if (!this.MAP_CALLJS[type]) return;
    this.MAP_CALLJS[type].forEach(fn => fn(param));
  }
}

window.callJs = ({type, param}) => bridge.emit(type, param);

export default bridge;
  • 应用 bridge 进行订阅事件:
// example/demo08-android-call-javascript/src/case2/index.jsx
import {useState, useEffect} from 'react'
import bridge from '../bridge';
import Other from './other';
import cs from './index.module.scss';

function Case2() {const [text, setText] = useState('');

  useEffect(() => {
    // 订阅相干事件,期待客户端调用
    bridge.on('VOLUME_UP', param => {setText(param.msg);
    });
    // 订阅相干事件,期待客户端调用
    bridge.on('BACK', param => {setText(param.msg);
    });

  }, [])

  return (<div className={cs.app}>
      <h3> 我是 app 组件 </h3>
      <div>
        app 组件的内容: {text}
      </div>
      <Other />
    </div>
  )
}

export default Case2
// example/demo08-android-call-javascript/src/case2/other.jsx
import React from 'react';
import bridge from '../bridge';
import cs from './index.module.scss';

export default class Other extends React.PureComponent {
  state = {content: ''}
  
  render() {const { content} = this.state;
    return <div className={cs.other}>
      <h3> 我是 Other 组件 </h3>
      Other 组件的内容: {content}
    </div>
  }

  componentDidMount() {
    bridge.on('VOLUME_DOWN', param => {this.setState({ content: '音量调小了'});
    });

    bridge.on('VOLUME_UP', param => {this.setState({ content: param.msg});
    });
  }
}

通过上述代码能够发现,咱们通过公布订阅模式将用户的事件操作逻辑收集起来,全局只挂载一次 window.callJs 而后当客户端调用前端的 callJs 函数时,依据事件类型循环调用刚刚前端监听的事件,并且把参数传递过来,上面咱们看执行后果:

通过运行后果发现逻辑曾经不会被笼罩了,而且咱们能够在任意组件中进行监听相干的 bridge 事件。

Node.js 事件触发器

在 node.js 中有一个内置模块叫事件触发器events,这块模块就是一个规范的公布订阅实现,上面咱们通过代码来感受一下它的性能和应用:

// example/demo09-node-event/demo01.mjs
import EventEmitter from 'events';

const emitter = new EventEmitter();

emitter.once('a', e => {console.log('1once.a', e);
});

emitter.on('a', e => {console.log('a1', e);
});

function callback() {console.log('a callback');
}
emitter.on('a', callback);
emitter.on('a', callback);
emitter.once('a', callback);
emitter.off('a', callback);

emitter.once('a', e => {console.log('2once.a', e);
});

emitter.once('a', e => {console.log('3once.a', e);
});

emitter.on('a', e => {console.log('a2', e);
});

emitter.emit('a', 'emit 收回事件 1');
console.log('==================');
emitter.emit('a', 'emit 收回事件 2');
console.log('==================');
emitter.emit('a', 'emit 收回事件 3')
console.log('==================');
emitter.emit('a', 'emit 收回事件 4');
console.log('==================');
emitter.emit('a', 'emit 收回事件 5');
console.log('==================');
emitter.emit('a', 'emit 收回事件 6');

运行后果:

📢 这里应用的 node 版本为 v17.8.0

公布订阅的实现原理

  • 感触完公布订阅的威力和利用场景之后,咱们须要理解一下这个设计模式的实现原理,如何实现一个公布订阅?咱们以 node.js 的事件触发器的性能为参考基准,用 70 行代码来实现一个通用的公布订阅性能。
  • 一个最根本的公布订阅最次要有 3 大性能:

    • 监听(on)=> 收集事件处理函数,进行分类寄存到数组里。
    • 触发(emit)=> 执行收集到的数据处理函数;先进先出,先监听的事件会被先触发。
    • 移除(off)=> 移除收集到的数据处理函数;后进先出,后监听的事件会被先移除。

📢 订阅(监听);公布(触发)

  • 理解了基本功能之后,咱们只有记住一句话就能手推出公布订阅设计模式的实现:” 订阅其实就是将事件处理函数收集起来,进行分类存到数组里;公布其实就是将收集起来的这些事件处理函数进行调用执行 ”,所以这就是为什么要先订阅后公布的起因。
// example/demo09-node-event/events.mjs
/**
 * 事件函数查看
 */
 function checkListener(listener) {if (typeof listener !== 'function') {throw Error('你传入的不是一个函数');
  }
}

class Events {constructor() {
    /** 事件记录 */
    this.MAP_EVENTS = {};}
  /**
   * 订阅事件
   * @param {String} key 事件名称
   * @param {Function} listener 事件回调函数
   * @param {Boolean} once 是否触发一次后移除事件
   */
  on(key, listener, once = false) {checkListener(listener);
    if (this.MAP_EVENTS[key]) {this.MAP_EVENTS[key].push({listener, once});
    } else {this.MAP_EVENTS[key] = [{listener, once}];
    }
    return this.MAP_EVENTS;
  }
  /**
   * 订阅事件 - 只有第一次触发事件时被回调
   * @param {String} key
   * @param {Function} listener
   */
  once(key, listener) {checkListener(listener);
    this.on(key, listener, true);
  }
  /**
   * 勾销订阅
   * @param {String} key 事件名称
   * @param {Function} listener 事件回调函数,匿名函数有效
   */
  off(key, listener) {checkListener(listener);
    const arrEvents = this.MAP_EVENTS[key] || [];
    if (arrEvents.length) {
      // 移除事件是后进先出,后监听的事件会被先移除
      const index = arrEvents.lastIndexOf(e => e.listener === listener);
      this.MAP_EVENTS[key].splice(index, 1);
    } else {console.log(` 你素来都没有订阅过 ${key}事件,所以你勾销个🥚`);
    }
  }
  /**
   * 触发事件
   * @param {String} key 事件名称
   * @param  {...any} args 事件参数
   */
  emit(key, ...args) {const arrEvents = this.MAP_EVENTS[key] || [];
    // 执行事件是先进先出;先监听的事件会被先执行
    arrEvents.forEach(e => e.listener.call(this, ...args));
    // 第一次触发后须要把 once 事件全副移除掉
    this.MAP_EVENTS[key] = arrEvents.filter(e => !e.once);
  }
}

// 默认事件对象,挂载在动态函数上
const defaultEvent = new Events();
Events.on = Events.prototype.on.bind(defaultEvent)
Events.once = Events.prototype.once.bind(defaultEvent)
Events.off = Events.prototype.off.bind(defaultEvent)
Events.emit = Events.prototype.emit.bind(defaultEvent)

export default Events;
  • 而后能够把后面例子中用的 node 内置的 events 换成咱们本人实现的;能够发现失去的后果是截然不同的,这就证实咱们实现的公布订阅是正确的了。
// example/demo09-node-event/demo02.mjs
import EventEmitter from './events.mjs';    // 其余逻辑代码没有变动,只须要把这个换成本人实现公布订阅即可

  • 仔细的同学会发现咱们本人实现的 events.mjs 外面有这么一段代码:
// 默认事件对象,挂载在动态函数上
const defaultEvent = new Events();
Events.on = Events.prototype.on.bind(defaultEvent)
Events.once = Events.prototype.once.bind(defaultEvent)
Events.off = Events.prototype.off.bind(defaultEvent)
Events.emit = Events.prototype.emit.bind(defaultEvent)
  • 这么设计的次要是为了给 懒人 筹备,因为有些人不想 new 间接用,那么你就能够像上面这样用:
// example/demo09-node-event/demo03.mjs
import Events from './events.mjs';

Events.on('coding', (...param) => {console.log('懒人 on coding =>', ...param);
})

Events.once('coding', (...param) => {console.log('懒人 once-coding =>', ...param);
})

Events.emit('coding', '张三');
console.log('============================');
Events.emit('coding', '李四');
console.log('============================');
Events.emit('coding', '王五');
console.log('============================');


// 除了应用应用动态函数,还能够创立一个新的事件对象,事件名称一样也不会抵触,相互隔离
const e1 = new Events();
e1.on('coding', (name) => {console.log('e1-on-coding', name);
});

e1.once('coding', (name) => {console.log('e1-once-coding', name);
});

e1.emit('coding', 'Ice King');
console.log('============================');
e1.emit('coding', 'Simon King');
console.log('============================');
e1.emit('coding', 'Alex King');
console.log('============================');

运行后果:

通过运行后果能够发现,对象之间尽管事件名称一样,然而都是相互隔离的,所以你能够依据你的业务场景创立不同的事件对象,进行分类管理,无效的防止事件名称同名抵触问题。

最初

​ 公布订阅这个设计模式的实现其实并不是特地的简单,我感觉最次要的不是它的代码实现,而是它的设计思维,以及利用场景;只有在对的利用场景下能力施展出它真正的威力;这篇文章我只是模仿演示了一些比拟常见的利用场景,它还有很多变种分支和应用技巧也是十分奇妙的。

正文完
 0