ウェブスプレッドシート

本章では、HTML, JavaScript, CSS というウェブブラウザでネイティブにサポートされる三つの言語を使って 99 行で書かれたウェブスプレッドシートを解説する。

本プロジェクトの ES5 バージョンは jsFiddle で公開されている。

はじめに

Tim Berners-Lee が 1990 年にウェブを考案したとき、ウェブページは HTML で書かれるものとされた。HTML では山括弧で囲まれたタグを使ってテキストに印をつけることで、ドキュメントの論理的な構造を表現できる。例えば、<a>...</a> の印で囲まれたテキストはハイパーリンクであり、ウェブ上の他のページにユーザーを導く。

1990 年代、ブラウザはテキストの表示に関する様々なタグを HTML の語彙に加えた。その中には悪名高い非標準タグもあり、例えば Netscape Navigator の <blink>...</blink> や Internet Explorer の <marquee>...</marquee> はブラウザ互換性と利便性に関する大規模な問題を引き起こした。

HTML の用途を本来の目的 ── ドキュメントの論理構造の表現 ── に制限するために、ブラウザ製作者たちは最終的に二つの言語を新しくサポートすることに合意した: ページの表示スタイルを表現する CSS と、動的な対話を表現する JavaScript (JS) である。

その後 HTML, CSS, JS という三つの言語は20 年にわたる共進化を経て書きやすい強力な言語となった。特に JS エンジンが改善されたことで、AngularJS などの大規模な JS フレームワークのデプロイが現実的になった。

現在、ウェブスプレッドシートなどのクロスプラットフォームのウェブアプリケーションはユビキタスであり、前世紀に開発された VisiCalc, Lotus 1-2-3, Excel のようなプラットフォーム固有のアプリケーションと同程度に利用されている。

AngularJS を使って 99 行で書かれたウェブアプリケーションはどれほどの機能を持つことができるだろうか? 実際に確かめてみよう!

概要

本書の Git レポジトリの spreadsheet ディレクトリには、先述した三つの言語の 2014 年後半における最新エディションで書かれたソースファイルがある: ドキュメントの構造を表す HTML5、表示を指定する CSS3、そして対話を表現する ES6 "Harmony" である。また、本プロジェクトではデータの永続化に web storage を、JS コードのバックグラウンド実行に web worker を利用する。執筆時点において、これらの規格は Firefox, Google Chrome, バージョン 11 以降の Internet Explorer, そして iOS 5 以降および Android 4 以降のモバイルブラウザでサポートされている。

ブラウザでスプレッドシートを開くと、次のページが表示される:

図 1. 初期画面
図 1初期画面

基本的な動作と概念

スプレッドシートは二次元格子状に並んだセル (cell) からなる。それぞれの (column) は A から始まる添え字を持ち、それぞれの (row) は 1 から始まる添え字を持つ。セルは A1 といった一意の座標 (coordinate) と 1874 といったコンテンツ (content) を持つ。セルのコンテンツは次に示す四つの (type) のいずれかに属する:

3920 と書かれたセルをクリックするとフォーカスが E1 に移動し、セルが持つ数式が入力ボックス内に表示される (図 2)。

図 2. 入力ボックス
図 2入力ボックス

A1 にフォーカスを移動してコンテンツを 1 に変更すると E1 の数式が再計算され、E1 に表示される数値が更新されて 2047 となる (図 3)。

図 3. 数式セルの更新
図 3数式セルの更新

ENTER キーを押してフォーカスを A2 に移し、そのセルのコンテンツを =Date() に変更する。さらに TAB キーを押してフォーカスを B2 に移し、そのセルのコンテンツを =alert() に変更する。もう一度 TAB キーを押してフォーカスを C2 に移すと、図 4 の状態となる。

図 4. 数式のエラー
図 4数式のエラー

以上の例から分かるように、数式の評価結果は次の三つのいずれかとなる:

続いて、無限ループして実行が停止しない JS コード =for(;;){} をセルに入力する。このときスプレッドシートは値の更新を試みてから不可能だと判断し、C2 の以前のコンテンツを自動的に復元する。

Ctrl+R または Cmd+R を押してページをリロードすると、スプレッドシートの内容が永続化されているのが分かる。つまりブラウザセッションをまたいで同じ内容を表示できる。スプレッドシートを最初の状態にリセットする処理は、左上にある円状矢印ボタンを押すと実行できる。

