Web●●○○○

JavaScript

JavaScript ・ じゃばすくりぷと

ブラウザにページの振る舞いを書くための言語として生まれ、いまはサーバまで覆うWebの共通語。

✦ 30秒で試す — 航海約10日で設計された言語が、いまWebのほぼすべてを動かすボタンを押した処理はどうなる?予想してみる

概要

JavaScript は、Web ページに「動き」を与えるためにブラウザに組み込まれたプログラミング言語です。HTML が文書の構造、CSS が見た目を担うのに対し、JavaScript は振る舞いを担います。ボタンのクリックに反応する、入力内容をその場で確かめる、ページを再読み込みせずにサーバからデータを取ってきて画面を書き換える — 現代の Web アプリの対話性は、ほぼすべて JavaScript の仕事です。

長いあいだ JavaScript は「ブラウザでそのまま動く唯一の言語」でした。いまは C++ や Rust をコンパイルした WebAssembly もブラウザで動きますが、WebAssembly は画面(DOM)に直接触れられず、JavaScript から呼ばれて重い計算を引き受ける補完役です。ページの振る舞いを書く言語という立場は変わっていません。

2009 年の Node.js 登場でブラウザの外、つまりサーバでも動くようになり、フロントエンドとバックエンドを同じ言語で書けるようになりました。いまでは CLI ツール、デスクトップアプリ、サーバレス関数まで JavaScript が動く場所は広がっています。言語仕様は ECMAScript という名前で標準化されており、2015 年の大改訂(ES2015、いわゆる ES6)以降は毎年少しずつ更新されています。

なぜ生まれたか

1990 年代半ばの Web ページは、サーバから受け取った HTML をただ表示するだけの静的な文書でした。フォームの入力チェック一つとっても、送信してサーバの応答を待たなければ結果が分かりません。ページの側で即座に反応する手段が何もなかったのです。

1995 年、Netscape 社のブレンダン・アイクが、ブラウザ内で動く軽量なスクリプト言語として JavaScript を約 10 日で設計しました(Java と名前が似ているのは当時の提携とマーケティングの都合で、言語としては別物です)。デザイナーや初心者でも書けるよう、型を宣言しない緩い文法にしたことが、のちの普及と、のちの苦労の両方の出発点になります。1997 年には Ecma International で ECMAScript として標準化され、ブラウザごとの方言を束ねる土台ができました。

転機は 2005 年ごろです。ページを再読み込みせずに裏でサーバと通信する手法が「Ajax」と名付けられ、Google Maps のようにデスクトップアプリ並みに操作できる Web アプリが登場しました。2008 年には Google Chrome の V8 エンジンが実行時に機械語へ変換する JIT コンパイルで速度を桁違いに上げ、JavaScript は「ちょっとした飾り付けの言語」から「アプリケーションを載せる土台」へと地位を変えます。その V8 をブラウザから取り出してサーバで動かしたのが Node.js です。

詳細

言語とホスト環境 — DOM は言語の一部ではない

JavaScript を理解するうえで最初に分けておきたいのが、「言語そのもの」と「それを動かすホスト環境」です。ECMAScript が決めているのは、変数・関数・オブジェクト・Promise といった言語の中身だけです。画面の要素を操作する DOM、通信の fetch、タイマーの setTimeout は、実はブラウザというホストが言語に足した API で、Node.js ではその代わりにファイルを読む fs やサーバを立てる http が足されています。同じ V8 エンジンがブラウザでも Node.js でも動いているのに、使える機能が違うのはこのためです。

エンジンの中では、ソースを構文解析してバイトコードにし、まずインタプリタで実行しながら、何度も呼ばれる関数を見つけて最適化した機械語に変換します(JIT コンパイル。コンパイラを実行中に働かせる方式)。関数の呼び出しはコールスタックに積まれ、オブジェクトはヒープに置かれて(スタックとヒープ)、使われなくなったものはガベージコレクションが回収します。

