ウェブスプレッドシート
本章では、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 以降のモバイルブラウザでサポートされている。
ブラウザでスプレッドシートを開くと、次のページが表示される:

基本的な動作と概念
スプレッドシートは二次元格子状に並んだセル (cell) からなる。それぞれの列 (column) は A
から始まる添え字を持ち、それぞれの行 (row) は 1
から始まる添え字を持つ。セルは A1
といった一意の座標 (coordinate) と 1874
といったコンテンツ (content) を持つ。セルのコンテンツは次に示す四つの型 (type) のいずれかに属する:
- テキスト:
B1
の+
やD1
の⇒
など。左寄せで表示される。 - 数値:
A1
の1874
やC1
の2046
など。右寄せで表示される。 - 数式:
E1
の=A1+C1
など。数式は評価され、評価結果 (この例では3920
) に置き換えられる。薄い青色の背景で表示される。 - 空: 上記の例で第
2
行のセルは全て空である。
3920
と書かれたセルをクリックするとフォーカスが E1 に移動し、セルが持つ数式が入力ボックス内に表示される (図 2)。

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

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

以上の例から分かるように、数式の評価結果は次の三つのいずれかとなる:
- 数値 (
E1
の2027
) - テキスト (
A2
の左寄せになった現在日時) - エラー (
B2
の中央寄せになった赤い文字列)
続いて、無限ループして実行が停止しない JS コード =for(;;){}
をセルに入力する。このときスプレッドシートは値の更新を試みてから不可能だと判断し、C2
の以前のコンテンツを自動的に復元する。
Ctrl+R
または Cmd+R
を押してページをリロードすると、スプレッドシートの内容が永続化されているのが分かる。つまりブラウザセッションをまたいで同じ内容を表示できる。スプレッドシートを最初の状態にリセットする処理は、左上にある円状矢印ボタンを押すと実行できる。
プログレッシブエンハンスメント
99 行のコードを見ていく前に、ブラウザの設定で JS を無効化した状態でページを再読み込みしたとき何が変わるかに注目してみよう (図 5)。実際に試してみると、次のことが分かる:
- 大きな格子は表示されず、セルを一つだけ持った 2x2 の表のみが表示される。
- 行と列のラベルはそれぞれ
{{ row }}
と{{ col }}
になる。 - リセットボタンは機能しない。
TAB
キーを押す、または唯一のセルに示されたテキストの最初の行をクリックすると編集可能な入力ボックスが現れる。

動的な対話 (JS) を無効化すると、コンテンツの構造 (HTML) と表示スタイル (CSS) だけが有効な状態となる。JS と CSS を無効化した状態でも利用できるウェブページはプログレッシブエンハンスメント (progressive enhancement, 累積的な品質改善) 原則を満たしていると言う。そういったウェブページは最も多くのユーザーからアクセス可能となる。
本章で解説するスプレッドシートはサーバーサイドコードが一切存在しないウェブアプリケーションなので、必要なロジックを提供する JS の利用は避けられない。ただ、CSS が完全に機能しない環境 (スクリーンリーダーやテキストモードのブラウザなど) でも正しく動作するようにはなっている。

図 6 に示すように、ブラウザの JS を有効化して CSS を無効化すると、次の影響が出る:
- 背景色と前景色が全てデフォルトになる。
- 入力ボックスとセルの値が両方とも表示される。
- これらの点を除けば、アプリケーションは完全に機能する。
コードのウォークスルー