プログレッシブエンハンスメント

99 行のコードを見ていく前に、ブラウザの設定で JS を無効化した状態でページを再読み込みしたとき何が変わるかに注目してみよう (図 5)。実際に試してみると、次のことが分かる:

図 5. JavaScript を無効にした状態
図 5JavaScript を無効にした状態

動的な対話 (JS) を無効化すると、コンテンツの構造 (HTML) と表示スタイル (CSS) だけが有効な状態となる。JS と CSS を無効化した状態でも利用できるウェブページはプログレッシブエンハンスメント (progressive enhancement, 累積的な品質改善) 原則を満たしていると言う。そういったウェブページは最も多くのユーザーからアクセス可能となる。

本章で解説するスプレッドシートはサーバーサイドコードが一切存在しないウェブアプリケーションなので、必要なロジックを提供する JS の利用は避けられない。ただ、CSS が完全に機能しない環境 (スクリーンリーダーやテキストモードのブラウザなど) でも正しく動作するようにはなっている。

図 6. CSS を無効化した状態
図 6CSS を無効化した状態

図 6 に示すように、ブラウザの JS を有効化して CSS を無効化すると、次の影響が出る:

コードのウォークスルー

図 7. ウェブスプレッドシートのアーキテクチャ
図 7ウェブスプレッドシートのアーキテクチャ

HTML と JS の様々なコンポーネントの関係を図 7 に示す。この図を理解するために、四つのソースコードファイルをブラウザが読み込む順番で見ていこう。

HTML

index.html の最初の行は、このドキュメントが UTF-8 エンコーディングの HTML5 で書かれることを宣言する:

<!DOCTYPE html><html><head><meta charset="UTF-8">

charset を宣言しないと、ブラウザはリセットボタンの Unicode 記号を ↻ と表示する可能性がある。エンコーディングの設定が不適切なために文字の表示がおかしくなる現象を文字化け (mojibake) と呼ぶ。

続いて JS リソースの読み込みと短いコードがある。これらは通常通り head タグ内に配置される:

  <script src="lib/angular.js"></script>
  <script src="main.js"></script>
  <script>
      try { angular.module('500lines') }
      catch(e){ location="es5/index.html" }
  </script>

<script src="..."> は読み込み中の HTML ページを起点とする相対パスから JS リソースを読み込む。例えば現在のページの URL が http://abc.com/x/index.html のとき lib/angular.jshttp://abc.com/x/lib/angular.js を意味する。

try{ angular.module('500lines') }main.js が問題なく読み込まれたかどうかを確認する。読み込まれていない場合は es5/index.html の読み込みをブラウザに指示する。これはリダイレクトを利用したグレースフルデグラデーション (graceful degradation, 滑らかな品質低下) と呼ばれるテクニックであり、ES6 をサポートしない古いブラウザでも ES5 に変換されたバージョンの JS プログラムをフォールバックとして利用できることを保証する。

この次には CSS リソースの読み込みと head 要素の終了タグ、そしてユーザーから見える要素を保持する body 要素の開始タグが続く:

  <link href="styles.css" rel="stylesheet">
</head><body ng-app="500lines" ng-controller="Spreadsheet" ng-cloak>

body 要素の開始タグに付いている ng-app 属性と ng-controller 属性は、500lines モジュールの Spreadsheet 関数を実行するよう AngularJS に伝える。Spreadsheet 関数はモデル (model) と呼ばれるオブジェクトを返し、このモデルがドキュメントのビュー (view) に対するバインディングを提供する。ng-cloak 属性はバインディングが作成されるまでドキュメントを表示しないよう指示する。

具体的な例として、次のスニペットで定義される button 要素をユーザーがクリックしたとする。このとき、その button 要素の ng-click 属性に設定されたコードが実行され、JS モデルが提供する二つの関数 resetcalc が呼び出される。

  <table><tr>
    <th><button type="button" ng-click="reset(); calc()"></button></th>

次の行は ng-repeat 属性を利用して列のラベルを最初の行に表示している:

    <th ng-repeat="col in Cols">{{ col }}</th>

