500 lines or less學(xué)習(xí)筆記(十三)——Web 電子表格(spreadsheet)

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:

01-initial.png

基本概念

電子表格跨越兩個(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妥箕,并在輸入框中顯示其公式滥酥。

02-input.png

現(xiàn)在讓我們將焦點(diǎn)設(shè)置在 A1 上,并將其內(nèi)容更改為“1”畦幢,從而使 E1 將其值重新計(jì)算為“2047”坎吻。

03-changed.png

ENTER 鍵將焦點(diǎn)設(shè)置為 A2 并將其內(nèi)容更改為 =Date(),然后按 TAB 鍵宇葱,將B2的內(nèi)容更改為=alert()瘦真,然后再次按 TAB 鍵將焦點(diǎn)設(shè)置為 C2

04-error.png

這表明一個(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-RCmd-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è)可編輯的輸入框汗菜。

05-nojs.png

當(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í)可以正常工作糜工,比如屏幕閱讀器和文本模式瀏覽器。

06-nocss.png

如上圖所示录淡,如果在瀏覽器中啟用 JS 并禁用 CSS捌木,則效果如下:

  • 所有背景和前景顏色都消失了。

  • 輸入框和單元格值都顯示赁咙,而不是只顯示一個(gè)钮莲。

  • 除此之外,應(yīng)用程序仍然與完整版本相同彼水。

代碼走讀

下面的圖顯示了HTML和JS組件之間的關(guān)聯(lián)崔拥。

00-architecture.png

為了理解這個(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-appng-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 屬性中使用 colrow 變量:

    <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模型中由對象 errsvals 表示:

      <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-classerror 類應(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ì)地顯示了控制層與工作層的交互:

00-flowchart.png

現(xiàn)在讓我們?yōu)g覽一下代碼类嗤。在第一行中,我們請求AngularJS 的 $scope

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

$scope 中的 $ 是變量名的一部分辨宠。這里我們還從 AngularJS 請求 $timeout 服務(wù)函數(shù)遗锣;稍后,我們將使用它來防止無限循環(huán)公式嗤形。

要將 ColsRows 放入模型中精偿,只需將它們定義為 $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-keydownng-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,從A2A1)滩字,否則為向下(+1造虏,從A2A3)。

接下來麦箍,我們使用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í),errsvals 包含用戶只讀的計(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ì)算 errsvals慢洋。因?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ù)將 errsvals 更新到用戶可見視圖祟霍。

處理程序就位后杏头,我們可以將工作表的狀態(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ì)算 errsvals,并將它們發(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 引入 constlet 聲明塊范圍的常量和變量冰沙;上面的 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ì)算中隱藏 xsheet 等詞法范圍變量:

        // 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è)訪問器,然后將生成的 errsvals 發(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è)值的類型決定,如 texterror 類選擇器是:

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)行速度要快得多况褪。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市更耻,隨后出現(xiàn)的幾起案子测垛,更是在濱河造成了極大的恐慌,老刑警劉巖秧均,帶你破解...
    沈念sama閱讀 219,366評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件食侮,死亡現(xiàn)場離奇詭異,居然都是意外死亡目胡,警方通過查閱死者的電腦和手機(jī)锯七,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,521評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來誉己,“玉大人眉尸,你說我怎么就攤上這事。” “怎么了噪猾?”我有些...
    開封第一講書人閱讀 165,689評論 0 356
  • 文/不壞的土叔 我叫張陵霉祸,是天一觀的道長。 經(jīng)常有香客問我袱蜡,道長丝蹭,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,925評論 1 295
  • 正文 為了忘掉前任戒劫,我火速辦了婚禮,結(jié)果婚禮上婆廊,老公的妹妹穿的比我還像新娘迅细。我一直安慰自己,他們只是感情好淘邻,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,942評論 6 392
  • 文/花漫 我一把揭開白布茵典。 她就那樣靜靜地躺著,像睡著了一般宾舅。 火紅的嫁衣襯著肌膚如雪统阿。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,727評論 1 305
  • 那天筹我,我揣著相機(jī)與錄音扶平,去河邊找鬼。 笑死蔬蕊,一個(gè)胖子當(dāng)著我的面吹牛结澄,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播岸夯,決...
    沈念sama閱讀 40,447評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼麻献,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了猜扮?” 一聲冷哼從身側(cè)響起勉吻,我...
    開封第一講書人閱讀 39,349評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎旅赢,沒想到半個(gè)月后齿桃,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,820評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡煮盼,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,990評論 3 337
  • 正文 我和宋清朗相戀三年源譬,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片孕似。...
    茶點(diǎn)故事閱讀 40,127評論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡踩娘,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情养渴,我是刑警寧澤雷绢,帶...
    沈念sama閱讀 35,812評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站理卑,受9級特大地震影響翘紊,放射性物質(zhì)發(fā)生泄漏藐唠。R本人自食惡果不足惜帆疟,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,471評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望宇立。 院中可真熱鬧踪宠,春花似錦、人聲如沸妈嘹。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,017評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽润脸。三九已至柬脸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間毙驯,已是汗流浹背倒堕。 一陣腳步聲響...
    開封第一講書人閱讀 33,142評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留爆价,地道東北人涩馆。 一個(gè)月前我還...
    沈念sama閱讀 48,388評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像允坚,于是被迫代替她去往敵國和親魂那。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,066評論 2 355

推薦閱讀更多精彩內(nèi)容