开源应用程序架构(第二卷)
Processing.js

迈克·卡默曼斯

Processing 编程语言最初由 Ben Fry 和 Casey Reas 开发,它起源于一个开源编程语言(基于 Java),旨在帮助电子艺术和视觉设计社区在视觉环境中学习计算机编程的基础知识。与大多数编程语言相比,它为 2D 和 3D 图形提供了一个高度简化的模型,因此它很快成为各种活动的理想选择,从通过编写小型可视化来教授编程到创建多墙艺术装置,并且能够执行各种各样的任务,从简单的读取字符串序列到充当流行的“Arduino”开源硬件原型板的编程和操作的实际 IDE。Processing 继续受到欢迎,它已稳固地成为一种易于学习、广泛使用的编程语言,适用于所有视觉内容,以及更多。

基本的 Processing 程序称为“草图”,它包含两个函数:setupdraw。第一个是程序的主要入口点,可以包含任何数量的初始化指令。完成 setup 后,Processing 程序可以执行以下两种操作之一:1) 调用 draw,并在完成时安排对 draw 的另一次固定间隔调用;或 2) 调用 draw,并等待用户的输入事件。默认情况下,Processing 执行前者;调用 noLoop 将导致后者。这允许以两种模式呈现草图,即固定帧速率图形环境和交互式、基于事件的更新图形环境。在这两种情况下,都会监控用户事件,并且可以在其自己的事件处理程序中处理它们,或者对于设置持久全局值的某些事件,直接在 draw 函数中处理它们。

Processing.js 是 Processing 的姊妹项目,旨在将其带到网络,而无需 Java 或插件。它最初是 John Resig 的尝试,试图通过使用当时全新的 HTML5 <canvas> 元素作为图形上下文将 Processing 语言移植到网络,并于 2008 年向公众发布了一个概念验证库。Processing.js 秉持“您的代码应该正常工作”的理念,经过多年的改进,可以使用网络标准和任何插件,来制作数据可视化、数字艺术、交互式动画、教育图表、视频游戏等。您可以使用 Processing 语言编写代码,无论是在 Processing IDE 还是您选择的任何喜欢的编辑器中,使用 <canvas> 元素将它包含在网页中,Processing.js 负责其余的工作,在 <canvas> 元素中渲染所有内容,并让用户以与使用普通独立 Processing 程序相同的方式与图形进行交互。

17.1. 工作原理?

Processing.js 作为开源项目有点不寻常,因为代码库是一个名为 processing.js 的单个文件,其中包含 Processing 代码,这是一个构成整个库的单个对象。就代码结构而言,我们在尝试通过每次发布清理代码时不断地在该对象内部重新排列代码。它的设计比较简单,其功能可以用一句话描述:它将 Processing 源代码重写为纯 JavaScript 源代码,并且每个 Processing API 函数调用都映射到 JavaScript Processing 对象中的一个对应函数,这会在 <canvas> 元素上产生与 Processing 调用在 Java Applet 画布上产生的相同效果。

为了提高速度,我们为 2D 和 3D 函数提供了两个单独的代码路径,当加载草图时,会使用其中一个或另一个来解析函数包装器,这样我们就可以避免在运行实例中添加膨胀代码。但是,就数据结构和代码流程而言,了解 JavaScript 意味着您可以阅读 processing.js,语法解析器除外。

统一 Java 和 JavaScript

将 Processing 源代码重写为 JavaScript 源代码意味着您可以简单地告诉浏览器执行重写的源代码,如果您正确地重写了代码,它就会正常工作。但是,确保重写正确需要,并且偶尔仍然需要,相当大的努力。Processing 语法基于 Java,这意味着 Processing.js 本质上必须将 Java 源代码转换为 JavaScript 源代码。最初,这是通过将 Java 源代码视为一个字符串,并迭代地将 Java 的子字符串替换为其 JavaScript 等效项来实现的。(对于那些对解析器早期版本的感兴趣的人,它可以在 这里 找到,从第 37 行运行到第 266 行。)对于一个小型语法集来说,这很好,但随着时间的推移,复杂性不断增加,这种方法开始失效。因此,解析器被完全重写以构建抽象语法树 (AST),首先将 Java 源代码分解为功能块,然后将每个块映射到其对应的 JavaScript 语法。结果是,以可读性为代价,Processing.js 现在实际上包含了一个即时 Java 到 JavaScript 的转译器。(读者可以查看 这段代码,一直到第 19217 行。)

这是 Processing 草图的代码

    void setup() {
      size(200,200);
      noCursor();
      noStroke();
      smooth(); }

    void draw() {
      fill(255,10);
      rect(-1,-1,width+1,height+1);
      float f = frameCount*PI/frameRate;
      float d = 10+abs(60*sin(f));
      fill(0,100,0,50);
      ellipse(mouseX, mouseY, d,d); }

