id:koharusugiura のブログ

フロントエンドエンジニアの id:koharusugiura による個人ブログです。

フロントエンドで知っておきたい要素指定の考え方

この記事は当時わたしが在籍していた株式会社アニメイトラボが公開していたブログであるアニメイトラボ開発者ブログで書かれたものです。株式会社アニメイトラボは2018年に親会社である株式会社アニメイトに権利義務を継承し、解散となりました。それに伴ってアニメイトラボ開発者ブログの記事も失われてしまったため、わたしの個人ブログであるこちらに転記しています。


はじめまして、アニメイトラボのフロントエンドエンジニア id:koharusugiura です。

今回は XPath について書きたいと思います。

HTML 文書から任意の要素を取得する際、多くの場合 jQuerySelectors API などでセレクターを使いますよね?

セレクターは細かい条件の指定はできませんが、簡単に要素が取得でます。しかし細かい条件によって要素の取得を行うことができません。

細かい条件で要素の取得を行う場合は、XPath を使うことで開発が捗ります。

XPathはセレクターよりも少々複雑な仕様となっていますので、まずは比較をしながらご説明したいと思います。

この記事は animateLAB Advent Calendar 2015 23 日目の記事です。

※ この記事でサンプルとして記載しているコードは特筆のない限り ECMAScript 3.1 に準拠しています。

JavaScript で XPath を扱う

JavaScript で XPath を扱う場合は DOM Level 3 XPath を使います。

DOM Level 3 XPath は複雑な条件指定ができますが、Selectors API と比べると少々難解な仕様です。なので、この記事では以下のような DOM Level 3 XPath をラップする関数を用います。

