JavaScriptのレガシーコードを読み解く
JavaScriptのド素人が「JavaScript本格入門」を読んだ後、ES5のレガシーコードを読んだ話です。
JavaScript入門書読破
「JavaScript本格入門」を読みました。簡潔でわかりやすいだけでなく構成もすばらしいです。こういう本を書ける人ってすごいと思います。
ネットで「JavaScript」と検索するとES2015以降とES5以前 の話が入り混じっていて「ESってうまいの?」な初心者は混乱します。この本を読んでJavaScriptの概要とES2015とES5の基本的の違いを理解できたことは大きかったです。お薦めです。
コードリーディングを実践
入門書を読んだ勢いで以前から気になっていたけど読み解けなかったJavaScriptのコードを読んでみました。
以前はわかりませんでしたがES5以前のレガシーコードで、入門書には登場しない外部ライブラリを多用してました。苦労しましたが入門書で得た基礎知識をもとに調査してなんとか読み解くことができました。
今後もレガシーコードを目にする機会は少なくない気がするので調査の中で得た知識をまとめておきます。
ES2015とES5
まずES2015とES5とはなんでしょう?
JavaScriptは元はNetscape Communications社が開発したLiveScriptというスクリプト言語でした。その後、Internet Explorerが採用したことで普及が進み、ブラウザ標準のスクリプト言語となりました。
しかし各ブラウザベンダが独自に開発を行ったためブラウザ間でスクリプトの互換性がないという問題が発生しました。そこで国際的な標準化団体であるEcma International(エクマ)が標準化を行うようになりました。1997年に初版をリリースし、今日現在の最新版は第10版です。
ECMAScript 2015とは、2015年にEcma Internationalが採択したJavaScript標準の第6版です。ES2015、ES6と呼ばれます。ES2015の1つ前の第5版はES5と呼ばれます。ES2015では大規模な変更が加えられたため、ES5以前とES2015以降のJavaScriptでは見栄えが大きく異なります。
ES5で書かれたスクリプトを読むにはES5の知識が必要で、ES2015で書かれたスクリプトを読むにはES2015の知識が必要です。
1. ライブラリ関連
ES2015には様々な機能が追加されており、外部ライブラリに頼ることなくできることが増えてます。
一方、ES5は外部ライブラリへの依存度が高いです。外部ライブラリについてある程度、知識がないと読み解けません。
細かいコードの話に入る前に登場したライブラリについて簡単に説明します。
RequireJS
モジュール管理ライブラリ。
どの言語でもある程度の規模のコードを書く場合は名前が衝突しないようモジュールやパッケージなどに分割する。JavaScriptはグローバルスコープが広くなりやすいので同様にモジュール分割は重要だがES5ではJavaScriptにモジュール機能がなかった。そのためRequireJSなどのモジュール管理ライブラリを使用する。
使い方を簡単に説明するとdefineでモジュールを定義してrequireでエントリポイントとなるJavaScriptファイルを呼び出す。
HTML側には<script>タグを1行だけ書く。残りの<script>タグはRequireJSが適切な順序とタイミングでHTMLに埋め込んでくれる。
ES2015ではモジュール機能が標準化されている。jsファイルがモジュールとなり、公開するものにはexportをつける。使う側はimport <識別子> from <モジュール名>で読み込む。
jQuery
多くの便利な静的メソッドを提供してくれるユーティリティ系ライブラリ。JavaScriptを知らなくても「jQuery」という単語を聞いたことがある人は多いはず。
代表的な機能としてはAjax用の非同期通信機能やDOM操作機能などがある。2000年代に隆盛を極めたが前者の機能はfetchやaxiosに、後者の機能はVue.jsやReactなどの後発ライブラリに押されている。
jQueryはコード内では「jquery」とも「$」とも表記することができる。頻繁に使用するため省略表記である「$」の方が一般的。なおJavaScriptの識別子名に使える記号は「$」と「_」だけ。
lodash
多くの便利な静的メソッドを提供してくれるユーティリティ系ライブラリ。著名なライブラリである Underscoreのクローンだがlodashも同じくらい人気がある。
配列や連想配列(オブジェクト)の操作に関わる機能が有名。ES2015で提供されているmap、reduce、filter、forEachなどがES5ではなかったため、配列やオブジェクトを操作する上で使用頻度の高いライブラリだった。
lodashはコード内では「_」と表記することが一般的。なおunderscoreもコード内では「_」と表記する。
bluebird
非同期処理におけるPromise機能を提供するライブラリ。Promise機能の老舗でかつては高速、高機能が売りだった。速度面の優位性は失われたが今でもファンが多い。
コードの中では「Promise」と表記するのが一般的。 一見するとES2015標準のPromise機能とそっくりだが、記法が少し異なるので注意。
ES2015でPromise機能が標準化されている。
2. 実行パス
RequireJSを利用した場合にHTMLの<script>タグから個々のモジュールへどのように制御が移っていくのかコードで追いかけます。
加えてモジュールをどのように定義するかも確認します。
HTML
HTMLには<script>タグが一行だけある。
<script data-main="index" src="//cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.js"></script>
data-main属性で指定されているindex.jsがまずは読み込まれる。それ以外のスクリプトは適切な順序とタイミングでRequireJSがHTMLに<script>タグで埋め込む。
index.js
index.jsには呼び出すモジュールが定義されている。
requirejs.config({ paths: { jquery: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery', lodash: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash', } });
RequireJSのオプションを指定している。pathsオプションでは読み込むモジュールのバージョンまで指定することができる。
require(['main']);
具体的な処理が記述されているmain.jsを呼び出している。
main.js
main.jsには具体的な処理が記載されている。
define(['jquery', 'lodash'], function($, _) { 'use strict'; 処理 });
処理内容はdefineのコールバックとして記述する。コールバックの中ではjQueryを$、lodashを_で参照できる。
Strictモードをモジュール内で有効にしている。Strictモードはスクリプト全体にかけると非Strictモードのコードが混在するスクリプトと連結した場合にエラーが発生する可能性がある。上記サンプルのように範囲を限定してかけることが望ましい。
クラス
main.jsで利用するクラスを別モジュールで定義する例を示す。
define(['jquery', 'lodash'], function($, _) { 'use strict'; var Foo; Foo = function(params) { ... }; return Foo; });
defineのコールバックの中でコンストラクタを定義する。最後にコンストラクタのFunctionオブジェクトを返す。
main.js側では以下のようにする。
define(['jquery', 'lodash', 'Foo'], function($, _, Foo) { 'use strict'; var params = { ... }; foo = new Foo(params); });
defineの第1引数で呼び出すモジュールを指定する。その後は通常通り。
3. クラス関連
ここからはRequireJSから離れて、ES5でどのようにコーディングするか見ていきます。
まずはクラスの定義です。
基本
ES5における基本的なクラスの定義は下記の通り。
var Foo; Foo = function(bar) { ... };
ES5ではクラスを関数と同じFunctionオブジェクトで表現する。正確には「クラス」ではなく「コンストラクタ」の定義である。
関数とクラスの定義方法が同じなので一見するとどちらかわかりずらい。見分ける一番簡単な方法は変数名の表記を見ることだ。Pascal表記ならクラス、Camel表記なら関数である。
ES2015の下記と同義。
class Foo { constructor(bar) { ... } ... }
応用
少し実践的なクラスの定義方法を紹介する。
Foo = function(params) { $.extend(this, { ... // クラスのデフォルト定義 }, params || {} ); _.bindAll(this, [メソッド名]); );
$.extend()
$はjQueryを表す。よって$.extendはjQuery.extendと同じ。
$.extendはオブジェクトを連結するjQueryの関数。標準のObject.assignとほぼ同じだがjQuery.extendは再帰的な連結ができる。jQuery.extendの第1引数でtrueを指定すると再帰的連結できるが上記の例では未指定(=false)なのでObject.assignと同じ結果が得られる。
クラスのデフォルト定義に引数paramsで指定された定義があれば連結する。引数次第で同じクラスのインスタンスでも全く異なるプロパティを持つことになる。ちょっと異なるよく似た小粒クラスを継承を使わずに定義する、ES5におけるクラス定義の定形の1つ。
_.bindAll()
_はlodashを表す。_.bindAllはlodash.bindAllと同じ。
_.bindAllは指定されたメソッドをthisオブジェクトに束縛している。つまり指定されたメソッド内のthisがFooオブジェクトを表すことを「どんな状況でも」保証する。
JavaScriptのthisは状況によって異なる箇所を示す可能性があるのでこのように防御策を講じている。(JavaScriptではメソッドが他のオブジェクトに格納されて実行される可能性がある)これもES5におけるクラス定義の定形の1つ。
インスタンスメソッド定義
クラスにインスタンスメソッドを追加する方法を以下の通り。
Foo.prototype = { doA: function(params) { ... }, doB: function(params) { ... } };
JavaScriptではインスタンスメソッドはクラスのprototypeに登録するのが一般的だ。コンストラクタの中で定義することもできるがprototypeを利用した方がリソースが節約できる。
ES2015ではclass Fooの中でメソッドを定義できる。
class Foo { doA (params) { ... } doB (params) { ... } }
またオブジェクトリテラルで定義する場合も下記の表記が許容されるようになっている。
{ doA(params) { ... }, doB(params) { ... } };
インスタンスメソッドの定義、、、ではない
funcs = {}; funcs.foo = function() { ... }; funcs.bar = function() { ... };
一見してクラスのインスタンスメソッド定義と勘違いしそうだが違う。funcsはクラスではなくオブジェクト=連想配列だ。連想配列の要素に無名関数を登録している。紛らわしいが騙されないようにしたい。
下記と同義。
funcs = { foo : function() { ... }; bar : function() { ... }; };
4. 関数関連
次は関数の定義について見てみる。
関数定義
ES5における関数の定義方法を以下に示す。
doSomething = function() { ... };
関数リテラルで作成した匿名関数を変数doSomethingに代入している。JavaScriptではよくある関数の定義方法だ。
この時点では実行されない。doSomething()と括弧付きで呼び出したタイミングで実行される。
匿名関数は呼び出しの前に宣言する必要がある。一方、function命令で定義した関数は実行前に評価されるため呼び出しの後に宣言しても問題ない。
ES2015では下記のようにアロー関数を使って記述することができる。
let doSomething = () => { ... };
変数宣言
var foo, bar, obj = { x: 1 };
ES5以前のJavaScriptでは変数を関数の上の方にまとめて宣言する。宣言する場所と利用する場所が離れているので読みにくく感じるが「hoisting」という問題を防ぐための慣習。異なる型などもまとめて宣言できる。
ES2015以降ではブロックスコープが導入された。letとconstを利用して使用する場所の近くで宣言できる。
5. 非同期通信
非同期通信について見ていきます。
単発の非同期通信
jQueryで非同期通信を行う方法を以下に示す。
$.ajax().done( 成功したときの処理 ).fail( 失敗したときの処理).alwasy( いつも実施する処理 );
複数の非同期通信を連続するときのcallback地獄対策
複数の非同期通信を連続させようとすると$.ajax().done( $.ajax.done( $.ajax.done($.ajax.done( ... ))))のような多段構造になり可読性、保守性が低下する。このようなcallbackがマトリョーシカ化した状態をcallback地獄と呼ぶ。
jQuery.Deferredを使ったcallback地獄対策を以下に示す。
asyncFuncA = function() { var deferred = $.Deferred(); // DefferedとPromiseをセットで作る 非同期処理 deffered.resolve(); // Defferedをpendingからresolve状態へ遷移させる return deferred.promise(); // Promiseを返す=asyncFunc().then( 次の処理 )を実行できる。 };
このようにjQuery.Deferredを利用した関数は次のような記述ができる。このようにthenで連結された記述をPromiseチェーンと呼び、callback地獄より好まれる。
asyncFuncA() .then(asyncFuncB) .then( asyncFuncC) .then( asyncFuncD) .then( asyncFuncE);
下記のように関数を配列に格納してPromise.eachでIterateすることもできる。
Promise.resolve([ asyncFuncA, asyncFuncB, asyncFuncC, asyncFuncD, asyncFuncE ]).each(function(fn){ return fn().then(function() { log('--------------------------------------------------'); }); });
ここで使っているPromiseはbluebirdライブラリのもの。Promise.eachは配列内の要素がPromiseなら非同期処理が完了してから次の要素へ進む。
このようなjQuery.Deferredとbluebirdを組み合わせた記述は非同期処理の定形パターンの1つ。
ES2015では標準化されたPromise機能でPromiseチェーンを記述できる。
複数の非同期通信を並列に実行する
先程は非同期処理A、B、C、Dを順番に(可読性の高い記述で)実行する方法を紹介した。
非同期処理A、B、Cを同時に並列実行し、全てが完了したらDを実行するにはどうすればよいだろう。
deferreds = [ Promiseを返す非同期処理の配列 ]; $.when.apply($, deferreds).done( ... ).fail( ... ).always( ... );
$.whenは複数の非同期処理を並列に実行し、全て終わるのを待ってから次の処理へ進む。通常、複数の非同期処理を動的に作成して配列にするのでapplyと組み合わせて上記のように$.when.applyとすることが多い。
ES2015のPromise.allに相当する。
_.defer(this.poll);
コールスタックが空になるまで、関数の呼び出しを遅延させる。setTimeoutを0秒指定した場合と同等。複雑な処理、時間がかかる処理を実行するときに使う。
6. コレクション関連
最後に配列やコレクション関連の処理を見ていく。
削除
_.remove(array, function(value, index, collection) { return value === 'john' } );
配列の中から第2引数の条件にマッチする要素を削除し、削除後の配列を返す。
第2引数の表記法は省略記法も含めて下記の通り色々ある。
_.remove(array, {'name': 'john', 'age': 30}); _.remove(array, [ 'age', 30] ); _.remove(array, 30);
検索
_.find(array, {'name: 'john'})
Collectionの中から第2引数の条件にマッチする要素があるか探し、マッチした要素を返す。第2引数は表記法は省略記法も含めて色々ある。
ES2015のArray.findと同じ。
繰り返し
_.each(array, function(value, index, array) { ... }
ES2015のArray.forEachと同じ。