这是其 Processing.js 转换

    function($p) {
        function setup() {
            $p.size(200, 200);
            $p.noCursor();
            $p.noStroke();
            $p.smooth(); }
        $p.setup = setup;

        function draw() {
            $p.fill(255, 10);
            $p.rect(-1, -1, $p.width + 1, $p.height + 1);
            var f = $p.frameCount * $p.PI / $p.__frameRate;
            var d = 10 + $p.abs(60 * $p.sin(f));
            $p.fill(0, 100, 0, 50);
            $p.ellipse($p.mouseX, $p.mouseY, d, d); }
        $p.draw = draw; }

这听起来很棒,但是将 Java 语法转换为 JavaScript 语法时会遇到一些问题

  1. Java 程序是隔离的实体。JavaScript 程序与网页共享世界。
  2. Java 是强类型语言。JavaScript 不是。
  3. Java 是一个基于类/实例的面向对象语言。JavaScript 不是。
  4. Java 具有不同的变量和方法。JavaScript 没有。
  5. Java 允许方法重载。JavaScript 不允许。
  6. Java 允许导入编译后的代码。JavaScript 根本不知道那是什么。

处理这些问题一直是用户需要什么与我们在给定网络技术的情况下能做什么之间的权衡。以下部分将更详细地讨论每个问题。

17.2. 重大差异

Java 程序有自己的线程;JavaScript 会锁定您的浏览器。

Java 程序是隔离的实体,在系统上应用程序的更大池中以自己的线程运行。另一方面,JavaScript 程序在浏览器内部运行,并且以桌面应用程序不具备的方式相互竞争。当 Java 程序加载文件时,程序会等待资源加载完成,然后操作按预期继续进行。在程序是隔离的实体的环境中,这是可以的。操作系统保持响应,因为它负责线程调度,即使程序需要一个小时才能加载所有数据,您仍然可以使用计算机。在网页上,情况并非如此。如果您有一个 JavaScript“程序”正在等待资源加载完成,它会锁定其进程,直到该资源可用。如果您使用的是使用每个选项卡一个进程的浏览器,它会锁定您的选项卡,浏览器其余部分仍然可以使用。如果您使用的是没有此功能的浏览器,您的整个浏览器将似乎冻结。因此,无论进程代表什么,脚本运行的页面都将无法使用,直到资源加载完成,并且您的 JavaScript 可能完全锁定整个浏览器。

在现代网络中,这是不可接受的,在现代网络中,资源以异步方式传输,并且预期页面在后台加载资源时正常运行。虽然这对传统网页来说很好,但对于网络应用程序来说,这是一个真正的难题:如何在 JavaScript 没有空闲机制的情况下让 JavaScript 空闲,等待资源加载?虽然 JavaScript 中没有显式线程,但有一个事件模型,并且有一个 XMLHTTPRequest 对象用于从任意 URL(不仅限于 XML 或 HTML)请求任意数据。该对象附带几个不同的状态事件,我们可以使用它来异步获取数据,同时浏览器保持响应。对于控制源代码的程序来说,这很棒:您只需在调度数据请求后让它停止运行,并在数据可用时让它恢复执行。但是,对于基于同步资源加载理念编写的代码来说,这几乎不可能。在应该以固定帧速率运行的程序中注入“空闲”不是一种选择,因此我们必须想出其他方法。

对于某些事情,我们决定强制同步等待。例如,使用字符串加载文件使用同步 XMLHTTPRequest,并且会停止页面执行,直到数据可用。对于其他事情,我们不得不发挥创意。例如,加载图像使用浏览器的内置机制来加载图像;我们在 JavaScript 中构建一个新的 Image,将其 src 属性设置为图像 URL,浏览器负责其余工作,并通过 onload 事件通知我们图像已准备就绪。这甚至不依赖于 XMLHTTPRequest,它只是利用了浏览器的功能。

为了在您已经知道要加载哪些图像时简化操作,我们添加了预加载指令,这样草图就不会在预加载完成之前开始执行。用户可以通过草图开头的注释块来指示要预加载的任意数量的图像;Processing.js 然后跟踪未完成的图像加载。图像的 onload 事件告诉我们它已完成传输,并被认为已准备好呈现(而不是仅下载但尚未解码为内存中的像素数组),之后我们可以使用正确的值(widthheight、像素数据等)填充相应的 Processing PImage 对象,并将图像从列表中清除。一旦列表为空,草图就会执行,其生命周期中使用的图像将不需要等待。

以下是一个预加载指令的示例

    /* @pjs preload="./worldmap.jpg"; */

    PImage img;

    void setup() {
      size(640,480);
      noLoop();
      img = loadImage("worldmap.jpg"); }

    void draw() {
      image(img,0,0); }

