JavaScript : コールバックがないならDOMの変更を監視する

月花です。

いろんなJavaScriptなりjQueryなりのライブラリを入れていると困るのが、ライブラリの動作にコールバックやフックがないことですよね。
なにか要素が追加されるとき、その追加された要素にCSSを適用したいがクラスもなければIDもない、さらにコールバックも無ければお手上げになってしまいます。

そこで、MutationObserver によるDOM変更の監視で解決しました。

たとえば、下記のように、DOMが追加や削除されるが、コールバックもなく追加されたものかどうか判断もできない場合です。
下記の場合ですと自作関数での追加なのでそこを編集してやればいいのですが、ライブラリを利用する場合、これに対応するコードはミニマム化されているので読んだりいじったりは困難です。

See the Pen dqywjm by gekka (@gekka9) on CodePen.

このような場合に追加や削除されたことを掴み、追加された要素やその親要素、兄弟要素に対してなにか処理をしてやりたい場合、MutationObserverによってDOMを監視することが可能です。

もくじ

  • MutationObserver
    • MutationObserver
    • MutationCallback
    • MutationRecord
    • MutationObserverInit
    • 簡単な実装例
  • MutationRecord
    • 追加されたノードに対して、さらに変更を加える
    • 何が起こったかで分岐してみる
    • 監視を停止してみる
  • MutationObserverInit
    • children と descendants の違い
  • 対応ブラウザと代替手段
    • MutationObserver の IE11対応 (※ 2018/08/22 追記)
      • Array.from()
    • Mutation events (非推奨)
    • なぜ Mutation events は非推奨なのか
    • できる限り Mutation events を使わないようにする
  • おわりに

MutationObserver

基本的には、下記のドキュメントにある。
developer.mozilla.org

が、あまり深く書いてくれていないので、下記も併読してもう少し深いところに突っ込んでいく。
DOM Standard


一旦、とりあえず最低限動くところまでを見ていこう。

MutationObserver

この子は、ある指定されたオブジェクトに対してそれ自身やその子孫の監視をする処理の本体で、監視開始や監視終了の関数を持っている。

関数名 引数 -
コンストラク function callback 変更を検知した際に呼び出すコールバック関数を渡す
observe() Node target, MutationObserverInit options 監視を開始する。監視対象と、どのような変更を検知するかの設定を渡す
disconnect() - 監視を停止する
takeRecords() - コールバック関数に渡される変更を返し、そのキューを空にする

MutationCallback

変更が検知されたときに呼び出される関数。

引数 -
MutationRecord 変更があったノードなど、検知した変更に関するデータオブジェクト
MutationObserver 変更を検知した MutationObserverインスタンス自身

MutationRecord

検知後、コールバック関数に渡される、検知した変更に関するデータオブジェクト。
変更された要素自身や変更箇所の隣人要素などが入っている。

MutationObserverInit

observe() にオプションとして渡す、監視対象の設定オブジェクト。
最低限、下記のうち少なくとも1つを明示的に設定する必要がある。

項目名 -
childList boolean trueにすると子ノードを監視対象とする
attributes boolean trueにすると、対象の属性値の変更を監視対象とする
characterData boolean trueにすると、対象の文字データの変更を監視対象とする

ここで、ノードとは簡単に言うとDOM構造の一部のことで、子ノードとは対象の要素の中に入れ子となっているDOM要素を指す。
今回の例でいくと、

<div>
  <ul id="target">
    <li>A</li>
    <li>B</li>
    <li>C</li>
  </ul>
</div>

ulタグを対象とした場合、childList をtrueにすると、ul・liの変更や、新たな子タグの追加を監視する。
一方で、characterData を true にした場合、もともと ul は li しか持っておらず直下にテキストは存在しないため、ノードは持っているがテキストデータは持っていないことになるので、テキストの追加を監視することになる(もちろん、追加した後に変更や削除があった場合、それらも検知される)。
もし childList と characterData を true にした場合は、DOM構造自体の変更だけでなく、ul 直下のテキストも検知することができる(そこにテキストを追加してはいけないが)。

簡単な実装例

上記を踏まえて、一度動かしてみよう。

See the Pen NLWVwR by gekka (@gekka9) on CodePen.

