コハム

Webクリエイターのコハムが提供する、Web制作に役立つ情報をまとめたブログです。

JavaScript初心者からの卒業のために:開発者が陥りがちな10の罠

The 10 Most Common JavaScript Issues Developers Face

記事は上記記事を意訳したものです。

※当ブログでの翻訳記事は元サイト様に許可を得て掲載しています。


今日、JavaScriptはほぼすべての最新のWebアプリケーションの中核にあります。そのため、JavaScript関連の問題とそれらを引き起こすミスを見つけることは、Web開発者にとって最も重要な課題となっています。

シングルページアプリケーション(SPA)開発、グラフィックスやアニメーション、サーバーサイドJavaScriptプラットフォーム用の強力なJavaScriptベースのライブラリやフレームワークは、もはや目新しいものではありません。JavaScriptはWebアプリ開発の世界で普遍的な存在となり、そのためますます重要なスキルとして習得する必要があります。

一見、JavaScriptは非常にシンプルに見えるかもしれません。確かに、Webページに基本的なJavaScript機能を組み込むことは、JavaScriptに慣れていない経験豊富なソフトウェア開発者にとっても、比較的簡単な作業です。しかし、この言語は当初考えられているよりも遥かに微妙で、強力で、複雑です。実際、JavaScriptの多くの微妙な点が、機能しない原因となる一般的な問題につながることがあります。ここでは、そのような10の問題について議論します。JavaScriptの達人開発者になる道のりで、これらの問題を認識し、回避することが重要です。

JavaScript問題1: thisの不適切な参照

JavaScript開発者の間で、JavaScriptのthisキーワードに関する混乱は少なくありません。

JavaScriptのコーディング技術とデザインパターンが年々洗練されるにつれて、コールバックやクロージャー内での自己参照スコープの増加に伴い、「this混乱」の原因となるJavaScript問題が増加しています。

以下のコードスニペットを考えてみましょう:

const Game = function() {
    this.clearLocalStorage = function() {
        console.log("Clearing local storage...");
    };
    this.clearBoard = function() {
        console.log("Clearing board...");
    };
};

Game.prototype.restart = function () {
    this.clearLocalStorage();
    this.timer = setTimeout(function() {
        this.clearBoard();    // この "this" は何を指しているのか?
    }, 0);
};

const myGame = new Game();
myGame.restart();

上記のコードを実行すると、次のエラーが発生します:

Uncaught TypeError: this.clearBoard is not a function

なぜでしょうか?これはすべてコンテキストに関係しています。このエラーが発生する理由は、setTimeoutを呼び出すと、実際にはwindow.setTimeoutを呼び出しているからです。その結果、setTimeoutに渡される匿名関数はwindowオブジェクトのコンテキストで定義されており、windowオブジェクトにはclearBoardメソッドがありません。

伝統的な、古いブラウザ互換の解決策は、thisへの参照を変数に保存し、それをクロージャーで継承させることです:

Game.prototype.restart = function () {
    this.clearLocalStorage();
    const self = this;   // thisへの参照を保存する(まだthisである間に)
    this.timer = setTimeout(function(){
        self.clearBoard();    // ああ、OK、'self'が誰かわかりました!
    }, 0);
};

あるいは、新しいブラウザでは、bind()メソッドを使って適切な参照を渡すことができます:

Game.prototype.restart = function () {
    this.clearLocalStorage();
    this.timer = setTimeout(this.reset.bind(this), 0);  // 'this'にバインド
};

Game.prototype.reset = function(){
    this.clearBoard();    // OK、正しい'this'のコンテキストに戻りました!
};

JavaScript問題2: ブロックレベルスコープが存在すると考えること

JavaScript採用ガイドで議論されているように、JavaScript開発者の間で一般的な混乱の源(そしてバグの一般的な原因)は、JavaScriptが各コードブロックに新しいスコープを作成すると仮定することです。これは多くの他の言語では真実ですが、JavaScriptでは真実ではありません。例えば、次のコードを考えてみましょう:

for (var i = 0; i < 10; i++) {
    /* ... */
}
console.log(i);  // これは何を出力するでしょうか?

console.log()の呼び出しがundefinedを出力するか、エラーを投げると推測した場合、その推測は間違いです。信じられないかもしれませんが、これは10を出力します。なぜでしょうか?