例えば JS モデルが Cols["A","B","C"] と定義しているなら、それぞれ A, B, C をラベルに持つ三つのセル (th 要素) が最初の行に作成される。{{ col }} は式の補間を指示する AngularJS の記法であり、それぞれの th 要素には col の現在の値が埋め込まれる。

同様に、次の二行は Rows に含まれる値 (例えば [1,2,3]) を走査し、値ごとに行 (tr 要素) を作成し、各行の最初の要素として row をラベルに持った th 要素を作成する:

</tr><tr ng-repeat="row in Rows">
    <th>{{ row }}</th>

このコードで <tr ng-repeat> タグは </tr> タグによって閉じられていないので、変数 rowth 要素の中で利用できる。続いて現在の行にデータセル (td 要素) を作成し、二つの変数 col, row を両方使って ng-class 属性を設定する:

    <td ng-repeat="col in Cols" ng-class="{ formula: ('=' === sheet[col+row][0]) }">

このコードには説明が必要な要素がいくつかある。HTML 要素の class 属性は、CSS を使って異なる要素に異なるスタイルを適用するために利用されるクラス (class) の集合を表す。上記のコードの td 要素を AngularJS が処理するとき、ng-class 属性に含まれる式 ('=' === sheet[col+row][0]) が評価され、結果が true のとき formula クラスが要素に追加される。formula クラスを持つ要素には薄い青色の背景色が設定される (styles.css の 8 行目)。

('=' === sheet[col+row][0]) では、文字列 sheet[col+row] の最初の文字が = かどうかを確認することで、現在のセルのコンテンツが数式がどうかを判定している。sheet は JS モデルが提供するオブジェクトである。セルの座標を表す文字列 ("E1" など) をキーとするプロパティを持ち、対応するバリューにはセルのコンテンツ ("=A1+C1" など) が格納される。col は数値ではなく文字列なので、col+row に含まれる + は加算ではなく文字列の連結を意味することに注意してほしい。

上記の td 要素の中には、sheet[col+row] に格納されたセルのコンテンツを編集するための入力ボックスがある。

       <input id="{{ col+row }}" ng-model="sheet[col+row]" ng-change="calc()"
        ng-model-options="{ debounce: 200 }" ng-keydown="keydown( $event, col, row )">

ここで重要な要素が ng-model である。この属性によって、入力ボックス内の編集可能な文字列と JS モデルの間に双方向バインディング (two-way binding) が作成される。言い換えれば、ユーザーが入力ボックスに何かを入力すると JS モデルが sheet[col+row] の値を入力されたコンテンツに更新し、さらに calc 関数を呼び出して全ての数式セルの値を再計算する。

ユーザーがキーを押したままにしたとき calc が絶え間なく呼び出され続ける事態を防ぐため、ng-model-options 属性を使って更新の頻度を最大でも 200 ミリ秒に一度に制限している。

input 要素の id 属性は座標を表す文字列 col+row に設定される。HTML 要素の id 属性は同じドキュメントに含まれる他の HTML 要素の id 属性と異なる必要がある。この制約があることで、#A1 といった ID セレクタが単一の HTML 要素を指すようになる。これに対して .formula のようなクラスセレクタは要素の集合を表す。最後に、ユーザーが UP/DOWN/ENTER キーを押すと、keydown に実装されたキーボードナビゲーションのロジックが ID セレクタを使ってセルのフォーカスを移動する。

td 要素の中には入力ボックスの他にセルの評価結果を表示するための div 要素が存在する。評価結果は JS モデルにおいて errs, vals という二つのオブジェクトで表される:

      <div ng-class="{ error: errs[col+row], text: vals[col+row][0] }">
        {{ errs[col+row] || vals[col+row] }}</div>

数式セルの評価でエラーが発生した場合は errs[col+row] に含まれるエラーメッセージが補間される。さらに ng-class が要素に error クラスを割り当てるので、CSS による異なるスタイルの適用が可能になる (赤い文字で中央寄せなど)。

エラーが発生しなかった場合は || 演算子の右辺にあるセルの評価結果結果 vals[col+row] が補間される。この値が空でない文字列でないとき最初の文字 vals[col+row][0] は真値となり、セルに text クラスが適用される (テキストが左寄せとなる)。

空文字列と数値は先頭の文字を持たないので、計算結果が空文字列または数値の場合は ng-class がクラスを割り当てることはなく、CSS はデフォルトの右寄せを適用する。

