この投稿は別サイトからのクロス投稿です(クロス元:https://qiita.com/okayu_tar_gz/i...

追記(2019-04-08)
最適化しました。詳しくは一番下をご覧ください

はじめに

Webの役割というものが日に日に増している昨今、いかがお過ごしでしょうか。
私は、正しく綺麗なHTMLを書こうとするも途中で我に返り途方に暮れお蔵入りになるという残念な日々を過ごしています。
最近のWebページは、HTMLのみならずJavaScriptも盛んに使ってユーザーによる操作にレスポンスを返していて、正直、私には複雑すぎて追えません。
(2019年春アニメ公式ホームページを後学のために読み解こうとしましたがあまりにも難解で諦めてしまいました)

さて、昨今のリッチな対話型Webページを利用させていただくにあたり、少しばかり困ったことがあったのでここに残します。
それは、「VimiumなどのHit-a-Hint系拡張機能で、インタラクティブコンテンツなどの操作が可能な要素を適切に取得することができない場合がある」ということです。

詳しく書いていきます。

Hit-a-Hintとは

Hit-a-HintというのはWebブラウザの拡張機能の一種です。
あらかじめ設定しておいたホットキーを押下することで、Webページ上の操作可能な要素(リンクやボタンなど)の付近にそれぞれ一意の「ヒント」と呼ばれる文字列が表示されます。そのヒントをタイプすることでその要素にフォーカスを移したり、選択したり、クリックしたりといった動作を行うことができます。

つまり、キータイプだけでマウスで行うような操作ができるようにする拡張機能です。便利なため、多くの拡張機能に取り入れられた機能です。

インタラクティブコンテンツとは

HTML要素を特徴ごとに分けた、「コンテンツカテゴリー」の一種です。
インタラクティブコンテンツ(Interactive Content)、「対話型コンテンツ」と訳すそれには、ユーザーに入力をさせるために固有にデザインされた要素が含まれます。

詳細: 「コンテンツカテゴリー - ウェブデベロッパーガイド | MDN」の該当部分

リンクやボタンなど、Hit-a-Hintが取得するような要素のカテゴリーのことです。

実際問題、インタラクティブコンテンツに属する要素だけが操作可能だとは限らない

JavaScriptでイベントリスナーを登録してしまえば、どんなものも操作可能になります。
それは適切なコーディングでは決してありませんが、Webには溢れかえっています。(ただのdivにクリックイベントを紐付けるというような)

「困ったこと」は、この不適切なコーディングに起因します。

そんななか拡張機能はどのようにして操作可能な要素を取得しているのでしょうか。

Vimiumの要素取得方法

Vimiumという拡張機能の場合、タグ名や属性値などを使っているようです(該当するソースコード)。

「適切なコーディングがなされていれば」、それで全く問題ありません。

問題への対処

ユーザーは諦めるしか無いのか

Vimiumでは、[role="button"]などのような属性値も使って操作可能かを判断しています。
そこで、よく使うお気に入りのサイトなどでは、ユーザースクリプトを使ってtargetElem.setAttribute('role', 'button');のようなJavaScriptを実行するようにしておくことで、Vimiumでも操作が可能になります。

Web開発者はどうすればよいのか

お願いです。[role="button"]だけでもいいですからつけてください。

本題

つまらない愚痴はこのあたりで終えまして、技術の力でWebページの「インタラクティブコンテンツに属する要素を含む全ての操作可能な要素」を適切に取得する方法を模索していこうと思います。

なお、以後以下のようにします。

  • 対話型要素: 操作が可能な要素
    • デフォルト対話型要素: インタラクティブコンテンツに属する操作が可能な要素
    • カスタム対話型要素: インタラクティブコンテンツに属さないが操作が可能な要素

(ひどいネーミングセンスですね。良い案があればぜひ教えてください)

HTMLを読み解く方法

ここでは、問題点は一旦無視し、Vimium同様に、タグ名や属性値をもとに操作可能かを判断してみます。

デフォルト対話型要素とされる要素は何でしょうか。

  • WHATWGの定義
  • MDNの定義
  • role属性・disabled属性・ aria-disabled属性・hidden属性・CSSなど

を考えた結果、以下のようになりました。

const interactiveContentSelectors = [  
    '[onclick]',  
    '[role="button" i]',  
    '[role="checkbox" i]',  
    '[role="combobox" i]',  
    '[role="link" i]',  
    '[role="menuitem" i]',  
    '[role="menuitemcheckbox" i]',  
    '[role="menuitemradio" i]',  
    '[role="radio" i]',  
    '[role="radiogroup" i]',  
    '[role="searchbox" i]',  
    '[role="separator" i]',  
    '[role="slider" i]',  
    '[role="spinbutton" i]',  
    '[role="switch" i]',  
    '[role="tab" i]',  
    '[role="textbox" i]',  
    'a[href]',  
    'audio[controls]',  
    'button:not([disabled]):not([aria-disabled="true" i])',  
    'details',  
    'embed',  
    'iframe',  
    'img[usemap]',  
    'input:not([type="hidden" i]):not([disabled]):not([aria-disabled="true" i])',  
    'label',  
    'menu:not([type="toolbar" i])',  
    'object[usemap]',  
    'select:not([disabled]):not([aria-disabled="true" i])',  
    'textarea:not([disabled]):not([aria-disabled="true" i])',  
    'video[controls]',  
];  

const interactiveContents = [...document.querySelectorAll(interactiveContentSelectors.join(', '))]  
    // isNotHiddenAndIsNotAriaHidden  
    // (その要素 or その要素の祖先)が`hidden`属性で非表示にされていない && `aria-hidden`で非表示だと伝えられていない場合: `true`  
    .filter((elem) => {  
        return elem.closest([  
            '[hidden]',  
            '[aria-hidden="true" i]',  
        ].join(', ')) === null;  
    })  
    // isDisplayed  
    // その要素のサイズが縦横ともに0pxより大きい場合: `true`  
    .filter((elem) => {  
        const rect = elem.getBoundingClientRect();  

        return rect.width > 0 && rect.height > 0;  
    })  
    ;  

なお、私はWAI-ARIAには詳しくないので間違っているかもしれません。一応以下文書に軽く目を通しはましたが。


Vimiumの問題点ですでに述べたとおり、残念ながらこの方法は完璧ではありません。
カスタム対話型要素にはほぼ対応できていないからです。

しかしながら、要素にJavaScriptイベントがあたっているかどうかを調べる方法は残念ながら今の所ないようで、とても難しいです。
(もしあったら、[...document.all].filter(elem => elem.hasEventListener('click'))みたいな感じにできるのですが…)

CSSを読み解く方法

人間は、操作可能であるかをどのようにして判断しているのでしょうか?

「見た目がそれっぽい」から? 話題の機械学習とか使えばできるのかもしれませんが、私は機械学習は知らないので語れません。
「カーソルが変わる」から? それだ!

const interactiveContents = [...document.querySelectorAll('body *')]  
    // isNotHiddenAndIsNotAriaHidden  
    // (その要素 or その要素の祖先)が`hidden`属性で非表示にされていない && `aria-hidden`で非表示だと伝えられていない場合: `true`  
    .filter((elem) => {  
        return elem.closest([  
            '[hidden]',  
            '[aria-hidden="true" i]',  
        ].join(', ')) === null;  
    })  
    // isDisplayed  
    // その要素のサイズが縦横ともに0pxより大きい場合: `true`  
    .filter((elem) => {  
        const rect = elem.getBoundingClientRect();  
        return rect.width > 0 && rect.height > 0;  
    })  
    // isChangedCursorStyle  
    // カーソルのスタイルが変わっている)場合: `true`  
    .filter((elem) => {  
        const computedStyle = getComputedStyle(elem);  
        return computedStyle.cursor !== 'auto';  
    })  
    ;  

この方法なら、カスタム対話型要素も取得できます。

しかし、この方法には致命的な欠陥が存在します。
それは、「操作可能な要素の子孫も取得されてしまう」ということです。

<a>hoge<span>huga</span>piyo</a>  

上記のようなHTMLでは、spanまでも取得してしまうのです。

確かにspanも操作可能ではあり、spanを操作すれば親のaもバブリングでイベントが発火しますが、欲しいのはspanでは決してありません。

組み合わせた方法

2つの方法を組み合わせてみます。

const interactiveContentSelectors = [  
    '[onclick]',  
    '[role="button" i]',  
    '[role="checkbox" i]',  
    '[role="combobox" i]',  
    '[role="link" i]',  
    '[role="menuitem" i]',  
    '[role="menuitemcheckbox" i]',  
    '[role="menuitemradio" i]',  
    '[role="radio" i]',  
    '[role="radiogroup" i]',  
    '[role="searchbox" i]',  
    '[role="separator" i]',  
    '[role="slider" i]',  
    '[role="spinbutton" i]',  
    '[role="switch" i]',  
    '[role="tab" i]',  
    '[role="textbox" i]',  
    'a[href]',  
    'audio[controls]',  
    'button:not([disabled]):not([aria-disabled="true" i])',  
    'details',  
    'embed',  
    'iframe',  
    'img[usemap]',  
    'input:not([type="hidden" i]):not([disabled]):not([aria-disabled="true" i])',  
    'label',  
    'menu:not([type="toolbar" i])',  
    'object[usemap]',  
    'select:not([disabled]):not([aria-disabled="true" i])',  
    'textarea:not([disabled]):not([aria-disabled="true" i])',  
    'video[controls]',  
].join(', ');  

const interactiveContents =  
    [...document.querySelectorAll('body *')]  
        .filter((elem) => {  
            return (  
                // その要素がデフォルト対話型要素であった場合: `true`  
                elem.matches(interactiveContentSelectors)  
                ||  
                // その要素のカーソルのスタイルが通常状態ではない場合: `true`  
                getComputedStyle(elem).cursor !== 'auto'  
            );  
        })  
        // isNotHiddenAndIsNotAriaHidden  
        // (その要素 or その要素の祖先)が`hidden`属性で非表示にされていない && `aria-hidden`で非表示だと伝えられていない場合: `true`  
        .filter((elem) => {  
            return elem.closest([  
                '[hidden]',  
                '[aria-hidden="true" i]',  
            ].join(', ')) === null;  
        })  
        // isDisplayed  
        // その要素のサイズが縦横ともに0pxより大きい場合: `true`  
        .filter((elem) => {  
            const rect = elem.getBoundingClientRect();  

            return rect.width > 0 && rect.height > 0;  
        })  
        // 入れ子になっていた場合の処理  
        .reduce((acc, cur) => {  
            // デフォルト対話型要素の場合は加える  
            if (cur.matches(interactiveContentSelectors)) {  
                acc.push(cur);  
            } else {  
                let ancestor = cur.parentElement;  

                // 先祖をさかのぼり、すべてが、対話型要素ではない場合は加える  
                while (ancestor !== document.body && !acc.includes(ancestor)) {  
                    ancestor = ancestor.parentElement;  
                }  
                if (ancestor === document.body) {  
                    acc.push(cur);  
                }  
            }  

            return acc;  
        }, [])  
    ;  

上のものでは、

  • すべてのデフォルト対話型要素
  • 先祖が対話型要素でないカスタム対話型要素

を取得することができます。

今の所、これが一番漏れが少なくノイズも少ないです。

おわりに

実行速度が残念なので、そこをどうにかしたいです…。

追記: 最適化

実行速度が残念な感じだったのは、getComputedStyle()で時間がかかりすぎていることが原因でした。
というわけで、アルゴリズムは基本的にそのままに最適化をしました。格段と速くなりました。
(ついでに変数名もマシにして、全体を関数として定義しました)

const getOperableElements = (parent = 'body *') => {  
    const interactiveElementsSelector = [  
        '[onclick]',  
        '[role="button" i]',  
        '[role="checkbox" i]',  
        '[role="combobox" i]',  
        '[role="link" i]',  
        '[role="menuitem" i]',  
        '[role="menuitemcheckbox" i]',  
        '[role="menuitemradio" i]',  
        '[role="radio" i]',  
        '[role="radiogroup" i]',  
        '[role="searchbox" i]',  
        '[role="separator" i]',  
        '[role="slider" i]',  
        '[role="spinbutton" i]',  
        '[role="switch" i]',  
        '[role="tab" i]',  
        '[role="textbox" i]',  
        'a[href]',  
        'audio[controls]',  
        'button:not([disabled]):not([aria-disabled="true" i])',  
        'details',  
        'embed',  
        'iframe',  
        'img[usemap]',  
        'input:not([type="hidden" i]):not([disabled]):not([aria-disabled="true" i])',  
        'label',  
        'menu:not([type="toolbar" i])',  
        'object[usemap]',  
        'select:not([disabled]):not([aria-disabled="true" i])',  
        'textarea:not([disabled]):not([aria-disabled="true" i])',  
        'video[controls]',  
    ].join(', ');  

    const hiddenElementsSelector = [  
        '[hidden]',  
        '[aria-hidden="true" i]',  
    ].join(', ');  

    const operableElements =  
        [...document.querySelectorAll(parent)]  
            .reduce((acc, cur) => {  
                /* フィルタリング */  

                // (その要素 or その要素の祖先)が非表示な場合: 即`return`  
                if (cur.closest(hiddenElementsSelector) !== null) return acc;  

                // !(その要素のサイズが縦横ともに0pxより大きい)場合: 即`return`  
                const rect = cur.getBoundingClientRect();  
                if (!(rect.width > 0 && rect.height > 0)) return acc;  

                /* /フィルタリング */  

                /* 追加処理 */  

                // その要素がデフォルト対話型要素であった場合: 追加して`return`  
                if (cur.matches(interactiveElementsSelector)) {  
                    acc.push(cur);  
                    return acc;  
                }  

                // その要素のカーソルのスタイルが通常状態ではない場合:  
                if (getComputedStyle(cur).cursor !== 'auto') {  
                    let ancestor = cur.parentElement;  

                    // 先祖をさかのぼり、すべてが、対話型要素ではない場合: 追加して`return`  
                    while (ancestor !== document.body && !acc.includes(ancestor)) {  
                        ancestor = ancestor.parentElement;  
                    }  
                    if (ancestor === document.body) {  
                        acc.push(cur);  
                        return acc;  
                    }  
                }  

                /* /追加処理 */  

                return acc;  
            }, [])  
        ;  

    return operableElements;  
};  
// 使い方  
console.log(getOperableElements());  
// こんなふうにもできるよ!  
console.log(getOperableElements('main'));  

これで実用的になりましたかね。

最後までお付き合いいただきありがとうございました。

関連記事

この記事へのコメント

まだコメントはありません
+1
55
@okayuの大して技術的ではないブログ
このエントリーをはてなブックマークに追加