Skip to content

Web 电子表格

文章信息

作者:Audrey Tang

译者:Claude, skhe

原文Web Spreadsheet

许可CC BY-NC-SA 3.0

Audrey 是一位自学成才的程序员和翻译者,以独立承包商的身份与 Apple 合作,从事云服务本地化和自然语言技术方面的工作。Audrey 此前设计并领导了第一个可运行的 Perl 6 实现,并在 Haskell、Perl 5 和 Perl 6 的计算机语言设计委员会中任职。目前 Audrey 是 g0v 的全职贡献者,并领导台湾首个电子法规制定项目。

本章介绍了一个仅用 99 行代码编写的 Web 电子表格 (web spreadsheet),使用了浏览器原生支持的三种语言:HTML、JavaScript 和 CSS。

本项目的 ES5 版本可在 jsFiddle 上获取。

(本章也有繁体中文版本)。

引言

当 Tim Berners-Lee 在 1990 年发明万维网时,网页 (web pages) 是用 HTML 编写的,通过用尖括号标签 (angle-bracketed tags) 标记文本来为内容分配逻辑结构。用 <a>…</a> 标记的文本成为_超链接_ (hyperlinks),可以将用户引导到 Web 上的其他页面。

在 20 世纪 90 年代,浏览器在 HTML 词汇表中加入了各种展示性标签 (presentational tags),其中包括一些臭名昭著的非标准标签,如 Netscape Navigator 的 <blink>…</blink> 和 Internet Explorer 的 <marquee>…</marquee>,导致了广泛的可用性和浏览器兼容性问题。

为了将 HTML 限制在其最初的用途——描述文档的逻辑结构——浏览器厂商最终同意支持另外两种语言:CSS 用于描述页面的展示样式,JavaScript (JS) 用于描述其动态交互。

从那时起,这三种语言在二十年的协同演化中变得更加简洁和强大。特别是 JS 引擎的改进使得部署大规模 JS 框架(如 AngularJS)成为可能。

如今,跨平台 Web 应用程序(如 Web 电子表格)已经像上个世纪的平台专属应用程序(如 VisiCalc、Lotus 1-2-3 和 Excel)一样无处不在且广受欢迎。

一个使用 AngularJS 的 Web 应用程序能在 99 行代码内提供多少功能?让我们来看看实际效果!

概览

电子表格目录包含了我们对 2014 年末版本的三种 Web 语言的展示:HTML5 用于结构,CSS3 用于展示,以及 JS ES6 "Harmony" 标准用于交互。它还使用了 Web Storage 进行数据持久化,以及 Web Workers 在后台运行 JS 代码。截至撰写本文时,这些 Web 标准已被 Firefox、Chrome 和 Internet Explorer 11+ 以及 iOS 5+ 和 Android 4+ 上的移动浏览器所支持。

现在让我们在浏览器中打开电子表格(图 19.1):

基本概念

电子表格跨越两个维度,列从 A 开始,行从 1 开始。每个单元格都有一个唯一坐标(如 A1)和内容(如 "1874"),内容属于以下四种类型之一:

  • 文本:B1 中的 "+" 和 D1 中的 "->",左对齐。
  • 数字:A1 中的 "1874" 和 C1 中的 "2046",右对齐。
  • 公式:E1 中的 =A1+C1,计算结果为 "3920",以浅蓝色背景显示。
  • 空值:第 2 行的所有单元格当前为空。

点击 "3920" 将焦点设置到 E1,在输入框中显示其公式(图 19.2)。

现在将焦点设置到 A1 并将其内容更改为 "1",导致 E1 重新计算其值为 "2047"(图 19.3)。

按 ENTER 将焦点设置到 A2 并将其内容更改为 =Date(),然后按 TAB,将 B2 的内容更改为 =alert(),然后再次按 TAB 将焦点设置到 C2(图 19.4)。

这表明公式可以计算为数字(E1 中的 "2047")、文本(A2 中的当前时间,左对齐)或错误(B2 中的红色字母,居中对齐)。

