500lines中最小的項(xiàng)目灰粮,99行實(shí)現(xiàn)一個(gè)Web電子表格,主要借助了AngularJS框架忍坷,可以學(xué)習(xí)下粘舟。
作者
Audrey Tang,自學(xué)成才的程序員和翻譯佩研,Audrey作為云服務(wù)本地化和自然語言技術(shù)的獨(dú)立承包商與蘋果合作柑肴。Audrey以前設(shè)計(jì)并領(lǐng)導(dǎo)過第一個(gè) Perl 6 實(shí)現(xiàn),并在 Haskell旬薯、Perl 5 和 Perl 6 的計(jì)算機(jī)語言設(shè)計(jì)委員會(huì)任職晰骑。目前,Audrey 是一個(gè)全職的g0v貢獻(xiàn)者和領(lǐng)導(dǎo)臺(tái)灣省的第一個(gè)電子規(guī)則制定項(xiàng)目绊序。本文介紹一個(gè) Web 電子表格硕舆,它是用 Web 瀏覽器支持的三種語言(HTML、JavaScript和CSS)編寫的骤公,共99行抚官。
引言
1990 年,Tim Berners-Lee 發(fā)明了全球資訊網(wǎng)阶捆,當(dāng)時(shí)的網(wǎng)頁文件(Web pages)都是以 HTML 寫成凌节,使用尖括號標(biāo)記(tags)來標(biāo)記文字,給內(nèi)容安排邏輯結(jié)構(gòu)洒试。以 <a>…</a>
標(biāo)記的文字會(huì)變成超鏈接(hyperlinks)倍奢,把用戶導(dǎo)引至其他網(wǎng)頁。
在 20 世紀(jì) 90 年代垒棋,瀏覽器加入了各種展示性標(biāo)記到 HTML 詞匯表卒煞,包括一些非標(biāo)準(zhǔn)標(biāo)記,例如來自 Netscape Navigator 的 <blink>…</blink>
和來自 Internet Explorer 的 <marquee>…</marquee>
捕犬,在可用性和瀏覽器兼容性方面造成了廣泛的問題跷坝。
為了將 HTML 限制在描述文檔邏輯結(jié)構(gòu)的原始目的上酵镜,瀏覽器開發(fā)者最后同意支持兩種附加語言:CSS 來形容網(wǎng)頁的展示風(fēng)格,以及 JS 來描述其動(dòng)態(tài)互動(dòng)功能柴钻。
從那時(shí)開始淮韭,這三種程式語言經(jīng)過了 20 年的共同進(jìn)化,已經(jīng)變得更加簡潔和強(qiáng)大贴届。JS 引擎的效能獲得高度提升靠粪,使得大規(guī)模的 JS 框架開始盛行,例如 AngularJS毫蚓。
如今占键,跨平臺(tái)的應(yīng)用網(wǎng)站(Web applications,例如電子部表格)元潘,已經(jīng)跟上個(gè)世紀(jì)的桌面應(yīng)用程序(如 VisiCalc畔乙、Lotus 1-2-3 和 Excel)一樣普及了。
使用 AngularJS 的網(wǎng)頁應(yīng)用可以在 99 行里面提供多少功能翩概?讓我們來看看牲距!
概述
在 spreadsheet 目錄里,包含了三種 Web 程式語言在 2014 年末版本的展示范例:描述結(jié)構(gòu)的 HTML5钥庇、描述展示風(fēng)格的 CSS3牍鞠,以及描述互動(dòng)功能的 JS ES6 “Harmony” 。它也用到 Web Storage 來保存資料评姨,以及利用 Web Worker 在后臺(tái)運(yùn)行 JS 代碼难述。在撰寫本文時(shí),這些 Web 標(biāo)準(zhǔn)都已獲得 Firefox吐句、Chrome胁后、Internet Explorer 11+,以及移動(dòng)瀏覽器 iOS 5+ 和 Android 4+ 的支持嗦枢。
讓我們在瀏覽器中打開 spreadsheet:
基本概念
電子表格跨越兩個(gè)維度择同,列從A開始,行從1開始净宵。每個(gè)單元格都有一個(gè)唯一的坐標(biāo)(如A1)和內(nèi)容(如“1874”)敲才,屬于以下四種類型之一:
文本:B1中的
+
和D1中的“?”,左對齊择葡。數(shù)字:A1中的“1874”和C1中的“2046”紧武,向右對齊。
公式:E1中的
=A1+C1
敏储,計(jì)算值為“3920”阻星,以淺藍(lán)色背景顯示。空:第2行中的所有單元格當(dāng)前都為空。
單擊“3920”將焦點(diǎn)設(shè)置為E1妥箕,并在輸入框中顯示其公式滥酥。
現(xiàn)在讓我們將焦點(diǎn)設(shè)置在 A1 上,并將其內(nèi)容更改為“1”畦幢,從而使 E1 將其值重新計(jì)算為“2047”坎吻。
按 ENTER 鍵將焦點(diǎn)設(shè)置為 A2 并將其內(nèi)容更改為 =Date()
,然后按 TAB 鍵宇葱,將B2的內(nèi)容更改為=alert()
瘦真,然后再次按 TAB 鍵將焦點(diǎn)設(shè)置為 C2。
這表明一個(gè)公式的結(jié)果可以是一個(gè)數(shù)字(E1中的“2047”)黍瞧、一個(gè)文本(A2中的當(dāng)前時(shí)間诸尽,向左對齊)或一個(gè)錯(cuò)誤(B2中的紅色字母,居中對齊)印颤。
接下來您机,讓我們嘗試輸入 =for(;;){}
,永不終止的無限循環(huán)的JS代碼年局。電子表格將通過在嘗試更改后自動(dòng)恢復(fù) C2 的內(nèi)容來防止這種情況往产。
現(xiàn)在使用 Ctrl-R 或 Cmd-R 在瀏覽器中重新加載頁面,以驗(yàn)證電子表格內(nèi)容是否持久某宪,在瀏覽器會(huì)話中保持不變。要將電子表格重置為其原始內(nèi)容锐朴,請按左上角的 ?
按鈕兴喂。
漸進(jìn)增強(qiáng)
在深入研究99行代碼之前,有必要在瀏覽器中禁用JS焚志,重新加載頁面衣迷,并注意差異:
屏幕上只保留一個(gè)2x2表格,只有一個(gè)內(nèi)容單元格酱酬,而不是一個(gè)大表格壶谒。
行和列標(biāo)簽被
{{Row}}
和{{col}}
替換。按
?
按鈕不起作用膳沽。按 TAB 鍵或單擊內(nèi)容的第一行仍會(huì)顯示一個(gè)可編輯的輸入框汗菜。
當(dāng)我們禁用動(dòng)態(tài)交互(JS)時(shí),內(nèi)容結(jié)構(gòu)(HTML)和表示樣式(CSS)仍然有效挑社。如果一個(gè)網(wǎng)站在 JS 和 CSS 都被禁用的情況下仍然可用陨界,我們說它堅(jiān)持漸進(jìn)增強(qiáng)原則,使它的內(nèi)容能夠被最多的讀者訪問痛阻。
因?yàn)槲覀兊碾娮颖砀袷且粋€(gè)沒有服務(wù)器端代碼的 Web 應(yīng)用程序菌瘪,所以我們必須依賴 JS 來提供所需的邏輯。但是阱当,當(dāng) CSS 沒有完全獲得支持時(shí)俏扩,它確實(shí)可以正常工作糜工,比如屏幕閱讀器和文本模式瀏覽器。
如上圖所示录淡,如果在瀏覽器中啟用 JS 并禁用 CSS捌木,則效果如下:
所有背景和前景顏色都消失了。
輸入框和單元格值都顯示赁咙,而不是只顯示一個(gè)钮莲。
除此之外,應(yīng)用程序仍然與完整版本相同彼水。
代碼走讀
下面的圖顯示了HTML和JS組件之間的關(guān)聯(lián)崔拥。
為了理解這個(gè)圖,讓我們按照瀏覽器加載它們的順序來瀏覽這四個(gè)源代碼文件凤覆。
- 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">
如果沒有字符集聲明链瓦,瀏覽器可能會(huì)將重置按鈕的Unicode符號顯示為a??
, 也就是亂碼:由解碼問題導(dǎo)致的錯(cuò)誤文本。
接下來的三行是 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="…">
標(biāo)記從與 HTML 頁面相同的路徑加載 JS 資源慈俯。例如,如果當(dāng)前URL為 http://abc.com/x/index.html
拥峦,則 lib/angular.js
引用 http://abc.com/x/lib/angular.js
贴膘。
try{ angular.module('500lines') }
測試 main.js
是否正確加載;如果沒有略号,它會(huì)告訴瀏覽器導(dǎo)航到 es5/index.html
刑峡。這種基于重定向的優(yōu)雅降級技術(shù)確保了對于不支持ES6的2015年以前的瀏覽器,我們可以將 JS 程序解析成 ES5 版本作為回退玄柠。
接下來的兩行加載 CSS 資源突梦,關(guān)閉 head
部分,然后開始包含用戶可見部分的 body
部分:
<link href="styles.css" rel="stylesheet">
</head>
<body ng-app="500lines" ng-controller="Spreadsheet" ng-cloak>
上面的 ng-app
和 ng-controller
屬性告訴 AngularJS 調(diào)用 500lines
模塊的電子表格函數(shù)羽利,該函數(shù)將返回一個(gè)模型:一個(gè)在文檔視圖上提供綁定的對象(ng-cloak
屬性在綁定就位之前隱藏文檔以防顯示宫患。)
作為一個(gè)具體的例子,當(dāng)用戶單擊下一行中定義的 <button>
時(shí)这弧,其 ng-click
屬性將觸發(fā)并調(diào)用 reset()
和 calc()
娃闲,這是 JS 模型提供的兩個(gè)命名函數(shù):
<table><tr>
<th><button type="button" ng-click="reset(); calc()">?</button></th>
下一行使用 ng-repeat
在頂行顯示列標(biāo)簽列表:
<th ng-repeat="col in Cols">{{ col }}</th>
例如,如果 JS 模型將 Cols
定義為 ["A","B","C"]
匾浪,那么將有三個(gè)標(biāo)題單元格(th
)相應(yīng)地標(biāo)記畜吊。{{col}}
告訴 AngularJS 插入表達(dá)式,用 col
的當(dāng)前值填充每個(gè) th
中的內(nèi)容户矢。
類似地玲献,接下來的兩行遍歷 Rows
中的值[1,2,3]
等等,為每個(gè)創(chuàng)建一行,并用其編號標(biāo)記最左側(cè)的第 th
個(gè)單元格:
</tr><tr ng-repeat="row in Rows">
<th>{{ row }}</th>
由于 <tr ng-repeat>
標(biāo)記尚未由 </tr>
關(guān)閉捌年,因此 row
變量仍然可用于表達(dá)式瓢娜。下一行在當(dāng)前行中創(chuàng)建一個(gè)數(shù)據(jù)單元(td
),并在其 ng-class
屬性中使用 col
和 row
變量:
<td ng-repeat="col in Cols" ng-class="{ formula: ('=' === sheet[col+row][0]) }">
這里有幾個(gè)重點(diǎn)礼预。在 HTML 中眠砾,class
屬性描述了一組類名,這些類名允許 CSS 對它們進(jìn)行不同的樣式設(shè)置托酸。這里的 ng-class
計(jì)算表達(dá)式 ('=' === sheet[col+row][0])
褒颈;如果為 true,則 <td>
將 formula
作為一個(gè)附加類獲取励堡,該類為單元格提供淡藍(lán)色背景谷丸,如styles.css的第8行中使用 .formula
類選擇器定義的那樣。
上面的表達(dá)式通過測試 =
是否是 sheet[col+row]
中字符串的初始字符([0]
)來檢查當(dāng)前單元格是否是公式应结,其中 sheet
是一個(gè)JS模型對象刨疼,坐標(biāo)(如"E1"
)是屬性,單元格內(nèi)容(如"=A1+C1"
)是值鹅龄。請注意揩慕,因?yàn)?col
是字符串而不是數(shù)字,所以 col+row
中的 +
表示串聯(lián)而不是加法扮休。
在 <td>
中迎卤,我們?yōu)橛脩籼峁┝艘粋€(gè)輸入框,用于編輯存儲(chǔ)在 sheet[col+row]
中的單元格內(nèi)容:
<input id="{{ col+row }}" ng-model="sheet[col+row]" ng-change="calc()"
ng-model-options="{ debounce: 200 }" ng-keydown="keydown( $event, col, row )">
這里的關(guān)鍵屬性是 ng-model
玷坠,它支持 JS 模型和輸入框的可編輯內(nèi)容之間的雙向綁定蜗搔。實(shí)際上,這意味著每當(dāng)用戶在輸入框中進(jìn)行更改時(shí)侨糟,JS模型將更新 sheet[col+row]
以匹配內(nèi)容,并觸發(fā)其 calc()
函數(shù)以重新計(jì)算所有公式單元格的值瘩燥。
為了避免在用戶按住某個(gè)鍵時(shí)重復(fù)調(diào)用 calc()
秕重, ng-model-options
將更新速率限制為每 200 毫秒一次。
此處的 id 屬性用坐標(biāo) col+row
取值厉膀。HTML元素的id屬性必須與同一文檔中所有其他元素的id不同溶耘。這確保 #A1
ID 選擇器引用單個(gè)元素,而不是類選擇器 .formular
之類的元素集服鹅。當(dāng)用戶按下 UP/DOWN/ENTER 時(shí)凳兵,keydown()
中的鍵盤導(dǎo)航邏輯將使用ID選擇器來確定要重點(diǎn)關(guān)注哪個(gè)輸入框。
在輸入框之后企软,我們放置一個(gè) <div>
來顯示當(dāng)前單元格的計(jì)算值庐扫,在JS模型中由對象 errs
和 vals
表示:
<div ng-class="{ error: errs[col+row], text: vals[col+row][0] }">
{{ errs[col+row] || vals[col+row] }}</div>
如果在計(jì)算公式時(shí)發(fā)生錯(cuò)誤,文本插值將使用 errs[col+row]
中包含的錯(cuò)誤消息,ng-class
將 error
類應(yīng)用于元素形庭,從而允許CSS以不同的方式對其進(jìn)行樣式設(shè)置(使用紅色字母铅辞、與中心對齊等)。
如果沒有錯(cuò)誤萨醒,||
右側(cè)的 vals[col+row]
將被取值斟珊。如果是非空字符串,則初始字符([0]
)將計(jì)算為 true
富纸,并將 text
類應(yīng)用于左對齊文本的元素囤踩。
因?yàn)榭兆址蛿?shù)值沒有初始字符,ng-class
不會(huì)為它們分配任何類晓褪,所以 CSS 可以將它們的樣式設(shè)置為默認(rèn)情況下的右對齊方式堵漱。
最后,我們用 </td>
關(guān)閉列級的 ng-repeat
循環(huán)辞州,用 </tr>
關(guān)閉行級循環(huán)怔锌,并用以下命令結(jié)束 HTML 文檔:
</td>
</tr></table>
</body></html>
JS: 主控制層
main.js
文件根據(jù) index.html
中的 <body>
元素的要求定義 500 行模塊及其電子表格控制器功能。
作為 HTML 視圖和后臺(tái)工作層之間的橋梁变过,它有四個(gè)任務(wù):
定義列和行的數(shù)量和標(biāo)題埃元。
為鍵盤移動(dòng)和重置按鈕提供事件處理程序。
當(dāng)用戶更改電子表格時(shí)媚狰,將其新內(nèi)容發(fā)送給工作人員岛杀。
當(dāng)計(jì)算結(jié)果從工作層到達(dá)時(shí),更新視圖并保存當(dāng)前狀態(tài)崭孤。
下圖中的流程圖更詳細(xì)地顯示了控制層與工作層的交互:
現(xiàn)在讓我們?yōu)g覽一下代碼类嗤。在第一行中,我們請求AngularJS 的 $scope
:
angular.module('500lines', []).controller('Spreadsheet', function ($scope, $timeout) {
$scope
中的 $
是變量名的一部分辨宠。這里我們還從 AngularJS 請求 $timeout
服務(wù)函數(shù)遗锣;稍后,我們將使用它來防止無限循環(huán)公式嗤形。
要將 Cols
和 Rows
放入模型中精偿,只需將它們定義為 $scope
的屬性:
// Begin of $scope properties; start with the column/row labels
$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
語法可以很容易地在具有起點(diǎn)和終點(diǎn)的范圍內(nèi)循環(huán),輔助函數(shù) range
定義為生成器:
function* range(cur, end) { while (cur <= end) { yield cur;
上面的 function*
意味著 range
返回一個(gè)迭代器赋兵,其中有一個(gè) while
循環(huán)笔咽,每次只 yield
一個(gè)值。每當(dāng) for
循環(huán)需要下一個(gè)值時(shí)霹期,它將在 yield
行之后立即恢復(fù)執(zhí)行:
// If it’s a number, increase it by one; otherwise move to next letter
cur = (isNaN( cur ) ? String.fromCodePoint( cur.codePointAt()+1 ) : cur+1);
} }
為了生成下一個(gè)值叶组,我們使用 isNaN
來查看 cur
是否意味著一個(gè)字母(NaN
代表“不是一個(gè)數(shù)字”),如果是字母历造,我們得到字母的碼點(diǎn)值甩十,將其遞增1船庇,然后將碼點(diǎn)值轉(zhuǎn)換回下一個(gè)字母。否則枣氧,我們只需將數(shù)字增加1溢十。
接下來,我們定義 keydown()
函數(shù)來處理跨行的鍵盤導(dǎo)航:
// UP(38) and DOWN(40)/ENTER(13) move focus to the row above (-1) and below (+1).
$scope.keydown = ({which}, col, row)=>{ switch (which) {
箭頭函數(shù)從 <input ng keydown>
接收參數(shù) ($event, col, row)
达吞,使用析構(gòu)分配將 $event.which
賦值到 which
參數(shù)中张弛,并檢查它是否在三個(gè)導(dǎo)航鍵代碼中:
case 38: case 40: case 13: $timeout( ()=>{
如果是,我們使用 $timeout
在當(dāng)前 ng-keydown
和 ng-change
處理程序之后安排焦點(diǎn)更改酪劫。因?yàn)?$timeout
需要一個(gè)函數(shù)作為參數(shù)吞鸭,所以 ()=>{…}
語法構(gòu)造了一個(gè)函數(shù)來表示焦點(diǎn)更改邏輯,它首先檢查移動(dòng)方向:
const direction = (which === 38) ? -1 : +1;
const
聲明符意味著在函數(shù)執(zhí)行期間 direction
不會(huì)改變覆糟。如果鍵碼為38(向上)刻剥,則移動(dòng)方向?yàn)橄蛏希?1,從A2到A1)滩字,否則為向下(+1造虏,從A2到A3)。
接下來麦箍,我們使用ID選擇器語法(例如 "#A3"
)檢索目標(biāo)元素漓藕,該語法由一個(gè)模板字符串構(gòu)成,該字符串寫在一對反引號中挟裂,連接前導(dǎo) #
享钞、當(dāng)前列和目標(biāo) row + direction
:
const cell = document.querySelector( `#${ col }${ row + direction }` );
if (cell) { cell.focus(); }
} );
} };
我們對 querySelector
的結(jié)果進(jìn)行了額外的檢查,因?yàn)閺?strong>A1向上移動(dòng)將產(chǎn)生選擇器 #A0
诀蓉,它沒有相應(yīng)的元素栗竖,因此不會(huì)觸發(fā)焦點(diǎn)更改—在最下面一行按向下鍵也是如此。
接下來渠啤,我們定義 reset()
函數(shù)狐肢,以便重置按鈕可以還原工作表的內(nèi)容:
// Default sheet content, with some data cells and one formula cell.
$scope.reset = ()=>{
$scope.sheet = { A1: 1874, B1: '+', C1: 2046, D1: '->', E1: '=A1+C1' }; }
init()
函數(shù)嘗試從 localStorage 中恢復(fù) sheet
內(nèi)容的以前狀態(tài),如果是首次運(yùn)行應(yīng)用程序沥曹,則默認(rèn)為初始內(nèi)容:
// Define the initializer, and immediately call it
($scope.init = ()=>{
// Restore the previous .sheet; reset to default if it’s the first run
$scope.sheet = angular.fromJson( localStorage.getItem( '' ) );
if (!$scope.sheet) { $scope.reset(); }
$scope.worker = new Worker( 'worker.js' );
}).call();
在上面的 init()
函數(shù)中份名,有些東西需要關(guān)注:
我們使用
($scope.init = ()=>{…}).call()
語法來定義函數(shù)并立即調(diào)用它。因?yàn)?localStorage 只存儲(chǔ)字符串架专,所以我們使用
angular.fromJson()
從 JSON 表示形式解析sheet
結(jié)構(gòu)同窘。在
init()
的最后一步玄帕,我們創(chuàng)建了一個(gè)新的 Web 工作線程部脚,并將其分配給worker
范圍屬性。盡管worker
不是直接在視圖中使用的裤纹,但是通常使用$scope
來共享模型函數(shù)之間使用的對象委刘,在這里是init()
和下面的calc()
之間丧没。
當(dāng) sheet
保存用戶可編輯的單元格內(nèi)容時(shí),errs
和 vals
包含用戶只讀的計(jì)算結(jié)果(錯(cuò)誤和值):
// Formula cells may produce errors in .errs; normal cell contents are in .vals
[$scope.errs, $scope.vals] = [ {}, {} ];
有了這些屬性锡移,我們可以定義 calc()
函數(shù)呕童,每當(dāng)用戶更改工作表時(shí),該函數(shù)都會(huì)觸發(fā):
// Define the calculation handler; not calling it yet
$scope.calc = ()=>{
const json = angular.toJson( $scope.sheet );
在這里淆珊,我們對 sheet
的狀態(tài)進(jìn)行快照夺饲,并將其存儲(chǔ)在常量 json
(一個(gè)JSON字符串)中。接下來施符,我們從 $timeout
構(gòu)造一個(gè) promise
往声,如果花費(fèi)的時(shí)間超過99毫秒,它將取消即將進(jìn)行的計(jì)算:
const promise = $timeout( ()=>{
// If the worker has not returned in 99 milliseconds, terminate it
$scope.worker.terminate();
// Back up to the previous state and make a new worker
$scope.init();
// Redo the calculation using the last-known state
$scope.calc();
}, 99 );
由于我們確保通過HTML中的 <input ng-model-options>
屬性戳吝,calc()
最多每200毫秒調(diào)用一次浩销,因此這種安排為 init()
留出101毫秒的時(shí)間來將 sheet
恢復(fù)到最后一個(gè)已知的良好狀態(tài),并生成一個(gè)新的工作進(jìn)程听哭。
工作線程的任務(wù)是根據(jù)工作表的內(nèi)容計(jì)算 errs
和 vals
慢洋。因?yàn)閙ain.js和worker.js是通過消息傳遞進(jìn)行通信的,所以我們需要一個(gè) onmessage
處理程序來接收準(zhǔn)備好的結(jié)果:
// When the worker returns, apply its effect on the scope
$scope.worker.onmessage = ({data})=>{
$timeout.cancel( promise );
localStorage.setItem( '', json );
$timeout( ()=>{ [$scope.errs, $scope.vals] = data; } );
};
如果調(diào)用 onmessage
陆盘,我們知道 json
中的工作表快照是穩(wěn)定的(即普筹,不包含無限循環(huán)公式),因此我們?nèi)∠?9毫秒超時(shí)礁遣,將快照寫入localStorage斑芜,并使用 $timeout
函數(shù)計(jì)劃UI更新,該函數(shù)將 errs
和 vals
更新到用戶可見視圖祟霍。
處理程序就位后杏头,我們可以將工作表的狀態(tài)發(fā)布到工作線程,并在后臺(tái)開始計(jì)算:
// Post the current sheet content for the worker to process
$scope.worker.postMessage( $scope.sheet );
};
// Start calculation when worker is ready
$scope.worker.onmessage = $scope.calc;
$scope.worker.postMessage( null );
});
JS:后臺(tái)工作線程
使用 Web 工作線程來計(jì)算公式沸呐,而不是使用 JS 主線程來執(zhí)行任務(wù)醇王,有三個(gè)原因:
當(dāng)工作線程在后臺(tái)運(yùn)行時(shí),用戶可以繼續(xù)與電子表格交互崭添,而不會(huì)被主線程中的計(jì)算阻塞寓娩。
因?yàn)槲覀兘邮芄街械娜魏?JS 表達(dá)式,所以工作線程提供了一個(gè)沙盒呼渣,防止公式干擾包含它們的頁面棘伴,例如彈出
alert()
對話框。公式可以引用任何坐標(biāo)作為變量。其它坐標(biāo)可能包含另一個(gè)以循環(huán)引用結(jié)尾的公式胡野。為了解決這個(gè)問題水孩,我們使用工作線程的全局范圍對象
self
,并將這些變量定義為self
上的 getter 函數(shù)來實(shí)現(xiàn)循環(huán)預(yù)防邏輯阱穗。
有了這些認(rèn)識后饭冬,讓我們來看看工作線程的代碼。
工作線程的唯一目的是定義其 onmessage
處理程序揪阶。處理程序獲取 sheet
昌抠,計(jì)算 errs
和 vals
,并將它們發(fā)回主JS線程鲁僚。我們首先在收到消息時(shí)重新初始化三個(gè)變量:
let sheet, errs, vals;
self.onmessage = ({data})=>{
[sheet, errs, vals] = [ data, {}, {} ];
為了將坐標(biāo)轉(zhuǎn)換為全局變量炊苫,我們首先使用 for...in
循環(huán)對 sheet
中的每個(gè)屬性進(jìn)行迭代:
for (const coord in sheet) {
ES6 引入 const
、let
聲明塊范圍的常量和變量冰沙;上面的 const coord
意味著在循環(huán)中定義的函數(shù)將在每次迭代中捕獲 coord
的值劝评。
相反,JS的早期版本中的 var coord
會(huì)聲明一個(gè)函數(shù)范圍的變量倦淀,并且在每個(gè)循環(huán)迭代中定義的函數(shù)最終會(huì)指向同一個(gè) coord
變量蒋畜。
通常,公式變量不區(qū)分大小寫撞叽,并且可以選擇使用 $
前綴姻成。因?yàn)?JS 變量是區(qū)分大小寫的,所以我們使用 map
檢查同一坐標(biāo)的四個(gè)變量名:
// Four variable names pointing to the same coordinate: A1, a1, $A1, $a1
[ '', '$' ].map( p => [ coord, coord.toLowerCase() ].map(c => {
const name = p+c;
注意上面的箭頭函數(shù)語法:p => ...
與 (p) => { ... }
相同愿棋。
對于每個(gè)變量名(如 A1
和 $a1
)科展,我們在 self
上定義一個(gè)訪問器屬性,每當(dāng)在表達(dá)式中計(jì)算時(shí)糠雨, 該屬性都自動(dòng)會(huì)計(jì)算 vals["A1"]
的值:
// Worker is reused across calculations, so only define each variable once
if ((Object.getOwnPropertyDescriptor( self, name ) || {}).get) { return; }
// Define self['A1'], which is the same thing as the global variable A1
Object.defineProperty( self, name, { get() {
上面的 { get() { ... } }
語法是 { get: ()=>{ ... } }
的簡寫才睹。因?yàn)槲覀冎欢x了 get
而沒有定義 set
,所以變量變成只讀的甘邀,并且不能從用戶提供的公式中修改琅攘。
get
訪問器從檢查 vals[coord]
開始,如果已經(jīng)計(jì)算了則返回它:
if (coord in vals) { return vals[coord]; }
如果不是松邪,我們需要從 sheet[coord]
計(jì)算 vals[coord]
坞琴。
首先我們將其設(shè)置為 NaN
,這樣像將A1設(shè)置為 =A1
這樣的自引用將以 NaN
而不是無限循環(huán)結(jié)束:
vals[coord] = NaN;
接下來逗抑,我們檢查 sheet[coord]
是否是一個(gè)數(shù)字剧辐,方法是將其轉(zhuǎn)換為前綴為 +
的數(shù)字,將數(shù)字賦給 x
邮府,并將其字符串表示形式與原始字符串進(jìn)行比較荧关。如果它們不同,那么我們將 x
設(shè)置為原始字符串:
// Turn numeric strings into numbers, so =A1+C1 works when both are numbers
let x = +sheet[coord];
if (sheet[coord] !== x.toString()) { x = sheet[coord]; }
如果 x
的初始字符是 =
褂傀,則它是一個(gè)公式單元格忍啤。我們使用 eval.call()
計(jì)算 =
后的部分,使用第一個(gè)參數(shù) null
告訴 eval
在全局范圍內(nèi)運(yùn)行紊服,在計(jì)算中隱藏 x
和 sheet
等詞法范圍變量:
// Evaluate formula cells that begin with =
try { vals[coord] = (('=' === x[0]) ? eval.call( null, x.slice( 1 ) ) : x);
如果計(jì)算成功檀轨,結(jié)果將存儲(chǔ)到 vals[coord]
中。對于非公式單元格欺嗤,vals[coord]
的值僅為 x
参萄,可以是數(shù)字或字符串。
如果 eval
導(dǎo)致錯(cuò)誤煎饼,catch
塊將測試是否是因?yàn)楣揭昧?self
中尚未定義的空單元格:
} catch (e) {
const match = /\$?[A-Za-z]+[1-9][0-9]*\b/.exec( e );
if (match && !( match[0] in self )) {
在這種情況下讹挎,我們將缺少的單元格的默認(rèn)值設(shè)置為“0”,清除 vals[coord]
吆玖,然后使用 self[coord]
重新運(yùn)行當(dāng)前計(jì)算:
// The formula refers to a uninitialized cell; set it to 0 and retry
self[match[0]] = 0;
delete vals[coord];
return self[coord];
}
如果用戶稍后在 sheet[coord]
中為缺少的單元格提供內(nèi)容筒溃,則 Object.defineProperty
將覆蓋臨時(shí)值。
其他類型的錯(cuò)誤存儲(chǔ)在 errs[coord]
中:
// Otherwise, stringify the caught exception in the errs object
errs[coord] = e.toString();
}
如果出現(xiàn)錯(cuò)誤沾乘,vals[coord]
的值將保持為 NaN
怜奖,因?yàn)橘x值沒有完成執(zhí)行。
最后翅阵,get
訪問器返回存儲(chǔ)在 vals[coord]
中的計(jì)算值歪玲,該值必須是數(shù)字、布爾值或字符串:
// Turn vals[coord] into a string if it's not a number or Boolean
switch (typeof vals[coord]) {
case 'function': case 'object': vals[coord]+='';
}
return vals[coord];
} } );
}));
}
在為所有坐標(biāo)定義了訪問器之后掷匠,工作線程再次遍歷坐標(biāo)滥崩,使用 self[coord]
調(diào)用每個(gè)訪問器,然后將生成的 errs
和 vals
發(fā)回主 JS 線程:
// For each coordinate in the sheet, call the property getter defined above
for (const coord in sheet) { self[coord]; }
return [ errs, vals ];
}
CSS
styles.css 文件只包含幾個(gè)選擇器及其表示樣式讹语。首先钙皮,我們設(shè)置表格樣式,將所有單元格邊框合并在一起顽决,相鄰單元格之間不留空格:
table { border-collapse: collapse; }
標(biāo)題和數(shù)據(jù)單元格都具有相同的邊框樣式短条,但我們可以通過它們的背景顏色來區(qū)分它們:標(biāo)題單元格為淺灰色,默認(rèn)情況下數(shù)據(jù)單元格為白色才菠,公式單元格為淺藍(lán)色背景:
th, td { border: 1px solid #ccc; }
th { background: #ddd; }
td.formula { background: #eef; }
對于每個(gè)單元格的計(jì)算值慌烧,顯示的寬度是固定的○空單元格的高度最小屹蚊,長線用尾部省略號剪裁:
td div { text-align: right; width: 120px; min-height: 1.2em;
overflow: hidden; text-overflow: ellipsis; }
文本對齊和修飾由每個(gè)值的類型決定,如 text
和 error
類選擇器是:
div.text { text-align: left; }
div.error { text-align: center; color: #800; font-size: 90%; border: solid 1px #800 }
對于用戶可編輯的 input
框进每,我們使用絕對定位將其覆蓋在其單元格的頂部汹粤,并使其透明,以便具有單元格值的底層 div
通過以下方式顯示:
input { position: absolute; border: 0; padding: 0;
width: 120px; height: 1.3em; font-size: 100%;
color: transparent; background: transparent; }
當(dāng)用戶在輸入框上設(shè)置焦點(diǎn)時(shí)田晚,它會(huì)跳入前臺(tái):
input:focus { color: #111; background: #efe; }
此外嘱兼,底層 div
被折疊成一行,因此它完全被輸入框覆蓋:
input:focus + div { white-space: nowrap; }
結(jié)論
由于本書建議500行或更少贤徒,用 99 行代碼實(shí)現(xiàn)網(wǎng)絡(luò)電子表格是一個(gè)最小的例子芹壕,請隨時(shí)實(shí)驗(yàn)汇四,并擴(kuò)展到任何你喜歡的方向。
以下是一些想法踢涌,在401行的剩余空間中很容易實(shí)現(xiàn):
使用ShareJS通孽、AngularFire 或 GoAngular的協(xié)作在線編輯器。
標(biāo)記語法支持文本單元格睁壁,使用 angular-marked背苦。
OpenFormula標(biāo)準(zhǔn)中的常用公式函數(shù)(SUM、TRIM等)潘明。
通過 SheetJS 與流行的電子表格格式(如 CSV 和SpreadsheetML)進(jìn)行交互操作行剂。
導(dǎo)入和導(dǎo)出到在線電子表格服務(wù),如 Google 電子表格和EtherCalc钳降。
JS版本說明
本章旨在演示 ES6 中的新概念厚宰,因此我們使用 Traceur 編譯器將源代碼轉(zhuǎn)換為ES5,以便在2015年以前的瀏覽器上運(yùn)行遂填。
如果您希望直接使用 2010 版的JS固阁,那么 as-javascript-1.8.5目錄中的 main.js 和 worker.js 都是以 ES5 的樣式編寫的;源代碼與具有相同行數(shù)的 ES6 版本一行一行進(jìn)行了比較城菊。
對于喜歡更簡潔語法的人备燃,as-livescript-1.3.0 目錄使用 livescript 而不是 ES6 來編寫main.ls和worker.ls;它比JS版本短20行凌唬。
基于LiveScript語言并齐,as-react-livescript 目錄使用 ReactJS 框架;它比同等的 AngularJS 長 10 行客税,但運(yùn)行速度要快得多况褪。