ほとんどの他の言語では、上記のコードはエラーを引き起こすでしょう。なぜなら、変数iの「寿命」(つまりスコープ)はforブロックに制限されるからです。しかし、JavaScriptではそうではありません。変数iはforループが完了した後もスコープ内に残り、ループを抜けた後も最後の値を保持します。(この動作は変数のホイスティングとして知られています。)

JavaScriptでのブロックレベルスコープのサポートは、letキーワードを使用することで利用可能です。letキーワードは、ブラウザやNode.jsのようなバックエンドJavaScriptエンジンで長年にわたり広くサポートされています。もしこれが新しい情報であれば、スコープ、プロトタイプなどについて読み込む価値があります。

JavaScript問題3: メモリリークの作成

メモリリークは、意識的に回避するようにコーディングしない限り、JavaScriptでほぼ避けられない問題です。メモリリークが発生する方法は数多くありますが、ここでは2つの一般的な例を紹介します。

メモリリーク例1: 廃止されたオブジェクトへのぶら下がり参照

注意:この例は、レガシーなJavaScriptエンジンにのみ適用されます。現代のエンジンには、このケースを処理できるスマートなガベージコレクタ(GC)があります。

次のコードを考えてみましょう:

var theThing = null;
var replaceThing = function () {
  var priorThing = theThing;  // 前のthingへの参照を保持
  var unused = function () {
    // 'unused'は'priorThing'が参照される唯一の場所ですが、
    // 'unused'は決して呼び出されません
    if (priorThing) {
      console.log("hi");
    }
  };
  theThing = {
    longStr: new Array(1000000).join('*'),  // 1MBのオブジェクトを作成
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);    // 'replaceThing'を1秒ごとに呼び出す

上記のコードを実行してメモリ使用量を監視すると、重大なメモリリーク(1秒あたり1メガバイト!)があることがわかります。そして、手動のガベージコレクタでさえ役に立ちません。replaceThingが呼び出されるたびにlongStrがリークしているように見えます。しかし、なぜでしょうか?

より詳細に検討してみましょう:

各theThingオブジェクトには、独自の1MBのlongStrオブジェクトが含まれています。毎秒、replaceThingを呼び出すと、前のtheThingオブジェクトへの参照がpriorThingに保持されます。しかし、これはまだ問題にならないと思われるかもしれません。なぜなら、毎回、以前に参照されたpriorThingは参照解除されるはずだからです(priorThing = theThing;によってpriorThingがリセットされる時)。さらに、これはreplaceThing本体とunused関数(実際には使用されない)でのみ参照されています。

そのため、ここでなぜメモリリークが発生するのか、再び疑問に思うでしょう。

理解するためには、JavaScriptの内部動作をより深く理解する必要があります。クロージャは通常、各関数オブジェクトがその字句スコープを表す辞書スタイルのオブジェクトにリンクすることで実装されます。replaceThing内で定義された両方の関数が実際にpriorThingを使用する場合、priorThingが何度も再割り当てされても、両方の関数が同じ字句環境を共有するため、両方が同じオブジェクトを取得することが重要です。しかし、変数がクロージャによって使用されるとすぐに、そのスコープ内のすべてのクロージャで共有される字句環境に入ります。そして、このちょっとした違いが、この厄介なメモリリークにつながるのです。

メモリリーク例2: 循環参照

このコードフラグメントを考えてみましょう:

function addClickHandler(element) {
    element.click = function onClick(e) {
        alert("Clicked the " + element.nodeName)
    }
}

ここで、onClickはelementへの参照(element.nodeNameを介して)を保持するクロージャを持っています。また、onClickをelement.clickに割り当てることで、循環参照が作成されます。つまり、element → onClick → element → onClick → element...となります。

興味深いことに、elementがDOMから削除された場合でも、上記の自己参照の循環によって、elementとonClickがコレクトされるのを防ぎ、そのためメモリリークとなります。

メモリリークの回避:基本

JavaScriptのメモリ管理(特にガベージコレクション)は、主にオブジェクトの到達可能性の概念に基づいています。

以下のオブジェクトは到達可能と見なされ、「ルート」として知られています:

  • 現在の呼び出しスタックのどこかから参照されているオブジェクト(つまり、現在呼び出されている関数のすべてのローカル変数とパラメータ、およびクロージャスコープ内のすべての変数)
  • すべてのグローバル変数

オブジェクトは、ルートのいずれかから参照または参照のチェーンを通じてアクセス可能である限り、少なくともメモリに保持されます。

ブラウザにはガベージコレクタがあり、到達不可能なオブジェクトが占有するメモリをクリーンアップします。言い換えれば、オブジェクトはGCが到達不可能だと判断した場合にのみメモリから削除されます。残念ながら、もはや使用されていないが、GCがまだ到達可能だと考えている「ゾンビ」オブジェクトが残ってしまうのは比較的簡単です。

JavaScript問題4: 等価性に関する混乱

JavaScriptの便利な点の1つは、ブール値のコンテキストで参照される任意の値を自動的にブール値に強制変換することです。しかし、便利であると同時に混乱を招く場合もあります。例えば、以下の式は多くのJavaScript開発者にとって問題があることで知られています:

// これらはすべて'true'と評価されます!
console.log(false == '0');
console.log(null == undefined);
console.log(" \t\r\n" == 0);
console.log('' == 0);

// そしてこれらも!
if ({}) // ...
if ([]) // ...

最後の2つに関しては、空である(falseと評価されると思われるかもしれない)にもかかわらず、{}と[]は実際にはオブジェクトであり、JavaScriptではECMA-262仕様に従って、どんなオブジェクトもブール値のtrueに強制変換されます。

これらの例が示すように、型強制のルールは時として泥沼のように不明確です。したがって、型強制が明示的に望まれない限り、通常は===と!==(==と!=ではなく)を使用して、型強制の意図しない副作用を避けるのが最善です。(==と!=は2つのものを比較する際に自動的に型変換を行いますが、===と!==は型変換なしで同じ比較を行います。)

型強制と比較について話している間に、NaNを何か(NaN自身でさえ!)と比較すると常にfalseを返すことを言及する価値があります。したがって、等価演算子(==、===、!=、!==)を使用して値がNaNであるかどうかを判断することはできません。代わりに、組み込みのグローバルisNaN()関数を使用します:

console.log(NaN == NaN);    // False
console.log(NaN === NaN);   // False
console.log(isNaN(NaN));    // True

JavaScript問題5: 非効率的なDOM操作

DOM要素の追加は高コストな操作であり、連続して複数のDOM要素を追加するコードは非効率的で、うまく機能しない可能性があります。

複数のDOM要素を追加する必要がある場合、代わりにドキュメントフラグメントを使用することが効果的な代替方法であり、効率とパフォーマンスが向上します。

例えば:

const div = document.getElementById("my_div");
const fragment = document.createDocumentFragment();
const elems = document.querySelectorAll('a');

for (let e = 0; e < elems.length; e++) {
    fragment.appendChild(elems[e]);
}
div.appendChild(fragment.cloneNode(true));

このアプローチの本質的な効率性の向上に加えて、接続されたDOM要素を作成することは高コストですが、切り離された状態で作成して変更し、その後接続する方がはるかに良いパフォーマンスをもたらします。

JavaScript問題6: forループ内での関数定義の不適切な使用

次のコードを考えてみましょう:

var elements = document.getElementsByTagName('input');
var n = elements.length;    // この例では10個の要素があると仮定します
for (var i = 0; i < n; i++) {
    elements[i].onclick = function() {
        console.log("This is element #" + i);
    };
}

上記のコードに基づくと、10個の入力要素がある場合、そのいずれかをクリックすると「This is element #10」と表示されます!これは、いずれかの要素に対してonclickが呼び出される時点で、上記のforループが完了しており、iの値がすでに10になっているためです(すべての要素に対して)。

この JavaScript の問題を修正して、望ましい動作を実現する方法は以下の通りです:

var elements = document.getElementsByTagName('input');
var n = elements.length;    // この例では10個の要素があると仮定します
var makeHandler = function(num) {  // 外部関数
     return function() {   // 内部関数
         console.log("This is element #" + num);
     };
};
for (var i = 0; i < n; i++) {
    elements[i].onclick = makeHandler(i+1);
}

このコードの改訂版では、makeHandlerはループを通過するたびに即座に実行され、その時点でのi+1の値を受け取り、スコープ付きのnum変数にバインドします。外部関数は内部関数(このスコープ付きのnum変数も使用する)を返し、要素のonclickはその内部関数に設定されます。これにより、各onclickが適切なi値を受け取り、使用することが保証されます(スコープ付きのnum変数を介して)。

JavaScript問題7: プロトタイプ継承の適切な活用の失敗

驚くほど多くのJavaScript開発者が、プロトタイプ継承の特徴を完全に理解せず、そのため十分に活用できていません。

簡単な例を見てみましょう:

BaseObject = function(name) {
    if (typeof name !== "undefined") {
        this.name = name;
    } else {
        this.name = 'default'
    }
};

これはかなり簡単に見えます。名前が提供されればそれを使用し、そうでなければ名前を'default'に設定します。例えば:

var firstObj = new BaseObject();
var secondObj = new BaseObject('unique');

console.log(firstObj.name);  // -> 'default'が結果として得られます
console.log(secondObj.name); // -> 'unique'が結果として得られます

しかし、次のようにした場合はどうでしょうか:

delete secondObj.name;

すると次のようになります:

console.log(secondObj.name); // -> 'undefined'が結果として得られます

しかし、これが'default'に戻るのがより良いのではないでしょうか?これは、元のコードをプロトタイプ継承を活用するように修正すれば簡単に実現できます:

BaseObject = function (name) {
    if(typeof name !== "undefined") {
        this.name = name;
    }
};

BaseObject.prototype.name = 'default';

このバージョンでは、BaseObjectはそのプロトタイプオブジェクトからname属性を継承し、そこではデフォルトで'default'に設定されています。したがって、コンストラクタが名前なしで呼び出された場合、名前はデフォルトでdefaultになります。同様に、BaseObjectのインスタンスからname属性が削除された場合、プロトタイプチェーンが検索され、name属性はプロトタイプオブジェクトから取得され、その値はまだ'default'です。そのため、次のようになります:

var thirdObj = new BaseObject('unique');
console.log(thirdObj.name);  // -> 'unique'が結果として得られます

delete thirdObj.name;
console.log(thirdObj.name);  // -> 'default'が結果として得られます

JavaScript問題8: インスタンスメソッドへの不適切な参照の作成

簡単なオブジェクトを定義し、そのインスタンスを作成してみましょう:

var MyObjectFactory = function() {}
    
MyObjectFactory.prototype.whoAmI = function() {
    console.log(this);
};

var obj = new MyObjectFactory();

次に、便宜上、whoAmIメソッドへの参照を作成しましょう。おそらく、obj.whoAmI()ではなく、単にwhoAmI()でアクセスできるようにするためです:

var whoAmI = obj.whoAmI;

そして、新しいwhoAmI変数に関数への参照が格納されていることを確認するために、その値を出力してみましょう:

console.log(whoAmI);

出力:

function () {
    console.log(this);
}

ここまでは問題ありません。

しかし、obj.whoAmI()を呼び出した場合と、便宜的な参照whoAmI()を呼び出した場合の違いを見てみましょう:

obj.whoAmI();  // "MyObjectFactory {...}" を出力(予想通り)
whoAmI();      // "window" を出力(おっと!)

何が間違っているのでしょうか?whoAmI()の呼び出しはグローバル名前空間にあるため、thisはwindowに設定されます(または、strictモードではundefinedに設定されます)。MyObjectFactoryのobjインスタンスではありません!言い換えれば、thisの値は通常、呼び出しコンテキストに依存します。

アロー関数(function(params) {} の代わりに (params) => {} )は、通常の関数のthisのように呼び出しコンテキストに基づかない静的なthisを提供します。これにより、回避策が得られます:

var MyFactoryWithStaticThis = function() {
    this.whoAmI = () => { // ここでアロー表記に注目
        console.log(this);
    };
}

var objWithStaticThis = new MyFactoryWithStaticThis();
var whoAmIWithStaticThis = objWithStaticThis.whoAmI;

objWithStaticThis.whoAmI();  // "MyFactoryWithStaticThis" を出力(通常通り)
whoAmIWithStaticThis();      // "MyFactoryWithStaticThis" を出力(アロー表記の利点)

出力を一致させることができましたが、thisがインスタンスではなくファクトリへの参照であることに気付いたかもしれません。この問題をさらに修正しようとするのではなく、thisや(さらにはnew)に全く依存しないJavaScriptへのアプローチを検討する価値があります。これについては、「JavaScriptデベロッパーとして、これが私を夜も眠れなくさせる」で説明されています。

JavaScript問題9: setTimeoutまたはsetIntervalの最初の引数として文字列を提供すること

まず、はっきりさせておきましょう:setTimeoutまたはsetIntervalの最初の引数として文字列を提供すること自体は、間違いではありません。これは完全に正当なJavaScriptコードです。ここでの問題は、パフォーマンスと効率性に関するものです。多くの場合見落とされがちなのは、setTimeoutまたはsetIntervalの最初の引数として文字列を渡すと、その文字列が関数コンストラクタに渡されて新しい関数に変換されるということです。このプロセスは遅く非効率的で、ほとんどの場合必要ありません。

これらのメソッドの最初の引数として文字列を渡す代わりに、関数を渡す方法があります。例を見てみましょう。

以下は、文字列を最初のパラメータとして渡すsetIntervalとsetTimeoutの典型的な使用例です:

setInterval("logTime()", 1000);
setTimeout("logMessage('" + msgValue + "')", 1000);

より良い選択は、最初の引数として関数を渡すことです:

setInterval(logTime, 1000);   // logTime関数をsetIntervalに渡す
    
setTimeout(function() {       // 匿名関数をsetTimeoutに渡す
    logMessage(msgValue);     // (msgValueはこのスコープでもアクセス可能)
}, 1000);

JavaScript問題10: "Strict Mode"を使用しないこと

JavaScript採用ガイドで説明されているように、"strict mode"(つまり、JavaScriptソースファイルの先頭に'use strict';を含めること)は、実行時にJavaScriptコードのより厳密な解析とエラー処理を自主的に強制する方法であり、コードをより安全にする方法でもあります。

確かに、strict modeを使用しないことは真の「ミス」ではありませんが、その使用はますます推奨されており、その省略はますます悪い形式と見なされています。

strict modeの主な利点は以下の通りです:

  1. デバッグを容易にします。無視されたり、サイレントに失敗したりしていたコードエラーが、今ではエラーを生成したり例外を投げたりするようになり、コードベース内のJavaScriptの問題にもっと早く気付き、その原因をより迅速に特定できるようになります。

  2. 偶発的なグローバル変数を防ぎます。strict modeがない場合、未宣言の変数に値を割り当てると、自動的にその名前のグローバル変数が作成されます。これは最も一般的なJavaScriptのエラーの1つです。strict modeでは、そのような試みはエラーを投げます。

  3. thisの強制を排除します。strict modeがない場合、nullまたはundefinedのthis値への参照は自動的にglobalThis変数に強制されます。これは多くのフラストレーションを引き起こすバグの原因となります。strict modeでは、nullまたはundefinedのthis値を参照するとエラーが投げられます。

  4. 重複するプロパティ名やパラメータ値を禁止します。strict modeは、オブジェクト内で重複する名前のプロパティ(例:var object = {foo: "bar", foo: "baz"};)や関数の重複する名前の引数(例:function foo(val1, val2, val1){})を検出するとエラーを投げます。これにより、コード内のほぼ確実なバグを捕捉し、追跡に多大な時間を費やす可能性があったものを早期に発見できます。

  5. eval()をより安全にします。strict modeと非strict modeでは、eval()の動作に若干の違いがあります。最も重要な点は、strict modeでは、eval()ステートメント内で宣言された変数や関数が、包含するスコープで作成されないことです。(非strict modeでは包含するスコープで作成され、これもJavaScriptの一般的な問題の原因となることがあります。)

  6. deleteの無効な使用でエラーを投げます。delete演算子(オブジェクトからプロパティを削除するために使用)は、オブジェクトの設定不可能なプロパティには使用できません。非strict modeのコードでは、設定不可能なプロパティを削除しようとした場合、サイレントに失敗しますが、strict modeではそのような場合にエラーを投げます。

よりスマートなアプローチでJavaScript問題を軽減する

どのテクノロジーでも同じですが、JavaScriptがなぜ、そしてどのように機能し、機能しないかをよく理解すればするほど、コードはより堅牢になり、言語の真の力を効果的に活用できるようになります。逆に、JavaScriptのパラダイムと概念を適切に理解していないことが、多くのJavaScript問題の原因となっています。言語のニュアンスと微妙な点を徹底的に熟知することが、スキルを向上させ、生産性を高めるための最も効果的な戦略です。

©コハム