最後に </td> で列を走査する ng-repeat のループを閉じ、</tr> で行を走査する ng-repeat のループを閉じ、</body><html> で HTML ドキュメントを閉じる:

    </td>
</tr></table>
</body></html>

JS: コントローラー

main.js500lines モジュールとそこに含まれるコントローラー関数 Spreadsheet を定義する。Spreadsheetindex.htmlbody 要素が参照としていた関数である。

HTML ビューとバックグラウンドワーカーの橋渡し役として、このファイルには四つのタスクがある:

コントローラーとワーカーの間で発生する対話の詳細を図 8 のフローチャートに示す。

図 8. コントローラーとワーカーのフローチャート
図 8コントローラーとワーカーのフローチャート

ではコードを見ていこう。最初の行は AngularJS の $scope をリクエストする。このオブジェクトはコントローラーとビューの間でデータを共有するために利用される。$scope$ は変数名の一部である:

angular.module('500lines', []).controller('Spreadsheet', function ($scope, $timeout) {

また、AngularJS が提供するサービス関数 $timeout もリクエストされている。$timeout は数式評価の無限ループを防ぐために利用される。

ColsRows をモデルに加える処理は、それらを $scope のプロパティとして定義するだけで完了する:

  // $scope のプロパティはビュー (HTML) からも利用できる。
  // 列と行のラベルを設定する。
  $scope.Cols = [], $scope.Rows = [];
  for (col of range( 'A', 'H' )) { $scope.Cols.push(col); }
  for (row of range( 1, 20 )) { $scope.Rows.push(row); }

ES6 で追加された for...of 構文を使って特定の範囲を走査するループが簡潔に書かれている。ここで使われるヘルパー関数 rangeジェネレータとして定義される:

  function* range(cur, end) { while (cur <= end) { yield cur;

最初の function*rangeイテレータを返すと宣言する。その後の while ループで値が一つずつ yield される。yield した時点で range の実行は一旦停止し、range の返り値を使う for ループが「次の」値を要求するたびに実行が yield の直後から再開される:

    // 数字なら 1 だけ大きくする。
    // そうでないなら次の文字に進む。
    cur = (isNaN( cur ) ? String.fromCodePoint( cur.codePointAt()+1 ) : cur+1);
  } }

「次の」値を生成するために、isNaN 関数を使って cur が文字かどうかを判定する。もし cur が文字なら、その符号位置 (code point) を計算し、それに 1 を加えた符号位置を文字に変換することで次の文字を得る。そうでなければ cur は数値なので、次の値は 1 を加えるだけで得られる。

続いて、キーボードによるフォーカスの移動を処理する keydown 関数を定義する:

  // UP(38) は一行上にフォーカスを移動させる。
  // DOWN(40)/ENTER(13) は一行下にフォーカスを移動させる。
  $scope.keydown = ({which}, col, row)=>{ switch (which) {

このアロー関数は以前に説明した <input ng-keydown> から引数 ($event, col, row) を受け取る。そのとき分割代入を利用することで、$event.whichwhich パラメータに代入している。この which はキーコードを表すので、入力されたのが注目しているキーかどうかを判定する:

    case 38: case 40: case 13: $timeout( ()=>{

追加で処理が必要になるキーが押された場合は、$timeout 関数を使ってフォーカスの移動を現在のハンドラ (ng-keydownng-change) の後にスケジュールする。$timeout 関数は関数を受け取るので、ここでもアロー関数 ()=>{...} を使ってフォーカスの移動ロジックを表している。この関数は最初に移動の方向を計算する:

      const direction = (which === 38) ? -1 : +1;

direction の宣言で使われているキーワード const は、宣言される変数が関数の中で変化しないことを意味する。キーコードが 38 (上矢印キー) なら移動は上方向 (-1, A2 から A1 の方向) となり、そうでなければ下方向 (+1, A2 から A3 の方向) となる。

続いて、ID セレクタ ("#A3" など) を使ってフォーカスが移動する先のセル要素を取得する。ID セレクタの構築ではバックティックを使ったテンプレート構文を使って文字 # と現在の col、そして移動先の row + direction を連結している。

      const cell = document.querySelector( `#${ col }${ row + direction }` );
      if (cell) { cell.focus(); }
    } );
  } };

document.querySelector の返り値 cell に対して if (cell) とチェックしているのは、例えば A1 から上方向に移動しようとすると #A0 という合致する要素が存在しない ID セレクタが構築されるためである。このような場合はフォーカスの移動は実行しなくて問題ない。一番下の行から下に移動しようとしたときも同様となる。

続いて、リセットボタンが押されたときに sheet の内容を復元する reset 関数を定義する:

  // デフォルトシートにリセットする。
  // 二つのデータセルと一つの数式セルが含まれる。
  $scope.reset = ()=>{
    $scope.sheet = { A1: 1874, B1: '+', C1: 2046, D1: '->', E1: '=A1+C1' }; }

次の init 関数は localStorage から読み込んだ値で sheet の初期化を試みる。アプリケーションが初めて起動されたときはデフォルトシートで初期化する。

  // 初期化関数を定義し、すぐに呼び出す。
  ($scope.init = ()=>{
    // 前回のシートを復元する。最初の実行ではデフォルトシートを読み込む。
    $scope.sheet = angular.fromJson( localStorage.getItem( '' ) );
    if (!$scope.sheet) { $scope.reset(); }
    $scope.worker = new Worker( 'worker.js' );
  }).call();

この init 関数に関して言及しておきたいことがいくつある:

sheet はユーザーから編集可能なセルのコンテンツを保持するのに対して、errsvals はセルの出力 (それぞれ評価結果とエラー) を保持する。そのため errsvals はユーザーから編集できない:

  // 数式セルの評価で発生する可能性のあるエラーは .errs に格納される。
  // セルの通常の評価結果は .vals に格納される。
  [$scope.errs, $scope.vals] = [ {}, {} ];

これらのプロパティがあれば、ユーザーがシートを編集するたびに呼び出される calc 関数を定義できる:

  // 計算ハンドラを定義する。ここでは呼び出さない。
  $scope.calc = ()=>{
    const json = angular.toJson( $scope.sheet );

まず sheet の現在状態のスナップショットを取得し、その状態を表す JSON 文字列を定数 json に保存する。続いて、これからワーカーに指示する計算に 99 ミリ秒より長い時間がかかった場合に計算をキャンセルする promise$timeout 関数を利用して作成する:

    const promise = $timeout( ()=>{
      // 99 ミリ秒以内にワーカーが値を返さなかったら、処理を停止させる。
      $scope.worker.terminate();
      // 以前の状態を復元して新しいワーカーを作成する。
      $scope.init();
      // 復元された状態に対する計算を再実行する。
      $scope.calc();
    }, 99 );

HTML の <input ng-model-options> 属性を通して calc は 200 ミリ秒より短い頻度で呼び出されないことが保証されているので、計算時間を最長でも 99 ミリ秒としておけば、initsheet を以前の状態に戻して新しいワーカーを作成する処理に最低でも 101 ミリ秒が残される。

ワーカーは sheet を受け取って errsvals を計算する。main.jsworker.js はメッセージのやり取りを通して対話するので、ワーカーから送られてくる計算結果を処理する onmessage ハンドラが必要になる:

    // ワーカーから計算結果を受け取ったらスコープを更新する。
    $scope.worker.onmessage = ({data})=>{
      $timeout.cancel( promise );
      localStorage.setItem( '', json );
      $timeout( ()=>{ [$scope.errs, $scope.vals] = data; } );
    };

onmessage が呼ばれた段階で、変数 json が保持する sheet のスナップショットが安定 (無限ループなどを含まず、短い時間で評価できる) ことが保証される。そこで 99 ミリ秒のタイムアウトをキャンセルし、スナップショットを localStorage に書き込み、ユーザーが視認できるビューを新しい errsvals で更新する UI アップデートを $timeout 関数でスケジュールする。

このハンドラが定義されたら、sheet の状態をワーカーに送信してバックグラウンド処理を開始させることができる:

    // 現在のシートの状態をワーカーに送信して処理させる。
    $scope.worker.postMessage( $scope.sheet );
  };

  // ワーカーの準備が完了したら計算を開始する。
  $scope.worker.onmessage = $scope.calc;
  $scope.worker.postMessage( null );
});

