Shanshan Pythoner Love CPP

500 Lines or Less Chapter 19: Web Spreadsheet 翻译


Audrey是一名自学的程序员和翻译人员,与Apple合作,担任云服务本地化和自然语言技术的独立承包商。Audrey曾经设计和领导了Perl 6实施,并任职于Haskell,Perl 5和Perl 6的计算机语言设计。目前,Audrey是全职g0v贡献者,领导台湾首个电子规则制定项目。

本章介绍一个网页电子表格,由99个行的Web浏览器支持的三种语言编写:HTML,JavaScript和CSS。

该项目的ES5版本可作为jsFiddle

(本章还有繁体中文)。

介绍

当Tim Berners-Lee在1990年发明了网络时,网页是用HTML编写的,标有带有角括号的标签的文本,为内容分配逻辑结构。标记在<a>…</a>内的文本成为超链接,将用户引到网络上的其他页面。

在20世纪90年代,浏览器向HTML词汇添加了各种演示标签,包括来自Netscape Navigator的<blink>…</blink>和来自Internet Explorer的<marquee>…</marquee>等不好的标签,从而在可用性方面引起广泛的问题和浏览器兼容问题。

为了将HTML限制到其原始目的 - 描述文档的逻辑结构 - 浏览器制造商最终同意支持两种其他语言:CSS来描述页面的演示风格,以及JavaScript(JS)来描述其动态交互。

从那时起,三种语言二十年来变得更加简洁和强大。特别是,JS引擎的改进使其部署了诸如AngularJS之类的大型JS框架。

今天,跨平台的Web应用程序(如Web电子表格)与上个世纪的平台特定应用程序(如VisiCalc,Lotus 1-2-3和Excel)一样无处不在和流行。

Web应用程序在AngularJS的99条线路中提供了多少功能?让我们看看它的进步!

概述

电子表格目录包含我们在三个网络语言的2014版本的展示:HTML5 for structure,CSS3 for presentation以及JS ES6 “Harmony” standard for interaction。它还使用Web存储器进行数据持久性和Web工作人员在后台运行JS代码。在撰写本文时,这些网络标准由Firefox,Chrome和Internet Explorer 11+以及iOS 5+和Android 4+以上的移动浏览器支持。

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

基本概念

二维的电子表格,列以A开头,行从1开始。每个单元格都有一个唯一的坐标(如A1)和内容(如“1874”),属于四种类型之一:

  • 文本:B1中的“+”和D1中的“ - >”对齐,向左对齐。

  • 数字:“1874”在A1,“2046”在C1,向右对齐。

  • 公式:E1中的=A1+C1,计算为值“3920”,以浅蓝色背景显示。

  • 空值:第2行中的所有单元格当前为空。

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

现在我们把焦点放在A1上并将其内容改为“1”,使E1重新计算其值为“2047”(图19.3)。

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

这显示了公式可以计算出数字(E1中为“2047”),文本(A2中的当前时间,与左对齐)或错误(B2中的红色字母,与中心对齐)。

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

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

渐进式增强

在我们深入99行代码之前,有必要在浏览器中禁用JS,重新加载页面,并记下差异(图19.5)。

  • 不是一个大网格,只有一个2x2表格留在屏幕上,单个内容单元格。

  • 行和列标签被替换。

  • 按重置按钮不起作用。

  • TAB或点击第一行内容仍然显示可编辑的输入框。

当我们禁用动态交互(JS)时,内容结构(HTML)和演示风格(CSS)仍然有效。如果一个网站在禁用JS和CSS时仍然是有用的,我们说它坚持渐进的增强原则,使其内容可以访问最多的受众。

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

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

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

  • 输入框和单元格值都显示,而不是一次只显示一个。

  • 否则,应用程序仍然与完整版本相同。

代码演练

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

  • index.html: 19 lines

  • main.js: 38 lines (excluding comments and blank lines)

  • worker.js: 30 lines (excluding comments and blank lines)

  • styles.css: 12 lines

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

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

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

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

作为一个具体的例子,当用户点击下一行定义的<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"></th>

例如,如果JS模型将Cols定义为["A","B","C"],则将有相应标记的三个标题单元格(th)。``符号告诉AngularJS插入表达式,用col的当前值填充th里的每个内容。

类似地,接下来的两行遍历Rows - [1,2,3]中的值,依此类推 - 为每个行创建一行,并以最小的数字标记th最左边的单元格:

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