この例では、ulタグの childList を監視する。動作は li の追加と削除しかないため、その2パターンの変更を検知した際に、検知した回数をカウントするようにした。
また、コールバック関数に渡される MutationRecord は見ず、どのような変更であっても同じ動作をするし、disconnect() を呼んでいないため監視しっぱなしである。

次は、コールバック関数で込み入った処理を行うため、MutationRecord を詳しくみていく。

MutationRecord

項目名
type DOMString どの種類の検知に引っかかったかを保持している。例えば、childList の関しに引っかかったなら"childList"、attributesの検知なら"attributes"というように持つため、分岐処理をすることができる
target Node 変更があったノードそのもの。追加・削除されたノードや、変更されたノードを保持している
addedNodes NodeList 追加されたノードのリスト。今回は1つずつの追加のため、配列長は1となる
removedNodes NodeList 削除されたノードのリスト。今回は1つずつの削除のため、配列長は1となる
previousSibling Node 追加/削除があった要素の、1つ前のお隣さんノード。なければnullとなる
nextSibling Node 追加/削除があった要素の、1つ後のお隣さんノード。なければnullとなる
attributeName DOMString 変更があった要素の属性名を返す
attributeNamespace DOMString 変更があった要素の属性の名前空間を返す
oldValue DOMString attributes や characterData の変更の場合、変更前の値を保持している。childList の場合は null になる。使うには MutationObserverInit に設定が必要(後述)

追加されたノードに対して、さらに変更を加える

addedNodes を利用して、追加された要素に対して、追加後にさらに要素を変更してみる。

See the Pen KxKLGv by gekka (@gekka9) on CodePen.

addedNodes はノードのリストなので、配列長は1であるが、forEach を使って処理をしている。
極めて軽い処理なので、一瞬で動作が終わってしまうため、書き換え後のものが挿入されているように見えるが、実は挿入後に書き換えられている。

何が起こったかで分岐してみる

次に、attributes の監視に引っかかるような動作を追加し、分岐をしてみる。

See the Pen WgNqXJ by gekka (@gekka9) on CodePen.


type が childList の場合は、addedNodes や removedNodes の配列長を見ることで追加か削除が判別できる。
attributes では、古い値は oldValue に入っており、新しい値は target と attributeName を使って変更後に取得することができる。
characterData の場合も処理の基礎としては attributes と同様になる。

監視を停止してみる

何かの変更を検知して、一回だけ処理をすればいい場合は、コールバック関数の最後で監視を停止してやることができる。

See the Pen gdbYGO by gekka (@gekka9) on CodePen.


少し前の、変更するたびカウントを増やしていく例で、最初の一回のみで止めるようにした。
コールバック関数には、第二引数に MutationObserver のインスタンスそのものを受け取るため、これの disconnect() を呼んでやることで、コールバック関数内で自分自身を止めることができる。
もちろん、MutationObserver のインスタンスを持っている変数が使える場所である限り、任意の場所で止めることができる。

MutationObserverInit

前章までは、検知対象を絞り込むことに使っていたこのオプション配列だが、もう少し踏み込んだ設定もできる。

項目名
childList boolean 監視対象要素の子ノードを見るかどうか。以下、true で見る
attributes boolean 監視対象要素の属性値を見るかどうか
characterData boolean 監視対象要素のテキストを見るかどうか
subtree boolean 監視対象要素の子孫を見るかどうか。childList の場合は、あくまでも子への追加や削除を見ており、孫世代の要素が変わったとしても検知されない。また、attributes とcharacterData の場合は元々監視対象そのものの値の変更しか見ていない。これを true にしてやると、 childList は孫以降への変更も見るようになり、 attributes の場合は子以降の属性の変更も見るようになる。
attributeOldValue boolean 監視対象の変更前の属性値を MutationRecord に保持するかどうか。前章で使っている。
characterDataOldValue boolean 監視対象の変更前のテキストを MutationRecord に保持するかどうか
attributeFilter DOMStringの配列 attributes が true の場合有効で、どの属性の値の変更を検知するかのホワイトリスト

前章までで実際のコードを掲載していないのは characterData だが、処理の流れとしては attributes と同様なので、省略する。

children と descendants の違い

先程の例で、 subtree を true にせず、監視対象を元々の ul からその親の div に変えてみた。
subtree が孫を見るものかどうかの是非であるのが間違いないなら、これでは検知されないはずだ。