对于其他事情,我们不得不构建更复杂的“等待我”系统。与图像不同,字体没有内置的浏览器加载(或者至少没有像图像加载那样实用的系统)。虽然可以使用 CSS @font-face 规则加载字体并依赖于浏览器完成所有操作,但没有 JavaScript 事件可用于确定字体已完成加载。我们逐渐看到浏览器中添加了事件以生成用于字体下载完成的 JavaScript 事件,但这些事件“过早”出现,因为浏览器可能需要几毫秒到几百毫秒才能在下载完成后实际解析字体以在页面上使用。因此,对这些事件采取行动仍然会导致没有应用字体,或者如果存在已知的备用字体,则应用了错误的字体。与其依赖这些事件,我们嵌入了一个小型的 TrueType 字体,该字体仅包含字母“A”,其指标极小,并指示浏览器通过包含字体的字节码的 data URI(作为 BASE64 字符串)的 @font-face 规则加载此字体。该字体非常小,因此我们可以依赖于它可以立即使用。对于任何其他字体加载指令,我们将比较所需字体和此小型字体的文本指标。一个隐藏的 <div> 使用所需字体设置样式的文本进行设置,我们的小型字体作为备用字体。只要该 <div> 中的文本指标极小,我们就知道了所需的字体尚不可用,我们只需以设定的间隔轮询,直到文本具有合理的指标。

Java 是强类型语言;JavaScript 不是。

在 Java 中,数字 2 和 2.0 是不同的值,它们在数学运算中会有不同的行为。例如,代码 i = 1/2 将导致 i 为 0,因为数字被视为整数,而 i = 1/2.0i = 1.0/2 甚至 i = 1./2. 都将导致 i 为 0.5,因为数字被视为带有非零整数部分和零小数部分的小数。即使目标数据类型是浮点数,如果算术运算只使用整数,结果也将是整数。这使你可以在 Java 中(以及 Processing 中)编写相当有创意的数学语句,但这些语句在移植到 Processing.js 时可能会产生截然不同的结果,因为 JavaScript 只知道 "数字"。在 JavaScript 中,2 和 2.0 是同一个数字,这在使用 Processing.js 运行草图时可能会导致非常有趣的错误。

这听起来可能是一个大问题,起初我们也确信它会是一个问题,但你不能与现实世界的反馈争论:事实证明,对于那些使用 Processing.js 将他们的草图放到网上的人来说,这几乎从来都不是问题。我们并没有以某种酷炫而有创意的方式解决这个问题,而是以一种非常直接的方式解决了这个问题:我们没有解决它,并且作为一种设计选择,我们不打算重新考虑这个决定。除了添加一个带有强类型符号表的符号表,以便我们可以在 JavaScript 中伪造类型并根据类型切换功能外,这个不兼容性无法真正解决,而不会留下更难找到的边缘情况的错误,因此,为了避免给代码增加体积并减慢执行速度,我们保留了这个怪癖。这是一个有据可查的怪癖,"好的代码"不会试图利用 Java 的隐式数字类型转换。也就是说,有时你会忘记,结果可能会非常有趣。

Java 是一种基于类/实例的面向对象语言,具有独立的变量和方法空间;JavaScript 不是。

JavaScript 使用原型对象,以及随之而来的继承模型。这意味着所有对象本质上都是键值对,其中每个键都是一个字符串,而值可以是基本类型、数组、对象或函数。在继承方面,原型可以扩展其他原型,但没有真正的 "超类" 和 "子类" 的概念。为了让 "正确" 的 Java 风格的面向对象代码正常工作,我们必须在 Processing.js 中为 JavaScript 实现经典继承,而不会使其变得非常慢(我们认为我们在这一点上取得了成功)。我们还必须想出一个方法来防止变量名和函数名相互冲突。由于 JavaScript 对象的键值对性质,定义一个名为 line 的变量,然后定义一个类似 line(x1,y1,x2,y2) 的函数,将会得到一个对象,该对象将使用最后声明的任何内容作为键。JavaScript 首先为你设置 object.line = "some value",然后设置 object.line = function(x1,y1,x2,y2){…},覆盖了你认为你的变量 line 是什么。

为变量和方法/函数创建独立的管理会大大降低库的速度,因此文档再次解释了使用相同名称的变量和函数是一个坏主意。如果每个人都编写 "正确的" 代码,这不会是什么大问题,因为你希望根据变量和函数的用途或功能来命名它们,但现实世界做事情的方式有所不同。有时你的代码无法正常工作,那是因为我们认为让你的代码因命名冲突而崩溃,比你的代码总是正常工作但总是很慢更可取。不实现变量和函数分离的第二个原因是,这可能会破坏 Processing 草图中使用的 JavaScript 代码。JavaScript 的闭包和作用域链依赖于对象的键值对性质,因此通过编写我们自己的管理来插入一个楔子,也会严重影响 Just-In-Time 编译和基于函数闭包的压缩的性能。

Java 允许方法重载;JavaScript 不允许。

Java 的一项更强大的功能是你可以定义一个函数,例如 add(int,int),然后定义另一个同名函数,但参数数量不同,例如 add(int,int,int),或者参数类型不同,例如 add(ComplexNumber,ComplexNumber)。使用两个或三个整数参数调用 add 将自动调用相应的函数,而使用浮点数或 Car 对象调用 add 将生成一个错误。另一方面,JavaScript 不支持这一点。在 JavaScript 中,函数是一个属性,你可以对其进行解除引用(在这种情况下,JavaScript 将根据类型强制转换返回一个值,在本例中,当属性指向函数定义时返回 true,否则返回 false),或者可以使用执行运算符将其作为函数调用(你将知道它们是带有一对括号,括号之间有零个或多个参数)。如果你将一个函数定义为 add(x,y),然后将其调用为 add(1,2,3,4,5,6),JavaScript 可以接受。它将 x 设置为 1,y 设置为 2,并将忽略其余参数。为了使重载正常工作,我们将同名但参数数量不同的函数重写为一个编号的函数,以便源代码中的 function(a,b,c) 在重写后的代码中变为 function$3(a,b,c)function(a,b,c,d) 变为 function$4(a,b,c,d),以确保代码路径正确。

