500lines中最小的项目,99行实现一个Web电子表格,主要借助了AngularJS框架,可以学习下。
作者
Audrey Tang,自学成才的程序员和翻译,Audrey作为云服务本地化和自然语言技术的独立承包商与苹果合作。Audrey以前设计并领导过第一个 Perl 6 实现,并在 Haskell、Perl 5 和 Perl 6 的计算机语言设计委员会任职。目前,Audrey 是一个全职的g0v贡献者和领导台湾省的第一个电子规则制定项目。本文介绍一个 Web 电子表格,它是用 Web 浏览器支持的三种语言(HTML、JavaScript和CSS)编写的,共99行。
引言
1990 年,Tim Berners-Lee 发明了全球资讯网,当时的网页文件(Web pages)都是以 HTML 写成,使用尖括号标记(tags)来标记文字,给内容安排逻辑结构。以 …
标记的文字会变成超链接(hyperlinks),把用户导引至其他网页。
在 20 世纪 90 年代,浏览器加入了各种展示性标记到 HTML 词汇表,包括一些非标准标记,例如来自 Netscape Navigator 的 和来自 Internet Explorer 的
,在可用性和浏览器兼容性方面造成了广泛的问题。
为了将 HTML 限制在描述文档逻辑结构的原始目的上,浏览器开发者最后同意支持两种附加语言:CSS 来形容网页的展示风格,以及 JS 来描述其动态互动功能。
从那时开始,这三种程式语言经过了 20 年的共同进化,已经变得更加简洁和强大。JS 引擎的效能获得高度提升,使得大规模的 JS 框架开始盛行,例如 AngularJS。
如今,跨平台的应用网站(Web applications,例如电子部表格),已经跟上个世纪的桌面应用程序(如 VisiCalc、Lotus 1-2-3 和 Excel)一样普及了。
使用 AngularJS 的网页应用可以在 99 行里面提供多少功能?让我们来看看!
概述
在 spreadsheet 目录里,包含了三种 Web 程式语言在 2014 年末版本的展示范例:描述结构的 HTML5、描述展示风格的 CSS3,以及描述互动功能的 JS ES6 “Harmony” 。它也用到 Web Storage 来保存资料,以及利用 Web Worker 在后台运行 JS 代码。在撰写本文时,这些 Web 标准都已获得 Firefox、Chrome、Internet Explorer 11+,以及移动浏览器 iOS 5+ 和 Android 4+ 的支持。
让我们在浏览器中打开 spreadsheet:
基本概念
电子表格跨越两个维度,列从A开始,行从1开始。每个单元格都有一个唯一的坐标(如A1)和内容(如“1874”),属于以下四种类型之一:
文本:B1中的
+
和D1中的“⇒”,左对齐。数字:A1中的“1874”和C1中的“2046”,向右对齐。
公式:E1中的
=A1+C1
,计算值为“3920”,以浅蓝色背景显示。空:第2行中的所有单元格当前都为空。
单击“3920”将焦点设置为E1,并在输入框中显示其公式。
现在让我们将焦点设置在 A1 上,并将其内容更改为“1”,从而使 E1 将其值重新计算为“2047”。
按 ENTER 键将焦点设置为 A2 并将其内容更改为 =Date()
,然后按 TAB 键,将B2的内容更改为=alert()
,然后再次按 TAB 键将焦点设置为 C2。
这表明一个公式的结果可以是一个数字(E1中的“2047”)、一个文本(A2中的当前时间,向左对齐)或一个错误(B2中的红色字母,居中对齐)。
接下来,让我们尝试输入 =for(;;){}
,永不终止的无限循环的JS代码。电子表格将通过在尝试更改后自动恢复 C2 的内容来防止这种情况。
现在使用 Ctrl-R 或 Cmd-R 在浏览器中重新加载页面,以验证电子表格内容是否持久,在浏览器会话中保持不变。要将电子表格重置为其原始内容,请按左上角的 ↻
按钮。
渐进增强
在深入研究99行代码之前,有必要在浏览器中禁用JS,重新加载页面,并注意差异:
屏幕上只保留一个2x2表格,只有一个内容单元格,而不是一个大表格。
行和列标签被
{{Row}}
和{{col}}
替换。按
↻
按钮不起作用。按 TAB 键或单击内容的第一行仍会显示一个可编辑的输入框。
当我们禁用动态交互(JS)时,内容结构(HTML)和表示样式(CSS)仍然有效。如果一个网站在 JS 和 CSS 都被禁用的情况下仍然可用,我们说它坚持渐进增强原则,使它的内容能够被最多的读者访问。
因为我们的电子表格是一个没有服务器端代码的 Web 应用程序,所以我们必须依赖 JS 来提供所需的逻辑。但是,当 CSS 没有完全获得支持时,它确实可以正常工作,比如屏幕阅读器和文本模式浏览器。
如上图所示,如果在浏览器中启用 JS 并禁用 CSS,则效果如下:
所有背景和前景颜色都消失了。
输入框和单元格值都显示,而不是只显示一个。
除此之外,应用程序仍然与完整版本相同。
代码走读
下面的图显示了HTML和JS组件之间的关联。
为了理解这个图,让我们按照浏览器加载它们的顺序来浏览这四个源代码文件。
- index.html: 19行
- main.js: 38行(不包括注释和空行)
- worker.js: 30行(不包括注释和空行)
- styles.css: 12行
HTML
index.html
中的第一行声明它是用带有UTF-8编码的 HTML5 的:
如果没有字符集声明,浏览器可能会将重置按钮的Unicode符号显示为↻
, 也就是乱码:由解码问题导致的错误文本。
接下来的三行是 JS 声明,通常放在head部分中:
标记从与 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年以前的浏览器,我们可以将 JS 程序解析成 ES5 版本作为回退。
接下来的两行加载 CSS 资源,关闭 head
部分,然后开始包含用户可见部分的 body
部分:
上面的 ng-app
和 ng-controller
属性告诉 AngularJS 调用 500lines
模块的电子表格函数,该函数将返回一个模型:一个在文档视图上提供绑定的对象(ng-cloak
属性在绑定就位之前隐藏文档以防显示。)
作为一个具体的例子,当用户单击下一行中定义的 时,其
ng-click
属性将触发并调用 reset()
和 calc()
,这是 JS 模型提供的两个命名函数:
下一行使用 ng-repeat
在顶行显示列标签列表:
{{ col }}
例如,如果 JS 模型将 Cols
定义为 ["A","B","C"]
,那么将有三个标题单元格(th
)相应地标记。{{col}}
告诉 AngularJS 插入表达式,用 col
的当前值填充每个 th
中的内容。
类似地,接下来的两行遍历 Rows
中的值[1,2,3]
等等,为每个创建一行,并用其编号标记最左侧的第 th
个单元格:
{{ row }}
由于 标记尚未由
关闭,因此 row
变量仍然可用于表达式。下一行在当前行中创建一个数据单元(td
),并在其 ng-class
属性中使用 col
和 row
变量:
这里有几个重点。在 HTML 中,class
属性描述了一组类名,这些类名允许 CSS 对它们进行不同的样式设置。这里的 ng-class
计算表达式 ('=' === sheet[col+row][0])
;如果为 true,则 将 formula
作为一个附加类获取,该类为单元格提供淡蓝色背景,如styles.css的第8行中使用 .formula
类选择器定义的那样。
上面的表达式通过测试 =
是否是 sheet[col+row]
中字符串的初始字符([0]
)来检查当前单元格是否是公式,其中 sheet
是一个JS模型对象,坐标(如"E1"
)是属性,单元格内容(如"=A1+C1"
)是值。请注意,因为 col
是字符串而不是数字,所以 col+row
中的 +
表示串联而不是加法。
在 中,我们为用户提供了一个输入框,用于编辑存储在 sheet[col+row]
中的单元格内容:
这里的关键属性是 ng-model
,它支持 JS 模型和输入框的可编辑内容之间的双向绑定。实际上,这意味着每当用户在输入框中进行更改时,JS模型将更新 sheet[col+row]
以匹配内容,并触发其 calc()
函数以重新计算所有公式单元格的值。
为了避免在用户按住某个键时重复调用 calc()
, ng-model-options
将更新速率限制为每 200 毫秒一次。
此处的 id 属性用坐标 col+row
取值。HTML元素的id属性必须与同一文档中所有其他元素的id不同。这确保 #A1
ID 选择器引用单个元素,而不是类选择器 .formular
之类的元素集。当用户按下 UP/DOWN/ENTER 时,keydown()
中的键盘导航逻辑将使用ID选择器来确定要重点关注哪个输入框。
在输入框之后,我们放置一个 来显示当前单元格的计算值,在JS模型中由对象 errs
和 vals
表示:
{{ errs[col+row] || vals[col+row] }}
如果在计算公式时发生错误,文本插值将使用 errs[col+row]
中包含的错误消息,ng-class
将 error
类应用于元素,从而允许CSS以不同的方式对其进行样式设置(使用红色字母、与中心对齐等)。
如果没有错误,||
右侧的 vals[col+row]
将被取值。如果是非空字符串,则初始字符([0]
)将计算为 true
,并将 text
类应用于左对齐文本的元素。
因为空字符串和数值没有初始字符,ng-class
不会为它们分配任何类,所以 CSS 可以将它们的样式设置为默认情况下的右对齐方式。
最后,我们用
关闭列级的 ng-repeat
循环,用
关闭行级循环,并用以下命令结束 HTML 文档:
JS: 主控制层
main.js
文件根据 index.html
中的 元素的要求定义 500 行模块及其电子表格控制器功能。
作为 HTML 视图和后台工作层之间的桥梁,它有四个任务:
定义列和行的数量和标题。
为键盘移动和重置按钮提供事件处理程序。
当用户更改电子表格时,将其新内容发送给工作人员。
当计算结果从工作层到达时,更新视图并保存当前状态。
下图中的流程图更详细地显示了控制层与工作层的交互:
现在让我们浏览一下代码。在第一行中,我们请求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) {
箭头函数从 接收参数
($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
不会改变。如果键码为38(向上),则移动方向为向上(-1,从A2到A1),否则为向下(+1,从A2到A3)。
接下来,我们使用ID选择器语法(例如 "#A3"
)检索目标元素,该语法由一个模板字符串构成,该字符串写在一对反引号中,连接前导 #
、当前列和目标 row + direction
:
const cell = document.querySelector( `#${ col }${ row + direction }` );
if (cell) { cell.focus(); }
} );
} };
我们对 querySelector
的结果进行了额外的检查,因为从A1向上移动将产生选择器 #A0
,它没有相应的元素,因此不会触发焦点更改—在最下面一行按向下键也是如此。
接下来,我们定义 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 中恢复 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()
语法来定义函数并立即调用它。因为 localStorage 只存储字符串,所以我们使用
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()
函数,每当用户更改工作表时,该函数都会触发:
// 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 );
由于我们确保通过HTML中的 属性,
calc()
最多每200毫秒调用一次,因此这种安排为 init()
留出101毫秒的时间来将 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
中的工作表快照是稳定的(即,不包含无限循环公式),因此我们取消99毫秒超时,将快照写入localStorage,并使用 $timeout
函数计划UI更新,该函数将 errs
和 vals
更新到用户可见视图。
处理程序就位后,我们可以将工作表的状态发布到工作线程,并在后台开始计算:
// 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 主线程来执行任务,有三个原因:
当工作线程在后台运行时,用户可以继续与电子表格交互,而不会被主线程中的计算阻塞。
因为我们接受公式中的任何 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 ];
}
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; }
文本对齐和修饰由每个值的类型决定,如 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行的剩余空间中很容易实现:
使用ShareJS、AngularFire 或 GoAngular的协作在线编辑器。
标记语法支持文本单元格,使用 angular-marked。
OpenFormula标准中的常用公式函数(SUM、TRIM等)。
通过 SheetJS 与流行的电子表格格式(如 CSV 和SpreadsheetML)进行交互操作。
导入和导出到在线电子表格服务,如 Google 电子表格和EtherCalc。
JS版本说明
本章旨在演示 ES6 中的新概念,因此我们使用 Traceur 编译器将源代码转换为ES5,以便在2015年以前的浏览器上运行。
如果您希望直接使用 2010 版的JS,那么 as-javascript-1.8.5目录中的 main.js 和 worker.js 都是以 ES5 的样式编写的;源代码与具有相同行数的 ES6 版本一行一行进行了比较。
对于喜欢更简洁语法的人,as-livescript-1.3.0 目录使用 livescript 而不是 ES6 来编写main.ls和worker.ls;它比JS版本短20行。
基于LiveScript语言,as-react-livescript 目录使用 ReactJS 框架;它比同等的 AngularJS 长 10 行,但运行速度要快得多。