See the Pen zJYgyw by gekka (@gekka9) on CodePen.

一つ前の例のようなメッセージが出ていない。検知されなかったようだ。

ここで、
DOM Standard
には、

childList
Set to true if mutations to target’s children are to be observed.

subtree
Set to true if mutations to not just target, but also target’s descendants are to be observed.

とある。

children とあるのだから、 childList の場合は、subtree を true にしなくても検知するのでは、と考えていた。

children について、

An object that participates in a tree has a parent, which is either null or an object, and has children, which is an ordered set of objects. An object A whose parent is object B is a child of B.

descendants について、

An object A is called a descendant of an object B, if either A is a child of B or A is a child of an object C that is a descendant of B.

🤔?

ひとまず、 MutationObserver 自体の能力については一通り確認することができた。

対応ブラウザと代替手段

Can I use... Support tables for HTML5, CSS3, etc
上記を見ると、FireFoxChrome は早いこと対応しているが、IE は11以降からだった。

では、MutationObserver が使えない場合はどのようにすればよいだろうか。

MutationObserver の IE11対応 (※ 2018/08/22 追記)

IE11では、MutationObserver はサポートされているが、そこで扱うことになる NodeList の forEach() がサポートされていない。
つまり、これらの処理で渡される NodeList に対しては、IE11が扱えるループをしてやらねばならない。
そもそも、NodeList はあくまでも配列風 ( array-like ) オブジェクトであり、 Array を継承していない。
JavaScript における forEach は、制御構文ではなく Array.prototype に実装されている関数に過ぎず、これを継承していないオブジェクトでは呼び出すことができない。

Array.from()

様々なやり方があるが、ここでは単純な方法として、forEach の前で NodeList をArray に変換することにする。
そこで、Array.from() を使う。
単純な例として、第一引数のみを使い、ただ変換するだけの関数として扱う例を載せる。

第一引数では、array-likeオブジェクトもしくは iterableオブジェクトを受け取る。
ここで、NodeList は先述のように array-likeオブジェクトなので、単純に渡すだけで Arrayオブジェクトにしてくれる。
Array になってしまえば Array.prototype が実装されたオブジェクトとなるので、forEach を呼び出すことができる。

observer = new MutationObserver(function(mutations, observer){
    mutations.forEach( function(mutation){
        var arrayNodes = Array.from(mutation.addedNodes);
        arrayNodes.forEach( function(node){
            // 処理
        });
    });
});

MutationObserver の代替手段 Mutation events (非推奨)

Mutation events というものがある。

  • DOMAttrModified
  • DOMAttributeNameChanged
  • DOMCharacterDataModified
  • DOMElementNameChanged
  • DOMNodeInserted
  • DOMNodeInsertedIntoDocument
  • DOMNodeRemoved
  • DOMNodeRemovedFromDocument
  • DOMSubtreeModified

これらは、イベントリスナとして扱うことができ、 addEventListener の第一引数として渡すことで、各々のイベントに応じて発火する。

この中では、

  • DOMNodeInserted
  • DOMNodeRemoved
  • DOMAttrModified
  • DOMCharacterDataModified

これらが、それぞれ先程のコードに該当するだろう。

これらを使って、一番最初の単純な例を作ってみる。

See the Pen WgbebQ by gekka (@gekka9) on CodePen.


なぜ Mutation events は非推奨なのか

それは、これは同期的な処理だからである。
同期的にDOMを監視し、なにか処理を入れるということは他の動作に影響を及ぼしてモッサリしてしまう、ということである。
このため、非同期的な処理である MutationObserver を代わりに使うことが推奨されている。

できる限り Mutation events を使わないようにする

非推奨だからといって、絶対使わないというわけにもいかないのが現実。
でもできるだけ使いたくない。

であれば、 MutationObserver が使えればそれを、使えなければ Mutation events を使うようにしてやればよい。

See the Pen aazomY by gekka (@gekka9) on CodePen.


このように、 typeof MutationObserver が undefined かどうかで、そのブラウザが MutationObserver を実装しているかどうかを判別することができる。

おわりに