我们还基本解决了具有相同数量但类型不同的参数的函数的重载问题,只要参数类型在 JavaScript 中被视为 *不同* 即可。JavaScript 可以使用 typeof 运算符来判断属性的功能类型,这将根据属性所表示的内容返回 numberstringobjectfunction。声明 var x = 3,然后声明 x = '6' 将导致在初始声明后 typeof x 报告 number,在重新赋值后报告 string。只要参数数量相同的函数在参数类型上有所不同,我们就会重命名它们,并根据 typeof 运算符的结果进行切换。当函数接受 object 类型的参数时,这将不起作用,因此对于这些函数,我们有一个额外的检查,涉及 instanceof 运算符(它返回用于创建对象的函数的名称),以使函数重载正常工作。事实上,我们唯一不能成功地转译重载函数的地方是函数之间的参数数量相同,而参数类型是不同的数值类型。由于 JavaScript 只有一个数值类型,声明诸如 add(int x, int y)add(float x, float y)add(double x, double y) 之类的函数会导致冲突。然而,其他所有功能都能正常工作。

Java 允许导入编译后的代码。

有时,普通的 Processing 不够,需要额外的功能以 Processing 库的形式引入。这些库采用 .jarchive 的形式,包含编译后的 Java 代码,并提供诸如网络、音频、视频、硬件接口以及 Processing 本身不包含的其他奇特功能。

这是一个问题,因为编译后的 Java 代码是 Java 字节码。这给我们带来了很多头疼:如何在不编写 Java 字节码反编译器的情况下支持库导入?经过大约一年的讨论,我们终于找到了一个看似最简单的解决方案。与其试图同时覆盖 Processing 库,我们决定在草图中支持 import 关键字,并创建一个 Processing.js 库 API,以便库开发人员可以编写其库的 JavaScript 版本(在网络性质允许的情况下),因此,如果他们编写了一个通过 import processing.video 使用的包,本地 Processing 将选择 .jarchive,而 Processing.js 将选择 processing.video.js,从而确保 "一切正常"。此功能计划在 Processing.js 1.4 中发布,库导入是 Processing.js 仍然缺少的最后一个主要功能(我们目前只在将源代码转换为 JavaScript 之前从源代码中删除它,从而支持 import 关键字),并且将是实现功能等同的最后一步。

如果 JavaScript 不能做 Java 的事情,为什么要选择它呢?

这不是一个不合理的问题,它有多个答案。最明显的一个是,JavaScript 与浏览器捆绑在一起。你不需要自己 "安装" JavaScript,也不需要先下载插件;它就在那里。如果你想将某些东西移植到网络上,你就只能使用 JavaScript。不过,鉴于 JavaScript 的灵活性,"只能使用" 真的不能体现出这门语言的强大之处。所以,选择 JavaScript 的一个原因是 "因为它已经存在了"。现在,几乎所有我们感兴趣的设备都带有一个支持 JavaScript 的浏览器。对于 Java 来说,情况并非如此,它作为预装技术提供的越来越少,甚至根本没有提供。

然而,正确的答案是,JavaScript *确实* 可以做 Java 可以做的事情,只是速度会慢一些。尽管开箱即用的 JavaScript 不能做 Java 可以做的一些事情,但它仍然是一种图灵完备的编程语言,它可以模拟任何其他编程语言,只不过是以牺牲速度为代价。从技术上讲,我们可以编写一个完整的 Java 解释器,它带有一个 String 堆、独立的变量和方法模型、带有严格类层次结构的类/实例面向对象,以及其他所有来自 Sun(或者,现在是 Oracle)的东西,但这不是我们的目标:Processing.js 的目标是提供一个 Processing 到网络的转换,用尽可能少的代码来完成这项工作。这意味着,尽管我们决定不做某些 Java 的事情,但我们的库有一个巨大的优势:它可以非常、非常有效地处理嵌入的 JavaScript。

事实上,在 2010 年波士顿 Bocoup 的 Processing.js 和 Processing 人员之间的一次会议上,Ben Fry 问 John Resig 为什么他使用正则表达式替换,只进行部分转换,而不是进行适当的解析和编译。John 的回答是,对他来说,重要的是人们能够混合使用 Processing 语法 (Java) 和 JavaScript,而无需在两者之间进行选择。从那时起,这个最初的选择一直在塑造 Processing.js 的理念。我们一直在努力使它在我们的代码中保持真实,并且当我们看到所有 Processing.js 的 "纯网络" 用户时,我们可以看到一个明显的回报,他们从未使用过 Processing,并且会很乐意毫无问题地混合使用 Processing 和 JavaScript 语法。