因为<tr ng-repeat>尚未用</tr>关闭标签,所以行变量仍然可用于表达式。下一行创建一个数据单元格(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类选择器的8行代码的style.css定义浅蓝色背景。

上述的表达式检查当前的单元格是否是正确的公式,通过测试如果=sheet[col+row]中的字符串[0],此处的sheet是有坐标(如E1)作为属性,单元格内容(如"=A1+C1")为值的JS模型对象。 请注意,因为col是一个字符串而不是一个数字,col+row里的+表示连接而不是加法。

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

       <input id="" 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不同。这确保#A1 ID选择器引用单个元素,而不是一组元素,如类选择器.formula。当用户按UP/DOWN/ENTER键时,keydown()中的键盘导航逻辑将使用ID选择器来确定要关注的输入框。

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

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

如果在计算公式时发生错误,则文本插值使用errs[col+row]中包含的错误消息,ng-class将错误类应用于元素,允许CSS以不同的方式设计(用红色字母对齐到 中心等)。

当没有错误时,||的右侧的vals[col+row]被插入。如果它是一个非空字符串,则初始字符([0])将求值为true,将text类应用于左对齐文本的元素。

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

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

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

JS:主控制器

main.js文件根据index.html中的<body>元素的要求定义了500lines模块及其`Spreadshee控制器功能。

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

  • 定义列和行的尺寸和标签。

  • 提供键盘导航和重置按钮的事件处理程序。

  • 当用户更改电子表格时,将其新内容发送给工作人员。

  • 当计算结果从工作人员到达时,更新视图并保存当前状态。

图19.8中的流程图详细地显示了控制器与工作者的交互:

我们来看看代码。在第一行,我们要求AngularJS$scope的范围:

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

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

ColsRow放入模型中,只需将它们定义为$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代表“不是一个数字”)。如果是这样,我们得到这个字母的code point value,增加一个值,convert the codepoint返回下一个字母。 否则,我们只需增加一个数字。

接下来,我们定义处理键盘导航的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) {

arrow function<input ng-keydown>输入参数($event, col, row),使用destructuring assignment$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(UP),否则是向下(+1,从A2A3)。

接下来,我们使用ID选择器语法(例如"#A3")检索目标元素,该方法用一对反引号的template string构成,连接#,当前col和目标row + direction

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

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

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

  // 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恢复其先前状态的表格内容,如果是第一次运行应用程序,则默认为初始内容:

  // 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()函数中有几个重要的事:

  • 我们使用($scope.init = ()=>{…}).call()定义函数并立即调用它。

  • 因为localStorage只存储字符串,所以我们使用angular.fromJson()从JSON表示中解析表结构。

  • init()的最后一步,我们创建一个新的Web worker线程并将其分配给workerscope属性。虽然workers在视图中没有直接使用,但是通常使用$scope来共享模型函数中使用的对象,这种情况下在init()calc()之间。

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

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

使用这些属性,我们可以定义每当用户更改表单时触发的calc()函数:

  // Define the calculation handler; not calling it yet
  $scope.calc = ()=>{
    const json = angular.toJson( $scope.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 );

由于我们确保通过HTML中的<input ng-model-options>属性每隔200毫秒调用一次calc(),所以这会使init()恢复到最后一个已知状态的时间为101毫秒,构建新的worker。

workeer的任务是从sheet内容中计算errsval。因为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,并安排一个$timeout函数将errsvals更新到用户可见视图。

在程序处理到位后,我们可以将sheet的状态发布给worker,在后台开始计算:

    // 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: Background Worker

使用Web worker计算公式有三个原因,而不是使用主要的JS线程来完成任务:

  • 当工作人员在后台运行时,用户可以自由地继续与电子表格进行交互,而不会被主线程中的计算阻塞。

  • 因为我们接受公式中的任何JS表达式,所以worker提供了一个沙箱来阻止公式干扰包含页面,例如弹出一个alert()对话框。

  • 公式可以将任何坐标称为变量。其他坐标可能包含可能以循环引用结尾的另一个公式。为了解决这个问题,我们使用worker的全局范围对象self,并将这些变量定义为selfgetter函数来实现循环预防逻辑。

考虑到这些,让我们来看看worker的代码。

工人的唯一目的是定义onmessage。处理程序使用sheet,计算errsvals,并将其返回主JS线程。我们首先在收到消息时重新初始化三个变量:

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

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

  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) => { ... }1一样:

      // 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() { } }的简写。因为我们之定义了getset,变量变成只刻度并且不能被其他公式更改。

get开始于检查vals[coord],并且返回计算的值:

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

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

首先我们将其设置为NaN,所以自引用就是设置A1=A1,而不是无限的循环中。

        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()来评估part=after,使用第一个参数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 )) {

如果用户稍后在[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];
      } } );
    }));
  }

使用为所有坐标定义的访问器,worker再次通过坐标,使用self [coord]调用每个访问器,然后将生成的errvals发回主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; }

文本对齐和装饰由每个值的类型确定,如文本和错误类选择器所反映的:

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行代码的web spreadsheet 是一个最小的例子 —— 请随意尝试并扩展它。

这里有一些想法,在401行的剩余空间中都可以轻松使用:

  • 使用ShareJS,AngularFire或GoAngular的合作在线编辑器。

  • Markdown语法支持文本单元格,使用角标记。

  • 来自OpenFormula标准的通用公式函数(SUM,TRIM等)。

  • 通过SheetJS与流行的电子表格格式(如CSV和SpreadsheetML)进行互操作。

  • 从Google电子表格和EtherCalc导入和导出到在线电子表格服务。

A Note on JS versions

This chapter aims to demonstrate new concepts in ES6, so we use the Traceur compiler to translate source code to ES5 to run on pre-2015 browsers.

If you prefer to work directly with the 2010 edition of JS, the as-javascript-1.8.5 directory has main.js and worker.js written in the style of ES5; the source code is line-by-line comparable to the ES6 version with the same line count.

For people preferring a cleaner syntax, the as-livescript-1.3.0 directory uses LiveScript instead of ES6 to write main.ls and worker.ls; it is 20 lines shorter than the JS version.

Building on the LiveScript language, the as-react-livescript directory uses the ReactJS framework; it is 10 lines more longer than the AngularJS equivalent, but runs considerably faster.

If you are interested in translating this example to alternate JS languages, send a pull request—I’d love to hear about it!


Comments

Content