読者です 読者をやめる 読者になる 読者になる

Kyle Simpson「Advanced JavaScript」学習メモ(1)スコープ

JavaScript

Pluralsightの「Advanced JavaScript」(Kyle Simpson)学習メモ。

 

  • スコープとJavaScriptコンパイラ

JavaScriptのコンパイラは、コンパイルと実行の2つのフェーズに分けられる(実際はさまざまな高速化のためのテクノロジーがあるが、ここでは単純化する)。

 

<例1>

var foo = "bar";

function bar() {
  var foo = "baz";
}

function baz(foo) {
  foo = "bam";
  bam = "yay";
}

 

コンパイルフェーズではすべての宣言を検索し、スコープごとに登録する。例1だと、

 グローバル:foo、関数bar、関数baz

 関数bar: foo

 関数baz: foo

のように登録される。ここでは代入や実行は行われない。

 

そして、実行フェーズで、各行が実行されていく。

たとえば1行目:

var foo = "bar";

代入式を見つけたコンパイラは、現在のスコープ(グローバル)に変数fooという名前のLHS(Left Hand Side)参照がないか問い合わせる。そしてスコープは対応する変数を返し、代入が行われる。

 

注意が必要なのは、最後のbamへの代入。

function baz(foo) {
  foo = "bam";
  bam = "yay";
}

bazには変数bamのLHSがない。そのため、コンパイラは外側のグローバルスコープにbamのLHS参照を問い合わせる。ここでグローバルスコープは、変数bamが存在しないため新たに作成して返してしまう。”use strict”を宣言してstrictモードにすればこの問題は回避できる(当該行で参照エラーになる)。

LHS参照がないことを、undeclaredと呼ぶ。undefinedとの混同に注意である。undefinedは、宣言はされているが、undefinedという特別な値が保持されているということ。

 

<例2>

var foo= "bar";

function bar() {
  var foo = "baz";

  function baz(foo) {
    foo = "bam";
    bam = "yay";
  }
  baz();
}

bar();
foo;
bam;
baz();

 

上記例2では、まずコンパイルフェーズで

 グローバル:foo、関数bar

 関数bar: foo、関数baz

 関数baz: foo

がように登録される。

 

実行フェーズの13行目:

bar();

グローバルスコープに、barという名前のRHS(Right Hand Side: LHS以外)を問い合わせている。グローバルスコープは対応する関数オブジェクトを返し、”()”があるため該当関数が実行される。そして、bar内のスコープで変数fooへの代入以下が継続される。

 

最終行:

baz();

グローバルスコープにRHSを問い合わせているが、存在しないため参照エラーとなる。LHSと異なり、RHSは自動的に作成されない。

 

 

  • 関数宣言と関数式

 functionキーワードが式の最初にあったら関数宣言、そうでなければ関数式になる。

 

var foo = function bar() {
  var foo = "baz";
  
  function baz(foo) {
    foo = bar;
    foo;
  }
  baz();
};

 

この例では、1行目の関数barは関数式で表現されている。そのため、barのスコープはグローバルではなく、関数bar自身の中になる。

関数式による関数は匿名にせず、必ず名前を付けるべき。

  1. 関数内で、関数自身を参照することができない
  2. スタックトレースを追いづらく、デバッグが難しい
  3. 関数名で機能を表現できない

 

 

  • ブロックスコープ

function以外にも、例外としてcatchがブロックスコープを持つ。これを利用し、汎用的にブロックスコープを実現させるライブラリもある(後述)。

 

 

  • 構文スコープ(Lexical Scope、静的スコープ)

JavaScriptには構文スコープのみ存在する。構文スコープとは、コンパイル時に構文を解析してスコープを決定する方法で、ほとんどの言語で使われている。動的スコープを採用する言語は少数。

 

 動的スコープ

コードの構文ではなく、関数の呼び元を参照して解決する。動的に解析するのでランタイムのオーバーヘッドがある。

JavaScriptではthisがその役割を担う。

 

 

  • evalステートメント

evalはコード全体のパフォーマンスが悪化するため、使うべきではない。

 

var bar = "bar";

function foo(str) {
  eval(str);
  console.log(bar); //42
}

foo("var bar = 42;");

 

4行目でevalが実行されたとき、JavaScriptエンジンはあたかもコンパイルタイムに「var bar = 42;」が存在していたかのようにふるまい、スコープを参照する。そのためevalがあるとコンパイル時の最適化が難しくなる。

なお、strictモードだとeval専用のスコープが作られるため、パフォーマンス低下は小さい。

 

 

  • withステートメント

withステートメントは次の理由で使うべきではない。

  1. 誤ってグローバルスコープに変数を作ってしまいやすい
  2. パフォーマンスが悪化する。withはランタイムに構文スコープを作成するため、コンパイルタイムの最適化が働かない

なお、 strictモードではwithキーワードは禁止されている。

 

 

  •  IIFE(Immediately Invoked Function Expression)

スコープを作ってグローバルスコープの汚染を防ぐため、無名の即時実行関数を作るパターンで、とてもよく使われる。ただし、この場合も関数に名前を付けることを推奨する(例:iife)。

 

var foo = "foo";

(function() {
  var foo = "foo2";
  alert(foo); //foo2
})();

alert(foo); //foo

Ben Alman » Immediately-Invoked Function Expression (IIFE)

 

