イベント配信の役割
さまざまな対話型関数をページに追加する場合、私たちがよく知っている最も簡単な方法は、イベントをページ要素にバインドし、イベント処理関数で実行したいアクションを実行することです。次のようなコード:
element.onclick = function(event){ // Do anything. };
実行したいアクションが複雑でない場合は、実際の論理関数のコードをここに配置できます。将来変更する必要がある場合は、このイベント処理関数の場所に移動して変更してください。
さらに、コードを適切に再利用するために、論理関数の一部を関数に分割することもできます。
element.onclick = function(event){ // Other code here. doSomethingElse(); };
ここでの関数 doSomethingElse に相当する関数は他の場所でも使用される可能性があるため、このように分割します。さらに、座標を設定するような関数 (関数が setPosition と呼ばれると仮定します) がある場合もあり、ブラウザーのイベント オブジェクトのイベントによって提供されるポインターの位置などの情報も使用する必要があります:
element.onclick = function(event){ // Other code here. doSomethingElse(); setPosition(event.clientX, event.clientY); };
ここで推奨されないアプローチは、イベント オブジェクトを setPosition に直接渡すことです。これは、論理関数とイベント リスニングの責任を分離することが推奨されるためです。イベント処理関数自体がブラウザ イベント オブジェクトにアクセスできるようにするだけで、コードの結合が減り、独立したテストとメンテナンスが容易になります。
それでは、関数がますます複雑になったらどうなるでしょうか?前のアプローチに従うと、次のようになります:
element.onclick = function(event){ doMission1(); doMission2(event.clientX, event.clientY); doMission3(); // ... doMissionXX(); };
このように使用しても問題ありませんが、この場合、実際にはよりエレガントな書き方を検討できます:
element.onclick = function(event){ amplify.publish( "aya:clicked", { x: event.clientX, y: event.clientY }); };
この形式はイベント配布です。ここでのイベントはブラウザーのネイティブ イベント (イベント オブジェクト) を参照するのではなく、論理レベルのカスタム イベントを参照することに注意してください。上の aya:clicked は、何気なく書いたカスタム イベント名です (本当に?)。
明らかにこれで終わりではありません。これまでの複雑な機能を完了するには、カスタム イベントを実行する内容に関連付ける必要があります。
amplify.subscribe( "aya:clicked", doMission1); // ... amplify.subscribe( "aya:clicked", doMission2); // ...
また戻ってきたような?確かにそうですが、効果はあります。一方で、ブラウザーのネイティブ イベントのリスニングは分離され、固定化されているため、将来論理機能が変更される場合、たとえば、いくつかの機能が削減される場合は、カスタム イベントの関連するコード部分を削除するだけで済みます。ネイティブ イベントについてはもう心配する必要はありません。一方で、論理的な機能の調整は、コードの任意の位置でサブスクライブすることで機能を追加できるようになり、分類管理(イベント名のカスタマイズ)も自分で行うことができるようになりました。
簡単に言うと、イベント分散は、カスタム イベントの冗長性レイヤーを追加することでコード モジュール間の結合を減らし (単純なロジック関数しかない場合、冗長であると思われるでしょう)、ロジック関数をより明確にし、整理され、その後のメンテナンスが容易になります。
ちょっと待って、何度も海外旅行に行ったことがある私の目の前にいるあの有名人は何をしているのですか?
さて、いよいよこれを紹介する時が来ました。
AmplifyJS
イベントの配布には、特定のメソッドを実装する必要があります。イベント配布の設計パターンの 1 つはパブリッシュ/サブスクライブです。
AmplifyJS は、主に Ajax リクエスト、データ ストレージ、パブリッシュ/サブスクライブの 3 つの機能を提供するシンプルな JavaScript ライブラリです (それぞれ独立して使用できます)。このうちパブリッシュ/サブスクライブはコア機能であり、対応する名前は amplify.core です。
amplify.core はパブリッシュ/サブスクライブ設計パターンの簡潔かつ明確な実装であり、コメントを含めて合計 100 行以上あります。 Amplify のソース コードを読むと、パブリッシュ/サブスクライブ デザイン パターンの実装方法をより深く理解できるようになります。
コードの概要
amplify.core のソースコードの全体構造は次のとおりです:
(function( global, undefined ) { var slice = [].slice, subscriptions = {}; var amplify = global.amplify = { publish: function( topic ) { // ... }, subscribe: function( topic, context, callback, priority ) { // ... }, unsubscribe: function( topic, context, callback ) { // ... } }; }( this ) );
ご覧のとおり、amplify は amplify という名前のグローバル変数を (global の属性として) 定義します。この変数には、publish、subscribe、unsubscribe の 3 つのメソッドがあります。さらに、サブスクリプションは、パブリッシュ/サブスクライブ モードに関係するすべてのカスタム イベント名と関連関数を保存するローカル変数として機能します。
公開
publish は、カスタム イベント名 (または単にトピックと呼ばれる) を指定する必要があります。呼び出し後、特定のトピックに関連付けられたすべての関数が順番に呼び出されます。
publish: function( topic ) { // [1] if ( typeof topic !== "string" ) { throw new Error( "You must provide a valid topic to publish." ); } // [2] var args = slice.call( arguments, 1 ), topicSubscriptions, subscription, length, i = 0, ret; if ( !subscriptions[ topic ] ) { return true; } // [3] topicSubscriptions = subscriptions[ topic ].slice(); for ( length = topicSubscriptions.length; i < length; i++ ) { subscription = topicSubscriptions[ i ]; ret = subscription.callback.apply( subscription.context, args ); if ( ret === false ) { break; } } return ret !== false; },
[1],参数topic必须要求是字符串,否则抛出一个错误。
[2],args将取得除topic之外的其他所有传递给publish函数的参数,并以数组形式保存。如果对应topic在subscriptions中没有找到,则直接返回。
[3],topicSubscriptions作为一个数组,取得某一个topic下的所有关联元素,其中每一个元素都包括callback及context两部分。然后,遍历元素,调用每一个关联元素的callback,同时带入元素的context和前面的额外参数args。如果任意一个关联元素的回调函数返回false,则停止运行其他的并返回false。
subscribe
订阅,如这个词自己的含义那样(就像订本杂志什么的),是建立topic和callback的关联的步骤。比较特别的是,amplify在这里还加入了priority(优先级)的概念,优先级的值越小,优先级越高,默认是10。优先级高的callback,将会在publish的时候,被先调用。这个顺序的原理可以从前面的publish的源码中看到,其实就是预先按照优先级从高到低依次排列好了某一topic的所有关联元素。
subscribe: function( topic, context, callback, priority ) { if ( typeof topic !== "string" ) { throw new Error( "You must provide a valid topic to create a subscription." ); } // [1] if ( arguments.length === 3 && typeof callback === "number" ) { priority = callback; callback = context; context = null; } if ( arguments.length === 2 ) { callback = context; context = null; } priority = priority || 10; // [2] var topicIndex = 0, topics = topic.split( /\s/ ), topicLength = topics.length, added; for ( ; topicIndex < topicLength; topicIndex++ ) { topic = topics[ topicIndex ]; added = false; if ( !subscriptions[ topic ] ) { subscriptions[ topic ] = []; } // [3] var i = subscriptions[ topic ].length - 1, subscriptionInfo = { callback: callback, context: context, priority: priority }; // [4] for ( ; i >= 0; i-- ) { if ( subscriptions[ topic ][ i ].priority <= priority ) { subscriptions[ topic ].splice( i + 1, 0, subscriptionInfo ); added = true; break; } } // [5] if ( !added ) { subscriptions[ topic ].unshift( subscriptionInfo ); } } return callback; },
[1],要理解这一部分,请看amplify提供的API示意:
amplify.subscribe( string topic, function callback ) amplify.subscribe( string topic, object context, function callback ) amplify.subscribe( string topic, function callback, number priority ) amplify.subscribe( string topic, object context, function callback, number priority )
可以看到,amplify允许多种参数形式,而当参数数目和类型不同的时候,位于特定位置的参数可能会被当做不同的内容。这也在其他很多JavaScript库中可以见到。像这样,通过参数数目和类型的判断,就可以做到这种多参数形式的设计。
[2],订阅的时候,topic是允许空格的,空白符将被当做分隔符,认为是将一个callback关联到多个topic上,所以会使用一个循环。added用作标识符,表明新加入的这个元素是否已经添加到数组内,初始为false。
[3],每一个callback的保存,实际是一个对象,除callback外还带上了context(默认为null)和priority。
[4],这个循环是在根据priority的值,找到关联元素应处的位置。任何topic的关联元素都是从无到有,且依照priority数值从小到大排列(已排序的)。因此,在比较的时候,是先假设新加入的元素的priority数值较大(优先级低),从数组尾端向前比较,只要原数组中有关联元素的priority数值比新加入元素的小,循环就可以中断,且可以确定地用数组的splice方法将新加入的元素添加在此。如果循环一直运行到完毕,则可以确定新加入的元素的priority数值是最小的,此时added将保持为初始值false。
[5],如果到这个位置,元素还没有被添加,那么执行添加,切可以确定元素应该位于数组的最前面(或者是第一个元素)。
unsubscribe
虽然发布和订阅是最主要的,但也会有需要退订的时候(杂志不想看了果断退!)。所以,还会需要一个unsubscribe。
unsubscribe: function( topic, context, callback ) { if ( typeof topic !== "string" ) { throw new Error( "You must provide a valid topic to remove a subscription." ); } if ( arguments.length === 2 ) { callback = context; context = null; } if ( !subscriptions[ topic ] ) { return; } var length = subscriptions[ topic ].length, i = 0; for ( ; i < length; i++ ) { if ( subscriptions[ topic ][ i ].callback === callback ) { if ( !context || subscriptions[ topic ][ i ].context === context ) { subscriptions[ topic ].splice( i, 1 ); // Adjust counter and length for removed item i--; length--; } } } }
读过前面的源码后,这部分看起来就很容易理解了。根据指定的topic遍历关联元素,找到callback一致的,然后删除它。由于使用的是splice方法,会直接修改原始数组,因此需要手工对i和length再做一次调整。
Amplify使用示例
官方提供的其中一个使用示例是:
amplify.subscribe( "dataexample", function( data ) { alert( data.foo ); // bar }); //... amplify.publish( "dataexample", { foo: "bar" } );
结合前面的源码部分,是否对发布/订阅这一设计模式有了更明确的体会呢?
补充说明
你可能也注意到了,AmplifyJS所实现的典型的发布/订阅是同步的(synchronous)。也就是说,在运行amplify.publish(topic)的时候,是会没有任何延迟地把某一个topic附带的所有回调,全部都运行一遍。
结语
Pub/Sub是一个比较容易理解的设计模式,但非常有用,可以应对大型应用的复杂逻辑。本文简析的AmplifyJS是我觉得写得比较有章法而且简明切题(针对单一功能)的JavaScript库,所以在此分享给大家。