MutationObserver は非常に強力な手段であることがわかったが、実際にシステムに組み込んでみると思いの外発火する。
各種統計用の JavaScriptライブラリや reCAPTCHA 、lazyload など、様々な変更がひっきりなしに発火していることに気づく。
やむを得ず body の孫まで監視しているような状態では、とんでもない数になると思われる。
こうした変更すべてを検知していては、さすがに今の優秀なブラウザでも重くなりそうである。

コールバックが実装されていたり、追加要素にはクラスを振ってくれるライブラリをできるだけ使うことを前提に、最終手段として使っていきたい。
使う場合も、できれば subtree は使わず、終わったら disconnect() を呼び出すようにしたいところ。

JavaScript : 複雑なbindのリーディング

月花です。

今回は、複雑なJavaScriptを見つけて、リーディングに時間がかかったので、その仕組みを紹介したいと思います。
単純な修正なのに、2時間くらいかけて読んで30分くらいかけて修正することになったので、次修正するとき忘れないように書いておきます。

バインド時にイベントハンドラの動的生成をして、event や this をさらにその先へ引き回す、というコードです。
主に以下の内容についての解説です。

  • argumentsオブジェクト
  • apply()
  • .on()
$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
    $(document).on("click", ".targetB", returnFunc(fuga));
});

var returnFunc = function () {
    var func = arguments[0];
    
    return function applyImpl () {
        return func.apply(this, new Array(2, 3));
    };
};

var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

上記のコードはどのように動くだろうか。

See the Pen GBbNgN by gekka (@gekka9) on CodePen.

というように動作する。

今回はこのコードを読んでいく。
説明のため、各所に番号を振ったものを以下に掲載する

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge)); // [1] 関数を収めた変数を引数として渡す
    $(document).on("click", ".targetB", returnFunc(fuga));
});
var returnFunc = function () {
    var func = arguments[0]; // [2] argumentsによる引数の受け取り
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // [3] apply()による関数の呼び出し
    };
};

var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

目的

今回のコードリーディングの目的は、このクリックイベントの処理内で新たに event と this を使った追加の処理を挿入することである。
ざっくり読んでもわからなかったので、深く読んでみることになった。

もくじ

  • [1] 関数を収めた変数を引数として渡す
  • [2] argumentsによる引数の受け取り
  • [3] apply()による関数の呼び出し

[1] 関数を収めた変数を引数として渡す

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge)); // [1] 関数を収めた変数を引数として渡す
});

.on() の引数は以下のようになる。

第1引数 イベント種別 今回はclickなのでクリックしたときに発火する。
第2引数 セレクタ(オプション) 今回は targetA というクラスを指定する。※今回はイベントデリゲートの話はしません。
第3引数 データ (オプション) イベントハンドラにデータを渡すことができる。今回は省略されている。
第4引数 ハンドラ イベントハンドラを設定する。

したがって、ここで行っているのは、targetA というクラスを持つ要素に処理をバインドする処理である。

コード中のイベントハンドラとして記述している部分に着目すると、何やら関数を呼び出しているようだ。
この関数の定義を読んでみよう。

var returnFunc = function () {
    var func = arguments[0]; // ← ここはあとで解説する
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // ← ここもあとで解説する
    };
};

着目してほしいのは4行目のreturn文で、この関数は関数を返すものであることがわかる。
この関数によって返却されてきた関数こそがハンドラとしてバインドされることになる。
最終的に、バインドされるのは handler() 。

つまり、このクリックイベントを取得したい場合や、クリックされた要素自体を $(this) として掴むには、ここを以下のように修正すればよいことになる。

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
});
var returnFunc = function () {
    var func = arguments[0];
    
    return function handler (event) { // ← イベントを引数として取得
        piyo = $(this); // ← クリックされた要素自体の取得
        return func.apply(this, new Array(2, 3)); 
    };
};

なんと、$(this) が関数を生成する関数の、しかもreturn文の中に入ってしまった
これは良くない。なにか他の書き方ができないか、もっと読んでみよう。

ちなみに、よくある簡素な書き方では、

$(function() {
    $(document).on("click", ".targetA",function(event){
        piyo = $(this);
    });
});

というように、ネストの中で記述することができる。

[2] argumentsによる引数の受け取り

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
});
var returnFunc = function () {
    var func = arguments[0]; // [2] argumentsによる引数の受け取り
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // ← ここはあとで解説する
    };
};