以下示例展示了 JavaScript 和 Processing 如何协同工作。

    // JavaScript (would throw an error in native Processing)
    var cs = { x: 50,
               y: 0,
               label: "my label",
               rotate: function(theta) {
                         var nx = this.x*cos(theta) - this.y*sin(theta);
                         var ny = this.x*sin(theta) + this.y*cos(theta);
                         this.x = nx; this.y = ny; }};

    // Processing
    float angle = 0;

    void setup() {
      size(200,200);
      strokeWeight(15); }

    void draw() {
      translate(width/2,height/2);
      angle += PI/frameRate;
      while(angle>2*PI) { angle-=2*PI; }
      jQuery('#log').text(angle); // JavaScript (error in native Processing)
      cs.rotate(angle);           // legal JavaScript as well as Processing
      stroke(random(255));
      point(cs.x, cs.y); }

Java 中的很多东西都是承诺:强类型是给编译器的承诺,可见性是对谁会调用方法和引用变量的承诺,接口是实例包含接口描述的方法的承诺,等等。打破这些承诺,编译器就会报错。但是,如果你没有打破这些承诺——而这对于 Processing.js 来说是最重要的想法之一——那么你就不需要这些承诺的额外代码来使程序正常工作。如果你将一个数字放入一个变量中,并且你的代码将该变量视为其中包含一个数字,那么最终 var varnameint varname 是一样的。你需要类型吗?在 Java 中,你需要;在 JavaScript 中,你不需要,所以为什么要强加它呢?其他代码承诺也是如此。如果 Processing 编译器没有抱怨你的代码,那么我们可以剥离所有针对你的承诺的显式语法,它仍然可以正常工作。

这使得 Processing.js 成为一个非常有用的库,可用于数据可视化、媒体演示,甚至娱乐。本地 Processing 中的草图可以正常工作,而混合使用 Java 和 JavaScript 的草图也可以正常工作,同样,将 Processing.js 视为一个功能强大的画布绘制框架的纯 JavaScript 草图也可以正常工作。为了实现与本地 Processing 的功能等同,而不强迫使用 Java 独有的语法,该项目已被一个像网络本身一样广泛的受众接纳。我们已经看到了整个网络上使用 Processing.js 的活动。从 IBM 到 Google,每个人都使用 Processing.js 构建了可视化、演示,甚至游戏——Processing.js 正在发挥作用。

将 Java 语法转换为 JavaScript 代码,同时保持 JavaScript 代码不变,还有一个很大的好处:它让我们做到了我们之前没有想到的事情:Processing.js 可以与任何可以与 JavaScript 代码交互的工具配合使用。例如,我们现在看到了一个很有趣的趋势,人们开始使用 CoffeeScript(一种简单易懂、类似 Ruby 的编程语言,可以编译成 JavaScript 代码)与 Processing.js 结合使用,并取得了非常棒的效果。虽然我们最初的目标是基于解析 Processing 语法来构建 "Web 版 Processing",但人们却利用我们的成果,用全新的语法来实现它。如果我们只是将 Processing.js 作为 Java 解释器,那么他们就无法做到这一点。通过坚持代码转换而不是编写代码解释器,Processing.js 使 Processing 在 Web 上的覆盖范围远远超出了仅限于 Java,甚至超出了使用 Java 语法且通过 JavaScript 执行代码的范围。我们的代码不仅被最终用户所采用,还被那些试图将其与自身技术集成的开发者所采用,这真是太棒了,也给了我们很大的启发。很明显,我们做了一些正确的事情,Web 似乎对我们的成果很满意。

结果

随着 Processing.js 1.4.0 版本的发布,我们的工作成果是一个能够运行任何草图的库,前提是该草图不依赖于编译后的 Java 库导入。如果你可以用 Processing 编写草图,并且它能够运行,那么你就可以将其放到网页上,它就会自动运行。由于硬件访问和渲染管道不同部分的底层实现之间的差异,可能会存在时序差异,但总的来说,在 Processing IDE 中以每秒 60 帧的速度运行的草图,在配备现代浏览器的现代计算机上也会以每秒 60 帧的速度运行。我们已经达到了一个阶段,即 bug 报告开始减少,大多数工作不再是添加功能支持,而是修复 bug 和优化代码。

由于许多开发人员的努力,他们解决了 1800 多个 bug 报告,使用 Processing.js 运行的 Processing 草图 "运行正常"。即使是依赖于库导入的草图也可以运行,前提是该库代码可用。在理想情况下,该库的编写方式允许你使用一些搜索替换操作将其重写为纯 Processing 代码。在这种情况下,该代码几乎可以立即在网上运行。当该库执行无法用纯 Processing 代码实现,但可以用纯 JavaScript 代码实现的操作时,就需要更多工作来使用 JavaScript 代码有效地模拟该库,但移植仍然是可能的。唯一无法移植的 Processing 代码是那些依赖于浏览器本身无法访问的功能的代码,例如直接与硬件设备(如网络摄像头或 Arduino 板)交互或执行无人值守的磁盘写入操作,但即使这种情况也在发生变化。浏览器不断添加功能以允许更复杂的应用程序,而今天的一些限制因素可能在一年后就不复存在了,因此,希望在不久的将来,即使现在无法在网上运行的草图也能变得可移植。