以下のように、IIFEにグローバルオブジェクトを渡して、関数内では引数globalとして扱うと、メンバのpublicとprivateを区別しやすい。

(function(global) {
  
  //local functions
  function a() {
    ...
  };
  function b() {
    ...
  };
  
  //global function to expose outside
  global.globalFunction = function globalFunction(){
    ...
  };
  
})(window);

 

また、引数で”JQuery”を渡し、IIFEでは$ で受け取れば、関数内で$が確実にJQueryオブジェクトと保証される。

 

なお、次のスタイルを好むプログラマも多い(Douglas Crockfordなど)が、機能的には等価である。

(function() {
  ...
}());

 

 

  • letキーワード(ES6)

 letキーワードはブロックスコープになる。varの代替になるものではなく、使い分けるべき。

 

"use strict"
function foo() {
  var bar = "bar";
  for(let i=0; i<bar.length; i++) {
    alert(bar.charAt(i));
  }
}

 

letキーワードの注意点

  • ブロックの途中に置いても、スコープはブロック全体になる。常にブロック先頭に置くようにする
  • リファクタリングしづらくなる。誤って意図しないブロックスコープを作らないように気をつける
  • 暗黙的にスコープを作るので、メンテナンスしづらい。スコープであることを明示するようにする

 

たとえばスコープを明示する方法として、Keyle Simpsonが作成したライブラリを使う方法がある(ES3以降で動作)。

 

//need to use https://github.com/getify/let-er

"use strict"
function foo(bar) {
  let (baz) {
    baz = foo;
    console.log(baz);
  }
}

※let-erはブロックスコープを実現するため、内部的にcatchを利用している

 

ES6について

ES6はまだスタンダードではないが、開発者はすぐ使い始めるべきである。Babelなどを使えばES5に変換できる。

babeljs.io

 

 

  • 巻き上げ(Hoisting

LHS(varや関数宣言)はコンパイルフェーズで予め解析されて、該当するスコープに登録されている。そのため、スコープ内のLHSはすべてスコープ先頭に置かれたのと同じ意味になる。これを巻き上げと呼ぶ。

 

var a = b(); //OK。bはコンパイリングフェーズで関数として登録済
var c = d(); //エラー。dはLHSとして登録済だが、まだ関数が代入されていない

function b() {
  return c;
}

var d = function() {
  return b();
};

 

上記コードは2行目でエラーになる。これは、関数式がコンパイルフェーズではスコープとして登録されないから。

 

巻き上げは、たとえば相互再帰を実現するため必要な機能である。つまり、巻き上げがないと関数Aが関数Bを呼び、さらにBがAを呼ぶような処理が書けない。

 

なお、letで宣言された変数は、宣言前に参照しようとすると(たとえスコープ内でも)ReferenceErrorになる。これをTemporal Dead Zone(TDZ)と呼ぶ。

 

 

  • thisキーワード

thisへの値の束縛は4つのルールがある。どのルールが適用されるかは、関数の呼び出し位置と呼ばれ方に依存する。関数の定義のされ方は無関係。

 

関数のみでの呼び出し(デフォルト)

thisはstrictモードではundefined、non-strictモードではグローバルオブジェクトになる。

 

オブジェクト参照として呼び出し(暗黙的な束縛)

thisは参照元のオブジェクトになる。なお、thisはダイナミックスコープであり、構文スコープへのクロス参照は不可能である

function foo() {
  var bar = "bar1";
  baz();
}

function baz() {
  alert(this.bar);
}

var bar = "bar2";
foo(); //bar2

よくあるミスが上記のようなコード。構文スコープと混同すると、最終行でbar1を期待してしまう。関数bazは関数のみで呼び出されているので、thisはグローバルオブジェクトになる。

 

call、apply、bindを使った呼び出し(明示的な束縛)

call、applyを使うと、引数で渡されたオブジェクトがthisになる。

bindではハードバインディングが実現できる。以下コードでは、呼び出し方にかかわらず、関数foo内のthisは必ずobjになる。

function foo() {
  alert(this.bar);
}

var obj = { bar: 'bar' };
var obj2 = { bar: 'bar2' };

foo = foo.bind(obj);

foo();  //bar
foo.call(obj2); //bar

 

newキーワードでの呼び出し

newキーワードを関数コールの前に置く(関数であれば何でもよい。このとき該当関数はコンストラクタ関数と呼ばれる)と、次の4つが実行される。

  1. 空オブジェクトを作成
  2. このオブジェクトを関数に関連付ける(関数オブジェクトのprototypeを継承する)
  3. このオブジェクトを使って、該当の関数を呼び出す(=thisに設定)
  4. Returnのない関数なら、関数の実行終了時に、暗黙的にこのオブジェクト(this)を返す
function foo() {
  this.baz = "baz";
  alert(this.bar + " " + baz);  //undefined undefined
}

var bar = "bar";
var baz = new foo();

alert(baz.baz); //baz

(3行目、bazは存在するがアサインメント前なのでundefinedになる)

 

thisに束縛される値の優先順位は以下のとおり。bindよりもnewが優先される

  1. newキーワードによる呼び出し。
  2. call、apply、またはbindによる明示的な束縛を伴った呼び出し
  3. オブジェクト参照による、暗黙的な束縛での呼び出し
  4. 関数のみでの呼び出し