唐凤是一位自学成才的程序员和翻译员,她与苹果公司合作,担任云服务本地化和自然语言技术的独立承包商。唐凤曾设计并领导了第一个可用的 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 - 初始屏幕
电子表格跨越两个维度,列从 A 开始,行从 1 开始。每个单元格都有一个唯一的坐标(例如 A1)和内容(例如“1874”),它属于四种类型之一
=A1+C1
,计算结果为值“3920”,显示为浅蓝色背景。单击“3920”以将焦点设置在 E1 上,在输入框中显示其公式(图 19.2)。
图 19.2 - 输入框
现在让我们将焦点设置在 A1 上,并将其内容更改为“1”,导致 E1 将其值重新计算为“2047”(图 19.3)。
图 19.3 - 更改后的内容
按 ENTER 将焦点设置在 A2 上,并将它的内容更改为 =Date()
,然后按 TAB,将 B2 的内容更改为 =alert()
,然后再次按 TAB 将焦点设置在 C2
上(图 19.4)。
图 19.4 - 公式错误
这表明公式可以计算为数字(E1 中的“2047”)、文本(A2 中的当前时间,左对齐)或错误(B2 中的红色字母,居中对齐)。
接下来,让我们尝试输入 =for(;;){}
,这是 JS 代码中的一个无限循环,永远不会终止。电子表格会阻止这种情况,在尝试更改后自动恢复 C2 的内容。
现在用 Ctrl-R 或 Cmd-R 在浏览器中重新加载页面,以验证电子表格内容是否持久,在浏览器会话之间保持不变。要将电子表格重置为其原始内容,请按下左上角的“弯箭头”按钮。
在深入研究 99 行代码之前,禁用浏览器中的 JS、重新加载页面并注意差异是值得的(图 19.5)。
{{ row }}
和 {{ col }}
替换。图 19.5 - 禁用 JavaScript
当我们禁用动态交互(JS)时,内容结构(HTML)和表示样式(CSS)仍然有效。如果一个网站在禁用 JS 和 CSS 的情况下仍然有用,我们说它符合渐进增强原则,使其内容尽可能多地被访问。
由于我们的电子表格是一个没有服务器端代码的网页应用程序,我们必须依赖 JS 来提供所需的逻辑。但是,当 CSS 未完全支持时(例如,使用屏幕阅读器和文本模式浏览器),它仍然可以正常工作。
图 19.6 - 禁用 CSS
如 图 19.6 所示,如果我们在浏览器中启用 JS 并禁用 CSS,则效果如下
图 19.7 显示了 HTML 和 JS 组件之间的链接。为了理解该图,让我们按浏览器加载它们的顺序,依次浏览四个源代码文件。
图 19.7 - 架构图
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-app
和 ng-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
属性中使用 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
作为附加类,这将使单元格获得浅蓝色背景,如 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
不同。这确保了#A1
ID 选择器引用单个元素,而不是像类选择器.formula
那样引用一组元素。当用户按下向上/向下/回车键时,keydown()
中的键盘导航逻辑将使用 ID 选择器来确定要聚焦哪个输入框。
在输入框之后,我们放置一个<div>
来显示当前单元格的计算值,在 JS 模型中由对象errs
和vals
表示。
<div ng-class="{ error: errs[col+row], text: vals[col+row][0] }">
{{ errs[col+row] || vals[col+row] }}</div>
如果在计算公式时发生错误,文本插值将使用errs[col+row]
中包含的错误消息,并且ng-class
将error
类应用于元素,允许 CSS 以不同的方式对其进行样式设置(使用红色字母,居中对齐等)。
如果没有错误,将插值||
右侧的vals[col+row]
。如果它是一个非空字符串,则第一个字符([0]
)将计算为真,将text
类应用于将文本左对齐的元素。
由于空字符串和数字值没有初始字符,因此ng-class
不会为它们分配任何类,因此 CSS 可以使用右对齐作为默认情况对其进行样式设置。
最后,我们在列级别上用</td>
关闭ng-repeat
循环,用</tr>
关闭行级别循环,并用以下内容结束 HTML 文档:
</td>
</tr></table>
</body></html>
main.js
文件定义了500lines
模块及其Spreadsheet
控制器函数,这是index.html
中<body>
元素所要求的。
作为 HTML 视图和后台工作者之间的桥梁,它有四个任务:
图 19.8中的流程图更详细地展示了控制器-工作者交互。
图 19.8 - 控制器-工作者流程图
现在让我们逐步了解代码。在第一行,我们请求 AngularJS 的$scope
。
angular.module('500lines', []).controller('Spreadsheet', function ($scope, $timeout) {
$scope
中的$
是变量名的一部分。这里我们还从 AngularJS 请求$timeout
服务函数;稍后,我们将使用它来防止无限循环的公式。
要将Cols
和Rows
放入模型,只需将它们定义为$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-keydown
和ng-change
处理程序之后安排一个焦点更改。由于$timeout
需要一个函数作为参数,因此()=>{…}
语法构造一个函数来表示焦点更改逻辑,该逻辑首先检查移动方向。
const direction = (which === 38) ? -1 : +1;
const
声明符表示direction
在函数执行期间不会改变。移动方向要么向上(-1
,从A2到A1),如果键码是 38(向上),要么向下(+1
,从A2到A3)。
接下来,我们使用 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()
函数中需要注意一些事项。
($scope.init = ()=>{…}).call()
语法来定义函数并立即调用它。angular.fromJson()
从其JSON表示中解析sheet
结构。init()
的最后一步,我们创建一个新的web worker线程并将其分配给worker
作用域属性。虽然工作者不会直接在视图中使用,但通常使用$scope
来共享跨模型函数使用的对象,在本例中是在这里init()
和下面的calc()
之间。虽然sheet
保存用户可编辑的单元格内容,但errs
和vals
包含计算结果 - 错误和值 - 这些结果对用户是只读的。
// 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
的内容计算errs
和vals
。由于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,并使用一个更新errs
和vals
以供用户可见的视图的$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 );
});
使用 web 工作者来计算公式而不是使用主 JS 线程来完成任务,有三个原因。
alert()
对话框。self
,并将这些变量定义为self
上的getter 函数来实现循环预防逻辑。考虑到这些,让我们看看工作者的代码。
工作者的唯一目的是定义它的onmessage
处理程序。处理程序接收sheet
,计算errs
和vals
,并将它们发布回主 JS 线程。我们首先在收到消息时重新初始化三个变量。
let sheet, errs, vals;
self.onmessage = ({data})=>{
[sheet, errs, vals] = [ data, {}, {} ];
为了将坐标转换为全局变量,我们首先使用for…in
循环迭代sheet
中的每个属性。
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) => { ... }
相同。
对于每个变量名,如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
在全局作用域中运行,隐藏词法作用域变量,例如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 )) {
在这种情况下,我们将缺少的单元格的默认值设置为 "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]
调用每个访问器,然后将生成的 errs
和 vals
发送回主 JS 线程。
// For each coordinate in the sheet, call the property getter defined above
for (const coord in sheet) { self[coord]; }
return [ errs, vals ];
}
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; }
文本对齐方式和装饰由每个值的类型决定,如 text
和 error
类选择器所反映。
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 行空间中轻松实现。
SUM
、TRIM
等)。本章旨在展示 ES6 中的新概念,因此我们使用 Traceur 编译器 将源代码转换为 ES5,以便在 2015 年之前的浏览器上运行。
如果您希望直接使用 2010 年版的 JS,则 as-javascript-1.8.5 目录包含使用 ES5 风格编写的 main.js 和 worker.js;源代码 与 ES6 版本逐行可比,行数相同。
对于喜欢更简洁语法的用户,as-livescript-1.3.0 目录使用 LiveScript 而不是 ES6 来编写 main.ls 和 worker.ls;它比 JS 版本短 20 行。
基于 LiveScript 语言,as-react-livescript 目录使用 ReactJS 框架;它比 AngularJS 等效版本多 10 行,但运行速度明显更快。
如果您有兴趣将此示例转换为其他 JS 语言,请发送 拉取请求 - 我很乐意听到您的想法!