無駄と文化

実用的ブログ

とあるCSSハックの弔い

今日、私の職場でデザイナーに向けて共有していた CSS の雛形から、とある2行を削除した。
削除されたその部分はこのようになっていた。

body {
  /* text-align: center; */
}

.container {
  width: 980px;
  margin: 0 auto;
  /* text-align: left; */
}

コメントアウトされた2行が、本日、私の手で削除され git commit された部分だ。

コレが適用されている HTML の雛形がおよそ次のようになっていた。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="ja">
<head>
  ...
</head>

<body>
  <div class="container">
    <div class="header"> ... </div>

    <div class="wrapper"> ... </div>

    <div class="footer"> ... </div>
  </div><!-- /.container -->
</body>
</html>

CSS と HTML とを交互に見てもらえば分かるとおり、削除された2行の CSS は全く意味を成さない。だから削除した。


が、意味を成さないこの2行にはちゃんと意味がある。
実はこれ、IE5 用の CSS ハックなのだ。

今回の削除について、この2行が削除された理由や、そもそも記述された理由について気にする者は居ないだろう。
ひっそりと消されていくのを惜しんで、弔いの意味でこの記事を書く。


{margin: auto;} と中央配置

<body>{text-align: center;} して、直後の <div class="conatiner">{text-align: left;} に戻すこの記述は、<div class="conatiner">中央配置するためにある。


ブロック要素の中央配置といえば、{margin-left: auto; margin-right: auto;} がお馴染みだろうが、実は IE5 にはこれが効かない。
どうやら IE5 では margin に指定する値として auto が許容されていないらしい。

代わりに、という訳ではないがIE5ではブロック要素を中央配置するのに {text-align: center;} を使う。
本来、インライン要素やテキストノードの揃えを指定するための text-align がブロック要素にも効いてしまうのは明らかにバグなのだが。なにはともあれこれ以外に方法がないのでIE5向けにコーディングする上では必要な知識だったようだ。


実は弊社では数ヶ月前までIE7を、一昨年までIE6をサポートしていた。

今回のIE5用のCSSハックは、IE6をサポートしてた頃に私が使っていた雛形から持ってきたんだか、そのときに社内にあったコピペ用CSSを整理するときに残すかしたものだ。
約2年前の時点では確信を持ってこのハックの生き残りを決定したことを覚えている。

そのままなんとな~く据え置かれて、まさかいまだに残っていたとは。


CSSハックは歴史を学び、過去を想うためにある

私が HTML と CSS を学び始めたのは今から5年ほど前で、その当時でさえこの CSS ハックが現役で活躍する時代は過ぎ去っていた。
ただ、私が初めて HTML を学ぶために手に取った書籍がなかなかの良書で、古いブラウザに丁寧に対応するための CSS ハックをコラム的に紹介していたのが このCSS ハックを知るきっかけになった。

5年前は、いわゆるテーブルコーディングがはっきりと悪しき慣習だと認知され、これからはセマンティックなマークアップとCSSによる柔軟なレイアウトだ!という空気感だったように記憶している。

世の中にはスマホ向けサイトというものもあり。でも、レスポンシブで作るなんてのは現実的じゃなかった。そんな頃だ。


私が最初に手に取った書籍でも

  • テーブルコーディングは絶対にやめろ
  • <font>タグなんていまどきじゃない
  • CSSの表現力はすごいぞ

といった事が初心者向けなりにしっかりと書かれていた。
私もそれを気に入って選んでいたし、読むほどにセマンティックなコーディングに憧れたものだ。


一方で、この記事でつらつらと書いてきたような CSS ハックについて解説されていたのも、私のWeb系人生にいい影響を与えたと思う。

インターネットエクスプローラー と ネットスケープ の CSS 実装合戦で Web 制作の現場がひどく混乱したこと。それでもCSSハックを駆使して古いブラウザをサポートして、多くの人に同じ情報を届けるのが Web 制作者の役目である事。そういった事を、初心者ながらに肌で感じられたのを覚えている。


CSS ハック自体が Web から消えていく

