500 行或更少
网页电子表格

唐凤

唐凤是一位自学成才的程序员和翻译员,她与苹果公司合作,担任云服务本地化和自然语言技术的独立承包商。唐凤曾设计并领导了第一个可用的 Perl 6 实现,并担任 Haskell、Perl 5 和 Perl 6 的计算机语言设计委员会成员。目前,唐凤是 g0v 全职贡献者,并领导着台湾的第一个电子规则制定项目。

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

该项目的 ES5 版本可以在 jsFiddle 上获得。

(本章节也提供 繁体中文 版本).

简介

当蒂姆·伯纳斯·李在 1990 年发明万维网时,网页是用 HTML 编写的,通过用尖括号包围的标签标记文本,为内容分配逻辑结构。在 <a>…</a> 中标记的文本变成了超链接,用于将用户指向网络上的其他页面。

在 1990 年代,浏览器在 HTML 词汇表中添加了各种表示样式的标签,其中包括一些臭名昭著的非标准标签,例如来自 Netscape Navigator 的 <blink>…</blink> 和来自 Internet Explorer 的 <marquee>…</marquee>,这些标签在可用性和浏览器兼容性方面造成了广泛的问题。

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

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

如今,跨平台网页应用程序(例如网页电子表格)与上个世纪的平台特定应用程序(例如 VisiCalc、Lotus 1-2-3 和 Excel)一样普遍和流行。

一个网页应用程序能够使用 AngularJS 在 99 行代码中提供多少功能?让我们看看它的实际效果!

概述

电子表格 目录包含我们对 2014 年后期三种网页语言版本的展示:HTML5 用于结构,CSS3 用于呈现,以及 JS ES6 “Harmony” 标准用于交互。它还使用了 网页存储 用于数据持久性,以及 网页工作线程 用于在后台运行 JS 代码。截至本文撰写之时,这些网页标准受到 Firefox、Chrome 和 Internet Explorer 11+ 的支持,以及 iOS 5+ 和 Android 4+ 上的移动浏览器。

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

Figure 19.1 - Initial Screen

图 19.1 - 初始屏幕

基本概念

电子表格跨越两个维度,A 开始,1 开始。每个单元格都有一个唯一的坐标(例如 A1)和内容(例如“1874”),它属于四种类型之一

单击“3920”以将焦点设置在 E1 上,在输入框中显示其公式(图 19.2)。

Figure 19.2 - Input Box

图 19.2 - 输入框

现在让我们将焦点设置在 A1 上,并将其内容更改为“1”,导致 E1 将其值重新计算为“2047”(图 19.3)。

Figure 19.3 - Changed Content

图 19.3 - 更改后的内容

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

Figure 19.4 - Formula Error

图 19.4 - 公式错误

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

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

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

渐进增强

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

Figure 19.5 - With JavaScript Disabled

图 19.5 - 禁用 JavaScript

当我们禁用动态交互(JS)时,内容结构(HTML)和表示样式(CSS)仍然有效。如果一个网站在禁用 JS 和 CSS 的情况下仍然有用,我们说它符合渐进增强原则,使其内容尽可能多地被访问。

由于我们的电子表格是一个没有服务器端代码的网页应用程序,我们必须依赖 JS 来提供所需的逻辑。但是,当 CSS 未完全支持时(例如,使用屏幕阅读器和文本模式浏览器),它仍然可以正常工作。

Figure 19.6 - With CSS Disabled

图 19.6 - 禁用 CSS

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

代码演练

图 19.7 显示了 HTML 和 JS 组件之间的链接。为了理解该图,让我们按浏览器加载它们的顺序,依次浏览四个源代码文件。

Figure 19.7 - Architecture Diagram

图 19.7 - 架构图

HTML

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

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

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

接下来的三行是 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。这种基于重定向的优雅降级技术确保对于没有 ES6 支持的 2015 年之前的浏览器,我们可以使用翻译成 ES5 版本的 JS 程序作为后备。

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

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

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

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

  <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"],那么将有三个标题单元格 (th) 对应标记。{{ col }} 表示法告诉 AngularJS插值表达式,用 col 的当前值填充每个 th 中的内容。

类似地,接下来的两行遍历 Rows 中的值——[1,2,3] 等等——为每个值创建一个行,并将最左侧的 th 单元格用它的编号标记

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

由于 <tr ng-repeat> 标签尚未用 </tr> 关闭,所以 row 变量仍然可用于表达式。下一行在当前行创建了一个数据单元格 (td),并在它的 ng-class 属性中使用 colrow 变量

    <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] 中的单元格内容

       <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 模型和输入框的可编辑内容之间启用双向绑定。在实践中,这意味着每当用户在输入框中进行更改时,JS 模型都会更新 sheet[col+row] 以匹配内容,并触发它的 calc() 函数来重新计算所有公式单元格的值。

为了避免在用户按住并保持某个键时重复调用 calc()ng-model-options 将更新速率限制为每 200 毫秒一次。

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

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

      <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 文档:

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

JS:主控制器

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

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

图 19.8中的流程图更详细地展示了控制器-工作者交互。

Figure 19.8 - Controller-Worker Flowchart

图 19.8 - 控制器-工作者流程图

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

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

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

要将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语法使循环遍历具有起点和终点的范围变得容易,辅助函数range定义为一个生成器

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