function $x(xpath, context) {
  context = context || document;
  var result = [];
  var xpathEvaluator = new XPathEvaluator();
  var xpathExpression = xpathEvaluator.createExpression(xpath, null);
  var domObject = xpathExpression.evaluate(context, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
  var element;
  while ((element = domObject.iterateNext())) {
    result.push(element);
  }
  element = undefined;
  return result;
}

タグの名前から任意の要素を取得

// Selectors
var elements = document.querySelectorAll('a');

// XPath
var elements = $x('//a');

XPath は要素の取得を行う際に、ルート要素からたどります。そのため、// を前置します。

// は木構造 (データ構造) の省略を意味します。今回の場合はルート要素からの省略になり、HTML 文書中に含まれる全ての子要素を走査します。

ID から任意の要素を取得

// Selectors
var element = document.querySelector('#element-id');

// XPath
var element = $x('id("element-id")')[0];

XPath には、 JavaScript のように関数があります。

id 関数は引数に与えられた ID を持つ要素を取得します。

任意の親要素を直上に持つ子要素を取得

// Selectors
var elements = document.querySelectorAll('p > *')

// XPath
var elements = $x('//p/*');

XPath は各要素を / で区切ることによって木構造を表現できます。

p 要素の子要素を全て取得する場合はセレクターと同様に * を用います。

任意の親要素を持つ子要素を取得

// Selectors
var elements = document.querySelectorAll('p *');

// XPath
var elements = $x('//p//*');

前述した通り、// は木構造の省略を意味します。これはルート要素からではなく、木構造の途中からでも使えます。

特定の属性を持つ任意の要素を取得

// Selectors
var elements = document.querySelectorAll('a[href="https://example.com/"]');

// XPath
var elements = $x('//a[@href = "https://example.com/]');

XPath では [] で囲むことにより細かい条件を指定できます。

@attribute-name で属性の名前が取れ、=!= で任意の値と合致、もしくは合致しないかを比較します。

クラス名から任意の要素を取得

HTML の class 属性 は複数の値が持てる属性となっています。そのため、セレクターよりも汎用性の高い XPath では条件の指定が複雑です。

// Selectors
var elements = document.querySelectorAll('.element-class-name');

// XPath
var elements = $x('//*[contains(concat(" ", normalize-space(@class), " "), " element-class-name ")]');

normalize-space 関数は引数に指定された文字列の前後の空白を除去し、また連続された空白を一つにし、空白の正規化を行います。正規化を行なった class 属性に concat 関数を使い前後に空白を付与します。

上記の処理を行うことで class 属性が class-name-1 class-name-2 class-name-3 という文字列になります。そこで、contains 関数で前後に空白をつけたクラス名が含まれているか、確認できます。

少し複雑ではありますが、HTML 文書を扱う上では頻出する条件なので覚えてておくと良いでしょう。

XPathの強み

実は、これまでのものはセレクターでもできる条件でした。ここからは、XPathを使うメリットをお伝えします。

セレクターでは要素は名前が完全一致していないと取得ができません。ですが、XPath では local-name 関数で要素の名前を得られるので、要素の名前を元にした条件の指定ができます。

// XPath
var elements = $x('//*[string-length(local-name()) = 2 and starts-with(local-name(), "h")]');

string-length 関数で要素の名前が 2 文字であるかを確認し、starts-with 関数で h という文字列で要素の名前がはじまっているかを確認しています。

上のような XPath 式で h1h2 といった見出し要素の取得ができます。

任意の条件に合致する要素の子要素を取得できる

セレクターでは任意の子要素を持つ親要素を取得することはできません。ですが、XPath はセレクターとは違い [ と ] で囲む条件に子要素を含むことができるため、取得することが可能です。

<div class="wrapper">
  <div class="content">
    <h2 class="title">headline</h2>
    <p>paragraph.</p>
  </div>
</div>
// XPath
$x('//div[contains(concat(" ", normalize-space(@class), " "), " content ") and h2[contains(concat(" ", normalize-space(@class), " "), " title ")]]');

最後に

DOM Level 3 XPath はこの記事を執筆時点 (2015 年 12 月 25 日) での最新版である Internet Explorer 11 を含め、Internet Explorer では使えません。ですが、それ以外のウェブブラウザーでは Firefox、Safari、Google Chrome、Microsoft Edge と多くの環境で使えます。

もちろん、Internet Explorer で使えないというのは、決して無視できる要素ではありません。

ですが、XPath のように複雑な条件指定は魅力的です。対象とする環境を絞れるのであればセレクターではなく XPath を使うことを検討してみてはいかがでしょうか。

フロントエンドがサーバー負荷を抑えるためにできること

この記事は当時わたしが在籍していた株式会社アニメイトラボが公開していたブログであるアニメイトラボ開発者ブログで書かれたものです。株式会社アニメイトラボは2018年に親会社である株式会社アニメイトに権利義務を継承し、解散となりました。それに伴ってアニメイトラボ開発者ブログの記事も失われてしまったため、わたしの個人ブログであるこちらに転記しています。


はじめまして、アニメイトラボのフロントエンドエンジニア id:koharusugiura です。

フロントエンドエンジニアとして働いていると、いかにサーバーサイドに負担を掛けずに処理を行うかについて考えることも多いと思います。

そこで今回は、サーバーに画像の転送を行う前にクライアント側で画像加工をする話について書きます。

この記事は animateLAB Advent Calendar 2015 15 日目の記事です。

JavaScript で画像処理を行う

ウェブアプリケーションで画像ファイルの加工が要件にある場合、サーバー側で画像加工を処理するケースが大半だと思います。

しかし、データ通信のことを考えると、最適な考え方とは言えない気がしています。

近年、日本のインターネット回線の速度は大きく向上しているとはいえ、モバイルデータ回線はまだまだ速度的に完璧とは言えません。

また AWS などの PaaS、SaaS におけるデータ通信にかかる料金も決して無視できるようなものではありません。ですので、サーバーへの転送は極力最小限にとどめるべきです。

サーバー側での画像ファイルの加工を行わず、クライアント側で加工しようとする場合はHTML5から採用された canvas 要素 を使うのが最も手軽です。

例えばウェブブラウザー上に表示されたウェブページで、ドラッグ & ドロップされた画像ファイルにコピーライト表記を付与するという処理を行う場合には、下記のようなスクリプトを書くことになるでしょう。

/**
 * @licence MIT
 */

/**
 * data URI から Blob を生成
 * @param {string} sourceDataUri
 * @return {Blob}
 */
function dataUriToBlob(sourceDataUri) {
  var _ref = sourceDataUri.match('data:([^;]+);base64,(.+)') || [];
  var type = _ref[1];
  var data = _ref[2];
  if (typeof type === 'undefined' || typeof data === 'undefined') {
    throw new TypeError('Invalid data URI.');
  }
  var bytes = atob(data);
  var sourceLength = bytes.length;
  var buffer = new Uint8Array(sourceLength);
  var i;
  for (i = 0; i < sourceLength; ++i) {
    buffer[i] = bytes.charCodeAt(i);
  }
  return new Blob([buffer], { type: type });
}

/**
 * 画像ファイルに文字列を乗せる
 * @param {Blob} sourceBlob
 * @param {string} text
 * @return {Promise}
 */
function fillText(sourceBlob, text) {
  var image = new Image();
  var canvasElement = document.createElement('canvas');
  var canvasContext = canvasElement.getContext('2d');
  image.crossOrigin = 'anonymous';
  return new Promise(function(resolve, reject) {
    image.addEventListener('load', function() {
      var resultBlob;
      var resultDataUri;
      URL.revokeObjectURL(this.src);
      try {
        canvasElement.width = this.width;
        canvasElement.height = this.height;
        canvasContext.drawImage(this, 0, 0);
        canvasContext.font = '25px Arial';
        canvasContext.fillStyle = 'white';
        canvasContext.fillText(text, 5, this.height - 5);
        resultDataUri = canvasElement.toDataURL(sourceBlob.type);
        resultBlob = dataUriToBlob(resultDataUri);
        resolve(resultBlob);
      } catch (error) {
        reject(error);
      }
    });
    image.addEventListener('error', reject);
    image.src = URL.createObjectURL(sourceBlob);
  });
}

document.addEventListener('drop', function(event) {
  event.preventDefault();
  var dataTransfer = event.dataTransfer;
  var file = (dataTransfer.files || [])[0];
  fillText(file, '\u00a9 animateLAB, Inc.').then(function(blob) {
    var body = new FormData();
    body.append('imagedata', blob);
    return fetch('/upload.cgi', {
      method: 'post',
      body: body
    }).then(function(response) {
      if (response.status === 200) {
        return response.text();
      } else {
        throw new Error([response.status, response.statusText].join(': '));
      }
    }).then(function(uri) {
      open(uri, '_blank');
    });
  }).catch(function(error) {
    alert(error.message);
  });
  return false;
});

function cancelEvent(event) {
  event.preventDefault();
  return false;
}
document.addEventListener('dragenter', cancelEvent);
document.addEventListener('dragleave', cancelEvent);
document.addEventListener('dragover', cancelEvent);

JavaScript で data URI を Blob にする

上記スクリプトの肝は dataUriToBlob 関数です。

JavaScript で HTTP リクエストを扱う API である XMLHttpRequestfetch で画像ファイルの転送を行う際には、 Blob オブジェクト、Blob オブジェクトを継承したインターフェースの File オブジェクトであることを要求します。

これらのオブジェクトは JavaScript でバイナリーファイルを意味するものです。

canvas 要素は CanvasHTMLElement.prototype.toBlob メソッドを使うことによって Blob オブジェクトを得られるのですが、CanvasHTMLElement.prototype.toBlob メソッドはこの記事を執筆時点 (2015 年 12 月 15 日) では Firefox 以外のウェブブラウザーでは正式サポートされていません。

Google Chrome 47 (2015 年 12 月 15 日時点の stable) 以降では、実験的なサポートがされていますが、chrome://flags から有効にする必要のあるものとなっており、多くの環境で使えるものとは言い難いものとなっています。

そのため上記スクリプトでは、CanvasHTMLElement.prototype.toDataURL メソッドが返す data URI を、Blob オブジェクトに変換させています。

この記事では、本筋と異なるため詳しい説明を省きますが data URI は汎用的に任意のデータを HTTP リソースとして扱えるようにする仕様です。

データは文字列しか受けつけませんが、Base64 の形式でエンコードし、文字列の形にするとバイナリーも data URI にできます。dataUriToBlob 関数では Base64 のデコードに window.atob メソッドを使用しています。

window.atob メソッドは、UTF-8 の文字列を扱えないといった一部制限がありますが、画像ファイルをエンコードした Base64 文字列のデコードをする範囲では問題がありません。

しかし、window.atob メソッドが返すデコード済みの値は string となります。そのままでは Blob オブジェクトにしても画像ファイルとしては正しく認識されません。

そのため、dataUriToBlob 関数では window.atob メソッドが返した値を String.prototype.charCodeAt を使い、一文字づつコードに置き変えています。

この処理はループを用いて行っているため、画像ファイルが長大化すると多くの処理時間を要すようになってしまいます。想定される画像ファイルが大きいのであれば Web Workers を使うことを考えても良いでしょう。

また、モバイル端末で動作するものを含め、ここ数年の内に提供が開始されたウェブブラウザーであれば縦 1080px、横 1920pxの PNG 画像程度であれば遅くとも数百ミリ秒の内に処理を完了させられます。Web Workers を使い、並列処理をする意義はないと思われます。

Blob オブジェクトの作成は Blob インターフェースの第一引数に前段で作成したコード群を渡し、第二引数でオプションという形で MIME Type の指定をするだけです。これで JavaScript の扱うことのできるバイナリーファイルの用意ができました。

Firefox では window.atob メソッドが遅い?

まだ検証が済んでいないのですが、Firefox では window.atob メソッドが非常に遅いと感じています。

簡単に検証できるスクリプトを jsPerf に用意しました。Create a blob from a base64 encoded string. を筆者の環境 (メモリー 32GB、64bit Windows 10) で確認しています。

Firefox 42 を確認すると、以下のように window.atob メソッドを使わずに Base64 のデコードを JavaScript で行ったほうが四倍弱高速という結果になりました。

f:id:koharusugiura:20151214095600p:plain

この記事では環境に応じて条件分岐を行うような処理は省きたかったため、HTMLCanvasElement.prototype.toDataURL メソッドのみを使用しています。

ですが、実際の運用に乗せる場合は Firefox での速度も気にしなくてはなりません。ですので HTMLCanvasElement.prototype.toBlob メソッドが存在しない環境でのみ、Blob オブジェクトを data URI から得るようにするべきでしょう。

この度、上記の検証を行う際に binary-base64 という window.atob メソッドを使わずに Base64 デコードを行うライブラリーを作成してみました。機会があれば、ぜひご活用ください。こちらのライブラリーは window.atob メソッドとは違い、UTF-8 の文字列も扱え、またウェブブラウザー以外の JavaScript 実行環境でも動きます。

JavaScript でバイナリーファイルをサーバーに転送する

XMLHttpRequest や fetch API でバイナリーファイルをサーバーに転送する方法はいくつかありますが、上記スクリプトでは FormData インターフェースを使っています。

FormData インターフェースは XMLHttpRequest で、XMLHttpRequest.prototype.send メソッドの引数に渡し、fetch API でも第二引数にオプションという形で与えるオブジェクトに body という名前のキーで渡すだけで、サーバーに Content-Type: multipart/form-data という形式のリクエストを送ることができます。

これは下記のような HTML のフォームから送信されるリクエストと同等のものとなっています。これは、多くのウェブアプリケーションフレームワークが対応している形式のリクエストです。

FormData インターフェースを使うことで、クライアント側の処理もサーバー側の処理も簡素に済ませられます。

<form action="upload.cgi" enctype="multipart/form-data" method="post">
  <input name="imagedata" type="file">
  <button type="submit">submit</button>
</form>

転送状況を表示させる

実際の運用では画像ファイルの転送のように時間がかかる処理を行う場合、ユーザーに進行状況が見えるようになっていると親切です。

上記のスクリプトでは、XMLHttpRequest ではなく fetch API を使っていますが、クライアント側からの転送状況をユーザーに見せる場合には XMLHttpRequest を使う必要があります。

XMLHttpRequest オブジェクトは upload という XMLHttpRequestUpload オブジェクトの属性を持ちます。この属性の progress イベントを監視することにより、転送の進行状況を得られます。

得られた値は適宜処理して progress 要素を用いてユーザーの目に見えるようにすると良いでしょう。

var progressBar = document.getElementById('upload-progress');
var request = new XMLHttpRequest();
var body = new FormData();
body.append('imagedata', blob);
request.addEventListener('load', function(event) {
  // ...
});
request.upload.addEventListener('progress', function(event) {
  var percent = event.loaded / event.total;
  progressBar.value = percent;
});
request.send(body);

最後に

上記のスクリプトでは固定された値を画像に当てはめるだけでしたが、実際には文字の色や大きさ、表示位置を変えたりする処理も必要になります。またユーザーによって入力された任意の文字列を当てはめるような処理に変えたいという要件もありえます。そうすると、どのような加工がされるのかプレビューの表示は必須でしょう。

サーバー側で画像加工を処理すると、変更の確認をするにはサーバーへの転送が必要となります。通信速度によっては、プレビュー表示に時間掛かることにつながります。これは、ユーザーの離脱にもつながりかねません。

クライアント側で画像加工を処理する大きなメリットは、ほぼリアルタイムでのプレビューの表示ができる点にあります。プレビュー表示に時間がかかるということはありません。また、画像ファイルの転送も最後の一度だけで済むので、無用な転送量に悩まされることもなくなります。

ですが、クライアント側に処理を偏重させすぎると、ユーザー端末に不必要な負荷をかけてしまうことになります。そのため、「クライアント側で処理するべき」か、「サーバー側で処理するべき」かを見極めることが重要となります。

上記のように書いてしまうと少々大げさですが、多くの場合はクライアント側で転送を省略することにより、高速に処理を完了させることができるため、恩恵のほうが大きいと感じています。

ぜひ、ご検討とご活用ください。