ぶれすとつーる

だいたいjavascript

ES6 Map/Setのキーの比較まわりの挙動の変化について

今日Firefox29がでてその変更内容の中で

更新された ECMAScript6 仕様草案に準拠するため、Map オブジェクトおよび Set オブジェクトがキーと値の同一性を確認するときは、-0 と +0 を同一として扱うようになりました。

ってかいてるのみて、ほとんど出た当初のことしか記憶になかったけど色々更新されてるっぽいので追って見る

Rev13(December 21, 2012)まで

15.14.5.9 Map.prototype.set ( key , value )

The following steps are taken:
23. Let M be the result of calling ToObject with the this value as its argument.
24. ReturnIfAbrupt(M).
25. If M does not have a [[MapData]] internal data property throw a TypeError exception. 26. Let entries be the List that is the value of M’s [[MapData]] internal data property.
27. Repeat for each Record {[[key]], [[value]]} p that is an element of entries,
a. If SameValue(p.[[key]], key), then
i. Set p.[[value]] to value.
ii. Return undefined.
28. Let p be the Record {[[key]]: key, [[value]]: value}
29. Append p as the last element of entries. 30. Return undefined.

この段階ではキーの同値比較はSameValueを使っている(27.a)

9.2.3 The SameValue Algorithm

The internal comparison abstract operation SameValue(x, y), where x and y are ECMAScript language values, produces true or false. Such a comparison is performed as follows:

1. ReturnIfAbrupt(x).
2. ReturnIfAbrupt(y).
3. If Type(x) is different from Type(y), return false.
4. If Type(x) is Undefined, return true.
5. If Type(x) is Null, return true.
6. If Type(x) is Number, then
  a. If x is NaN and y is NaN, return true. 
  b. If x is +0 and y is -0, return false.
  c. If x is -0 and y is +0, return false.
  d. If x is the same Number value as y, return true.
  e. Return false.
7.  If Type(x) is String, then
  a. If x and y are exactly the same sequence of characters (same length and same characters in corresponding positions) return true; otherwise, return false.
8.  If Type(x) is Boolean, then
  a. If x and y are both true or both false, then return true; otherwise, return false.
9. Return true if x and y are the same Object value. Otherwise, return false.

SameValue Algorithmでは明確に+0と-0を分けている(6.b, 6.c)

つまりこの時点ではMapは以下の挙動をとる事になる

var map = new Map();
map.set(+0, 10);
map.set(-0, 20);

map.get(+0) // 10;
map.get(-0) // 20;

しかし、この後2013 1/29に行われたtc39のes6についてのミーティングでこんな議題があがったらしい

[AWB] Map/Setとかにおいて比較方法が一つしかないのってきっとそれってよくないと思うんだよね -0と+0とか同じものとして扱える方法だってあってもいいと思うんだ Map/Setに対する等価比較の方法をパラメータ化しませんか

(多分こんな感じ)

この日は明後日また考えようみたいな感じになって1/31にまたこの件について話し合われた

[AWB] コンストラクタをこう変更するのはどうだろう

今
    Map(iterator=undefined)
    Set(iterator=undefined)

提案:
    Map(iterator=undefined, comparator="default")
    Set(iterator=undefined, comparator="default")

    Comparator Selector
        "default" // デフォルトではこれが使われる
        "==" // == を使った比較
        "===" // === を使った比較
        "is" // Object.is を使った比較

"default"は+0/-0を等価として扱い、すべてのNaN値が同等であることを除いて「Object.is」と同じです。
他の値が設定されたらエラー.....

[WH] comparatorって同値関係なんだよね、==と===は推移関係だからこれにあてはまらないんじゃない?

http://ja.wikipedia.org/wiki/%E5%90%8C%E5%80%A4%E9%96%A2%E4%BF%82 http://ja.wikipedia.org/wiki/%E6%8E%A8%E7%A7%BB%E9%96%A2%E4%BF%82

[AWB] それじゃdefaultとisでいこうか

(みたいな感じのやりとり)

それがRev14(March 8, 2013 )で一部反映された

Rev14(March 8, 2013 )

新しくSameValueZeroという比較アルゴリズムが新しく作られた。(defaultのこと) アルゴリズム仕様書につらつらかいてあるけど注釈があるからそれからよむと

NOTE SameValueZero differs from SameValue only in its treatment of +0 and -0.