これは関数を定義して変数に収める記述だが、よく見ると関数定義に引数がないにも関わらず呼び出し時に変数を渡している。
そしてこれは、渡された変数をちゃんと使っている。

argumentsオブジェクト

JavaScriptの関数では、argumentsというローカル変数が提供される。
この中には様々なデータが入っているが、単純に連番の添字を見た場合には引数が入っている。
argumentsの中には定義した引数の数に関わらず(たとえゼロでも)呼びだれたときに渡されたものをそのまま保持している。
なのでたとえば、

for (  var i = 0;  i < arguments.length;  i++  ) {
    console.log ( arguments[ i ] );
}

というようにループすることができる。
おそらくこれが本来の使い方で、決して引数を定義する必要がないわけではない

arguments の中には他にも、呼び出し元関数を収めた arguments.caller や 呼び出し先関数(つまり arguments がローカル変数として所属する関数自身)を収めた arguments.callee などがあった
現在は caller は削除、 callee は非推奨となっている。
callee は無名関数での再帰処理に使うことができた。本来名前がないから再帰ができない無名関数だが、arguments.callee にはきちんと収まるため、arguments.callee を擬似的な識別子として扱えることを利用できた。

しかし、この arguments はスマートな処理追加の解法とは関係なさそうだ。

[3] apply()による関数の呼び出し

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
    $(document).on("click", ".targetB", returnFunc(fuga));
});
var returnFunc = function () {
    var func = arguments[0];
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // [3] apply()による関数の呼び出し
    };
};

var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

JavaScript での関数の呼び出しは、 単純に関数名に引数を渡すように書くことで呼び出すが、それよりアドバンスな呼び出しをする関数が提供されている。

apply(thisArg, argsArray)

apply関数は、JavaScriptの関数オブジェクトが持っている関数なので、関数なら基本的に使用できる。

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge)); // hoge() を渡している
});
var returnFunc = function () {
    var func = arguments[0]; // hoge() を受け取っている
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // hoge() をapply() を使って呼び出す
    };
};

// このコードでの呼び出し先関数 hoge() の定義
var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
第1引数 this ここで渡したオブジェクトが呼び出し先の関数内で this を使って参照するオブジェクトとなる。
第2引数 引数の配列 呼び出し先関数の引数を収めた配列

これは、たとえばすでに配列になっているものを分解して引数として渡す手間を省いたり、既存の関数を非破壊的に上書きすることに使われる。

今回の記述を追いかけてみると、[1] の章で書いたとおり、handler() 関数はハンドラなので、 this にはクリックされた要素自体が入っていることが想定されるものである。
なので、handler() にとっての this をapply() を使って呼び出し先に渡してやることで、呼び出し先にとっての this が handler() にとっての this と同じものになる。
第2引数として渡した配列長2の配列はそのまま呼び出し先関数の第1引数・第2引数となり、キモとなる演算(ここでは加算や乗算)が行われる。

これらを踏まえると、

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
});
var returnFunc = function () {
    var func = arguments[0];
    
    return function handler (event) { 
        piyo = $(this); // ← これと
        return func.apply(this, new Array(2, 3)); 
    };
};
var hoge = function(foo, bar){
    piyo = $(this);// ← これ
    result = foo + bar;
    alert(result);
}

このコメントアウトした2行の $(this) は等価になる。
なので、[1] の章 で書いたように $(this) を handler() の中で使うように書くよりもむしろ handler() では書かずに、呼び出し先変数で書くべきだろう。

しかし、この修正では残念ながら event を呼び出し先変数に渡すことはできなかった。
正確には、 apply() の引数として追加してやればよいが、そのためには既存の関数に引数の定義を追加しなくてはならない。
解決したのは this を使う処理部分だけだが、 event を使った処理は大したことない上に仮に呼ばれすぎでも問題はない処理なので、これで良いこととしてしまった。

まとめ

結局このコードはなんだったのか

$(function() {
    // returnFunc() に hoge() や fuga() という関数を渡す
    // returnFunc() は渡された引数を使ってハンドラを動的に生成し、返す
    // 返ってきた関数はハンドラとしてバインドされ
    $(document).on("click", ".targetA", returnFunc(hoge));
    $(document).on("click", ".targetB", returnFunc(fuga));
});