ホスト: ブラウザホスト: Node.jsJS エンジン(V8 など)構文解析 → インタプリタ → JIT で機械語コールスタック・ヒープ・GC言語の中身: 関数・オブジェクト・PromiseECMAScript が決めるのはここだけJS エンジン(V8)構文解析 → インタプリタ → JIT で機械語コールスタック・ヒープ・GC言語の中身: 関数・オブジェクト・Promiseブラウザと同じエンジンホストが足す APIDOM・fetch・setTimeout・localStorageホストが足す APIfs・http・process・setTimeout
言語エンジンとホスト環境。エンジン(言語の中身)は同じでも、ホストが足す API が違う

イベント駆動とシングルスレッド

ブラウザ上の JavaScript の基本は、DOM の読み書きと、イベントへの反応です。document.querySelector で要素を取り出し、addEventListener で「クリックされたらこの関数を呼ぶ」と登録する — プログラムが上から下へ流れて終わるのではなく、「出来事(イベント)が起きるたびに関数が呼ばれる」イベント駆動の書き方が、この言語の基本の考え方です。

実行モデルの核心は、JavaScript のコードを動かすスレッドが1本しかないことです。関数を実行する場所(コールスタック)は一つで、重い計算で塞がっているあいだは、クリックにもスクロールにも反応できず、画面の描画も止まります。そこで時間のかかる処理(通信、タイマーなど)はホスト(ブラウザや Node.js)に預け、終わったら呼んでほしい関数(コールバック)をキューに並べてもらい、スタックが空いたときに一つずつ取り出して実行します。この循環がイベントループです。

キューは2種類あります。タイマーやクリック、通信の完了といった外からの出来事はタスクキューに、Promise の then や await の続きはマイクロタスクキューに並びます。イベントループは「スタックが空になったら、まずマイクロタスクを空になるまで全部実行し、それからタスクを1つだけ実行する(その合間に必要なら画面を描く)」という規則で回ります。だから setTimeout(f, 0) より Promise.resolve().then(g) のほうが先に動きます。fetch(url).then(h) の h も、通信が終わって Promise が解決した時点でマイクロタスクになります。

コールスタックJS が動く場所は1つだけonClick()main()Web API(ホスト)タイマー・通信・DOM イベントJS の外で待つ時間のかかる処理を預けるイベントループ① マイクロタスクを全部② タスクを1つ ③ 必要なら描画空いたら実行マイクロタスクキューPromise の then・await の続きタスクキュータイマー・クリック・通信の完了Promise が解決したら終わったら積む①②
イベントループの規則: スタックが空いたら、マイクロタスクを全部 → タスクを1つ → 必要なら描画

出力の順番は、書いた順ではなくこの規則で決まります。下の部品でコードの行を並べ替えて実行し、console.log の出力順が変わる理由を確かめてください。重い計算の行を入れると、そのあいだ画面が固まる様子も見えます。仕組みの詳しい話はイベントループの記事にあります。

⚡ 体験: コードを並べて実行し、出力の順番を決める規則を見る
  1. 1console.log('A')同期
  2. 2setTimeout(() => console.log('B'), 0)タスク
  3. 3Promise.resolve().then(() => console.log('C'))マイクロ
  4. 4console.log('D')同期
行を足す
コールスタック(1本だけ)空イベントループ① スタックが空になったら② マイクロタスクを全部③ タスクを1つブラウザ・OS が待つ(Web API)タイマー・通信(OS の epoll などが見張る)画面(UI)—マイクロタスクPromise← 先頭から取り出すタスクタイマー・I/O・クリック← 先頭から取り出す出力メインスレッド画面の更新時間 →(見やすいように引き延ばしています)
同期の処理マイクロタスク(then)タスク(タイマー)通信の応答重い計算・固まる
出力の順…押したクリック0 回待たされたクリック…

左のコードは上から順に1本のスタックで動きます。出力の順番を予想してから「実行」を押してください。行は ↑↓ で並べ替え、下のボタンで足せます。

「一人の料理人が、オーブンに任せられる作業は任せて、手が空いたら次の注文をさばく」ようなものです。スレッドを増やさずに大量の同時接続をさばけるこの性質は、Node.js がサーバで受け入れられた理由でもあります。反対に、重い計算を1本のスレッドで続けると全体が止まるので、ブラウザでは Web Worker(別スレッドで JS を動かす仕組み)、Node.js ではワーカースレッドや別プロセスに逃がします。

非同期処理の書き方の進化