JS: バックグラウンド処理

数式の計算にメイン JS スレッドではなくウェブワーカーを使う理由は三つある:

これらの点を頭に入れたら、実際のワーカーのコードを見ていこう。

ワーカーが持つ唯一の責務は onmessage ハンドラの定義である。本アプリケーションのワーカーが定義するハンドラは sheet を受け取り、errsvals を計算し、この二つの値をメイン JS スレッドに送り返す:

let sheet, errs, vals;
self.onmessage = ({data})=>{
  [sheet, errs, vals] = [ data, {}, {} ];

セルの座標をグローバルスコープにするために、まず for...in ループを使って sheet のプロパティを走査する:

  for (const coord in sheet) {

ES6 で追加された constlet を使うと、ブロックスコープの定数と変数を定義できる。上記のコードのように const coord としたループの本体の定義で関数を定義すると、全ての関数は異なる値を持った coord をキャプチャする。

これに対して、以前のバージョンの JS で利用できる var coord関数スコープ (function scoped) の変数を定義する。そのためループの本体で関数を定義した場合、全ての関数は同じ coord 変数をキャプチャする。

慣習的に、セルの数式に含まれる変数は大文字と小文字を区別せず、先頭に $ を付けても意味が変わらないとされる。JS 変数は大文字と小文字を区別するので、map を使って同じ座標を意味する四つの変数名を処理する:

    // 同じ座標を意味する四つの変数名: A1, a1, $A1, $a1
    [ '', '$' ].map( p => [ coord, coord.toLowerCase() ].map(c => {
      const name = p+c;

アロー関数構文の省略形に注意してほしい: p => ...(p) => { ... } に等しい。

A1$a1 といった変数名のそれぞれに対して、式中で使われたときの評価結果を vals["A1"] とするアクセッサプロパティself に定義する:

      // ワーカーは計算をまたいで再利用されるので、各変数は一度だけ定義する。
      if ((Object.getOwnPropertyDescriptor( self, name ) || {}).get) { return; }

      // self['XX'] はグローバル変数 XX に等しい。
      Object.defineProperty( self, name, { get() {

上記のコードで使われている構文 { get() { ... } }{ get: ()=>{ … } } の省略形である。get だけを定義して set を定義していないので、変数は読み込み専用となる。つまり、ユーザーが与えた数式からセルのコンテンツを変更することはできない。

get アクセッサは最初に vals[coord] の存在を確認する。もし存在するなら vals[coord] は以前に計算された値なので、それをそのまま返す:

        if (coord in vals) { return vals[coord]; }

もし vals[coord] が存在しないなら、sheet[coord] を使って計算する必要がある。

まず vals[coord]NaN に設定する。こうしておくと、セル A1=A1 を設定したときなどに発生する自己参照で無限ループが発生しなくなる:

        vals[coord] = NaN;

続いて単項演算子 + を使って sheet[coord] を数値に変換し、その結果を x に代入する。そうした上で x の文字列表現を元の文字列と比較する。もし両者が異なっていたら、元の文字列を x に代入する。

        // 数式の評価で可能な場合は数値演算を行うために、
        // 数値文字列を数値に変換する。
        let x = +sheet[coord];
        if (sheet[coord] !== x.toString()) { x = sheet[coord]; }

もし x の最初の文字が = なら座標 coord のセルは数式セルなので、= より後ろの文字列を eval.call で評価する。eval の第一引数に null を渡しているので、第二引数はグローバルスコープで評価される。そのため xsheet といったレキシカルスコープの変数は数式の評価時に参照できない。

        // = で始まる数式セルのコンテンツを評価する。
        try { vals[coord] = (('=' === x[0]) ? eval.call( null, x.slice( 1 ) ) : x);

評価が成功した場合は、その結果が vals[coord] に格納される。数式でないセルに対しては x (数値または文字列) がそのまま vals[coord] の値となる。

eval で例外が発生した場合は、その例外が空のセルを参照したために発生したかどうかを catch ブロックで調べる:

        } catch (e) {
          const match = /\$?[A-Za-z]+[1-9][0-9]*\b/.exec( e );
          if (match && !( match[0] in self )) {

もしそうなら空のセルにデフォルト値 0 を割り当て、vals[coord] を削除し、self[coord] を呼び出して現在の計算を再実行する:

            // 数式が初期化されていないセルを参照しているときは、
            // そのセルを 0 に設定して計算を再実行する。
            self[match[0]] = 0;
            delete vals[coord];
            return self[coord];
          }

ここでデフォルト値 0 を設定したセルにユーザーがコンテンツを設定して sheet[coord] が値を持ったときは、Object.defineProperty によってデフォルト値が上書きされる。

他の種類のエラーは errs[coord] に格納する:

          // それ以外の場合は、例外を文字列化して errs[coord] に格納する。
          errs[coord] = e.toString();
        }

eval で例外が発生したときは評価結果の代入文が実行されないので、vals[coord]NaN のままとなる。

最後に、get アクセッサは vals[coord] に格納された評価結果を返す。この評価結果は数値、数値値、文字列のいずれかである必要がある:

        // vals[coord] が数値でも真偽値でもなければ文字列に変換する。
        switch (typeof vals[coord]) {
            case 'function': case 'object': vals[coord]+='';
        }
        return vals[coord];
      } } );
    }));
  }

全ての座標に対するアクセッサの定義が完了したら、ワーカーはセルの座標をもう一度走査して self[coord] でアクセッサを呼び出し、最後に errsvals をメイン JS スレッドに送り返す:

  // シートの各座標に対して、上で定義したプロパティゲッターを呼び出す。
  for (const coord in sheet) { self[coord]; }
  return [ errs, vals ];
}