17.3. 代码组件

Processing.js 以一个大型的单个文件形式呈现和开发,但在架构上它代表了三个不同的组件:1) 启动器,负责将 Processing 源代码转换为 Processing.js 风格的 JavaScript 代码并执行它;2) 所有草图都可以使用的静态功能;3) 必须与单个实例绑定在一起的草图功能。

启动器

启动器组件负责三件事:代码预处理、代码转换和草图执行。

预处理

在预处理步骤中,Processing.js 指令将从代码中分离出来,并进行处理。这些指令分为两种:设置和加载指令。指令数量很少,符合 "它应该可以正常运行" 的理念,草图作者可以更改的唯一设置与页面交互有关。默认情况下,如果页面没有处于焦点状态,草图将继续运行,但 pauseOnBlur = true 指令会将草图设置为在页面失去焦点时暂停执行,并在页面再次获得焦点时恢复执行。默认情况下,键盘输入只会路由到获得焦点的草图。当人们在同一个页面上运行多个草图时,这一点尤其重要,因为意图发送到某个草图的键盘输入不应被另一个草图处理。但是,可以使用 globalKeyEvents = true 指令禁用此功能,将键盘事件路由到页面上运行的每个草图。

加载指令的形式是前面提到的图像预加载和字体预加载。由于图像和字体可以被多个草图使用,因此它们是在全局范围内加载和跟踪的,以防止不同的草图尝试对同一个资源进行多次加载。

代码转换

代码转换组件将源代码分解为 AST 节点,例如语句和表达式、方法、变量、类等。然后将该 AST 扩展为 JavaScript 源代码,在执行时构建与草图等效的程序。此转换后的源代码大量使用了 Processing.js 实例框架来设置类关系,其中 Processing 源代码中的类成为 JavaScript 原型,具有用于确定超类和超类函数和变量绑定的特殊函数。

草图执行

启动过程的最后一步是草图执行,它包括确定所有预加载是否已完成,如果已完成,则将草图添加到正在运行的实例列表中,并触发其 JavaScript onLoad 事件,以便任何草图监听器都可以采取相应的操作。此后,Processing 链将按顺序执行:setupdraw,如果草图是循环草图,则以接近草图所需帧速率的间隔长度设置对 draw 的间隔调用。

静态库

Processing.js 的大部分内容属于 "静态库" 类别,代表常量、通用函数和通用数据类型。其中许多实际上是双重职责,被定义为全局属性,但也由实例别名,以加快代码路径。全局常量(如键码和颜色映射)位于 Processing 对象本身中,在实例构建时通过 Processing 构造函数进行设置,然后进行引用。这也适用于自包含的辅助函数,这使我们能够尽可能接近 "编写一次,到处运行" 的目标,而不会牺牲性能。

Processing.js 必须支持大量的复杂数据类型,不仅是为了支持 Processing 中使用的类型,也是为了支持其内部工作机制。这些类型也是在 Processing 构造函数中定义的。

管理

静态代码库中的最终功能是所有当前在页面上运行的草图的实例列表。该实例列表根据草图加载的画布存储草图,以便用户可以调用 `Processing.getInstanceById('canvasid')` 并获取对草图的引用,用于页面交互目的。

实例代码

实例代码采用 `p.functor = function(arg, …) ` 的形式,用于 Processing API 的定义,以及 `p.constant = …` 用于草图状态变量的定义(其中 `p` 是我们对正在设置的草图的引用)。这些代码都不位于专门的代码块中。相反,代码是根据功能组织的,以便与 PShape 操作相关的实例代码定义在 PShape 对象附近,而与图形功能相关的实例代码定义在 Drawing2D 和 Drawing3D 对象附近或内部。

为了保持速度,许多可以作为具有实例包装器的静态代码编写的代码实际上是作为纯实例代码实现的。例如,`lerpColor(c1, c2, ratio)` 函数,该函数确定对应于两种颜色线性插值的颜色,被定义为一个实例函数。与其让 `p.lerpColor(c1, c2, ratio)` 作为某个静态函数 `Processing.lerpColor(c1, c2, ratio)` 的包装器,不如说 Processing.js 中没有其他东西依赖于 `lerpColor`,这意味着如果我们将它写成纯实例函数,代码执行速度会更快。虽然这确实会 "膨胀" 实例对象,但大多数我们坚持使用实例函数而不是静态库包装器的函数都很小。因此,以牺牲内存为代价,我们创建了非常快的代码路径。虽然完整的 Processing 对象在最初设置时会占用大约 5 MB 的内存,但单个草图的先决条件代码只占大约 500 KB。

17.4. 开发 Processing.js

Processing.js 的开发工作非常密集,我们之所以能够做到这一点,是因为我们的开发方法坚持一些基本原则。由于这些规则影响着 Processing.js 的架构,在结束本章之前,有必要简要了解一下这些规则。

使其工作