接下来,让我们尝试输入 =for(;;){},这是一个永不终止的无限循环的 JS 代码。电子表格会在尝试更改后自动恢复 C2 的内容来阻止这种情况。

现在用 Ctrl-R 或 Cmd-R 在浏览器中重新加载页面,以验证电子表格内容是持久化的,在浏览器会话之间保持不变。要将电子表格重置为其原始内容,请按左上角的"弯箭头"按钮。

渐进增强

在我们深入 99 行代码之前,值得在浏览器中禁用 JS,重新加载页面,并注意其中的差异(图 19.5)。

  • 大网格不再显示,屏幕上只剩下一个 2x2 的表格,只有一个内容单元格。
  • 行和列标签被替换为
  • 按下重置按钮没有效果。
  • 按 TAB 或点击内容的第一行仍然会显示一个可编辑的输入框。

当我们禁用动态交互 (JS) 时,内容结构 (HTML) 和展示样式 (CSS) 仍然有效。如果一个网站在 JS 和 CSS 都被禁用的情况下仍然可用,我们就说它遵循了渐进增强 (progressive enhancement) 原则,使其内容对尽可能多的受众可访问。

因为我们的电子表格是一个没有服务器端代码的 Web 应用程序,我们必须依赖 JS 来提供所需的逻辑。然而,当 CSS 没有被完全支持时,如使用屏幕阅读器和文本模式浏览器时,它仍然能正确工作。

如图 19.6 所示,如果我们在浏览器中启用 JS 并禁用 CSS,效果是:

  • 所有背景色和前景色都消失了。
  • 输入框和单元格值同时显示,而不是一次只显示一个。
  • 除此之外,应用程序仍然与完整版本一样工作。

代码详解

图 19.7 展示了 HTML 和 JS 组件之间的关联。为了理解这个图表,让我们按照浏览器加载的顺序逐一查看四个源代码文件。

  • index.html: 19 行
  • main.js: 38 行(不包括注释和空行)
  • worker.js: 30 行(不包括注释和空行)
  • styles.css: 12 行

HTML

index.html 的第一行声明它是用 UTF-8 编码的 HTML5:

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

如果没有字符集声明,浏览器可能会将重置按钮的 Unicode 符号显示为 ↻,这是乱码 (mojibake) 的一个例子:由编码问题导致的文本混乱。

接下来的三行是 JS 声明,按惯例放在 head 部分中:

html
  <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 的 2015 年之前的浏览器,我们可以使用转译为 ES5 版本的 JS 程序作为回退方案。

接下来的两行加载 CSS 资源,关闭 head 部分,并开始包含用户可见部分的 body 部分:

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

上面的 ng-appng-controller 属性告诉 AngularJS 调用 500lines 模块的 Spreadsheet 函数,该函数会返回一个模型 (model):一个提供文档视图绑定 (bindings) 的对象。(ng-cloak 属性在绑定就位之前隐藏文档。)

作为一个具体的例子,当用户点击下一行定义的 <button> 时,其 ng-click 属性会触发并调用 reset()calc(),这两个是 JS 模型提供的命名函数:

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

下一行使用 ng-repeat 在顶行显示列标签列表:

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

例如,如果 JS 模型将 Cols 定义为 ["A","B","C"],那么将有三个相应标签的表头单元格 (th)。 标记告诉 AngularJS 进行表达式插值 (interpolate),用 col 的当前值填充每个 th 的内容。

类似地,接下来的两行遍历 Rows 中的值——[1,2,3] 等等——为每个值创建一行,并在最左边的 th 单元格中标注其数字:

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

因为 <tr ng-repeat> 标签尚未被 </tr> 关闭,row 变量仍可用于表达式。下一行在当前行中创建一个数据单元格 (td),并在其 ng-class 属性中同时使用 colrow 变量:

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

这里发生了几件事。在 HTML 中,class 属性描述了一组类名,允许 CSS 以不同方式设置样式。这里的 ng-class 计算表达式 ('=' === sheet[col+row][0]);如果为真,那么 <td> 会获得 formula 作为额外的类,这会给单元格一个浅蓝色背景,如 styles.css 第 8 行中用 .formula 类选择器定义的那样。

