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(),然后按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时仍然是有用的,我们说它坚持渐进的增强原则,使其内容可以访问最多的受众。
因为我们的电子表格是一个没有服务器端代码的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-app和ng-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属性中使用col和row变量:
<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模型中由对象errs和val表示:
<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服务函数; 稍后,我们将使用它来防止无限循环的公式。
将Cols和Row放入模型中,只需将它们定义为$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-keydown和ng-change之后。因为$timeout要求函数作为输入,()=>{…}构造了通过检查移动方向来检查焦点变化的逻辑函数。
const direction = (which === 38) ? -1 : +1;
const表示在函数运行期间direction不会被改变。移动方向要么是向上(-1,从A2到A1),如果键码是38(UP),否则是向下(+1,从A2到A3)。
接下来,我们使用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保存用户可编辑的单元格内容,但是errs和vals包含对用户只读的计算结果 - 错误和值:
// 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内容中计算errs和val。因为main.js和worker.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函数将errs和vals更新到用户可见视图。
在程序处理到位后,我们可以将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,并将这些变量定义为self的getter函数来实现循环预防逻辑。
考虑到这些,让我们来看看worker的代码。
工人的唯一目的是定义onmessage。处理程序使用sheet,计算errs和vals,并将其返回主JS线程。我们首先在收到消息时重新初始化三个变量:
let sheet, errs, vals;
self.onmessage = ({data})=>{
[sheet, errs, vals] = [ data, {}, {} ];
为了将坐标转换为全局变量,我们首先使用for... in循环遍历表中的每个属性:
for (const coord in sheet) {
ES6引入const,let声明块作用域常量和变量; 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() { … } }的简写。因为我们之定义了get和set,变量变成只刻度并且不能被其他公式更改。
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在全局范围内运行,求值时隐藏x和sheet的词法范围变量:
// 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]调用每个访问器,然后将生成的err和vals发回主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!