いまやブラウザは毎月のように新しいバージョンがリリースされ、自動で更新されてるようになっている。
W3C といった標準化団体がコンセンサスを取りながら機能追加を図ってくれていて、「他のブラウザでは使えない高機能な CSS が使えちゃう俺カッコイイ(ドヤァ」という空気も完全に消え失せている。

これから先にはCSSハックといった『バグでバグをカバーする黒魔術』は生まれないだろう。


今回削除された2行の記述はコミットログに残り続けるけれど、あらゆる人の記憶からは薄れていくと思う。
ま、これは、ただの思い出話だけど、そんな感じで。


私からは以上です。

Python で文字列の類似度を比較する

日本語の処理をしているときに厄介なのが表記揺れですよね。
「コンピューター」と「コンピュータ」、「問い合わせ」と「問い合せ」など。人間が見れば同じ単語だと分かっても、プログラムで処理する際に単純に等号で比較してしまうと別の単語扱いになってしまいます。

今回は類似度を用いて二つの単語を評価することで、表記揺れの問題に対処してみます。


単語間の類似度を算出する

単純に文字列が 等しいか/異なるか 二者択一で評価するのではなく、類似度 を用いて評価してみましょう。

類似度は 0~1 の float で表される値で、二つの単語が全く異なれば 0 、全く一致すれば 1 に評価されます。
そして、全て一致しないにしても似ている単語同士であれば 1に近い少数 に評価されます。

「一致はしないけど、まぁまぁ似てるから同じ単語なんちゃう?」というファジーな評価をするわけですね。


今回は Python を使います。はっきり言って、ものすごく簡単です。

差分計算のための difflib というライブラリがあり、その中の difflib.SequenceMatcher クラスで類似度が出せそうです。

# -*- coding: utf-8 -*-

import difflib


str1 = u"スパゲッティー"
str2 = u"スパゲティ"

s = difflib.SequenceMatcher(None, str1, str2).ratio()

print str1, "<~>", str2
print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333

という訳で、「スパゲッティー」と「スパゲティ」の類似度は 83.3% です。
いい感じですね。


いろいろな単語同士を比較する

似ていない単語同士を比べた場合も見てみたいので、もうちょっといろいろなパターンを試しましょう。

# -*- coding: utf-8 -*-

import difflib


# 互いに類似度を比較する文字列のリスト
strs = [
    u"スパゲッティー",
    u"スパゲッティ",
    u"スパゲティ",
    u"カペッリーニ",
]

# リスト内包表記で strs の中の文字列から重複なしの組み合わせを作る
for (str1, str2) in [
        (str1, str2)
        for str1 in strs
        for str2 in strs
        if str1 < str2
    ]:
    print str1, "<~>", str2

    # 類似度を計算、0.0~1.0 で結果が返る
    s = difflib.SequenceMatcher(None, str1, str2).ratio()
    print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲッティ
# >> match ratio: 0.0
# >>
# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333
# >>
# >> スパゲティ <~> スパゲッティ
# >> match ratio: 0.0
# >>
# >> カペッリーニ <~> スパゲッティー
# >> match ratio: 0.307692307692
# >>
# >> カペッリーニ <~> スパゲッティ
# >> match ratio: 0.0
# >>
# >> カペッリーニ <~> スパゲティ
# >> match ratio: 0.0

「カッペリーニ」と「スパゲッティー」の類似度 30.8% を高いと見るか低いと見るか微妙なところですが、75% あたりで線引きするのがよさそうです。


ところで、「スパゲティ」と「スパゲッティ」の類似度 0% に注目してください。

日本語には 半角文字/全角文字 というもう一つ厄介なものがありましたね。
おそらく同じ物を指しているであろう単語でも半角カタカナでタイピングするだけで類似度が 0% に。これはまずい。


半角文字/全角文字 を正規化してから比較する

類似度の算出に掛ける前処理として、

  • 半角カタカナ → 全角カタカナ
  • 全角英数字 → 半角英数字

と変換を掛けてあげるのがいいでしょう。


unicodedata.normalize() を使います。

# -*- coding: utf-8 -*-

import unicodedata


str1 = u"スパゲッティ"
print str1

# >> スパゲッティ

normalized_str1 = unicodedata.normalize('NFKC', str1)
print normalized_str1

# >> スパゲッティ


先ほどのコードを書き換えましょう。

# -*- coding: utf-8 -*-

import unicodedata
import difflib


# 互いに類似度を比較する文字列のリスト
strs = [
    u"スパゲッティー",
    u"スパゲッティ",
    u"スパゲティ",
    u"カペッリーニ",
]

# リスト内包表記で strs の中の文字列から重複なしの組み合わせを作る
for (str1, str2) in [
        (str1, str2)
        for str1 in strs
        for str2 in strs
        if str1 < str2
    ]:
    # unicodedata.normalize() で全角英数字や半角カタカナなどを正規化する
    normalized_str1 = unicodedata.normalize('NFKC', str1)
    normalized_str2 = unicodedata.normalize('NFKC', str2)

    print str1, "<~>", str2

    # 類似度を計算、0.0~1.0 で結果が返る
    s = difflib.SequenceMatcher(None, normalized_str1, normalized_str2).ratio()
    print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲッティ
# >> match ratio: 0.923076923077
# >>
# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333
# >>
# >> スパゲティ <~> スパゲッティ
# >> match ratio: 0.909090909091
# >>
# >> カペッリーニ <~> スパゲッティー
# >> match ratio: 0.307692307692
# >>
# >> カペッリーニ <~> スパゲッティ
# >> match ratio: 0.166666666667
# >>
# >> カペッリーニ <~> スパゲティ
# >> match ratio: 0.0

「スパゲティ」と「スパゲッティ」の類似度は 90.9% !今度こそいい感じです。


difflib.SequenceMatcher について補足

ドキュメントを読むと、difflib.SequenceMatcher クラスは4つの引数を受け取れることになっています。

  • isjunk - 類似度を比較するときに無視する文字を評価関数で指定する。デフォルトは None
  • a - 比較される文字列の一つめ
  • b - 比較される文字列の二つめ
  • autojunk - 自動 junk ヒューリスティック の 有効化/無効化 フラグ。デフォルトは True(有効)


ab は比較される二つの文字列です。

s = difflib.SequenceMatcher(a=str1, b=str2)

というように名前付きで指定することも可能です。


isjunk に関数を渡すと、類似度を算出するときに無視させる文字を指定することができます。
c を文字列の中の一文字として、isjunk(c)True を返せばその文字は無視される。isjunk(c)False を返せばそのまま使われる。といった具合ですね。

単語のつなぎとしてのハイフン - や括弧 (, ) などを無視させたければ、

isjunk = lambda c: c in ["-", "(", ")"]
s = difflib.SequenceMatcher(isjunk=isjunk, a=str1, b=str2)

というように適当な無名関数を渡してあげればよさそうです。


autojunk については日本語ドキュメントの説明をそのまま引用させてもらいます。

自動 junk ヒューリスティック: SequenceMatcher は、シーケンスの特定の要素を自動的に junk として扱うヒューリスティックをサポートしています。 このヒューリスティックは、各個要素がシーケンス内に何回現れるかを数えます。ある要素の重複数が (最初のものは除いて) 合計でシーケンスの 1% 以上になり、そのシーケンスが 200 要素以上なら、その要素は “popular” であるものとしてマークされ、シーケンスのマッチングの目的からは junk として扱われます。このヒューリスティックは、 SequenceMatcher の作成時に autojunk パラメタを False に設定することで無効化できます。

英語圏では isthe などの一般的な単語が多出するので、それを無視するためのものなのでしょう。


まとめ

Python で類似度の算出をやってみました。

驚きだったのは、類似度算出の difflib.SequenceMatcher にしても、半角/全角 正規化の unicodedata.normalize() にしても、標準ライブラリだけでサクッと処理できてしまうことですね。いやー、Python すごい。


さて、表記揺れに対処するという視点で言えば、日本語にはまだまだ厄介な問題が多くあります。

  • 「Excel」と「エクセル」のような、アルファベット/カタカナ の表記揺れ
  • 「スターバックス」と「スタバ」のような、略語の表記揺れ
  • 「後程」と「のちほど」のような、漢字表記を ひらく/ひらかない 表記揺れ

これらについては今回とは違ったアプローチで対処していく必要があります。 それぞれ場合へのアプローチについては、また別のエントリーで解説していきたいと思います。


私からは以上です。

Fetch API が 4xx エラーを reject してくれない

最近のフロントエンド開発に関して、 jQuery への依存を極力減らしてPure JS だけでいろいろな処理を書くように心掛けています。
具体的には ECMAScript2015 で書いたものを Babel でトランスパイルして、Browserify でバンドルするというスタイルですね。ホント、Babel 様々です。


その流れで、
jQuery.ajax() からの置き換えで Fetch API を試しています。

jQuery.ajax() 代替の候補としては他にも SuperAgentaxios があるんですが、Fetch API は WHATWG によって策定が進んでいる次期標準の安心感がありますし、とりあえず手を付けておいても損はないんじゃないでしょうか。


jQuery.ajax() と fetch() の大きな違い

さて、本題です。

jQuery.ajax()fetch() はインターフェースも似ているので、慣れている人であればそれほど戸惑うことなく置き換え可能でしょう。
jQuery.ajax()jQuery.Deferred 形式でコールバックを処理してくれます、対して fetch()Promise形式でコールバックを処理します。

jQuery.ajax('/path/to/api', {
  method: 'POST',
})
.then(function(response) {
  console.info(response);
})
.fail(function(err) {
  console.error(err);
})

// fetch() で同じ処理を書くとこうなる、
fetch('/path/to/api', {
  method: 'POST',
})
.then(function(response) {
  console.info(response);
})
.catch(function(err) {
  console.error(err);
})

ほぼ同じ形で書けてますね。
fail()catch() に読み替えるところだけを注意すれば大丈夫だと思います。


ただ、

これだけではないんです、一つ大きな違いがあるんですよね。
それは、fetch() はサーバー側でエラーが起こってもレスポンスを reject してくれないということです。

レスポンスをreject してくれないということは、catch() の中でエラー処理できないということであり、then() の方にレスポンスが流れて行ってしまうということです。

jQuery.ajax() であればサーバー側でエラーが起きたときにfail() の中でエラーを捕捉して処理できていました。
困りましたね。エラーは catch() の中で処理するもんだと思っていたら、そもそもサーバーエラーを catch() に渡してくれないとは。

どうやらこれは Fetch API の仕様であり、正しい動作のようです。


「仕様」と言われてしまっても catch() の中でエラー処理したいですよね。
今回はそのための方法について解説します。


触って理解する - Fetch API はどのように動作するか

と、その前に、
実際に触れるデモがある方が理解しやすいと思うので、Fetch API を使ったデモを用意しました。

まずは確実にサーバーエラーを返してくれる JSON API を作っています。

以下の4つの URL に GET リクエストすることで、それぞれステータスコードが 200, 200, 400, 500 のレスポンスを返してくれます。


これらの API に fetch() を使ってリクエストを送信し、レスポンスを表示してみましょう。


普通に書いた場合のデモがこちらです。

4つの青いボタンを押すと、それぞれの URL にリクエストを投げ、alert() でレスポンスを表示します。
then() の中で処理された場合には「成功ハンドラで処理されました。」と表示され、catch() の中で処理された場合には「失敗ハンドラで処理されました。」と表示されます。

それぞれのボタンを押してみてください。

右上のボタン以外は「成功ハンドラで処理されました。」と表示されてしまいますね?
これが件の、fetch() はサーバーエラーが起きてもレスポンスを reject してくれないという仕様です。


ソースはこんな感じ。

// レスポンスに対して共通で行う前処理
var prepare = function(response) {
  // ステータスコードとステータステキストを表示
  console.info('ok?: ', response.ok);
  console.info('status: ', response.status);
  console.info('statusText: ', response.statusText);

  // レスポンスボディをJSONとしてパース
  return response.json();
}

// 正常終了時の処理
var onFulfilled = function(data) {
  var message = ([
    '成功ハンドラで処理されました。',
    'data: ' + JSON.stringify(data, null, '  '),
  ]).join('\n');

  console.log(data);
  alert(message);
};

// エラー終了時の処理
var onRejected = function(err) {
  var message = ([
    '失敗ハンドラで処理されました。',
    'error: ' + err.message,
  ]).join('\n');

  console.error(err);
  alert(message);
};

fetch('/path/to/api')
  .then(prepare)
  .then(onFulfilled)
  .catch(onRejected);

これに手を加えていい感じにしていきます。


レスポンスが reject() されるのはネットワークエラーのときだけ

MDN の解説を読むと次のように書かれています。

A fetch() promise will reject with a TypeError when a network error is encountered, although this usually means permission issues or similar — a 404 does not constitute a network error, for example.
An accurate check for a successful fetch() would include checking that the promise resolved, then checking that the Response.ok property has a value of true.

意訳

fetch() の Promise はネットワークエラーが発生した時に結果を reject します。"404" のようなアクセス許可に関するエラーなどはネットワークエラーには含まれません。
fetch() が成功したかどうかの確認のためには、Promise が resolved になったことをチェックし、さらに response.ok というプロパティが true であるかをチェックする必要があります」


というわけで、

  • fetch() が結果を reject() するのはネットワークエラーのときだけ
  • サーバー側の処理が正常に行われたかどうかは、response.ok を見れば判断できる

という事実が語られています。

確かに、先ほどのデモでわざとネットワークを切断した状態でボタンを押すと "Failed to fetch" といったメッセージとともに結果が reject されているのを見ることができます。


補足

先ほどのデモで右上のリクエストが失敗ハンドラで処理されるのは何故でしょう?

不正な JSON が返されたときのエラーは fetch() の中ではなく response.json() の中で投げられています。
返ってきたのが不正な JSON だったために、response.json() での JSON のパースに失敗してエラーが投げられるんですね。

『不正な JSON が返ってくることなんてある?』と思いますか?
JSON が返ってくることを期待している API から予期せず HTML などが返されるとパースに失敗することはあり得ます。


response.ok をチェックする

MDN さんが response.ok をチェックせよと言っているので、そのように書き換えましょう。
handleErrors() という関数を追加します。

// レスポンスに対して共通で行う前処理
// 4xx系, 5xx系のエラーをさばくハンドラ
var handleErrors = function(response) {
  // 4xx系, 5xx系エラーのときには response.ok = false になる
  if (!response.ok) {
    throw Error(response.statusText);
  }

  return response;
}

// ...

fetch('/path/to/api')
  .then(handleErrors)
  .then(prepare)
  .then(onFulfilled)
  .catch(onRejected);

これを反映させたデモがこちら。


response.ok の他にも処理が正常に行われたかをチェックするのに使えそうなプロパティがあります。
response.statusステータスコードをチェックすることができます。4xx系と5xx系とで処理を分けるなどにも使えそうですね。

さらに、response.statusText でステータスコードに紐づいたメッセージを参照することができます。例えば 400 を返してくるレスポンスであれば、response.statusText には "Bad Request" が格納されています。
今回はこの response.statusText をエラーメッセージとして渡しています。


これにて、サーバーエラー時のレスポンスが失敗ハンドラで処理されるようになりましたね。


サーバーから受け取ったエラーメッセージを使う

とはいえ、
私が用意した JSON API の方ではエラー時にも日本語のエラーメッセージを添えた JSON を返しているんですよね。
それをそのまま捨ててしまうのはもったいない。適切なエラーメッセージで状況を説明することはユーザーの混乱を避けるためにも有用だと思われます。

なので、response.ok をチェックしつつ JSON をパースしてエラーメッセージを取り出すようにしてみましょう。


このように

// 4xx系, 5xx系のエラーをさばくハンドラ
var handleErrors = function(response) {
  // 4xx系, 5xx系エラーのときには response.ok==false になる
  if (!response.ok) {
    return response.json().then(function(err) {
      throw Error(err.message);
    });
  } else {
    return response;
  }
}

デモにも反映させてみます。


これにてサーバーエラーをちゃんと catch() の中で処理しつつ、サーバーからのエラーメッセージを参照できるようになりました。
めでたし。


私からは以上です。


参考

Fetch API のエラー処理について TJ VanToll 氏 のエントリを大いに参考にさせていただきました。
感謝と御礼にかえて。


おまけ

今回のデモのために書いたコード一式を GitHub に置いています。

github.com


手元にクローンしてから、プロジェクトルートで

$ composer install
$ php -S localhost:8000 -t public

を実行して、ブラウザで localhost:8000 にアクセスすると今回のデモをローカルで試すことができます。
コードを部分的に書き換えていろいろな動作を試すと Fetch API に対する理解がさらに深まるかも知れません。

JSON API の作成には Slimフレームワーク を使いました。