上面的表达式通过测试 = 是否是 sheet[col+row] 字符串的初始字符([0])来检查当前单元格是否是公式,其中 sheet 是一个 JS 模型对象,以坐标(如 "E1")为属性,以单元格内容(如 "=A1+C1")为值。注意,因为 col 是字符串而不是数字,col+row 中的 + 表示连接而非加法。

<td> 内部,我们给用户一个输入框来编辑存储在 sheet[col+row] 中的单元格内容:

html
       <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 毫秒一次。

这里的 id 属性使用坐标 col+row 进行插值。HTML 元素的 id 属性必须与同一文档中所有其他元素的 id 不同。这确保了 #A1 ID 选择器引用的是单个元素,而不是像类选择器 .formula 那样引用一组元素。当用户按下 UP/DOWN/ENTER 键时,keydown() 中的键盘导航逻辑将使用 ID 选择器来确定将焦点设置到哪个输入框。

在输入框之后,我们放置一个 <div> 来显示当前单元格的计算值,在 JS 模型中由 errsvals 对象表示:

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

如果在计算公式时发生错误,文本插值使用 errs[col+row] 中包含的错误消息,ng-classerror 类应用到元素上,允许 CSS 以不同的样式呈现它(红色字母、居中对齐等)。

当没有错误时,|| 右侧的 vals[col+row] 被插值替代。如果它是一个非空字符串,初始字符([0])将计算为真,从而将 text 类应用到元素上,使文本左对齐。

因为空字符串和数值没有初始字符,ng-class 不会给它们分配任何类,所以 CSS 可以将它们设置为右对齐作为默认情况。

最后,我们在列级别用 </td> 关闭 ng-repeat 循环,用 </tr> 关闭行级别循环,并以以下内容结束 HTML 文档:

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

JS:主控制器

main.js 文件定义了 500lines 模块及其 Spreadsheet 控制器函数,这是 index.html<body> 元素所要求的。

作为 HTML 视图和后台 Worker 之间的桥梁,它有四个任务:

  1. 定义列和行的维度和标签。
  2. 为键盘导航和重置按钮提供事件处理程序。
  3. 当用户更改电子表格时,将新内容发送给 Worker。
  4. 当计算结果从 Worker 返回时,更新视图并保存当前状态。

图 19.8 中的流程图更详细地展示了控制器与 Worker 之间的交互:

现在让我们逐步查看代码。在第一行中,我们请求 AngularJS 的 $scope

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

$scope 中的 $ 是变量名的一部分。这里我们还从 AngularJS 请求了 $timeout 服务函数;稍后我们将用它来阻止无限循环公式。

要将 ColsRows 放入模型中,只需将它们定义为 $scope 的属性:

js
  // 开始 $scope 属性; 从列/行标签开始
  $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 被定义为一个生成器 (generator):

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

上面的 function* 表示 range 返回一个迭代器 (iterator),其中的 while 循环每次 yield 一个值。每当 for 循环需要下一个值时,它会在 yield 行之后恢复执行:

js
    // 如果是数字,加一;否则移到下一个字母
    cur = (isNaN( cur ) ? String.fromCodePoint( cur.codePointAt()+1 ) : cur+1);
  } }

为了生成下一个值,我们用 isNaN 检查 cur 是否表示一个字母(NaN 代表"不是一个数字")。如果是,我们获取该字母的码点值,加一,然后将码点转换回来以获得下一个字母。否则,我们简单地将数字加一。

接下来,我们定义处理跨行键盘导航的 keydown() 函数:

js
  // UP(38) 和 DOWN(40)/ENTER(13) 将焦点移到上方(-1)和下方(+1)的行
  $scope.keydown = ({which}, col, row)=>{ switch (which) {

箭头函数从 <input ng-keydown> 接收参数 ($event, col, row),使用解构赋值 (destructuring assignment) 将 $event.which 赋给 which 参数,并检查它是否是三个导航键码之一:

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

如果是,我们使用 $timeout 来安排在当前 ng-keydownng-change 处理程序之后执行焦点切换。因为 $timeout 需要一个函数作为参数,()=>{…} 语法构造了一个表示焦点切换逻辑的函数,它首先检查移动方向:

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

const 声明符意味着 direction 在函数执行期间不会改变。移动方向是向上 (-1,从 A2 到 A1) 如果键码是 38 (UP),否则是向下 (+1,从 A2 到 A3)。

接下来,我们使用 ID 选择器语法(如 "#A3")检索目标元素,使用一对反引号编写的模板字符串 (template string) 构造,连接前导的 #、当前的 col 和目标 row + direction

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

我们对 querySelector 的结果进行额外检查,因为从 A1 向上移动会产生选择器 #A0,它没有对应的元素,因此不会触发焦点切换——在最底行按 DOWN 也是如此。

接下来,我们定义 reset() 函数,让重置按钮可以恢复工作表的内容:

js
  // 默认工作表内容, 包含一些数据单元格和一个公式单元格
  $scope.reset = ()=>{
    $scope.sheet = { A1: 1874, B1: '+', C1: 2046, D1: '->', E1: '=A1+C1' }; }

init() 函数尝试从 localStorage 恢复工作表内容的先前状态,如果是首次运行应用程序则使用初始内容:

js
  // 定义初始化函数,并立即调用它
  ($scope.init = ()=>{
    // 恢复先前的 .sheet; 如果是首次运行则重置为默认值
    $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 表示中解析工作表结构。
  • init() 的最后一步,我们创建一个新的 Web Worker 线程并将其赋给 worker 作用域属性。虽然 Worker 没有直接在视图中使用,但习惯上使用 $scope 来共享跨模型函数使用的对象,在这里就是 init() 和下面的 calc() 之间共享。

虽然 sheet 保存用户可编辑的单元格内容,但 errsvals 包含计算结果——错误和值——对用户来说是只读的:

js
  // 公式单元格可能在 .errs 中产生错误; 正常单元格内容在 .vals 中
  [$scope.errs, $scope.vals] = [ {}, {} ];

有了这些属性,我们可以定义 calc() 函数,当用户对 sheet 进行更改时触发:

js
  // 定义计算处理程序; 暂不调用
  $scope.calc = ()=>{
    const json = angular.toJson( $scope.sheet );

这里我们对 sheet 的状态做一个快照并将其存储在常量 json 中,一个 JSON 字符串。接下来,我们从 $timeout 构造一个 Promise,如果接下来的计算耗时超过 99 毫秒则取消它:

js
    const promise = $timeout( ()=>{
      // 如果 Worker 在 99 毫秒内没有返回,终止它
      $scope.worker.terminate();
      // 回退到先前的状态并创建新的 Worker
      $scope.init();
      // 使用最后已知的状态重新进行计算
      $scope.calc();
    }, 99 );

由于我们通过 HTML 中的 <input ng-model-options> 属性确保 calc() 每 200 毫秒最多调用一次,这种安排为 init() 留出了 101 毫秒来将 sheet 恢复到最后一个已知正常的状态并创建新的 Worker。

Worker 的任务是根据 sheet 的内容计算 errsvals。因为 main.jsworker.js 通过消息传递 (message-passing) 进行通信,我们需要一个 onmessage 处理程序来接收结果:

js
    // 当 Worker 返回时,将其效果应用到作用域上
    $scope.worker.onmessage = ({data})=>{
      $timeout.cancel( promise );
      localStorage.setItem( '', json );
      $timeout( ()=>{ [$scope.errs, $scope.vals] = data; } );
    };

如果 onmessage 被调用,我们知道 json 中的工作表快照是稳定的(即不包含无限循环公式),所以我们取消 99 毫秒的超时,将快照写入 localStorage,并通过一个 $timeout 函数安排 UI 更新,将 errsvals 更新到用户可见的视图中。

有了处理程序,我们可以将 sheet 的状态发送给 Worker,在后台开始计算:

js
    // 将当前工作表内容发送给 Worker 处理
    $scope.worker.postMessage( $scope.sheet );
  };

  // Worker 就绪时开始计算
  $scope.worker.onmessage = $scope.calc;
  $scope.worker.postMessage( null );
});

JS:后台 Worker

使用 Web Worker 来计算公式而不是使用主 JS 线程有三个原因:

  1. 当 Worker 在后台运行时,用户可以自由地继续与电子表格交互,而不会被主线程中的计算阻塞。
  2. 因为我们在公式中接受任何 JS 表达式,Worker 提供了一个沙箱 (sandbox),防止公式干扰包含它们的页面,比如弹出一个 alert() 对话框。
  3. 公式可以将任何坐标作为变量引用。其他坐标可能包含另一个公式,最终可能形成循环引用 (cyclic reference)。为了解决这个问题,我们使用 Worker 的全局作用域对象 self,并将这些变量定义为 self 上的 getter 函数来实现循环防止逻辑。

有了这些考虑,让我们看看 Worker 的代码。

Worker 的唯一目的是定义其 onmessage 处理程序。处理程序接收 sheet,计算 errsvals,然后将它们发送回主 JS 线程。当我们收到消息时,首先重新初始化这三个变量:

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

为了将坐标转换为全局变量,我们首先使用 for…in 循环遍历 sheet 中的每个属性:

js
  for (const coord in sheet) {

ES6 引入了 constlet 来声明块级作用域 (block scoped) 的常量和变量;上面的 const coord 意味着循环中定义的函数会捕获每次迭代中 coord 的值。

相比之下,早期版本的 JS 中的 var coord 会声明一个函数作用域 (function scoped) 的变量,循环每次迭代中定义的函数最终都会指向同一个 coord 变量。

按照惯例,公式变量不区分大小写,并且可以有一个可选的 $ 前缀。因为 JS 变量是区分大小写的,我们使用 map 遍历同一坐标的四个变量名:

js
    // 四个变量名指向同一坐标: A1, a1, $A1, $a1
    [ '', '$' ].map( p => [ coord, coord.toLowerCase() ].map(c => {
      const name = p+c;

注意上面的箭头函数简写语法:p => ... 等同于 (p) => { ... }

对于每个变量名,如 A1$a1,我们在 self 上定义一个访问器属性 (accessor property),每当它们在表达式中被求值时计算 vals["A1"]

js
      // Worker 在多次计算中被重用,所以每个变量只定义一次
      if ((Object.getOwnPropertyDescriptor( self, name ) || {}).get) { return; }

      // 定义 self['A1'],与全局变量 A1 是同一回事
      Object.defineProperty( self, name, { get() {

上面的 { get() { … } } 语法是 { get: ()=>{ … } } 的简写。因为我们只定义了 get 而没有定义 set,变量变成了只读的,不能被用户提供的公式修改。

get 访问器首先检查 vals[coord],如果它已经被计算过,就直接返回:

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

如果没有,我们需要从 sheet[coord] 计算 vals[coord]

首先我们将它设置为 NaN,这样像将 A1 设置为 =A1 这样的自引用会得到 NaN 而不是无限循环:

js
        vals[coord] = NaN;

接下来我们通过用前缀 +sheet[coord] 转换为数字来检查它是否是一个数字,将数字赋给 x,并将其字符串表示与原始字符串进行比较。如果不同,我们将 x 设置为原始字符串:

js
        // 将数字字符串转换为数字,这样当两者都是数字时 =A1+C1 才能正常工作
        let x = +sheet[coord];
        if (sheet[coord] !== x.toString()) { x = sheet[coord]; }

如果 x 的初始字符是 =,那么它是一个公式单元格。我们用 eval.call() 计算 = 之后的部分,使用第一个参数 null 告诉 eval 在全局作用域中运行,从而将 xsheet 等词法作用域变量隐藏在求值过程之外:

js
        // 计算以 = 开头的公式单元格
        try { vals[coord] = (('=' === x[0]) ? eval.call( null, x.slice( 1 ) ) : x);

如果求值成功,结果存储在 vals[coord] 中。对于非公式单元格,vals[coord] 的值就是 x,可能是数字或字符串。

如果 eval 产生错误,catch 块会测试是否因为公式引用了尚未在 self 中定义的空单元格:

js
        } 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] 重新运行当前计算:

js
            // 公式引用了未初始化的单元格; 将其设置为 0 并重试
            self[match[0]] = 0;
            delete vals[coord];
            return self[coord];
          }

如果用户稍后在 sheet[coord] 中给缺失的单元格赋予内容,那么临时值会被 Object.defineProperty 覆盖。

其他类型的错误存储在 errs[coord] 中:

js
          // 否则,将捕获的异常转为字符串存储在 errs 对象中
          errs[coord] = e.toString();
        }

在出错的情况下,vals[coord] 的值将保持为 NaN,因为赋值没有执行完成。

最后,get 访问器返回存储在 vals[coord] 中的计算值,它必须是数字、布尔值或字符串:

js
        // 如果 vals[coord] 不是数字或布尔值,将其转为字符串
        switch (typeof vals[coord]) {
            case 'function': case 'object': vals[coord]+='';
        }
        return vals[coord];
      } } );
    }));
  }