// ハンドラを作成して返す関数の定義
var returnFunc = function () {
    // argumentsによる引数(中身は関数)の受け取り
    var func = arguments[0]; 
    
    // ハンドラ関数を生成して返却
    return function handler () {
        // apply() を使って呼び出すことで、呼び出し先に this を引き回す
        return func.apply(this, new Array(2, 3));
    };
};

// キモとなる演算を行う関数の定義
var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

とこのように、バインド時にイベントハンドラの動的生成をして event や this をさらにその先へ引き回す、というコードであることがわかった。

結局私がしたい修正は、 event や this を使って追加の処理をしたかっただけだったので、[3] の章のように呼び出し先関数を修正することで対応した。

コードリーディングを終えて

確かに動的にハンドラを生成するためだったり重複コードをまとめるのにはこの手法は有効であると感じたが、読むのに時間がかかってしまった。

なかでも this の引き回しは修正の難易度が高かった。
最初に修正したのは handler() の中だったが、異なる呼び出し先をこれから呼ぼうという場所で書くは憚られ、結果として呼び出し先変数でも書けることがわかったので良かったが、気づいていなければかき混ぜてしまう修正になりかねなかった。

確かにスマートな書き方だがメンテナンスが楽じゃないという点では微妙で、私がPMならレビューで弾く、そんなコードだった。

jQuery : セレクタフィルタの様々な罠

月花です。

チェックボックスjQueryでわちゃわちゃしていたときにハマったのでメモします。
jQuery、卒業したいですね。

前提

こういうフォームがあります。

<!doctype html>
    <body>
        <form name="hoge">
            <label><input type="checkbox" name="fruits[]" value="4種盛り合わせ">4種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="3種盛り合わせ">3種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="レモン">レモン</label>
            <label><input type="checkbox" name="fruits[]" value="オレンジ">オレンジ</label>
            <label><input type="checkbox" name="fruits[]" value="ぶどう">ぶどう</label>
            <label><input type="checkbox" name="fruits[]" value="なし">なし</label>
        </form>
    </body>
</html>

果物を選択するチェックボックスが6つ並んでいます。

問題はこの「盛り合わせ」という2つの選択肢で、これに関しての仕様として、
1. チェックされたら、盛り合わせ以外の他の選択肢のチェックを外す
2. チェックされている間は、盛り合わせ以外の他の選択肢をチェックできない
3. ただし盛り合わせ同士は相互にチェックし合えるが、片方を押したら片方が非選択に戻る
ということになっています。

しかもなんとこのチェックボックス、上位フレームワークの影響でid属性を付けることはできません。
さらに、value値はなんと日本語です。勘弁してほしい。

セレクタだけで直接1要素を拾うことができず、value値が日本語で比較もあんまりしたくないので、フィルタを駆使して、上記を実装します。

:first は使えない

仕様の、3 が邪魔をして、:firstだけでなく、2番目を取る必要があります。
2番目をビシっと抜き出す、:second があれば良かったんですが、無いので、筆者は:nth-child()を使うことにしました。

わかってる人はアレっと思うでしょうが、後述ですし、そういう人にとって為になる情報はこの記事にはありません。

:nth-child() も使えない

とりあえずjQueryで1番目のチェックボックスが拾えれば、2番目も同様に拾えるし、なんとかなるやろってことで、やってみます。

$('input[name^=fruits]:nth-child(1)').change( function(){
    alert($(this).val());
});

しかしこれはうまく行きません。
1番目だけでなく、どこを押しても押した要素が拾われてしまいます。

これは、inputタグは全てそれぞれのlabelタグの中に入っているためです。
:nth-child() は親要素の長子を1とし、次子を2というように指定するのですが、それぞれのinputはそれぞれのlabelの長子なので、全てのinputが適合してしまい、全てのinputにchangeイベントがバインドされる結果となりました。

:nth-of-type()も、名前にchildが入っていないのですが、これは子要素のうち、このフィルタがくっついているセレクタで絞り込むため、長子しかいないこの場合は結局同じです。

:eq()を使う

筆者は、:eq()を知りませんでした。
正確には、知ってはいたんですけど、「eqがイコールの省略なのはわかるけど、何と何を比べてんねん」と思って詳しく知りませんでした。

