最近面试季,有很多同窗在面试前端的时候遇到一些问题来问个人的时候,才发现以前博客里面介绍的关于前端架构有些东西没有说清楚,特别是关于如何使用事件巧妙地进行模块的解耦。特地写这篇博客详细说一下。html
原本想一篇写完,可是写着写着发现废话比较多。决定开个系列分2~3篇来写,本文主要介绍:前端
这算是(一),接下来的(二)会介绍事件在前端游戏开发中的应用。node
(了解的同窗能够直接跳过这一节)git
在构建前端应用的时候免不了要和事件打交道,有些同窗可能以为事件不就是鼠标点击执行特定的函数之类的吗?程序员
此“事件”非彼“事件”。这里的“事件”,其实是指“观察者模式(Observer Pattern)”在前端的一种呈现方式。所谓观察者模式能够类比博客“订阅/推送”,你经过RSS订阅了某个博客,那么这个博客有新的博文就会自动推送给你;当你退订阅这个博客,那么就不会再推送给你。github
用JavaScript代码能够怎么表示这么一个场景?面试
var blog = new Blog; // 假设已有一个Blog类实现subscribe、publish、unsubscribe方法 var readerFunc1 = function(blogContent) { console.log(blogContent + " will be shown here."); } var readerFunc2 = function(blogContent) { console.log(blogContent + " will be shown here, too."); } blog.subscribe(readerFunc1); // 读者1订阅博客 blog.subscribe(readerFunc2); // 读者2订阅博客 blog.publish("This is blog content."); // 发布博客内容,上面的两个读者的函数都会被调用 blog.unsubscribe(readerFunc1); // 读者1取消订阅 blog.publish("This is another blog content."); // readerFunc1函数再也不调用,readerFunc2继续调用
能够把上面的“新文章”当作是一个事件,“订阅文章”则是“监听”这个事件,“发布新文章”则是“触发”这个事件,“取消订阅文章”就是“取消监听”“新文章”这个事件。假如“监听”用on
来表示,“触发”用emit
来表示,“取消监听”用off
来表示,那么上面的代码能够从新表示为:ajax
var blog = new Blog; // 假设已有一个Blog类实现on、emit、off方法 var readerFunc1 = function(blogContent) { console.log(blogContent + " will be shown here."); } var readerFunc2 = function(blogContent) { console.log(blogContent + " will be shown here, too."); } blog.on("new post", readerFunc1); // 读者1监听事件 blog.on("new post", readerFunc2); // 读者2监听事件 blog.emit("new post", "This is blog content."); // 发布博客内容,触发事件,上面的两个读者的函数都会被调用 blog.off("new post", readerFunc1); // 读者1取消监听事件 blog.emit("new post", "This is another blog content."); // readerFunc1函数再也不调用,readerFunc2继续调用
这就是前端中观察者模式的一种具体的表现,使用on
来监听特定的事件,emit
触发特定的事件,off
取消监听特定的事件。再举一个场景“小猫听到小狗叫就会跑”:npm
var dog = new Dog; var cat = new Cat; dog.on("park", function() { cat.run(); }); dog.emit("park");
巧妙利用观察者模式可让前端应用开发耦合性变得更加低,开发效率更高。可能说“变得更有趣”会显得有点不专业,但确实会变得有趣。编程
上面可能比较疑惑的一个点就是,on
、emit
、off
函数该怎么实现?
若是要本身实现一遍也不很复杂:每一个“事件名”对应的就是一个函数数组,每次on
某个事件的时候就是把函数压到对应的函数数组当中;每次emit
的时候至关于把事件名对应的函数数组遍历一遍进行调用;每次off
的时候把目标函数从数组当中剔除。这里有个简单的实现,有兴趣的能够了解一下。
重复发明轮子的事情就不要作了,其实现成有不少JavaScript的事件库,直接拿来用就行了。比较流行、经常使用的就是EventEmitter2这个事件库,本文主要使用这个库来展开对观察者模式在前端应用中的讨论。但实际上,你可使用任何本身构建的或者第三方的事件库来实践本文所说起的应用方式。
EventEmitter原本是Node.js中自带的events
模块中的一个类,可见Node.js文档。可供开发者自定义事件,后来有人把它从新实现了一遍,优化了实现方式,提升了性能,新增了一些方便的API,这就是EventEmitter2。固然,后来陆续出现了EventEmitter3,EventEmitter4。可见没有女友的程序员也是比较无聊地只好重复发明和优化轮子。
EventEmitter2能够供浏览器、或者Node.js使用。安装过程和API就不在这里累述,参照官方文档便可。使用Browserify或者Node.js能够很是方便地引用EvenEmitter2,只须要require便可。示例:
var EventEmitter2 = require('eventemitter2').EventEmitter2; var emitter = new EventEmitter2; emitter.on("Hello World", function() { console.log("Somebody said: Hello world."); }); emitter.emit("Hello World"); // 输出 Somebody said: Hello world.
但在实际应用当中,不多单纯EventEmitter直接实例化来使用。比较多的应用场景是,为某些已有的类添加事件的功能。如上面的第一章中的“小猫听到小狗叫就会跑”的例子,Cat
和Dog
类自己就有本身的类属性、方法,须要的是为已有的Cat、Dog添加事件功能。这里就须要让EventEmitter做为其余类的父类进行继承。
var EventEmitter2 = require('eventemitter2').EventEmitter2; // Cat子类继承父类构造字 function Cat() { EventEmitter2.apply(this); // Cat 构造子,属性初始化等 } // 原型继承 Cat.prototype = Object.create(EventEmitter2.prototype); Cat.prototype.constructor = Cat; // Cat类方法 Cat.prototype.run = function () { console.log("This cat is running..."); } var cat = new Cat; console.assert(typeof cat.on == "function"); // => true console.assert(typeof cat.run == "function"); // => true
很棒是吧,这样就能够即有EventEmitter2的原型方法,也能够定义Cat自身的方法。
这一点都不棒!每次定义一个类都要从新写一堆啰嗦的东西,下面作个继承的改进:构建一个函数,只须要传入已经定义好的类就能够在不影响类原有功能的状况下,让其拥有EventEmitter2的功能:
// Function `eventify`: Making a class get power of EventEmitter2! // @copyright: Livoras // @date: 2015/3/27 // All rights reserve! function eventify(klass) { if (klass.prototype instanceof EventEmitter2) { console.warn("Class has been eventified!"); return klass; } function Tempt() { klass.apply(this, arguments); EventEmitter2.call(this); }; function Tempt2() {}; Tempt2.prototype = Object.create(EventEmitter2.prototype) Tempt2.prototype.constructor = EventEmitter2; var temptProp = Object.create(Tempt2.prototype); var klassProp = klass.prototype; for (var attr in klassProp) { temptProp[attr] = klassProp[attr]; } Tempt.prototype = temptProp; Tempt.prototype.constructor = klass; return Tempt; }
上面的代码能够的实现原理在这里并不重要的,有兴趣的能够接下来的博客,会继续讨论eventify
的实现原理。在这里只须要知道,有了eventify就能够很方便的给类添加EventEmitter2的功能,使用方法以下:
// Dog类的构造函数和原型方法定义 function Dog(name) { this.name = name; } Dog.prototype.park = function() { console.log(this.name + " parking...."); } // 使Dog具备EventEmitter2功能 Dog = eventify(Dog); var dog = new Dog("Jerry"); dog.on("somebody is coming", function() { dog.park(); }) dog.emit("somebody is coming") // 输出 Jerry is parking....
如上面的代码,如今没有必要为Dog类从新书写类继承代码,只须要按正常的方式定义好Dog类,而后传入eventify函数便可使Dog获取EventEmitter2的功能。本文接下来的讨论会持续使用eventify
函数。
注意:若是你正在使用CoffeeScript,直接使用CoffeeScript自带的extends进行类继承便可,无需上面复杂的代码:
class Dog extends EventEmitter2 constructor: -> super.apply @, arguments park: -> // ...
当一个前端应用足够复杂的时候,每每须要对应用进行“组件化”。所谓组件化,就是把一个大的应用拆分红多个小的应用。每一个“应用”具备本身独特的结构和内容、样式和业务逻辑,这些小的应用称为“组件”(Component)。组件的复用性通常很强,是DRY原则的应用典范,多个组件的嵌套、组合,构建成了一个完成而复杂的应用。
举我在《一种SPA(单页面应用)架构》举过的例子,博客的评论功能组件:
这个评论组件的功能大概如此:可显示多条评论(comment);每条评论多条有本身的回复(reply);评论或者回复都会显示有用户头像,鼠标放到用户头像上会显示该用户的信息(相似微博的功能)。
这里能够把这个功能分好几个组件:
组件这样的关系能够用树的结构来表示:
这里要注意的是组件之间的关系通常有两种:嵌套和组合。嵌套,如,每一个commentBox有comment和user-info-card,comment和user-info-card是嵌套在commentBox当中的,因此这两个组件和commentBox之间都是嵌套的关系;组合,comment和user-info-card都是做为commentBox的子组件存在,他们两个互为兄弟,是组合的关系。处理组件之间的嵌套和组合关系是架构层面须要解决的最重要的问题之一,不在本文讨论范围内,故不累述。但接下来咱们讨论的“组件之间以事件的形式进行消息传递”和这些组件之间的关系密切相关。
当开始按照上面的设计进行组件化的时候,咱们首先要作的是为每一个组件构建一个超类,全部的组件都应该继承这个超类:
component.js:
eventify = require("./eventify.js"); // Component构造函数 function Component(parent) { this.$el = $("...") this.parent = parent; } // Component原型方法 Component.prototype.init = function () {/* ... */}; module.exports = eventify(Component);
这里为了方便起见,Component基本什么内容都没有,几乎只是一个“空”的类,而它经过eventify函数得到了“超能力”,因此继承Component的类一样具备事件的功能。
注意Component构造函数,每一个Component在示例化的时候应该传入一个它所属的父组件的实例parent
,接下来会看到,组件之间的消息通讯能够经过这个实例来完成。而$el
能够看做是该组件所负责的HTML元素。
如今把注意力放在commentsBox、comment、user-info-card三个组件上,暂且忽略reply。
目前要实现的功能是:鼠标放到comment组件的用户头像上,就会显示用户信息。要把这个功能完成大概是这么一个事件流程:comment组件监听用户鼠标放在头像上的交互事件,而后经过this.parent
向父组件(commentsBox)传递该事件(this.parent
就是commentsBox),commentsBox获取到该事件之后触发一个事件给user-info-card,user-info-card能够经过this.parent
监听到该事件,显示用户信息。
// comment-component.js // 从Component类中继承得到Comment类 // ... // 原型方法 Comment.prototype.init = function () { var that = this; this.$el.find("div.avatar").on("mouseover", function () { // 这里的that.parent至关于父组件CommentsBox,在Comment组件被示例化的时候传入 that.parent.emit("comment:user-mouse-on-avatar", this.userId); }) }
上述代码为当用户把鼠标放到用户头像的时候触发一个事件comment:user-mouse-on-avatar
,这里须要注意的是,经过组件名:事件名
给这样的事件命名方式能够区分事件的来源组件或目标组件,是一种比较好的编程习惯。
// comments-box-component.js // 从Component类中继承得到CommentsBox类 // ... // 原型方法 CommentsBox.prototype.init = function() { var that = this; this.on("comment:user-mouse-on-avatar", function (userId) { // 这里接受到来自Comment组件的事件 that.emit("user-info-card:show-user-info", userId); // 把这个事件传递给user-info-card组件 }); }
上述代码中commentsBox获取到来自comment组件的comment:user-mouse-on-avatar
事件,因为user-info-card组件也同时拥有commentsBox的实例,因此commentsBox能够经过触发自身的事件user-info-card:show-user-info
来给user-info-card组件传递事件。再一次注意这里到事件名,user-info-card:
前缀说明这个事件是由user-info-card组件所接收的。
// user-info-card-component.js // 从Component类中继承得到UserInfoCard类 // ... // 原型方法 UserInfoCard.prototype.init = function () { var that = this; this.parent.on("user-info-card:show-user-info", function (userId) { $.ajax({ // 经过ajax获取用户数据 url: "/users/" + userId, method: "GET" }).success(function(data) { that.render(data); // 渲染用户信息 that.show(); // 显示信息 }) }); }
上述代码中,user-info-card组件经过this.parent
获取到来自其父组件(也就是commentsBox)的事件user-info-card:show-user-info
,而且获得所传入的用户id;而后经过ajax向服务器发送用户id,请求用户数据渲染页面数据而后显示。
这样,消息就经过事件机制从comment到达了它的父组件commentsBox,而后经过commentsBox到达它的兄弟组件user-info-card。完成了一个父子组件之间、兄弟之间的消息传递过程:
按照这种消息传递方式的事件有四种类型:
每一个组件只要hold住一个其父组件实例,就能够完成:
两个功能。
如今能够把注意力放到reply组件上,reply做为comment的子组件,负责显示这条评论下的回复。相似地,它有回复者的用户头像,鼠标放上去之后也能够显示用户的信息。
user-info-card是commentsBox的子组件,reply是comment的子组件;user-info-card和reply既不是父子也不是兄弟节点关系,reply没法按照上面的方式比较直接地把事件传递给它;reply的鼠标放到头像上的事件须要先传递给其父组件comment,而后通过comment传递给commentsBox,最后经过commentsBox传递给user-info-card组件。以下:
看起来好像比较麻烦,reply离它根组件commentsBox高度为二,嵌套了两层。假设reply嵌套了不少层,那么事件的传递就相似浏览器的事件冒泡同样,须要先冒泡到根节点commentsBox,再由跟节点把事件发送给user-info-card。
若是要真的这样写会带来至关大的维护成本,当组件之间的交互方式更改了甚至只是单单修改了事件名,中间层的负责事件转发的都须要把代码从新修改。并且,这些负责转发的组件须要维护和本身业务逻辑并不相关的逻辑,违反单一职责原则。
解决这个问题的方式就是:提供一个组件之间共享的事件对象eventbus,能够负责跨组件之间的事件传递。全部的组件均可以从这个这个总线上触发事件,也能够从这个总线上监听事件。
commom/eventbus.js
var EventEmitter2 = require('eventemitter2').EventEmitter2; module.exports = new EventEmitter2; // eventbus是一个简单的EventEmitter2对象
那么reply组件和user-info-card就能够经过eventbus进行之间的信息交换,在reply组件中:
// reply.js // 从Component类中继承得到Reply类 // ... eventbus = require("../common/eventbus.js"); // 原型方法 Reply.prototype.init = function () { var that = this; this.$el.find("div.avatar").on("mouseover", function () { // 触发eventbus上的事件user-info-card:show-user-info eventbus.emit("user-info-card:show-user-info", that.userId); }) }
在user-info-card组件当中:
// user-info-card-component.js // 从Component类中继承得到UserInfoCard类 // ... eventbus = require("../common/eventbus.js"); // 原型方法 UserInfoCard.prototype.init = function () { var that = this; // 原来的逻辑不变 this.parent.on("user-info-card:show-user-info", getUserInfoAndShow); // 新增获取eventbus的事件 eventbus.on("user-info-card:show-user-info", getUserInfoAndShow); function getUserInfoAndShow (userId) { $.ajax({ // 经过ajax获取用户数据 url: "/users/" + userId, method: "GET" }).success(function(data) { that.render(data); // 渲染用户信息 that.show(); // 显示信息 }); }; };
这样user-info-card和就跨越了组件嵌套组合的关系,直接进行组件之间的信息事件的交互。
那么问题就来了:
若是全部的组件都往eventbus上post事件,那么就会带来eventbus上事件的维护的困难;咱们能够类比一下JavaScript里面的全局变量,假如全部函数都不本身维护局部变量,而都使用全局变量会带来什么问题?想一想都以为可怕。既然这个事件交互只是在局部组件之间交互的,那么就尽可能不要把它post到eventbus,eventbus上的事件应该尽可能少,越少越好。
那何时使用eventbus上的事件?这里给出一个原则:当组件嵌套了三层以上的时候,带来局部事件转发维护困难的时候,就能够考虑祭出eventbus。而在实际当中不多会出现三层事件传播这种状况,也可保持eventbus事件的简洁。(按照这个原则上面的reply是不须要使用eventbus的,可是为了阐述eventbus而使用,这点要注意。)
(系列待续)
做者:戴嘉华
转载请注明出处,保留 原文连接 和做者信息