【JavaScript】 必須知識?スコープとレキシカル環境
更新日:2020/03/01
JavaScriptに限らず、定義した変数の有効範囲(スコープ)を把握しておくことは重要です。
しかしJavaScriptのスコープは、他のプログラム言語と比較して少し特殊です。
そこで今回は、スコープについて解説してみます。
スコープとは
scope(スコープ)を日本語にすると範囲。
プログラム言語で使用する場合は、可視範囲といったほうがしっくりとくるかもしれません。
ここではJavaScriptのスコープについて簡単に解説します。
内から外は見える。外から内は見えない
関数の内側から外側の変数を使用できます。
しかし関数の外側から、関数内部の変数を使用することはできません。
let x = 1;
function a(){
let y = 2;
console.log( x , y ); // 1 2
}
console.log( x , y ); // 1 ReferenceError→関数aの変数yは見えない!
上の例では、外側から関数aの変数yを参照するとReferenceErrorになります。
同名の変数は近いものを使用
内側と外側で同じ名前の変数が定義されている場合、近い方(内側)の変数が参照されます。
let x = 1;
function a(){
let x = "hello";
console.log( x ) // hello
}
動的スコープと静的スコープ
プログラム言語でスコープは、大きく分けて動的と静的の二種類があります。
動的スコープ
関数を実行した位置で、関数のスコープが決まるのが動的スコープです。
動的スコープの例
let x = 1;
function a( ) {
console.log( x );
}
function b( ) {
let x = 2;
a( ); // 2
}
b( );
JavaScriptは動的スコープではないので、実際の結果は異なります。
ここでは疑似的な言語としておいてください。
上の例では関数b内で、関数aを実行しています。
関数aでは、関数bの変数xを参照しています。
function b( ) {
let x = 2;
function a( ) {
console.log( x );
}
}
関数aのコードが、関数bに中に入ったイメージですね。
静的スコープ
静的スコープは、定義された位置でスコープが決まります。
レキシカルスコープとも呼ばれています。
JavaScriptを含むほとんどのプログラム言語が、静的スコープです。
静的スコープの例
let x = 1;
function a( ) {
console.log( x );
}
function b( ) {
let x = 2;
a( ); // 1
}
b( );
動的スコープとは異なり、関数aが宣言されている位置のスコープでxを判断します。
よって、xは1です。
関数式でも同じです。
let x = 1; // (1)
let a = function( ) {
console.log( x );
};
let b = function( ) {
let x = 2;
a ( ); // 1
let b2 = function( ) {
let x = 3;
a ( ); // 1
};
b2( );
};
b( );
関数aをどこで実行しても、(1)のxを参照しているのがわかりますね。
備考:レキシカルスコープ
静的スコープの別名をレキシカルスコープといいます。
レキシカル(lexical)は字句や語彙という意味で、ソースコード上で文字として入力されている位置でスコープが決まる様子から用いられた言葉です。
JavaScriptの場合、大元の言語使用であるECMAScriptでも、レキシカルという言葉が頻繁に使用されているので、静的スコープよりもレキシカルスコープという言葉が合っているようです。
参考:【JavaScript】 ECMAScriptってなに?
備考:グローバルスコープ
JavaScriptにはグローバルスコープという言葉があります。
グローバルスコープは内側から外側に参照していってとき、一番外側になるスコープです。
ただし単に関数の外側のスコープとして用いているケースもあります。
JavaScriptは環境を記憶する
次の例を見てください。
let message = "さんこんにちは";
function a( name ){
let m = name + message;
return function ( ){
console.log( m );
};
}
let a2 = a( "Taro" );
let a3 = a( "Hanako" ); // 2回呼び出したので変数mが上書きされるはず
a2( ); // Taroさんこんにちは ← 上書きされていない!!
a3( ); // Hanakoさんこんにちは
関数aは、関数aに与えた引数nameに外部のmessage を付加した文字列を出力する関数を返します。
JavaScriptはレキシカルスコープなので、関数a内で定義されている無名関数は、関数aの変数mを参照できます。
しかし関数aを2回呼び出しているため、変数mは後から呼び出した内容で上書きされているはずですが…
上書きされていませんね。
実は関数aでは、関数aの引数や変数を記憶して、無名関数とひとまとめにして返しています。
つまり次の図のように、別々のレキシカルスコープが作成されているのです。
message : "さんこんにちは";
a2 :
name : "Taro"
m : "Taroさんこんにちは"
function ( ) {
console.log( m );
};
a3 :
name : "Hanako"
m : "Hanakoさんこんにちは"
function ( ) {
console.log( m );
};
これがJavaScriptの最も大きな特徴です。
レキシカルスコープの仕組み・スコープチェイン
内側から外側に、スコープをたどっていく仕組みをスコープチェインといいます。
JavaScriptのレキシカルスコープは、このスコープチェインで実現されています。
スコープチェインに関連するオブジェクト
まずはスコープチェインで利用されるオブジェクトについて、簡単に紹介します。
環境レコード(オブジェクト)
関数のローカル変数や引数がセットされたオブジェクトです。
レキシカル環境(オブジェクト)
環境レコードと、親(外側)のレキシカル環境への参照がセットされたオブジェクトです。
スコープオブジェクトと呼ばれることもある?
この参照をたどっていくことで、外側の変数を探すことができる。
クロージャ
関数オブジェクトにレキシカル環境(オブジェクト)をセットしたものを、クロージャと呼ぶことがある。
関数オブジェクトにレキシカル環境は必ずセットされるので、関数オブジェクト、イコール、クロージャである。
グローバルレキシカル環境
スクリプトが実行されると、一番外側になるレキシカル環境が作成されます。
これがグローバルレキシカル環境です。
関数定義
関数宣言や関数式で関数を定義すると、関数オブジェクトが作成されます。
そのとき親となる、外側のレキシカル環境オブジェクトへの参照が、関数オブジェクトにセットされます。
function a( ) { // グローバルレキシカル環境への参照を持つ
function b( ) { // 関数aのレキシカル環境への参照を持つ
function c ( ) { // 関数bのレキシカル環境への参照を持つ
}
}
}
この時点で、内から外へと参照をたどるスコープチェインの仕組みができていますね。
関数呼び出し
関数が呼び出されると関数自身のレキシカル環境オブジェクトが作成されます。
またローカル変数も新しく作成されます。
そしてレキシカル環境オブジェクト内の環境レコードに、引数や変数がセットされます。
つまり関数を呼び出すごとに、新規で変数や引数の情報を持ったオブジェクトが作成されるのです。
この結果同じ関数でも、呼び出しごとに変数の値が維持されます。
ただし、通常は関数終了とともにローカル変数やレキシカル環境は破棄されます。
維持されるパターン
GC(ガベージコレクション)は一定のタイミングで、使用されなくなったオブジェクトや変数を削除する仕組みです。
どこからも参照されなくなったとき、使用されていないとみなされます。
次の例は、参照が残らないパターンです。
function a( name ) {
console.log( name );
}
そして次が参照が残るパターンです。
function a( name ){
let m = name;
return function ( ){
console.log( m );
};
}
let a2 = a( "Taro" );
変数a2は、関数aで作成した関数オブジェクトです。
この関数オブジェクトのレキシカル環境オブジェクトは、関数aのレキシカル環境オブジェクトを参照しています。
関数aのレキシカル環境オブジェクトは、その外側のレキシカル環境オブジェクトを参照しています。
そしてその外側へと、チェーンしているわけです。
このように参照がのこっているため、GCの対象になりません。
破棄されないので、変数は維持されます。
Goole Chromeでスコープチェーンを確認してみよう!
関数オブジェクトとスコープチェーンの関係をGoogle Chromeのデベロッパツールで確認してみます。
デベロッパツールの開き方は、次のリンク先を見てください。
Webサイトのスタイル(css)をブラウザで調べる方法
コードの準備
次のコードを実行して、スコープを確認してみます。
JavaScript
let c = "abcde";
let d = 12345;
let a = function( arg1 ){
let x = 10;
return function ( arg2 ) {
let y = 20;
console.log(x + arg1 );
return function () {
console.log (y + arg2 );
}
}
};
let a1 = a();
let a2 = a1();
上のコードだけではブラウザで動作しないので、htmlで記述する。
html例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script>
<!-- JavaSriptコードを記述 -->
</script>
</head>
<body>
</body>
</html>
確認手順
(1) 作成したhtmlをGoogle Chromeで開く
(2) デベロッパツールを開く
(3) Sourceタブを開く
(4) Pageタブから開いたhtmlを選択する
(5) ブレークポイントをセットする
(6) htmlを再読み込み
(7) Scopeタブで確認したい変数を開き、[[Scopes]]を確認する。
内容の解説
関数aから返された関数がセットされた、変数a1のスコープを確認してみます。
デベロッパツール
▼Script
c: "abcde"
d: 12345
▶a: f ()
▶a1: f ()
length: 0
・・・・(省略)
▼[[Scopes]]: Scopes[3]
▶0: Closure (a) {arg1: 1000 , x: 10}
▶1: Script {c: "abcde", d: 12345, a f, a1 f}
▶2: Global {parent: Window, opner: null, top: Window, ...}
▶a2: f ()
[[Scopes]]に3つのスコープがセットされているのがわかります。
・・・ 0:
<script> ・・・ 1:
let a = function( arg1 ){ 0:
return function ( arg2 ) { ・・・ a1
}
}
</script>
1つめ「0: Closure (a) {arg1: 1000, x: 10}」は、関数aへのスコープです。
Closureと表示されているので、Google Chromeでは関数をクロージャとして認識しているのがわかります。
2つめ「1: Script {c: "abcde", d: 12345, a f, a1 f}」は、scriptタグ直下のスコープです。
a1で使用していない変数cやdがセットされているのがわかります。
3つめ「2: Global {parent: Window, opner: null, top: Window, ...}」は、グローバルスコープです。
JavaScriptで参照できる最上位のスコープです。
例えばscriptタグ直下の変数cを関数a1から参照する場合、次のようにスコープを順番にチェックしていきます。
a1のローカル変数をチェック
↓
関数aのスコープをチェック
↓
scriptタグ直下のスコープをチェック
変数a2については、ご自分で確認してみてください!
全ての変数が維持されるわけではない?
JavaScriptのレキシカル環境は外部の変数を維持します。
しかし全ての変数を維持するのも問題です。
例えば次のような、Web上のhtmlを取得して、タイトルとディスクリプションを抜き出すコードがあるとします。
loadHtml()とgetHtmlTitle()・getHtmlDescription()は別のコードで定義されている仮想的な関数とします。
function a( url ){
let htmlData = loadHtml( url ) // 外部htmlを呼ぶ
let title = getHtmlTitle(htmlData);
let description = getHtmlDescription(htmlData);
return {
getTitle : function(){ return obj.title;},
getDescription : function(){ return obj.title;}
}
}
let b = a( "https://・・・・" );
b.getTitle();
ここでhtmlDataは、かなり大きな容量になることが予想されます。
しかもレキシカル環境の仕組みから考えると、この関数を呼び出すたびに別環境が作成されて、個別にhtmlDataの参照が維持されそうです。
リターンしている関数内ではhtmlDataを使っていないので、関数aが終了したらhtmlDataを破棄して欲しいですね。
実際の参照はどうなっているのか、関数getTitle()のスコープをデベロッパツールで確認してみます。
デベロッパツール
▼Script
▼b:
▼getTitle:: f ()
length: 0
・・・・(省略)
▼[[Scopes]]: Scopes[3]
▶0: Closure (a) {title: "・・・" , description: "・・・"}
▶1: Script {b: {…}}
▶2: Global {parent: Window, opner: null, top: Window, ...}
▶getDescription: f ()
getTitle()内でtitleとdescriptionを使用しているため、スコープ上で値が保持されています。
しかしhtmlDataは保持されていません。
どうやらブラウザの内部的にうまくやっているようです。
無駄なデータが保持される心配はしなくてよさそうですね。
ただし次のコードのように関数宣言すると、関数を使用していなくても変数が維持されてしまうので注意が必要です。
function a( url ){
let htmlData = loadHtml( url ) // 外部htmlを呼ぶ
let title = getHtmlTitle(htmlData);
let description = getHtmlDescription(htmlData);
function xxx( ){ // htmlDataを参照する関数
console.log( htmlData );
}
return {
getTitle : function(){ return obj.title;},
getDescription : function(){ return obj.title;}
}
}
let b = a( "https://・・・・" );
b.getTitle();
変数bから、xxx()はどうやっても呼び出すことができません。
ですが、 htmlData がスコープ内にあります!!
↓ ↓ ↓ ↓ ↓
デベロッパツール
▼Script
▼b:
▼getTitle:: f ()
length: 0
・・・・(省略)
▼[[Scopes]]: Scopes[3]
▶0: Closure (a) {title: "・・・" , description: "・・・" , htmlData: "・・・" }
▶1: Script {b: {…}}
▶2: Global {parent: Window, opner: null, top: Window, ...}
▶getDescription: f ()
使わないので、ホントにムダですね。
デバッグ用に作成して消し忘れとか、あるかもしれません…
あるいはコールバックで渡すとか。
setInterval(
function(){ console.log( htmlData ); }
, 1000 );
htmlData全体が必要なら上のコードは仕方ありませんが、一部だけでいいなら、別の変数に抜き出しておきましょう。
let tmp = 抜き出す関数( htmlData );
setInterval(
function(){ console.log( tmp ); }
, 1000 );
こうすることでムダなメモリの維持が最小限ですみます。
念のため、コードの最後でnullを代入しておくと保険になります。
htmlData = null;
ちなみに、同じ関数内で作成された関数は、同じレキシカル環境オブジェクトを参照します。
グローバル環境オブジェクトまで参照
↑ 参照
関数aを作成した関数の呼び出しで作成された環境オブジェクト
xxxxx : "・・・";
xxxxx : "・・・";
↑ 参照
関数aの呼び出しで作成された環境オブジェクト
htmlData : "・・・・";
title : "・・・・";
description : "・・・・";
xxx : ↑ 参照
function ( ) {
console.log( htmlData );
};
b.getTitle : ↑ 参照
function ( ) {
return title;
};
b.getDescription : ↑ 参照
function ( ) {
return description;
};
スコープ上の変数を変更すると、他の関数も影響を受ける様子がイメージできますね。
ブロックレベルのレキシカル環境
JavaScriptのレキシカル環境は関数スコープだけではなく、ブロックスコープでも構築されています。
ブロックごとにレキシカル環境が作成される
ブロックスコープとは { } で囲われた内側です。
次の例を見てください。
function a(){
let obj = {};
let i = "外側";
obj.f1 = () => console.log( i );
{
let i = "内側";
obj.f2 = () => console.log( i );
obj.f3 = [];
for( let i = 0 ; i < 3 ; i ++){
obj.f3[ i ] = () => console.log( i );
}
}
return obj;
}
const b = a( );
obj.f1( ); // 外側
obj.f2( ); // 内側
obj.f3[ 0 ]( ); // 0
obj.f3[ 1 ]( ); // 1
obj.f3[ 2 ]( ); // 2
この例は、変数i の動きを追っています。
forループの { } だけでなく、単なる { } でもi の値が維持されているのがわかるともいます。
ここで注目するのが、forループです。
ここでは3つのアロー関数が作成されています。
基本的に同じブロック内で作成した関数は、同じレキシカル環境への参照を持ちます。
例えば関数内で二つの関数を定義するコードがあるとします。
function a(){
let x = 100;
function a1(){ console.log( x ); };
function a2(){ console.log( x ); };
}
このとき変数xを含むレキシカル環境が一つ作成され、二つの関数は、ともに同じレキシカル環境を参照します。
では先ほどのforループは、どうでしょうか?
for( let i = 0 ; i < 3 ; i ++){
obj.f3[ i ] = () => console.log( i );
}
同じレキシカル環境を参照しているとしたら、i はカウントアップ後の値となっているはずです。
しかし結果は次の通り。
obj.f3[ 0 ]( ); // 0
obj.f3[ 1 ]( ); // 1
obj.f3[ 2 ]( ); // 2
アロー関数が作成された時点での i の値が表示されています。
つまりループ毎にレキシカル環境が作成されているということです。
Google Chromeのデベロッパツールで確認してみます。
デベロッパツール
▼Script
▼b:
・・・・(省略)
▼f3:: Array(3)
▼0:: () => console.log( i )
・・・・(省略)
▼[[Scopes]]: Scopes[5]
▶0: Block (a) {i: 0} // ←ここ注目
▶1: Block (a) {i: "内側"}
▶2: Closure (a) {i: "外側"}
▶3: Script {b: {…}}
▶4: Global {parent: Window, opner: null, top: Window, ...}
▼1:: () => console.log( i )
・・・・(省略)
▼[[Scopes]]: Scopes[5]
▶0: Block (a) {i: 1} // ←ここ注目
▶1: Block (a) {i: "内側"}
▶2: Closure (a) {i: "外側"}
▶3: Script {b: {…}}
▶4: Global {parent: Window, opner: null, top: Window, ...}
▼2:: () => console.log( i )
・・・・(省略)
▼[[Scopes]]: Scopes[5]
▶0: Block (a) {i: 2} // ←ここ注目
▶1: Block (a) {i: "内側"}
▶2: Closure (a) {i: "外側"}
▶3: Script {b: {…}}
▶4: Global {parent: Window, opner: null, top: Window, ...}
それそれが、専用の変数i を所持していることがわかると思います。
変数をvarで宣言すると?
先ほどの例のforループで変数i をvarで宣言してみます。
for( var ii = 0 ; ii < 3 ; ii ++){
obj.f3[ ii ] = () => console.log( ii );
}
letで宣言した変数と同名のものをvarで宣言できないので、ii に変更してあります。
実行すると、次のようになります。
obj.f3[ 0 ]( ); // 3
obj.f3[ 1 ]( ); // 3
obj.f3[ 2 ]( ); // 3
最初に予想したもの(i はカウントアップ後の値となっているはず)と同じ結果になりました。
変数をletで宣言するとその変数はブロックスコープとなり、ブロック内でのみ有効です。
しかしvarは関数スコープとなり、関数全体で有効となります。
function a() {
var x = "x";
let y = "y";
{
var x = 100;
let y = 200;
}
console.log( x ); // 100
console.log( y ); // y
}
a( );
varは関数内のどこで宣言しても、全て同じ変数となります。
デベロッパツールで見てみましょう。
デベロッパツール
▼Script
▼b:
・・・・(省略)
▼f3:: Array(3)
▼0:: () => console.log( i )
・・・・(省略)
▼[[Scopes]]: Scopes[4]
▶0: Block (a) {i: "内側"}
▶1: Closure (a) {i: "外側", ii: 3} // ← ii はここにいる!!
▶2: Script {b: {…}}
▶3: Global {parent: Window, opner: null, top: Window, ...}
▼1:: () => console.log( i )
・・・・(省略)
▼[[Scopes]]: Scopes[4]
▶0: Block (a) {i: "内側"}
▶1: Closure (a) {i: "外側", ii: 3} // ← ii はここにいる!!
▶2: Script {b: {…}}
▶3: Global {parent: Window, opner: null, top: Window, ...}
▼2:: () => console.log( i )
・・・・(省略)
▼[[Scopes]]: Scopes[4]
▶0: Block (a) {i: "内側"}
▶1: Closure (a) {i: "外側", ii: 3} // ← ii はここにいる!!
▶2: Script {b: {…}}
▶3: Global {parent: Window, opner: null, top: Window, ...}
forループで定義したiiは、"外側"と同じレキシカル環境にセットされていますね。
実はforのループごとに、レキシカル環境は作成されています。
しかしループ内で作成した関数で参照されていないので、最適化されてスコープチェイン上にあらわれていないのです。
まとめ
JavaScriptは関数の中に関数を定義できるという不思議ちゃんな言語です。
そのため本来単純なはずのレキシカルスコープが複雑化し、JavaScript初心者を悩ませることとなったのです。
頑張って理解しましょう。
更新日:2020/03/01
関連記事
スポンサーリンク
記事の内容について
こんにちはけーちゃんです。
説明するのって難しいですね。
「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。
裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。
掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。
ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php
このサイトは、リンクフリーです。大歓迎です。