非同期の結果を受け取る書き方は、コールバック関数の入れ子(いわゆる「コールバック地獄」)から始まり、ES2015 で「いつか決まる結果」を表すオブジェクト Promise が入り、ES2017 の async/await で同期処理と同じ見た目で書けるようになりました。const res = await fetch(url) の一行の裏では、関数がいったん中断してスタックを空け、Promise が解決したら続きがマイクロタスクとして再開されています。API の応答は多くの場合 JSON で返り、await res.json() でオブジェクトに変換して使う — フロントエンドで最も頻出するパターンです。

落とし穴は、await を付け忘れると Promise そのものが変数に入ってしまうこと、そして await を並べると一つずつ順番に待つことです。互いに独立した通信なら Promise.all で同時に投げたほうが速く終わります。また、catch されなかった Promise の失敗は静かに消えがちなので、エラーの扱いは明示的に書きます。

動的型付けと TypeScript

JavaScript は動的型付けの言語で(型システム)、変数の型を宣言せずに書け、"1" + 1 が "11" になるような暗黙の型変換も行われます。この緩さは書き始めの敷居を下げた一方、規模が大きくなると「実行してみるまで間違いに気づけない」問題を生みました。答えとして広まったのが TypeScript です。JavaScript に型注釈を足した言語で、ビルド時に型の食い違いを検出し、トランスパイルで型を消して素の JavaScript を出力します。中規模以上の開発では TypeScript を使うのがほぼ標準になりました。

エコシステムとフレームワーク

npm には世界最大級のパッケージ群が登録されており、React・Vue・Svelte などの UI フレームワークが SPA 開発を支えています。Next.js のようなフレームワークは同じコードで SSR や SSG を実現し、「どこで JavaScript を実行して HTML を作るか」を選べるようにしました。何百ものモジュールを配信用に束ねるのはバンドラの仕事です。一方で、依存パッケージが数百に膨らむことは、そのどれかに混じった脆弱性や悪意あるコード(サプライチェーン攻撃)を抱え込むことでもあります。

実務での落とし穴

歴史的経緯による罠がいくつかあります。== は型変換を伴う比較なので原則 === を使う、var は関数単位のスコープで宣言の巻き上げが起きるので const / let を使う、this の指す先が呼び出し方で変わる(アロー関数は外側の this を引き継ぐ)、数値はすべて 64 ビットの浮動小数点数なので 0.1 + 0.2 !== 0.3 になり、2^53 を超える整数は正確に表せない(BigInt を使う)— などが定番です。また、ユーザーの入力を innerHTML で無防備に DOM へ差し込むと XSS の温床になります。表示は textContent で行うのが基本です。

試問

JavaScript のコードの行を並べ、実行できる部品です。いまは上から console.log(‘A’)(その場で A と出力)、setTimeout で B を出力する予約、Promise.then で C を出力する予約、console.log(‘D’) の4行があります。行は ↑↓ で並べ替え、× で消し、下のボタンで足せます。「実行」を押すと、出力された文字が順に並びます。

✦ 命題挑戦中

3行以上のコードで、出力の順番が、書いた順(上から)のちょうど逆になるようにせよ

  • 3行以上で、どの行も1文字を出力する(重い計算なし)
  • 実行して、出力の順番が書いた順(上から)のちょうど逆になる
⚡ 体験: 出力の順番を、書いた順の逆にする
  1. 1console.log('A')同期
  2. 2setTimeout(() => console.log('B'), 0)タスク
  3. 3Promise.resolve().then(() => console.log('C'))マイクロ
  4. 4console.log('D')同期
行を足す
コールスタック(1本だけ)空イベントループ① スタックが空になったら② マイクロタスクを全部③ タスクを1つブラウザ・OS が待つ(Web API)タイマー・通信(OS の epoll などが見張る)画面(UI)—マイクロタスクPromise← 先頭から取り出すタスクタイマー・I/O・クリック← 先頭から取り出す出力メインスレッド画面の更新時間 →(見やすいように引き延ばしています)
同期の処理マイクロタスク(then)タスク(タイマー)通信の応答重い計算・固まる
出力の順…押したクリック0 回待たされたクリック…

左のコードは上から順に1本のスタックで動きます。出力の順番を予想してから「実行」を押してください。行は ↑↓ で並べ替え、下のボタンで足せます。