编写可工作的代码听起来像是一个同义反复的前提;你编写代码,当你完成时,你的代码要么工作,因为这是你想要做的,要么不工作,而你还没有完成。然而,"使其工作" 带有一个推论:使其工作,并且当你完成时,证明它。

如果说有什么东西比其他任何东西更能推动 Processing.js 以其速度发展,那就是测试的存在。任何需要修改代码的工单,无论是编写新代码还是重写旧代码,都无法标记为已解决,除非有一个单元测试或引用测试,允许其他人不仅验证代码是否按预期工作,而且验证代码是否在应该断开时断开。对于大多数代码来说,这通常涉及一个单元测试——一小段代码,它调用一个函数,并简单地测试该函数是否返回了正确的值,无论是合法还是非法函数调用。这不仅允许我们测试代码贡献,而且允许我们执行回归测试。

在任何代码被接受并合并到我们的稳定开发分支之前,修改后的 Processing.js 库将根据不断增长的单元测试集进行验证。特别是大的修复和性能测试,它们很容易通过自己的单元测试,但会破坏在重写之前运行良好的部分。对 API 中每个函数以及内部函数进行测试意味着,随着 Processing.js 的发展,我们不会意外地破坏与以前版本的兼容性。除了破坏性 API 更改,如果在代码贡献或修改之前没有测试失败,那么在新代码中也不允许任何测试失败。

以下是一个验证内联对象创建的单元测试示例。

    interface I {
      int getX();
      void test(); }

    I i = new I() {
      int x = 5;
      public int getX() {
        return x; }
      public void test() {
        x++; }};

    i.test();

    _checkEqual(i.getX(), 6);
    _checkEqual(i instanceof I, true);
    _checkEqual(i instanceof Object, true);

除了常规的代码单元测试外,我们还有视觉参考(或 "ref")测试。由于 Processing.js 是一个视觉编程语言的移植,因此某些测试无法仅使用单元测试来执行。测试椭圆是否在正确的像素上绘制,或者单像素宽的垂直线是否以清晰或平滑的方式绘制,无法在没有视觉参考的情况下确定。由于所有主流浏览器都以细微的差异实现了 `` 元素和 Canvas2D API,因此这些东西只能通过在浏览器中运行代码并验证生成的草图是否与原生 Processing 生成的草图相同来进行测试。为了让开发人员的生活更轻松,我们为此使用了自动测试套件,其中新的测试用例将通过 Processing 运行,生成 "它应该是什么样子" 的数据,用于像素比较。然后,这些数据作为注释存储在生成它的草图中,形成一个测试,然后这些测试由 Processing.js 在一个视觉参考测试页面上运行,该页面执行每个测试并执行 "它应该是什么样子" 和 "它看起来像什么" 之间的像素比较。如果像素不匹配,则测试失败,开发人员将看到三张图片:它应该是什么样子、Processing.js 如何渲染它以及两者的差异,将问题区域标记为红色像素,将正确区域标记为白色。与单元测试非常相似,这些测试必须通过,然后才能接受任何代码贡献。

使其快速

在开源项目中,使事情工作仅仅是函数生命周期中的第一步。一旦事情工作了,你希望确保事情运行得很快。基于 "如果你不能衡量它,你就不能改进它" 的原则,Processing.js 中的大多数函数不仅带有一个单元测试或 ref 测试,而且还带有一个性能(或 "perf")测试。一小段代码,它只调用一个函数,而不测试函数的正确性,会在一个特殊的性能测试网页上连续运行数百次,并记录它们的运行时间。这使我们能够量化 Processing.js 在支持 HTML5 的 `` 元素的浏览器中的性能(或不佳)。每次优化补丁通过单元测试和 ref 测试后,都会在我们的性能测试页面上运行。JavaScript 是一个奇怪的东西,实际上,漂亮的代码可能比包含相同行多次的代码慢几个数量级,这些代码使用内联代码而不是函数调用。这使得性能测试至关重要。仅仅通过在性能测试期间发现热循环,减少函数调用次数以内联代码,以及通过让函数在知道其返回值应该是多少时立即返回,而不是只在函数的最后进行一次返回,我们已经能够将库的某些部分的速度提高三个数量级。

我们尝试使 Processing.js 快速的另一种方法是查看运行它的内容。由于 Processing.js 高度依赖于 JavaScript 引擎的效率,因此有必要查看各种引擎提供的哪些功能可以加速。特别是现在浏览器开始支持硬件加速图形,当引擎提供新的和更有效的 数据类型和函数来执行 Processing.js 依赖的低级操作时,可以立即实现速度提升。例如,JavaScript 从技术上讲没有静态类型,但图形硬件编程环境有。通过将用于直接与硬件通信的数据结构公开给 JavaScript,如果我们知道它们只使用特定值,则可以显着加速代码部分。

使其小巧

有两种方法可以使代码变小。首先,编写简洁的代码。如果你要多次操作一个变量,将其压缩为一个操作(如果可能)。如果你要多次访问一个对象变量,将其缓存。如果你要多次调用一个函数,缓存其结果。在你拥有所有你需要的信息时就返回,并通常应用代码优化器会应用的所有技巧。JavaScript 对于这一点来说是一种非常好的语言,因为它提供了大量的灵活性。例如,而不是使用