CSS

styles.css はセレクタと適用すべき表示スタイルがいくつか書かれているだけのファイルである。まず、隣り合うセルの境界線をまとめて間に空白を生じさせない設定がある:

table { border-collapse: collapse; }

見出しセルとデータセルは同じ境界線を持つので、背景色を使って区別する: 見出しセルは明るいグレー、データセルはデフォルトの白、数式セルは明るい青の背景色とする:

th, td { border: 1px solid #ccc; }
th { background: #ddd; }
td.formula { background: #eef; }

セルの表示幅は各セルの計算された値に関係なく固定される。空セルの高さは非常に小さく、長い行は行末にリーダーを付けて途中まで表示される:

td div { text-align: right; width: 120px; min-height: 1.2em;
         overflow: hidden; text-overflow: ellipsis; }

テキストの寄せと装飾はセルが持つ値の型によって異なる。text クラスと error クラスには名前から想像される通りの値が設定される:

div.text { text-align: left; }
div.error { text-align: center; color: #800; font-size: 90%; border: solid 1px #800 }

ユーザーが編集可能な入力ボックスに対しては、絶対位置の指定を利用してセルの上に配置し、さらに色を透明にすることで下にある div 要素が持つセルの評価結果が見えるようにする:

input { position: absolute; border: 0; padding: 0;
        width: 120px; height: 1.3em; font-size: 100%;
        color: transparent; background: transparent; }

ユーザーが入力ボックスにフォーカスを移動させると、入力ボックスが前面に表示される:

input:focus { color: #111; background: #efe; }

さらに、そのとき下にある div は一行になり、入力ボックスによって完全に隠れて見えなくなる:

input:focus + div { white-space: nowrap; }

結論

本書の [英語の] 題名は 500 Lines or Less なので、99 行で作られたウェブスプレッドシートは利用可能なコードのわずかな部分しか使っていない ── 様々な実験をして好きなように機能を追加してみてほしい。

いくつかのアイデアを次に示す。どれも残りの 401 行で簡単に実装できるだろう:

JS のバージョンに関して

本章では ES6 の新しい機能を読者に紹介することを目標の一つとした。そのため ES5 以前にしか対応しないブラウザで実行するコードは Traceur コンパイラで作成される。

ES5 バージョンの JS を直接扱いたい場合は、ES5 に対応した main.jsworker.jsas-javascript-1.8.5 ディレクトリに用意してある。そのソースコードは ES6 バージョンと同じ行数であり、行ごとにほぼ同じ処理が書かれている。

さらに明快な構文による実装を確認したい場合は、as-livescript-1.3.0 ディレクトリに ES6 ではなく LiveScript を使った main.lsworker.ls がある。LiveScript バージョンは JS バージョンより行数が 20 行短い

as-react-livescript ディレクトリには ReactJS フレームワークと LiveScript 言語を利用した実装がある。AngularJS を使う実装より 10 行長いものの、実行速度は格段に速い。

この例を他の JS 言語に移植できたなら、ぜひ pull request を送ってほしい ── 他の言語を使った実装例を楽しみにしている!

広告