将来を見据えたウェブ・アプリケーションの構築:The Codestのエキスパート・チームによる洞察
The Codestが、最先端技術を駆使してスケーラブルでインタラクティブなウェブアプリケーションを作成し、あらゆるプラットフォームでシームレスなユーザー体験を提供することにどのように秀でているかをご覧ください。The Codestの専門知識がどのようにデジタルトランスフォーメーションとビジネス...
ブラウザ・テクノロジーの進歩に伴い、ウェブ・アプリケーションはより多くのロジックをフロントエンドに移し始めている。基本的なCRUDでは、サーバーの役割は認可、検証、データベースとの通信、そして必要なビジネスロジックに絞られる。残りのデータロジックは、結局のところ、UI側でアプリケーションの表現を担当するコードで簡単に処理できる。
この記事では、私たちのサッカーを維持するのに役立ついくつかの例とパターンを紹介しようと思う。 コード 効率的で、きちんとしていて、速い。
具体的な例について深入りする前に - この記事では、私の意見では、驚くべき方法でアプリケーションの速度に影響を与える可能性のある事例を示すことだけに焦点を当てたいと思います。しかし、これは、より高速なソリューションの使用がすべての可能なケースで最良の選択であることを意味するものではありません。例えば、ゲームレンダリングやキャンバス上の高度なグラフを必要とする製品、ビデオ操作、できるだけ早くリアルタイムで同期させたいアクティビティなどです。
アプリケーション・ロジックの大部分は配列に基づいています。配列のマッピング、ソート、フィルタリング、要素の合計などです。簡単で透過的で自然な方法で、さまざまなタイプの計算やグループ化などを実行できる組み込みメソッドを使用します。引数として関数を渡すと、ほとんどの場合、各繰り返しの間に要素の値、インデックス、配列が順番にプッシュされます。指定された関数は配列の各要素に対して実行され、その結果はメソッドによって異なる解釈がなされます。Array.prototypeのメソッドについては、多くのケースで動作が遅くなる理由に焦点を当てたいので、詳しく説明しない。
Arrayメソッドが遅いのは、要素ごとに関数を実行するからだ。エンジンの視点から呼び出された関数は、新しい呼び出しを準備し、適切なスコープを提供し、その他多くの依存関係を持たなければならない。そのため、特定のスコープで特定のコードブロックを繰り返すよりも、処理が長くなってしまう。そして、これは次の例を理解するのに十分な背景知識だろう:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value:Math.random() });
console.time('reduce');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('reduce による合計');
console.time('forループで合計');
forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) { forSum = randomArray[0].value; let forSum = randomArray[0].value
forSum += randomArray[index].value;
}
console.timeEnd('sum by for loop');
console.log(reduceSum === forSum);
})();
このテストがベンチマークほど信頼性が高くないことは承知しているが(ベンチマークについてはまた後ほど)、警告灯を点灯させるきっかけにはなった。私のコンピューター上のランダムなケースでは、forループを使ったコードは、同じ効果を達成する要素をマッピングしてから削減するのと比較すると、約50倍速くなることがわかった!これは、特定の計算対象に到達するためだけに作られた奇妙なオブジェクトを操作することだ。では、Arrayメソッドを客観的に見るために、もっと正当なものを作ってみよう:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value:Math.random() });
console.time('reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value }).value
console.timeEnd('reduce による合計');
console.time('forループで合計');
forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) { forSum = randomArray[0].value; let forSum = randomArray[0].value
forSum += randomArray[index].value;
}
console.timeEnd('sum by for loop');
console.log(reduceSum === forSum);
})();
このテストがベンチマークほど信頼性が高くないことは承知しているが(ベンチマークについてはまた後ほど)、警告灯を点灯させるきっかけにはなった。私のコンピューター上のランダムなケースでは、forループを使ったコードは、同じ効果を達成する要素をマッピングしてからreduceするのと比較すると、約50倍速くなることがわかった!これは、この特殊なケースでreduceメソッドを使って合計を求めるには、まとめたい純粋な値の配列をマッピングする必要があるからだ。では、Arrayメソッドを客観的に見るために、もっと正当なものを作ってみよう:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value:Math.random() });
console.time('reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value }).value
console.timeEnd('reduce による合計');
console.time('forループで合計');
forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) { forSum = randomArray[0].value; let forSum = randomArray[0].value
forSum += randomArray[index].value;
}
console.timeEnd('sum by for loop');
console.log(reduceSum === forSum);
})();
そして結局、50倍のブーストが4倍に減ってしまった。がっかりされた方には申し訳ない!最後まで客観的でいるために、両方のコードをもう一度分析してみよう。まず第一に、一見何の変哲もないように見える違いが、理論的な計算量の減少を2倍にした。純粋な要素をまずマッピングし、それから合計するのではなく、オブジェクトと特定のフィールドを操作し、最終的に目的の合計を得るために引き出すのである。問題は、他のプログラマーがこのコードを見たときに生じる。先に示したコードと比較すると、後者はある時点で抽象性を失っている。
というのも、2番目の操作では、奇妙なオブジェクトを操作しているため、私たちの関心のあるフィールドと、反復配列の2番目の標準的なオブジェクトを操作しているからです。あなたがどう考えるかは知らないが、私の視点では、2番目のコード例では、この奇妙に見えるリデュースよりも、forループのロジックの方がはるかに明確で意図がある。そして、それでも、もう神話の50ではないとはいえ、計算時間に関しては4倍速い!1ミリ秒が貴重である以上、この場合の選択は簡単だ。
2つ目に比較したかったのは、Math.maxメソッド、より正確には、100万個の要素を詰め込んで、最大と最小のものを抽出することです。コードを準備し、時間を計測するメソッドも準備し、コードを起動すると、とても奇妙なエラーが発生した。これがそのコードだ:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max with ES6 spread operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max with ES6 spread operator');
console.time('Math.max with for loop');
maxByFor = randomValues[0];
for (let index = 1; index maxByFor) { 次のようにします。
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max with ES6 spread operator');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max with ES6 spread operator');
console.time('Math.max with for loop');
maxByFor = randomValues[0];
for (let index = 1; index maxByFor) { 次のようにします。
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
ネイティブ・メソッドは再帰を使うが、v8ではコールスタックによって制限され、その数は環境に依存することがわかった。これにはとても驚いたが、結論はこうだ。配列の要素数がある一定の基準を超えない限り、ネイティブ・メソッドの方が速い。この要素数では、ループと比較するとforの結果は5倍速かった。しかし、前述の要素数を超えると、forループの勝ちであることは間違いない。
この段落で触れたい概念は再帰である。前の例では、Math.maxメソッドと引数の折りたたみでそれを見たが、そこではスタック・サイズの制限により、特定の数値を超える再帰呼び出しでは結果を得ることができないことが判明した。
ここでは、組み込みメソッドではなく、JSで書かれたコードの文脈で再帰がどのように見えるかを見ていこう。ここで紹介できる最も古典的なものは、もちろんフィボナッチ数列の第n項を求めることだろう。では、こう書いてみよう!
(() => {
const fiboIterative = (n) => { {.
a, b] = [0, 1]とする;
for (let i = 0; i { 次のようになる。
if(n < 2) {
return n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('Fibonacci sequence by for loop');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci sequence by for loop');
console.time('Fibonacci sequence by recursion');
const resultRecursive = fiboRecursive(30);
console.timeEnd('再帰によるフィボナッチ数列');
console.log(resultRecursive === resultIterative);
})();
さて、私のコンピューターで数列の30番目の項目を計算するこの特別なケースでは、反復アルゴリズムでは約200倍の時間で結果が得られる。
しかし、再帰アルゴリズムには修正できる点がひとつある。結局のところ、末尾再帰と呼ばれる戦術を使うと、より効率的に動作する。これは、より深い呼び出しのための引数として、前の反復で得られた結果を渡すことを意味する。これにより、必要な呼び出しの回数を減らすことができ、その結果、結果を高速化することができる。それに従ってコードを修正しよう!
(() => {
const fiboIterative = (n) => { {.
a, b] = [0, 1]とする;
for (let i = 0; i { { if(n === 0)
if(n === 0) { {.
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacci sequence by for loop');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacci sequence by for loop');
console.time('末尾再帰によるフィボナッチ数列');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacci sequence by tail recursion');
console.log(resultRecursive === resultIterative);
})();
尾部再帰アルゴリズムの結果は、場合によっては反復アルゴリズムよりもほぼ2倍速く結果(シーケンスの30番目の要素を計算)を出すことができた。これがv8側での末尾再帰の最適化によるものなのか、それともこの特定の反復回数に対するforループの最適化が欠けているためなのかはまったくわからないが、結果は明白で、末尾再帰の勝ちだ。
というのも、本質的にforループは、より低レベルの計算活動に対してより少ない抽象化を課しており、より基本的なコンピュータ操作に近いと言えるからだ。しかし、結果は否定できない。巧妙に設計された再帰は、反復よりも高速であることが判明したのだ。
最後の段落では、アプリケーションのスピードにも大きく影響する、ある操作方法について簡単に説明したいと思います。ご存知のように JavaScript はシングルスレッド言語であり、すべての操作をイベント・ループ機構で保持する。これは、繰り返し実行されるサイクルに関するもので、このサイクルのすべてのステップは、専用の指定されたアクションに関するものである。
このループを高速にし、すべてのサイクルの順番待ちを少なくするためには、すべての要素を可能な限り高速にする必要があります。メインスレッドで長い処理を実行するのは避けましょう。時間がかかりすぎる場合は、これらの処理を WebWorker に移すか、非同期で実行する部分に分割してください。一部の処理が遅くなるかもしれませんが、マウスの移動や保留中のHTTPリクエストの処理などのIO処理を含む、JSのエコシステム全体が向上します。
結論として、先に述べたように、アルゴリズムを選択することで節約できるミリ秒を追い求めることは、場合によっては無意味であることが判明するかもしれません。一方、スムーズな動作と高速な結果を必要とするアプリケーションでは、そのようなことを無視することは、アプリケーションにとって致命的なことになりかねません。場合によっては、アルゴリズムの速さとは別に、もう1つ質問すべきことがあります。そのコードを読んだプログラマは問題なく理解できるでしょうか?
唯一の方法は、パフォーマンス、実装の容易さ、適切な抽象化のバランスを確保し、少量のデータでも大量のデータでもアルゴリズムが正しく動作することを確信することである。この方法は非常に簡単で、賢く、アルゴリズムを設計する際に様々なケースを考慮し、平均的な実行に対して可能な限り効率的に動作するようにアレンジすることです。また、テストを設計することをお勧めします - アルゴリズムがどのように動作しても、異なるデータに対して適切な情報を返すことを確認してください。適切なインターフェイスに気を配る - メソッドの入力と出力の両方が読みやすく、明確で、何をしているかを正確に反映するように。
先に、上の例のアルゴリズムのスピード測定の信頼性について一周すると述べた。console.timeでの計測はあまり信頼できませんが、標準的な使用例を最もよく反映しています。いずれにせよ、以下にベンチマークを紹介する。ベンチマークは単純に与えられたアクティビティをある時間に繰り返し、ループにv8最適化を使用しているため、1回の実行とは少し違って見えるものもある。
ハッキングを楽しもう!
続きを読む