とかいてる。SameValueとほぼ一緒なんだけど+0と-0の扱いだけちょっと違う。

6. If Type(x) is Number, then
  a. If x is NaN and y is NaN, return true.
  b. If x is +0 and y is -0,return true.
  c. If x is -0 and y is +0,return true.
  d. If x is the same Number value as y, return true.
  e. Return false.

6.b, 6.cを見ると+0と-0に区別がないことがわかる

Map/Setはこの新しい比較アルゴリズム(SameValueZero = [default])を採用し、コンストラクタに比較アルゴリズムを選べる省略可能引数(MapComparator)を追加した

ちなみにMapComparatorには"is"しかサポートされてない(isを指定すると以前同様にSameValueで比較する)

15.14.4.9 Map.prototype.set ( key , value )

The following steps are taken:

1. Let M be the this value.
2. If Type(M) is not Object, then throw a TypeError exception.
3. If M does not have a [[MapData]] internal data property throw a TypeError exception.
4. If M’s [[MapData]] internal data property is undefined, then throw a TypeError exception.
5. Let entries be the List that is the value of M’s [[MapData]] internal data property.
6. If M’s [[MapComparator]] internal data property is undefined, then let same be the abstract operation SameValueZero.
7. Else, let same be the abstract operation SameValue.
8. Repeat for each Record {[[key]], [[value]]} p that is an element of entries,
  a. If same(p.[[key]], key), then
    i. Set p.[[value]] to value.
    ii. Return M.
9. Let p be the Record {[[key]]: key, [[value]]: value}
10. Append p as the last element of entries.
11. Return M.

これ(6)によって以下のような挙動になった

var map = new Map();
map.set(+0, 10);
map.set(-0, 20);

map.get(+0) // 20;
map.get(-0) // 20;

var map = new Map(undefined, 'is');
map.set(+0, 10);
map.set(-0, 20);

map.get(+0) // 10;
map.get(-0) // 20;

それから10ヶ月くらいしてまたこの辺(comparator)のことについてTC39で話し合われた(2013/11/20)

この話し合いで別に現状の"default"(SameValueZero)と"is"(SameValue)の違いって-0/0の違いだけで、それってObject.isを使ってサブクラスを作ってあげれば簡単に解決できるよねってことでコンストラクタ引数を削除することで合意する

そしてRev22(January 20, 2014)でこれが反映されてコンストラクタ引数のComparatorが排除された。

Rev22(January 20, 2014)

Eliminated comparator optional argument to Map and Set constructor (Map/SetのコンストラクタからComparator省略可能パラメータが排除されました)

これにともないSameValueZeroで一律比較されるようになった。

var map = new Map();
map.set(+0, 10);
map.set(-0, 20);

map.get(+0) // 20;
map.get(-0) // 20;

で、現在(2014/4)に至る

なんか「+0/-0区別するんだ、へー!!」みたいな初期の記憶でストップしてたけど色々変更あるみたいでこれからもときどきは見ないといけないなーって思った(こなみかん)

そういえば三ヶ月くらい前にes-discussでこんなやり取りがありましたね http://esdiscuss.org/topic/samevaluezero-comparator-and-compatibility

The implementations of Maps and Sets in the wild that I am aware of (IE11, SpiderMonkey, and V8 behind a flag) all currently use SameValue as comparator while the spec calls for SameValueZero.

fx, chromeはもう対応したみたいですがie11がSameValueで実装してしまってる部分に関しては新たな闇が生まれた感がありますね

v8 Issue 3069: Map and Set should consider +0 and -0 the same

SpiderMonkey Bug 2501 - Map.prototype.set, [[MapComparator]], SameValueZero, and forEach

connect-expiresかいた

response headersにexpires付与してくれるconnect middlewareさがしたらすごく大きなものばっかりでてきてそんなに機能必要じゃなかったのでちょろっとかいた。

connect-expires

var expires = require('connect-expires')
  ;

// ...adding some connect middleware
app.use(expires({
  pattern: /^\/img\//,
  duration: 1000 * 60 * 60 * 24
}));
app.use(express.static(path.join(__dirname, 'public')));
// ...

こんな感じに設定すれば/img/ほにゃららなリクエストはぜんぶexpiresが24時間適応される

文字列リテラルとU+2028

元気にインターネットしてたらユーザの入力した値をこんな感じでDOMに埋め込んでるサイトをみつけた

