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线程并将其分配给worker
scope属性。虽然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!