所有坐标的访问器定义完成后,Worker 再次遍历所有坐标,通过 self[coord] 调用每个访问器,然后将结果 errsvals 发送回主 JS 线程:

js
  // 对于工作表中的每个坐标,调用上面定义的属性 getter
  for (const coord in sheet) { self[coord]; }
  return [ errs, vals ];
}

CSS

styles.css 文件只包含几个选择器及其展示样式。首先,我们设置表格样式以合并所有单元格边框,不在相邻单元格之间留空隙:

css
table { border-collapse: collapse; }

表头单元格和数据单元格共享相同的边框样式,但我们可以通过背景色区分它们:表头单元格是浅灰色,数据单元格默认为白色,公式单元格获得浅蓝色背景:

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

每个单元格的计算值显示宽度是固定的。空单元格获得最小高度,长行以尾随省略号截断:

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

文本对齐和装饰由每个值的类型决定,反映在 texterror 类选择器中:

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

至于用户可编辑的输入框,我们使用绝对定位将其覆盖在单元格上方,并使其透明以便底层带有单元格值的 div 可以透过显示:

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

当用户将焦点设置到输入框时,它会跳到前景:

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

此外,底层的 div 被折叠成单行,因此完全被输入框覆盖:

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

结语

由于本书的主题是 500 行或更少,99 行的 Web 电子表格是一个最简示例——请随意在任何方向上进行实验和扩展。