<a href='javascript:edit(2,{"a":"どらえもん","b":["×","×"],"c":"ふごふご"},1)'>
ほげええ
</a>

入力値いろいろかえて遊んでみたらだいたいの文字列はエスケープはされてたけど曰くのU+2028はsyntax errorだしてた

Uncaught SyntaxError: Unexpected token ILLEGAL

これは文字列リテラルの仕様で含められない値として決められてるLineTerminatorがu+2028であることが起因してる

es5 #7.8.4

es5 #7.3


7.8.4 String Literals

DoubleStringCharacter ::
    SourceCharacter but not double-quote " or backslash \ or LineTerminator 
    \ EscapeSequence

SingleStringCharacter ::
    SourceCharacter but not single-quote ' or backslash \ or LineTerminator 
    \ EscapeSequence

7.3 Line Terminators

Code Point Value Name Formal Name
\u000A Line Feed <LF>
\u000D Carriage Return <CR>
\u2028 Line separator <LS>
\u2029 Paragraph separator <PS>

今回のこの問題をかんたんに再現したいならhrefのjavascript:疑似プロトコル中にu+2028が文字列リテラルに含めまれるようにして、あとはクリックイベントをエミュレートすればいい

検証コード

<a id="fuga" href="#">fugaa</a>
function fn(obj) {}

document.addEventListener('DOMContentLoaded', function () {
    var fuga = document.getElementById('fuga'),
        invalid = String.fromCharCode(0x2028),
        evt;
    
    fuga.setAttribute('href', 'javascript: fn({"a": "' + invalid + '"})');
    
    // クリックイベントをエミュレート
    evt = document.createEvent("MouseEvents");
    evt.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    fuga.dispatchEvent(evt);
    // Uncaught SyntaxError: Unexpected token ILLEGAL 
    // (クリックされたタイミングでjavascipt文字列を評価するから)
}, false);

とうぜん(javascript文字列中の)文字列リテラルにu+2028を含めてevalしても同じことがおこる

var invalid = String.fromCharCode(0x2028),
    code = "console.log('" + invalid + "')";

eval(code);
// SyntaxError: Unexpected token ILLEGAL

new Function系も当然だめ

var invalid = String.fromCharCode(0x2028),
    code = "console.log('" + invalid + "')";

new Function(code);
// SyntaxError: Unexpected token ILLEGAL

json文字列をevalで評価するときにもいったんstringifyされた段階で生成されるinvalidな文字列リテラルがevalで評価されちゃうのでエラーがおきる

var invalid = String.fromCharCode(0x2028),
    j = JSON.stringify({a: invalid}); // {a: "<LS>"} が生成されて次の行でそれをevalされるのでNG

eval('(' + j + ')');
// SyntaxError: Unexpected token ILLEGAL

というわけでJSON.parseつかおう(変換時にリテラルを生成しないから問題がおきない)

今回の件でいえばdata-*で取得するのが多分一番いい

それでもやっぱりまたそれらを文字列化して評価される恐れがないわけではないのでそういうのがありえる処理系なら空文字なりに置換しちゃいたいところ

ちなみにjson自身はなんでu+2028を含むリテラルが正常に扱えるかというとjsonはjsの仕様(ecma262)とは別で標準化されててecma404が適応されていてそっちにはその制約がないから

正規表現リテラルのes3からes5の間での変化

f:id:nazomikan:20140312013303j:plain

何度目かの聖書(JavaScript: The Good Parts)の輪読会をしてたときに、RegExpの章を担当してくれた子が、「正規表現リテラルから生成される正規表現オブジェクトは内容が同じなら参照も同じになります!」って説明してくれて、「あれ、そうだっけ」って思って調べた。

たしかに書いてる

RegExp objects made by regular expression literals share a single instance:
function make_a_matcher() {
    return /a/gi;
}
var x = make_a_matcher();
var y = make_a_matcher();
// Beware: x and y are the same object!
x.lastIndex = 10;
document.writeln(y.lastIndex);    // 10
via JavaScript: The Good Parts

半信半疑で実行するとやっぱりそんなことなかった

f:id:nazomikan:20140312013722p:plain

ということでどこかで仕様かわったんだろうなーって思ってみてたら3rdから5thの流れの中でそれらしい変更を見つけた

3rd

Regular Expression Literals

