2015年9月7日

RegExp.$1 と String.prototype.replace の悲惨な関係



この記事は全体として非推奨の機能の挙動について扱っています。コードを書く時の参考にしたりしないでください。

この記事は Firefox 44 までの挙動をもとに書いています。詳しくは 続報の記事 を参照してください。

RegExp非推奨プロパティRegExp.$1 なんてのがありますが、これが String.prototype.replace と大変相性がよろしくないというお話です。

そもそもこの RegExp.$1 というのは仕様が存在しないにも関わらずおおよそ全てのブラウザで実装されているというもので、この時点で互換性において悲惨な予感しかしないわけです。

基本的な動作としては、最後に行った RegExp.prototype.exec もしくは同等の操作の結果をグローバルな RegExp のプロパティに保存し、これを参照できるというものです。

/(a)/.exec("a");
console.log(RegExp.$1); // a

これは内部的に RegExp.prototype.exec を呼び出す String.prototype.matchString.prototype.searchString.prototype.replace でも同様です。

"a".replace(/(a)/, "b");
console.log(RegExp.$1); // a

ここで問題になるのが、String.prototype.replace は第二引数 replaceValue として関数が渡せるということです。最初の観測結果としては、String.prototype.replace を呼び出したで、その結果が取り出せましたが、この関数の中ではどうでしょう。

"a".replace(/(a)/, function() {
  console.log(RegExp.$1);
});

結果は以下のように、ブラウザによって異なっています。

Firefox
40.0.3
Chrome
45.0.2454.85
Safari
8.0.8
Edge
20.10240.16384.0
IE
11.0.10240.16384
a a a "" ""

Edge と IE では関数を呼び出す段階では結果が入っていません。あくまでも String.prototype.replace の後で結果を取り出すもの、という扱いのようです。

今度は、この関数の中でさらに RegExp.prototype.exec を呼び出して、String.prototype.replace の後の状態を見てみましょう。

"a".replace(/(a)/, function() {
  /b/.exec("b");
});
console.log(RegExp.$1);

結果は以下のようになりました。

Firefox Chrome Safari Edge IE
a b b a a

Chrome と Safari では関数内で実行した RegExp.prototype.exec の結果のままになっています。つまり、こちらはあくまでも最後に行ったマッチングの結果という扱いのようです。ここまでの動作をまとめると以下のようになります。

  • Firefox: 関数を呼び出す前に格納、呼び出した後に復元
  • Chrome と Safari: 関数を呼び出す前に格納
  • Edge と IE: 関数を呼び出した後で結果を格納

では、さきほどのコードで RegExp.prototype.exec を実行する代わりに例外を投げてみましょう。

try {
  "a".replace(/(a)/, function() {
    throw 1;
  });
} catch (e) {
}
console.log(RegExp.$1);

結果はこうでした。

Firefox Chrome Safari Edge IE
a a a "" ""

Edge と IE では、関数が例外を投げた場合、結果は格納されません。

まとめると以下のような挙動になります。

  • Firefox: 関数を呼び出す前に格納、呼び出した後に、関数の実行結果によらず復元
  • Chrome と Safari: 関数を呼び出す前に格納
  • Edge と IE: 関数を呼び出した後で、関数が正常に終了すれば結果を格納

いろいろ組み合わせて遊んでみましょう。

                                    //  Firefox | Chrome | Safari | Edge | IE
/(a)/.exec("a");                    // ---------+--------+--------+------+----
console.log(RegExp.$1);             //     a    |    a   |    a   |   a  |  a
"b".replace(/(b)/, function() {     //          |        |        |      |
  try {                             //          |        |        |      |
    console.log(RegExp.$1);         //     b    |    b   |    b   |   a  |  a
    "c".replace(/(c)/, function() { //          |        |        |      |
      console.log(RegExp.$1);       //     c    |    c   |    c   |   a  |  a
      /(d)/.exec("d");              //          |        |        |      |
      console.log(RegExp.$1);       //     d    |    d   |    d   |   d  |  d
      throw 1;                      //          |        |        |      |
    });                             //          |        |        |      |
  } catch (e) {}                    //          |        |        |      |
  console.log(RegExp.$1);           //     c    |    d   |    d   |   d  |  d
});                                 //          |        |        |      |
console.log(RegExp.$1);             //     b    |    d   |    d   |   b  |  b

もはや全てのブラウザで同じ値を返す状況の方が少ないです。

この RegExp.$1、もちろん ES6 にも入っていませんが、代わりに String.prototype.replace の挙動の方にかなりの変更が入っています。具体的には、第一引数 searchValue@@replace プロパティによって挙動を変更できます。更に、RegExpExec の中で明示的に exec プロパティを呼ぶという風に定義されているので*1exec プロパティを変更すると RegExp を渡したにも関わらず RegExp.$1 の値に変化が無い、という状況が様々に発生します。

var re = /foo/;
re[Symbol.replace] = function() {
  return "hello";
};
console.log("abc".replace(re, "X"));  // hello

var re2 = /foo/;
re2.exec = function() {
  return { 0: "a", index: 1, length: 1 };
};
console.log("abc".replace(re2, "X")); // aXc

ここで問題になるのが Firefox と Edge、IE の挙動です。このような状況で、RegExp.$1 はどう変化すべきでしょうか。RegExp.prototype.exec も何も実行されていない場合には関数を呼んだ後に格納するものがありません。おそらく RegExp.prototype.exec が実行されたかどうか、もしくは実際に searchValue[@@replace] を実行した後の RegExp.$1 の状況によって挙動を変える、という事になるでしょうか。エンジン間の差がより大きくなりそうです。

まとめ:RegExp.$1 とかはゼッタイに使わないでね


*1 ES5 の時点では "Do the search in the same manner as in String.prototype.match, including the update of searchValue.lastIndex." として、挙動は実装まかせでした。

0 件のコメント:

コメントを投稿