以下是一些想法,都可以在剩余的 401 行空间内轻松实现:

  • 使用 ShareJS、AngularFire 或 GoAngular 的协作在线编辑器。
  • 使用 angular-marked 为文本单元格提供 Markdown 语法支持。
  • 来自 OpenFormula 标准的常用公式函数(SUM、TRIM 等)。
  • 通过 SheetJS 与流行的电子表格格式(如 CSV 和 SpreadsheetML)互操作。
  • 从在线电子表格服务(如 Google Spreadsheet 和 EtherCalc)导入和导出。

关于 JS 版本的说明

本章旨在演示 ES6 中的新概念,因此我们使用 Traceur 编译器将源代码转译为 ES5 以在 2015 年之前的浏览器上运行。

如果你更愿意直接使用 2010 版的 JS,as-javascript-1.8.5 目录中有以 ES5 风格编写的 main.jsworker.js;其源代码与 ES6 版本逐行对应,行数相同。

对于偏爱更简洁语法的人,as-livescript-1.3.0 目录使用 LiveScript 代替 ES6 来编写 main.lsworker.ls;它比 JS 版本少了 20 行。

在 LiveScript 语言的基础上,as-react-livescript 目录使用 ReactJS 框架;它比 AngularJS 的等价物多了 10 行,但运行速度明显更快。

如果你有兴趣将这个示例翻译为其他的 JS 语言,请发送一个 pull request——我很乐意了解!

基于 CC BY-NC-SA 3.0 许可发布