JavaScriptのエラーと友達になるために必要なこと
はじめに
とあるマンガの有名なセリフで 「ボールはともだち こわくないよ」という言葉があります。
この言葉を引用して 「エラーはともだち こわくないよ」 があっても違和感ないでしょう。
JavaScript のエラーについて詳しく知り、エラーの原因を特定するためにどのようにエラーと向き合えばよいか、効率的にエラーをハンドリングするため方法も解説します。
以下のようなことを解説しています。
-
エラーに関して
- Errorオブジェクトの基礎となる要素
- スタックトレースの読み方
- エラーと例外について
- エラーのタイプについて
-
エラーの対処について
- try, catch, finallyについて
- グローバルにエラー処理をする onerrorメソッド について
- callback と promise でのエラーの対処について
- どの状況下で、エラーの対処方法を選定するのか
-
エラーを効率的に解決するためのハンドリングについて
想定読者
- JavaScript ではない他プログラミング言語を既に習得しており、JavaScript のエラーについて勉強したい方
- JavaScript を既に習得しているが、再度 JavaScript のエラーを勉強したい方
そもそもエラーとはなんだろう
Webアプリケーション開発におけるエラーとは、プログラムが正常に動作していない状況のことです。
具体的な例として、
- 存在しないファイルを読み込もうとすると
Module not found: Error: Can't resolve ~
- ネットワークに接続されていない状況でWebサイトにアクセスしようと
net::ERR_INTERNET_DISCONNECTED
上記の場合だと、プログラムが正常に指示された動作を行うことができない状況です。
このような状況に遭遇するとプログラムは開発者に対しエラーを投げ、「そのプログラムだと問題が発生する」と伝えます。
エラーが発生した原因の情報が収集され、エラーとして表示してくれるのです。
ですが、WebアプリケーションのユーザーがWeb開発がよく見るようなエラーを読むことがないように、対処するのは大切でしょう。
わかりやすいメッセージとして「ページが見つかりませんでした」などが表示します。
Webアプリケーションのわかりやすいメッセージの具体例
Errorオブジェクトについて
javascript のエラーに詳しくなりましょう。ここでの javascript のエラーは、エラーが発生した時に表示されるオブジェクトのことを指しています。
そのオブジェクトには、以下の情報で構成されています。
- エラーの種類
- エラーの原因(ステートメント/statement)
- スタックトレースに関する情報
extends を使用すると、Errorクラス を拡張させて、 Errorオブジェクト に手を加えて、プログラミングの問題を調査する時に表示される情報量を増やすことができます。(後半のエラーを拡張しようで詳細に解説します。)
エラーのプロパティ(インスタンスプロパティ)について
JavaScriptのより詳細を解説していきます。
エラーは主に3つから成り立っています。
- Message: 人間が読めるエラーの簡潔な説明
- Name: エラーの種類の名称で初期値は "Error"
- Stack: エラーは発生するまでに呼び出しされたメソッドのスタックトレース
スタックトレースについて
スタックトレースとは、例外や警告などのイベントが発生したときに、直前にプログラムが実行していたメソッドを一覧で示したものです。
スタックトレースは主に3つから成り立っています。
- 問題が発生する前の最後のステップ
- エラーの型とメッセージ
- ファイルのパス, 行番号、カラム数
具体例として、
$ node demo.js
/Users/jp-knj/Desktop/demo.js:10
notDefined();
^
RerefenceError: notDefined is not defined
at thirdFunction (/Users/jp-knj/Desktop/demo.js:10:3)
at secondFunction (/Users/jp-knj/Desktop/demo.js:6:3)
at firstFunction (/Users/jp-knj/Desktop/demo.js:2:3)
// 以下 省略
エラー名とメッセージとして RerefenceError: notDefined is not defined
が表示されます。
その後に呼び出されたメソッドの一覧が表示されます。 各メソッド呼び出しには、ファイルのパス、、行番号、カラム数を確認できます。
上記のスタックトレースをより詳細に解説すると
at thirdFunction // 呼び出された各メソッド
/Users/jp-knj/Desktop/demo.js // ファイルのパス
~/demo.js:10:3 // ファイル名、行番号、カラム数
表示された情報を元に code jump して、どのコードがエラーの原因であるかを特定するのができるのです。
このメソッドは、処理の順序を反映するかたちで表示されます。これにより、例外が発生した箇所と、それがどのように伝搬していったのかを確認できるのです。
例外の catch を実装すれば、例外がスタックを伝搬しプログラムをクラッシュさせてしまうことはありません。 しかし、致命的なエラーをそのままにし、意図的にプログラムをクラッシュさせるときもあります。
エラーと例外の重要な違いについて
私自身、エラーと例外との違いを曖昧にしていた開発者です。ですが、重要な違いがありますので理解しておきましょう。
- エラーとは、プログラムが正常に動作していない状況のこと
- 例外とは、投げられたエラーオブジェクトのこと
具体的な例として、
const demoError = TypeError('Hello', "someFile.js", 10)
throw demoError
demoErrorrオブジェクト
が例外です。
業務で使用する場合は、糖衣構文で使っていることが多いのではないでしょうか。基礎となる Errorオブジェクト について理解しておくと、糖衣構文を使った場合でも混乱することはないです。
throw TypeError('Hello', "someFile.js", 10)
JavaScript にはどんなエラーがあるのだろうか
JavaScriptには、定義されたさまざまなエラーのタイプがあります。 これは、Webアプリケーションで明示的にエラーの処理を行い限り、JavaScriptランタイムによって自動的に選択、定義されます。
JavaScriptで主なエラーのタイプ、各タイプはいつ、どのような理由で発生するのかを解説していきます。
TypeError
このエラーのタイプはよく開発者も多いのではないでしょうか。
変数や引数の型が想定された型でないことが検出されたときに表示されます。
具体例として
TypeError: Object doesn't support property or method {x} (Edge)
TypeError: "x" is not a function
関数でないものを、関数呼び出ししようとしたときに発生するエラーです。
特定の関数が定義されていることを想定しているが、定義されていないときも発生するでしょう。
ReferenceError
コード内の変数の参照に問題があるときに表示されます。
変数を使用する前に値を定義するのを忘れたか、またはコードでアクセスできない変数を使用しようとしている可能性があります。
いずれにしても、スタックトレースを確認することで、問題のある変数の参照を見つけ、修正にあたることができます。
具体例として
ReferenceError: "x" is not defined
変数は現在の実行コンテキストで利用可能である必要があります。関数の中で定義された変数は、その関数のスコープ内でしか定義されていないので、関数の外のどこからもアクセスできません。
RangeError
変数に有効な値の範囲外にある値が設定されたときに表示されます。
関数に引数として値を渡すときに発生し、与えられた値が関数のパラメータの範囲内にないことを示します。正しい値を渡すためには、引数に取り得る値の範囲を知る必要があるため、サードパーティのライブラリのドキュメントが不十分な場合、このエラーの解決には手間がかかる可能性があります。
RangeError: Invalid array length
a.length(配列の要素の数)を負の数にして、対応している値を超えてしまった
Arrayコンストラクタで不正な長さの配列を作成しようとした
toExponential()、toPrecision()、toFixed()などのメソッドに不正な値を渡した
SyntaxError
修正するのが最も簡単なものエラーです。JavaScriptはスクリプト言語ですので、スクリプトにあるエラーがあると、実行時に、このようなエラーが返されます。
コンパイル言語では、このようなエラーはコンパイル時に発見されます。したがって、エラーが解決されるまで、アプリケーションのバイナリを生成することはないでしょう。
SyntaxError: expected expression, got "x"
eslint を使って、発見しましょう
InternalError
InternalErrorは、JavaScriptのランタイムエンジンで例外が発生したときに表示されます。
これはコードの問題を意味することも、そうでないこともあります。
RangeError: Maximum call stack size exceeded
具体例として、関数の呼び出しが多すぎるときに表示されます。
JavaScriptエンジンにとって大きすぎるエンティティがコード内に存在する(例:switch文のcaseの分岐が多すぎる、配列初期化子が大きすぎる、再帰が多すぎる)
JavaScriptエンジンの作業負荷が急激に上昇してしまうのを避けましょう。
URIError
URIErrorは、decodeURIComponentのようなグローバルなURI処理関数が間違った方法で使用されたときに表示されます。
メソッド呼び出しに渡されたパラメータがURI標準に準拠しておらず、メソッドによって適切にパースされなかったことを意味します。
URIError: URI malformed
引数に問題がないかを調べるだけで対処できるので簡単です。
EvalError
後方互換性のために存在しています。
現在のECMAScriptの仕様ではEvalErrorクラスは返されないため、あくまでも古いバージョンで作業すると、直面するかもしれないエラーになります。
eval()メソッド
の呼び出しで実行されたコードに例外がないかどうか調査するために頭の片隅に置いておく程度でよいかもしれません。
エラーを拡張しよう
ある程度の状況を網羅できるだけのエラーのタイプクラスがあります。ですが、必要であれば、extends
を使用して拡張したエラータイプを定義できます。 (JavaScript が number, string、Errorオブジェクト など、何でも throw することができるおかげですね)
以下のような、プリミティブ型ではそのエラーのタイプや名前、スタックトレースはなく、エラーが発生した詳細についてはわかりません。
こんな時に、Errorクラスを使用します。
throw "An error occurred"
Errorクラスを拡張(extends
)して、独自のエラークラスを定義します。
具体例として
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
定義した ValidationError
を使用すると
throw ValidationError("Property not found: name")
そして instanceofキーワード
と組み合わせます。
try {
validateDemo() // ValidationError を投げるかもしれない処理
} catch (e) {
if (e instanceof ValidationError) {
// エラーハンドリングの処理
} else {
// エラーハンドリングの処理
}
}
エラーの対処方法を知ろう
開発する上で、どのようにエラーを検出し、防止するかを理解しておくことが重要です。
エラーの対処方法については主に3つほどあります
- throw/catch
- onerror()
- callback/promise
各対処方法を解説していき、さいごに選定する基準を紹介させていただきます。
throw/catch について
他言語と同様に、JavaScript にはエラーの処理に使える機能がいくつもあります。エラー処理について話す前に、throw と catch するために必要になる機能を理解しておきましょう。
throw について
名前からも明らかなように、throw は JavaScript で例外を投げるのに使用します。
number, string、Errorオブジェクト など、何でも throw することができる
しかし、number や string などのプリミティブ型は、エラーに関するデバッグ情報を持たないため、投げるのは効果が高くはないのではないでしょうか。
throw TypeError("Please provide a string")
try について
コードが例外を投げる可能性があることを示すために使用されます。
try {
// 例外を投げるかもしれない処理
}
catch について
ブロックをキャッチするのに使用します。つまり、 tryブロック により捕捉されたエラーを処理する役割を果たします。
catch (exception) {
// 例外をハンドリングする処理
}
そして、 try と catch をあわせて使用すると以下のようになります。
try {
// 何かしらの処理
} catch (exception) {
// エラーハンドリングの処理
}
1つの catchブロック の中で if...else文 や switch文 を使って、起こりうるすべてのエラーのパターンを処理することができます。
try {
// 何かしらの処理
} catch (exception) {
if (exception instanceof TypeError) {
// エラーのタイプが TypeError のときのハンドリング
} else if (exception instanceof RangeError) {
// エラーのタイプが RangeError のときのハンドリング
}
}
finally について
finally は、エラー処理後に実行されるコードブロックの定義に使用します。
なので、try → catch → finally の順で実行されます。
また、finally は try, catch の結果に関係なく実行されるのです。
以下の場合であっても、クラッシュする前にインタプリタにより finally が実行されます。
- catch がエラーを処理できないとき
- catch でエラーが発生したとき
ちなみに try は、catch や finally が存在しなと、インタプリタによりSyntaxError
が返されます。
onerrorメソッドについて
グローバルにエラーを処理するときに、必要なのは onerror()メソッド
です。HTML要素で発生するエラーを処理するのに使用できます。
具体例として、imgタグ
が指定されたURLの画像を見つけられなかった場合、 onerrorメソッド
を呼び出します。こうすると、ユーザーによるエラーの処理が可能になるのです。
imgタグ
がフォールバックできるように、 onerror
で別の画像URLを用意します。
let script = document.createElement('script');
script.src = "https://example.com/404.js"; // こんなスクリプトはありません
document.head.append(script);
script.onerror = function() {
alert("Error loading " + this.src); // Error loading https://example.com/404.js
};
具体例としてのグローバルにエラー処理するハンドラ関数
window.onerror = (event) => {
console.log("Error occurred: " + event)
}
このハンドラ関数を使用すると、コード内にある複数の try catch を除いた、イベント処理とエラー処理を一元化することができるようになります。
もちろん、単一責任の原則に沿って複数のエラーハンドラを window に関連することもできます。 (特定のハンドラに到達するまで、インタプリタがハンドラを順に追跡するらしいです。)
余談ではありますが、imgタグ
での画像読み込みエラーで onerrorメソッド
について解説されてありますが、ブラウザーの互換性 では image の onerrorメソッド
を追加するのは非推奨だったりします。(ドキュメントの更新がされてないんでしょうか)
callback について
具体的をみていきましょう
const calculateCube = (number, callback) => {
setTimeout(() => {
const cube = number * number * number
callback(cube)
}, 1000)
}
const callback = result => console.log(result)
calculateCube(4, callback)
上記の関数では、処理に時間を取り、 callback を利用することで後から結果を返すという非同期の仕組みを実装しています。
calculateCube()
に4ではなく文字列を入力すると、結果として NaN が返されます。
それを対処しようとすると
const calculateCube = (number, callback) => {
setTimeout(() => {
if (typeof number !== "number")
throw new Error("Numeric argument is expected")
const cube = number * number * number
callback(cube)
}, 1000)
}
const callback = result => console.log(result)
try {
calculateCube(4, callback)
} catch (e) {
console.log(e)
}
問題が解決するはずです。ですが、calculateCube()
に文字列を渡そうとすると、次のようになります。
try, catch を実装しているにもかかわらず、Uncaught Error: Numeric argument is expected
と表示されました。
setTimeout()メソッド
による遅延のため、 catch が実行された後にエラーが投げられます。
これは、予期せぬ遅延があるため発生し、このような状況を網羅しながら、業務でアプリケーションを開発する必要があります。
コールバックでエラーを処理する方法は、次の通りです。
const calculateCube = (number, callback) => {
setTimeout(() => {
if (typeof number !== "number") {
callback(new TypeError("Numeric argument is expected"))
return
}
const cube = number * number * number
callback(null, cube)
}, 2000)
}
const callback = (error, result) => {
if (error !== null) {
console.log(error)
return
}
console.log(result)
}
try {
calculateCube('hey', callback)
} catch (e) {
console.log(e)
}
コンソールの出力は
赤いエラーのメッセージは表示されなくなり、適切に処理されたことをわかります。
promise中のエラーの処理
promise は reject されてもプログラミングが終了することはない、という強みがあります。しかし、promise のエラーを処理するために、catch を追加する必要があります。
理解を深めるために、 promise を使用してcalculateCube()メソッド
を追加しましょう。
const delay = ms => new Promise(res => setTimeout(res, ms));
const calculateCube = async (number) => {
if (typeof number !== "number")
throw Error("Numeric argument is expected")
await delay(5000)
const cube = number * number * number
return cube
}
try {
calculateCube(4).then(r => console.log(r))
} catch (e) {
console.log(e)
}
delay()メソッド
を追加して、4の代わりに文字列を入力しようとすると、出力は以下のようになります。
promise
の引数に誤りがある場合のTypeError
の例
ここでも、他のすべての処理が完了した後に promise がエラーを投げることが原因となっています。この問題の解決方法は簡単です。以下のように promiseチェーン に catch() 呼び出しを追加するだけでOKです。
calculateCube("hey")
.then(r => console.log(r))
.catch(e => console.log(e))
これで、出力は以下のようになります。
誤った引数を利用したことによる TypeError の対処例
promiseを用いたエラー処理がいかに簡単であるかをご理解いただけたはずです。さらに、finally()ブロック と promise呼び出しを連鎖させ、エラー処理完了後に実行するコードを追加することもできます。
また、従来型のtry-catch-finally
を用いて、promiseのエラー を処理することもできます。その場合の promise 呼び出しは以下のようになります。
try {
let result = await calculateCube("hey")
console.log(result)
} catch (e) {
console.log(e)
} finally {
console.log('Finally executed")
}
これは非同期関数の中でのみ機能します。のエラーを処理する最適解は、promise呼び出しに、catch と finally を連鎖させることです。
状況ごとにエラーの対処方法を使い分けることなるでしょう。上記で解説した throw/catch, onerror(), callback,promiseでの対処方法を選定する基準を紹介させてください。
throw/catch を使う状況について
ほとんどの状況で、これを使用することになるのではないでしょうか。 catch の中で、起こりうるすべてのエラーに対する処理を実装するようになるでしょう。また、try の後にメモリのクリーンアップを行う必要がある場合は、finally の追加するのは忘れてはならないでしょう。ですが、 try, catch が多すぎると、保守の難易度が高くなります。そのような場合は、onerror()メソッド
や promise でエラーを処理することをおすすめします。 非同期 try、catch と promise の catch()メソッド
のどちらを選択するについてですが、非同期 try、catch の方が、コードが直感的になり、デバッグしやすいので推奨します。
onerror() を使う状況について
onerror() メソッド
は、アプリケーションで多くのエラーを処理する必要があり、問題の箇所がコードベース全体に散らばっていることが分かっている場合に推奨する対処方法ではないでしょうか。onerror() メソッド
を使用すると、エラーをアプリケーション内のイベントのように扱うことができます。複数のエラーハンドラを定義して、最初のレンダリング時にアプリの window に紐付けることができるためです。小規模なプロジェクトでは、エラーの範囲が狭いため onerror()メソッド
の設定が余計に難しくなるのはないでしょうか。try、catch を使用していき、複雑になり始めたら onerror()メソッド
を検討するでよいです。
callbackとpromise を使う状況について
callback と promise のエラー処理は、設計や構造から異なります。コードを書く前にこの2つのどちらかを選ぶのであれば、promise を選ぶのが安全ではないでしょうか。promise には catch と finally を連鎖させてエラーを簡単に処理する仕組みが組み込まれています。この対処方法は、エラーを処理するために引数を定義したり、既存の引数を再利用したりするよりも簡潔です。
コードベース内のミスでエラーが発生することは、必ず直面します。開発中やデバッグ中に、不必要な変更を加えてしまい、それが原因で新たなエラーが発生することすらあります。変更を加えるたびに自動でのテストを実行しておく方法もあるでしょう。ただし、これにより分かるのは、問題があるかないかという実行結果だけです。コードレビューでもエラーを取り除けるようにしたいですね。
エラーを解決しやすくするために
エラー解決の効果を最大化するためには、実装の際にいくつかの点に留意したいところです。
以下に具体的な例を上げています。
Errorクラスを拡張して、独自のエラークラスを定義しよう
記事の前半に解説させていただきました。Webアプリケーション固有の状況に合わせてエラー処理の対処方法を調整できるようになるためです。
デフォルトである Errorクラス ではなく、可能な限り拡張したエラータイプを使用することを推奨します。そうすることで、よりも多くの情報や背景が確認できるようになります。
Webフロントエンドでは、契約(契約による設計)に沿って実装されているプロジェクトもあります。
Errorクラス を拡張して、事前条件エラーを定義して意図を伝えるようにもなります。
class PreConditionError extends Error {}
エラーの表示方法に手を加えて、エラーに関するもっと詳しい情報を表示したりできます。エラーの解釈や処理の方法を細かく制御してもよいでしょう。
例外を放置しない
コードの奥深くで例外をほったらかしにしてしまう、という初歩的なミスを犯した経験がある開発者は多いのではないでしょうか。
実行が任意である処理があったとします。
このような場合、この処理を try に入れ、空の catch でその場しのぎをしたくなるかもしれません。そうすると、その処理からがあらゆる種類のエラーを引き起こすでしょう。
処理が肥大化したとき、エラーハンドリングされてないときは大きなリスクとなります。例外を処理する上でのベストプラクティスは何をすることでしょうか。すべての例外を処理する層を決定し、そこに至るまでに、例外処理に投げることです。この層は、コントローラ、ミドルウェア でもよいでしょう。
アプリケーションで発生しているすべてのエラーの場所を把握しながら対処法を賢く選択できるようになります。なにもしていなくても次の指針は示しやすくなります。
ログとエラーアラートを一元化する
エラーの処理における大事な要素として、ログを取ることが挙げられます。エラーのログを一元的に管理しないと、アプリケーションの使用状況に関する貴重な情報を見逃してしまうかもしれません。
アプリケーションのイベントログを確認することで、エラーに関する重要なデータを把握し、素早いデバッグに役立てることができます。
アプリケーションに適切なアラートメカニズムを設定しておいて、エラーが発生したときに、それが多くのユーザーに影響を及ぼす前に把握するのが理想です。
デフォルトの logger()
を使用するか、好みにあわせて logger を定義することを推奨します。
logger の中には、エラーのレベル (警告、デバッグ、情報など) に応じて処理方法を設定でき、中には logg をすぐにリモートロギングのサーバーに送信するものさえあります。
このようにして、アプリケーションの処理がアプリケーションの利用者に対してどのように実行されるかを監視することができるのです。
ユーザーに対し適切にエラーを通知する
エラー処理方法を定義する際に、ユーザーを念頭に置く必要があります。 アプリケーションの正常な機能を妨げるすべての問題は、目に見えるかたちで(アラートやエラーメッセージで)確認できるようにしましょう。
これにより、ユーザーによる解決策の実施が促せます。操作の再試行やログアウトと再ログインなど、エラーの簡単な解決方法を知っている場合は、アラートに必ず実装します。
日常的な使用に支障をきたさないエラーの場合、アラートは出さずに、後で解決できるように、ログを取っておくこともしたいですね。
さいごに
エラーは異物ではなく自然と発生するものです。場合によっては、ユーザーへの反応として、意図的にエラーを投げる必要があることさえあります。なので、エラーの構造と種類を理解することは非常に重要です。
アプリケーションを停止させてしまうエラーを特定し、それを防ぐための知識と道具を用意しておく必要があります。
「エラーは ともだち こわくないよ」
でも、時々理由もなく怒るのはやめてほしいです
間違った知識や内容がありましたら、ご指摘ください。
Discussion