A regular expression literal is an input element that is converted to a RegExp object (section 15.10) when it is scanned. The object is created before evaluation of the containing program or function begins. Evaluation of the literal produces a reference to that object; it does not create a new object. Two regular expression literals in a program evaluate to regular expression objects that never compare as === to each other even if the two literals' contents are identical. A RegExp object may also be created at runtime by new RegExp (section 15.10.4) or calling the RegExp constructor as a function (section 15.10.3).

5th

Regular Expression Literals

A regular expression literal is an input element that is converted to a RegExp object (see 15.10) each time the literal is evaluated. Two regular expression literals in a program evaluate to regular expression objects that never compare as === to each other even if the two literals' contents are identical. A RegExp object may also be created at runtime by new RegExp (see 15.10.4) or calling the RegExp constructor as a function (15.10.3).

後者の一文がなくなったってことは都度評価されて都度新しいオブジェクトが返却されてるんでしょうかね(返却される正規表現オブジェクト自体は内容が同じでも別物になるって次の文にかいてある)

正規表現リテラルもいろいろかわってたんだなー(遠い目

なるほどー。

jsコンソールよくわからないぽよ〜

不思議ぽよ〜

{} + ""

これをコンソールで実行すると0が返るぽよ〜

f:id:nazomikan:20140216011116p:plain

f:id:nazomikan:20140216011130p:plain

でもconsole.logでみると普通ぽよ〜

f:id:nazomikan:20140216011218p:plain

f:id:nazomikan:20140216011226p:plain

むずかしいぽよ〜

数分後ぽよ〜

f:id:nazomikan:20140216012147p:plain

解決したぽよ!!!!

ただ空文字を数値化してるだけの扱いになってたぽよ!!!

+"" // 0

ありがとうございますぽよ〜><

仕様書みたぽよ〜 (harmony Rev22)

f:id:nazomikan:20140216013316p:plain

式として始まるものは{とかfunctionとかclassとかlet [ではじまっちゃいけなくてそれぞれ、それではじまってたら解釈がかわるみたいぽよ〜

それで

{} + ""

{から始まってたのでオブジェクトと空文字の加算(式)じゃなくてブロックとして解釈されてたぽよ〜

一方で

console.log({} + "");

{からの開始じゃないから式として扱われるぽよ〜

案外深くなかったぽよ〜

コンソールなにもわるくないぽよ〜

関数宣言と関数式の区別するためのあれと同じぽよ〜

inline-block一回指定しちゃうとhasLayoutを元に戻せないとかいう糞みたいなほんとの話

最近うしろめたいことにちょっと時代錯誤なことしてて、そのせいでhasLayoutの思わぬ挙動に気づいた

<!DOCTYPE html>
<!--[if IE 7 ]><html lang="ja" class="ie7"><![endif]-->
<!--[if (gt IE 7)|!(IE)]><!--><html lang="ja"><!--<![endif]-->
...
<span class="foo">ほげえええ</span>
...
.foo {
    display: inline-block;
}

.ie7 .foo {
    display: inline;
}

こういう状態のとき、inline-blockはhasLayoutのフラグをtrueにするんだけど、ie7のときは上書いてるからhasLayoutはfalseだよねーって思うんだけど実際currentStyle.hasLayoutを参照してみたらtrueだった

ちなみにこういうのだとhasLayoutはfalseだった

.foo {
    display: inline-block;
    /display: inline;
}

1ブロックの中では後で宣言されたものしかよまない(上書きではなく、前が無視される)とかそういうことなんだろうと思う。

調べてたらこんな記事を見つけた On having layout

The display-property differs: while 'inline-block' sets haslayout = true, the flag will not be reset to false later on by overriding the value with 'block' or 'inline' in another rule set.

覆水盆に返らず、世知辛い話ですね。

DOMContentLoadedとスタイルシートの読込み

DOMContentLoadedの発火とスタイルシートの読み込みは間接的に関係あるらしい

MDNのDOMContentLoadedのページみてたら

The DOMContentLoaded event is fired when the document has been completely loaded and parsed, without waiting for stylesheets, images, and subframes to finish loading (the load event can be used to detect a fully-loaded page). Note: Stylesheet loads block script execution, so if you have a <script> after a <link rel="stylesheet" ...>, the page will not finish parsing - and DOMContentLoaded will not fire - until the stylesheet is loaded.

と書いてある(詳細:a-styles-sheet-that-is-blocking-scripts).

DOMContentLoadedイベントはドキュメントの読み込みと解析が完了したらスタイルシートの読み込みや画像の読み込み、フレームの読み込みの完了をまたずに発火します。

(注意) とはいえスタイルシートの読み込みはscriptの実行をブロックするので、もしあなたが<script><link rel="stylesheet" ...>の後ろにいれてたら、ページの読み込み完了やDOMContentLoadedの発火はスタイルシートのロードが終わるまで行われません。

多分こんな感じ。

検証してみた

スタイルシートがscriptの前にあるケース(ブロックされる)

サーバサイドはこんな感じでcssの読み込みだけ3秒(3000ms)ほど遅らせる

var static = require('node-static')
  , fileServer = new static.Server('./public/')
  , http = require('http')
  , path = require('path')
  ;

http.createServer(function (req, res) {
  req.addListener('end', function () {
    var url = req.url
      , ext = path.extname(url)
      ;

    if (ext === '.css') { //cssは遅らせる
      setTimeout(function () {
        fileServer.serve(req, res);
      }, 3000);
    } else {
      fileServer.serve(req, res);
    }
  }).resume();
}).listen(3010);

検証するページはこんな感じ (DOMContentLoadedが発生したらparseStartからの経過時間(ms)を出力)

public/index.html

<html>
<head>
  <script type="text/javascript">
  var parseStart = Date.now();
  (function (doc) {
    doc.addEventListener('DOMContentLoaded', function () {
      var onDomContentLoaded = Date.now()
        , elapsed = onDomContentLoaded - parseStart
        ;

      doc.body.innerHTML = doc.body.innerHTML + "<br>fire DOMContentLoaded " + elapsed;
    }, false);
  }(document));
  </script>
  <!-- scriptの前にスタイルシートが存在する(scriptの実行をブロックするはず) -->
  <link rel="stylesheet" href="./css/foo.css" type="text/css">
  <!-- js(パーススタートから実行までの経過時間を(ms)出力) -->
  <script type="text/javascript">
  document.write('exec foo.js: ' + (Date.now() - parseStart) + '<br>');
  </script>
</head>
<body>
hello world
</body>
</html>

出力結果

exec foo.js: 3005
hello world
fire DOMContentLoaded 3005

f:id:nazomikan:20140202190541p:plain

(青線がDOMContentLoaded, 赤がonload)

スタイルシートの読み込み(3000ms)とjsの実行を待ってからDOMContentLoadedが発生しています

スタイルシートがscriptの後ろにあるケース(ブロックされない)

次にscriptとlinkの順序を入れ替えてみます

public/index.html

<html>
<head>
  <script type="text/javascript">
  var parseStart = Date.now();
  (function (doc) {
    doc.addEventListener('DOMContentLoaded', function () {
      var onDomContentLoaded = Date.now()
        , elapsed = onDomContentLoaded - parseStart
        ;

      doc.body.innerHTML = doc.body.innerHTML + "<br>fire DOMContentLoaded " + elapsed;
    }, false);
  }(document));
  </script>
  <!-- 順番入れ替えた、scriptの後ろにスタイルシートあるので-->
  <!-- DOMContentLoadedはcssの読み込みを待たずに実行するはず -->
  <script type="text/javascript">
  document.write('exec foo.js: ' + (Date.now() - parseStart) + '<br>');
  </script>
  <link rel="stylesheet" href="./css/foo.css" type="text/css">
</head>
<body>
hello world
</body>
</html>

出力結果

exec foo.js: 0
hello world
fire DOMContentLoaded 1

f:id:nazomikan:20140202190712p:plain

(青線がDOMContentLoaded, 赤がonload)

表示されてから3秒後にスタイルがあたった感じになりました。

まとめ

スタイルシートそのものはDOMContentLoadedと直接関係しないけどscriptの実行が遅延させられることで間接的にDOMContentLoadedを遅らせる結果になる。

なのでDOMContentLoaded後に要素の座標計算するようなJSを書いてる場合、スタイルシートがscriptタグより後ろにある場合はDOMContentLoadedがスタイルシートの読み込みをまたないので、想定した(cssによって決定される)座標と違う座標がとれたりして死ぬので注意しないといけませんね。

(scriptの後ろにスタイルシート読み込んでるケースなんてほとんどみたことないけど)