if ((result = functionresult)!==null) {
  var = result;
} else {
  var = default;
}

在 JavaScript 中,这变成了

var = functionresult || default

还有一种形式的代码很小,那就是运行时代码。由于 JavaScript 允许你动态更改函数绑定,如果你可以在知道程序以 2D 而不是 3D 模式运行后说 "将 line2D 函数绑定到 `line` 函数调用",那么运行代码就会变得小得多,这样你就不必执行

if(mode==2D) { line2D() } else { line3D() }

对于每个可能处于 2D 或 3D 模式的函数调用。

最后,还有压缩的过程。有许多很好的系统可以让你通过重命名变量、去除空白以及应用某些难以手动完成但仍然保持代码可读性的代码优化来压缩你的 JavaScript 代码。这些系统的例子包括 YUI 压缩器和 Google 的 Closure Compiler。我们在 Processing.js 中使用这些技术来为最终用户提供带宽便利——压缩后去除注释可以使库的大小缩小多达 50%,并且利用现代浏览器/服务器交互进行 gzip 内容,我们可以以 65 KB 的 gzip 形式提供完整的 Processing.js 库。

如果一切皆失败,请告知用户

并非所有当前可以在 Processing 中完成的操作都可以在浏览器中完成。安全模型阻止了某些操作,例如将文件保存到硬盘驱动器以及执行 USB 或串口 I/O,而 JavaScript 中缺乏类型会导致意外后果(例如所有数学运算都是浮点运算)。有时,我们面临着一个选择,要么添加大量代码来启用边缘情况,要么将工单标记为 "无法修复" 问题。在这种情况下,会创建一个新的工单,通常标题为 "添加说明为什么..."。

为了确保这些内容不会丢失,我们为具有 Processing 背景的用户和具有 JavaScript 背景的用户提供了文档,介绍了预期行为和实际行为之间的差异。有些事情确实应该特别提及,因为无论我们对 Processing.js 投入了多少工作,有些事情我们都无法添加,而不会牺牲可用性。一个好的架构不仅涵盖了事物的方式,还涵盖了原因;如果没有这些,你每次团队发生变化时,都会陷入关于代码外观以及它是否应该不同的相同讨论中。

17.5. 经验教训

我们在编写 Processing.js 时学到的最重要的教训是,在移植语言时,重要的是结果的正确性,而不是你的移植中使用的代码是否与原始代码相似。即使 Java 和 JavaScript 语法非常相似,并且将 Java 代码修改为合法的 JavaScript 代码非常容易,但通常有必要查看 JavaScript 可以原生执行的操作,并利用这些操作来获得相同的功能结果。通过利用缺乏类型来循环利用变量,使用某些在 JavaScript 中很快但在 Java 中很慢的内置函数,或者避免在 Java 中很快但在 JavaScript 中很慢的模式,你的代码可能看起来完全不同,但具有完全相同的效果。你经常听到人们说不要重新发明轮子,但这只适用于使用单一编程语言。当你在移植时,需要重新发明尽可能多的轮子,才能获得你需要的性能。

另一个重要的教训是尽早返回,频繁返回,并且尽可能少地分支。一个if/then语句,后面紧跟着一个return语句,可以通过使用if-return/return结构来实现更快的执行速度(有时速度提升非常明显),利用return语句作为条件快捷方式。虽然在函数调用最终return语句之前,将整个函数状态聚合起来从概念上来说很漂亮,但这也会意味着你的代码路径可能会遍历与你将要返回的内容完全无关的代码。不要浪费周期,在拥有所有必要信息时就返回。

第三个教训涉及代码测试。在Processing.js中,我们有幸从非常好的文档开始,这些文档概述了Processing的“预期”工作方式,并提供了一套庞大的测试用例,其中大部分最初是“已知失败”的。这让我们可以做两件事:1)根据测试编写代码,2)在编写代码之前创建测试。通常的流程是先编写代码,然后再为该代码编写测试用例,实际上会创建有偏差的测试。你并不是在测试你的代码是否按照规范执行了它应该做的,而仅仅是在测试你的代码是否没有bug。在Processing.js中,我们通过根据文档为某个函数或一组函数的功能需求创建测试用例来开始。有了这些无偏见的测试,我们就可以编写功能完整的代码,而不仅仅是无bug的,但可能存在缺陷的代码。

最后一个教训也是最普遍的教训:将敏捷开发的规则应用于单个修复。没有人会从你进入开发模式,三天不与外界交流,然后写出完美的解决方案中获益。相反,将你的解决方案完善到可以工作的地步,甚至不一定涵盖所有测试用例,然后寻求反馈。独自工作,使用测试套件来捕捉错误,并不能保证代码质量或完整性。任何数量的自动化测试都不会指出你忘记为某些边缘情况编写测试,或者你选择的算法不是最好的,或者你本可以重新排序你的语句以使代码更适合JIT编译。将修复视为发布:尽早提供修复,经常更新,并将反馈纳入你的改进中。