:eq() は、そのセレクタの、親子関係を気にせず、ページの上から順番に振った通し番号を比較します。
要するに、DOM配列のインデックスです。なので、 :first と :eq(0) は等価になります。

ここで筆者は、しばらく nth-child() について考えていたため、インデックスの振り方でハマりました。
:nth-child() は、何番目の子供か、という通し番号です。0番目の子供というのは存在しないため、1から始まります。
:eq() はインデックスです。添字なので0から始まります。 めんどくさいですね。

これを使って、

$(function(){
    $('input[name^=fruits]:eq(0)').change( function(){
        if ($(this).is(':checked')) {
            //チェックされたとき
        } else {
            //チェックが外されたとき
        }
    });
    $('input[name^=fruits]:eq(1)').change( function(){
        if ($(this).is(':checked')) {
            //チェックされたとき
        } else {
            //チェックが外されたとき
        }
    });
});

これで、1番目の要素と2番目の要素だけにchangeイベントがバインドできました。

以外 をセレクタで表現する

1番目と2番目の要素だけにchangeイベントをバインドすることに成功しました。
その中で、問題の選択肢以外、というセレクタが必要になってきます。

これは、 :not() で解決します。

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0))').attr('disabled', 'disabled');
    }
});

このように、さきほどの :eq() を :not() の引数の中に押し込めてやります。
これで、フィルタを反転させて、押した要素以外の全てのチェックを外し、選択不可にしました。
しかし、問題の選択肢2つの間では自由に選択することができなければいけません。

そこで、 :not() を複数のフィルタで指定します。

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }
});

:not() の引数は、カンマ区切りでOR条件で複数のフィルタを指定できます。
この結果、1番目と2番目以外を選択不可にするということが実現できました。
ちなみに、半角スペースで区切ると、AND条件になります。

今回は、1番目でもなく かつ 2番目でもない、となればよいのでOR条件を使って ¬(1∨2) とし、実質 (¬1)∧(¬2) なので実現できています。
ド・モルガンの法則です。頻繁に使うんですが、普段は名前を意識しないので、名前を言われると懐かしさを感じますよね。

チェックが外されたときの挙動を追加して

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});

2番目の要素にも同様にバインド

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});
$('input[name^=fruits]:eq(1)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(1))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});

共通化できそうなんですけど、共通化すると同じ要素に2度バインドしたりしてしまうのでうまく行きませんでした。
もう少しスマートにできる気はするんですけど。

最終的にはこう

<!doctype html>
    <head>
        <script
            src="https://code.jquery.com/jquery-3.1.1.slim.min.js"
            integrity="sha256-/SIrNqv8h6QGKDuNoLGA4iret+kyesCkHGzVUUV0shc="
            crossorigin="anonymous"></script>
        <script type="text/javascript">
            $(function(){
                $('input[name^=fruits]:eq(0)').change( function(){
                    if ($(this).is(':checked')) {
                        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
                        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
                    } else {
                        $('input[name^=fruits]').removeAttr('disabled');
                    }
                });
                $('input[name^=fruits]:eq(1)').change( function(){
                    if ($(this).is(':checked')) {
                        $('input[name^=fruits]:not(:eq(1))').prop('checked', false);
                        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
                    } else {
                        $('input[name^=fruits]').removeAttr('disabled');
                    }
                });
            });
        </script>
    </head>
    <body>
        <form name="hoge">
            <label><input type="checkbox" name="fruits[]" value="4種盛り合わせ">4種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="3種盛り合わせ">3種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="レモン">レモン</label>
            <label><input type="checkbox" name="fruits[]" value="オレンジ">オレンジ</label>
            <label><input type="checkbox" name="fruits[]" value="ぶどう">ぶどう</label>
            <label><input type="checkbox" name="fruits[]" value="なし">なし</label>
        </form>
    </body>
</html>

終わりです。

ついでに色々調べてたんですけど、セレクタって結構やるやつですね。
jQueryの是非はともかく、ちゃんと論理演算を考えながら実装すれば、行数とバインドの節約が捗りそうです。
とはいっても、仕様変更・追加で継ぎ足し継ぎ足しのjQuery、まとめて改善しようにもすでに稼働しているので余計なテストが・・・。厄介すぎる・・・。

参考文献

www.jquerystudy.info