HTML と JS の様々なコンポーネントの関係を図 7 に示す。この図を理解するために、四つのソースコードファイルをブラウザが読み込む順番で見ていこう。
index.html
: 19 行main.js
: 38 行 (コメントと空行を除く)worker.js
: 30 行 (コメントと空行を除く)styles.css
: 12 行 (コメントと空行を除く)
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.js
は http://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 モデルが提供する二つの関数 reset
と calc
が呼び出される。
<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>
タグによって閉じられていないので、変数 row
はth
要素の中で利用できる。続いて現在の行にデータセル (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.js
は 500lines
モジュールとそこに含まれるコントローラー関数 Spreadsheet
を定義する。Spreadsheet
は index.html
の body
要素が参照としていた関数である。
HTML ビューとバックグラウンドワーカーの橋渡し役として、このファイルには四つのタスクがある:
- 列と行の個数とラベルを定義する。
- キーボードナビゲーションとリセットボタンに対するイベントハンドラを提供する。
- ユーザーがスプレッドシートを変更したとき、新しいコンテンツをワーカーに送信する。
- ワーカーから計算結果が送られてきたとき、ビューを更新して新しい状態を保存する。
コントローラーとワーカーの間で発生する対話の詳細を図 8 のフローチャートに示す。

ではコードを見ていこう。最初の行は AngularJS の $scope
をリクエストする。このオブジェクトはコントローラーとビューの間でデータを共有するために利用される。$scope
の $
は変数名の一部である:
angular.module('500lines', []).controller('Spreadsheet', function ($scope, $timeout) {
また、AngularJS が提供するサービス関数 $timeout
もリクエストされている。$timeout
は数式評価の無限ループを防ぐために利用される。
Cols
と Rows
をモデルに加える処理は、それらを $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.which
を which
パラメータに代入している。この which
はキーコードを表すので、入力されたのが注目しているキーかどうかを判定する:
case 38: case 40: case 13: $timeout( ()=>{
追加で処理が必要になるキーが押された場合は、$timeout
関数を使ってフォーカスの移動を現在のハンドラ (ng-keydown
と ng-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
関数に関して言及しておきたいことがいくつある:
($scope.init = ()=>{…}).call()
の構文を利用して、定義した関数をすぐに呼び出す。- localStorage に格納できるのは文字列だけなので、
angular.fromJson()
を使って JSON で表現されたsheet
をパースする。 - 最後のステップで新しいウェブワーカースレッドを作成し、スコープの
worker
プロパティに代入している。ワーカーはビューから直接は利用されないものの、モデル関連の関数の間でオブジェクトを共有するときは$scope
のプロパティを利用することが慣習とされている。本プロジェクトではinit
関数と後述のcalc
関数の間でウェブワーカーオブジェクトが共有される。
sheet
はユーザーから編集可能なセルのコンテンツを保持するのに対して、errs
と vals
はセルの出力 (それぞれ評価結果とエラー) を保持する。そのため errs
と vals
はユーザーから編集できない:
// 数式セルの評価で発生する可能性のあるエラーは .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 ミリ秒としておけば、init
が sheet
を以前の状態に戻して新しいワーカーを作成する処理に最低でも 101 ミリ秒が残される。
ワーカーは sheet
を受け取って errs
と vals
を計算する。main.js
と worker.js
はメッセージのやり取りを通して対話するので、ワーカーから送られてくる計算結果を処理する onmessage
ハンドラが必要になる:
// ワーカーから計算結果を受け取ったらスコープを更新する。
$scope.worker.onmessage = ({data})=>{
$timeout.cancel( promise );
localStorage.setItem( '', json );
$timeout( ()=>{ [$scope.errs, $scope.vals] = data; } );
};
onmessage
が呼ばれた段階で、変数 json
が保持する sheet
のスナップショットが安定 (無限ループなどを含まず、短い時間で評価できる) ことが保証される。そこで 99 ミリ秒のタイムアウトをキャンセルし、スナップショットを localStorage に書き込み、ユーザーが視認できるビューを新しい errs
と vals
で更新する UI アップデートを $timeout
関数でスケジュールする。
このハンドラが定義されたら、sheet
の状態をワーカーに送信してバックグラウンド処理を開始させることができる:
// 現在のシートの状態をワーカーに送信して処理させる。
$scope.worker.postMessage( $scope.sheet );
};
// ワーカーの準備が完了したら計算を開始する。
$scope.worker.onmessage = $scope.calc;
$scope.worker.postMessage( null );
});
JS: バックグラウンド処理
数式の計算にメイン JS スレッドではなくウェブワーカーを使う理由は三つある:
- ウェブワーカーはバックグラウンドで実行されメインスレッドの計算をブロックしないので、ユーザーはスプレッドシートとの対話を続けられる。
- 数式には任意の JS 式を書けるので、ウェブワーカーが提供するサンドボックス (sandbox) が必要になる。サンドボックスを用意しないと、
alert()
でダイアログボックスを出すといった親ページに対する干渉が数式から可能になる。 - 数式は任意のセルを座標で参照できる。参照されるセルの数式も他のセルを参照できるので、循環参照が作成される可能性がある。この問題を解決するために、ワーカーのグローバルスコープオブジェクト
self
を利用し、self
のプロパティとして変数のゲッター関数を定義することで循環参照の回避ロジックを実装する。
これらの点を頭に入れたら、実際のワーカーのコードを見ていこう。
ワーカーが持つ唯一の責務は onmessage
ハンドラの定義である。本アプリケーションのワーカーが定義するハンドラは sheet
を受け取り、errs
と vals
を計算し、この二つの値をメイン JS スレッドに送り返す:
let sheet, errs, vals;
self.onmessage = ({data})=>{
[sheet, errs, vals] = [ data, {}, {} ];
セルの座標をグローバルスコープにするために、まず for...in
ループを使って sheet
のプロパティを走査する:
for (const coord in sheet) {
ES6 で追加された const
と let
を使うと、ブロックスコープの定数と変数を定義できる。上記のコードのように 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
を渡しているので、第二引数はグローバルスコープで評価される。そのため x
や sheet
といったレキシカルスコープの変数は数式の評価時に参照できない。
// = で始まる数式セルのコンテンツを評価する。
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]
でアクセッサを呼び出し、最後に errs
と vals
をメイン 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 行で簡単に実装できるだろう:
- ShareJS, AngularFire, GoAngular のいずれかを利用して協調的オンラインエディタを実装する。
- angular-marked を使ってテキストセルで Markdown 構文をサポートする。
-
OpenFormula 規格 にある関数 (
SUM
やTRIM
など) を数式セルで使えるようにする。 - SheetJS を使って CSV や SpreadsheetML といった有名なスプレッドシートフォーマットをサポートする。
- Google Spreadsheet や EtherCalc といったオンラインスプレッドシートサービスとの相互運用 (インポートとエクスポート) を実装する。
JS のバージョンに関して
本章では ES6 の新しい機能を読者に紹介することを目標の一つとした。そのため ES5 以前にしか対応しないブラウザで実行するコードは Traceur コンパイラで作成される。
ES5 バージョンの JS を直接扱いたい場合は、ES5 に対応した main.js
と worker.js
を as-javascript-1.8.5 ディレクトリに用意してある。そのソースコードは ES6 バージョンと同じ行数であり、行ごとにほぼ同じ処理が書かれている。
さらに明快な構文による実装を確認したい場合は、as-livescript-1.3.0 ディレクトリに ES6 ではなく LiveScript を使った main.ls
と worker.ls
がある。LiveScript バージョンは JS バージョンより行数が 20 行短い。
as-react-livescript ディレクトリには ReactJS フレームワークと LiveScript 言語を利用した実装がある。AngularJS を使う実装より 10 行長いものの、実行速度は格段に速い。
この例を他の JS 言語に移植できたなら、ぜひ pull request を送ってほしい ── 他の言語を使った実装例を楽しみにしている!