上面的function*表示range返回一个迭代器,它具有一个while循环,该循环将yield一次一个值。每当for循环需要下一个值时,它将从yield行之后恢复执行。

    // If it’s a number, increase it by one; otherwise move to next letter
    cur = (isNaN( cur ) ? String.fromCodePoint( cur.codePointAt()+1 ) : cur+1);
  } }

要生成下一个值,我们使用isNaN来查看cur是否被用作字母(NaN代表“非数字”。)。如果是,则获取字母的代码点值,将其加 1,然后将代码点转换回以获取其下一个字母。否则,我们只需将数字加 1 即可。

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

  // UP(38) and DOWN(40)/ENTER(13) move focus to the row above (-1) and below (+1).
  $scope.keydown = ({which}, col, row)=>{ switch (which) {

箭头函数<input ng-keydown>接收参数($event, col, row),使用解构赋值$event.which分配到which参数中,并检查它是否在三个导航键码中。

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

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

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

const声明符表示direction在函数执行期间不会改变。移动方向要么向上(-1,从A2A1),如果键码是 38(向上),要么向下(+1,从A2A3)。

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

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

我们对querySelector的结果进行额外的检查,因为从A1向上移动将生成选择器#A0,它没有对应的元素,因此不会触发焦点更改 - 从底部行按下向下也是如此。

接下来,我们定义reset()函数,以便重置按钮可以恢复sheet的内容。

  // 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()函数尝试从localStorage中的先前状态恢复sheet内容,如果这是我们第一次运行应用程序,则默认为初始内容。

  // 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()函数中需要注意一些事项。

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

  // Formula cells may produce errors in .errs; normal cell contents are in .vals
  [$scope.errs, $scope.vals] = [ {}, {} ];

有了这些属性,我们可以定义calc()函数,该函数在用户更改sheet时触发。

  // Define the calculation handler; not calling it yet
  $scope.calc = ()=>{
    const json = angular.toJson( $scope.sheet );

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

    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 );

由于我们确保calc()通过 HTML 中的<input ng-model-options>属性最多每 200 毫秒调用一次,因此这种安排为init()留下了 101 毫秒的时间来将sheet恢复到上次已知良好状态并创建一个新的工作者。

工作者的任务是从sheet的内容计算errsvals。由于main.jsworker.js通过消息传递进行通信,因此我们需要一个onmessage处理程序来接收结果(一旦它们准备就绪)。

    // 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; } );
    };

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

有了处理程序,我们就可以将sheet的状态发布到工作者,在后台启动它的计算。

    // 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:后台工作者

使用 web 工作者来计算公式而不是使用主 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将声明一个函数级变量,并且在每次循环迭代中定义的函数最终将指向同一个coord变量。

通常,公式变量不区分大小写,并且可以选择具有$前缀。由于 JS 变量区分大小写,因此我们使用map遍历同一个坐标的四个变量名。

    // Four variable names pointing to the same coordinate: A1, a1, $A1, $a1
    [ '', '$' ].map( p => [ coord, coord.toLowerCase() ].map(c => {
      const name = p+c;

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

对于每个变量名,如A1$a1,我们在self上定义一个访问器属性,该属性在表达式中评估时计算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: ()=>{ … } }的简写。因为我们只定义了get,而不是set,所以这些变量变为只读,不能从用户提供的公式中修改。

get访问器首先检查vals[coord],如果它已经被计算出来,则简单地返回它。

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

如果不是,我们需要从sheet[coord]计算vals[coord]

首先,我们将其设置为NaN,因此像将A1设置为=A1这样的自引用最终将得到NaN而不是无限循环。

        vals[coord] = NaN;

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

        // 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的第一个字符是=,那么它是一个公式单元格。我们使用eval.call()评估=后面的部分,使用第一个参数null告诉eval全局作用域中运行,隐藏词法作用域变量,例如xsheet,使其不参与评估。

        // Evaluate formula cells that begin with =
        try { vals[coord] = (('=' === x[0]) ? eval.call( null, x.slice( 1 ) ) : x);

如果评估成功,则结果将存储到vals[coord]中。对于非公式单元格,vals[coord]的值只是x,它可能是一个数字或一个字符串。

如果eval导致错误,则catch块测试它是否是因为公式引用了self中尚未定义的空单元格。

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

            // 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]中为缺少的单元格提供内容,那么临时值将被Object.defineProperty覆盖。

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

          // Otherwise, stringify the caught exception in the errs object
          errs[coord] = e.toString();
        }

如果发生错误,vals[coord] 的值将保持为 NaN,因为赋值操作未完成执行。

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

        // 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];
      } } );
    }));
  }

在为所有坐标定义了访问器后,工作线程再次遍历坐标,使用 self[coord] 调用每个访问器,然后将生成的 errsvals 发送回主 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 文件包含一些选择器及其展示样式。首先,我们对表格进行样式设置,将所有单元格边框合并在一起,相邻单元格之间没有空格。

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; }

文本对齐方式和装饰由每个值的类型决定,如 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; }

当用户将焦点设置在输入框上时,它会弹到最前面。

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

此外,底层的 div 会折叠成一行,因此它完全被输入框覆盖。

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

结论

由于本书是500 行或更少,因此一个包含 99 行的网页电子表格是一个最小示例,请随意尝试并在任何方向进行扩展。

以下是一些想法,所有这些都可以在剩余的 401 行空间中轻松实现。

关于 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 语言,请发送 拉取请求 - 我很乐意听到您的想法!