什么是设计模式?

  • 设计模式(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-nocheckimport 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-nocheckimport 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-nocheckimport 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.jsximport { 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.jsximport { 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.jsximport 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.jsconst 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.jsximport { 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.jsximport 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.mjsimport 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.mjsimport 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.mjsimport 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('============================');

运行后果:

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

最初

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