Web 的交互性发展轨迹,最初始于用于验证 HTML 表单的简单 JavaScript 代码片段,但最近确实开始加速发展。一种新型 Web 应用程序开始涌现,它具有基于通过不断增加的 JavaScript 量直接操作浏览器 DOM(文档对象模型)的、交互性越来越强的用户界面。Google Wave 在 2009 年 5 月于旧金山举行的 Google I/O 开发者大会上首次公开展示,是这种新型 Web 应用程序的典范。Wave 可以被描述为一个客户端/服务器应用程序,其中客户端是执行 JavaScript 应用程序的浏览器,而服务器是“云”,而不是被实现为由服务器渲染的一系列单独的 HTML “页面” 。
为实现这种新一代 Web 应用程序而负责的关键浏览器技术并非特别新颖:JavaScript 在浏览器中运行,以操作浏览器 DOM,作为实际渲染 UI 和响应用户事件的手段;CSS(层叠样式表)用于控制 UI 的视觉样式;而 XHR (XmlHttpRequest) 子系统允许 JavaScript 应用程序代码与 Web 服务器异步通信,而无需完全刷新页面,从而使增量 UI 更新成为可能。还有许多其他浏览器技术,读起来就像字母汤:XML、VML、SVG、JSON、XHTML、DTD……清单不胜枚举。
奇怪的是,这些浏览器技术已经可用多年,但直到现在,主流开发人员才将它们拼凑在一起,创建出引人注目的交互式 Web 应用程序。为什么?Google Web Toolkit 团队的观点——当然,这种观点可以无休止地争论——是,主要障碍实际上是实现细节。在各种可用的浏览器上,将它们全部编码在一起以提供快速可靠的性能实在太难了。
我们的回应是设计 GWT(Google Web Toolkit),它允许开发人员将大部分时间用于使用 Java 语言而不是 JavaScript 编写和调试应用程序代码。使用 Java 意味着开发人员可以利用 Java IDE(集成开发环境)的生产力。然后,一旦他们对自己的 Java 代码感到满意,开发人员就可以使用 GWT 的交叉编译器将 Java 源代码转换为功能等效且优化的 JavaScript。交叉编译的想法往往会引起人们的怀疑,我们已经听到了很多关于此事的怀疑,因此让我们退后一步,描述一下我们是如何决定采用这种方法的——以及事情实际上是如何发展的。
GWT 的诞生源于 Google 软件工程师 Joel Webber 和我制作的一个原型,旨在解决可能最好被描述为 Web 开发的过度约束问题。这在很大程度上要归功于 Google 地图和 Gmail 的成功,有几个要点同时向我们明确:
换句话说,浏览器——特别是 XHR、JavaScript 和 DOM——为交付应用程序提供了一个功能强大但令人沮丧的平台。
与此同时,我们对 JavaScript 是否是一种适合编写关键业务应用程序的语言提出了疑问。一方面,JavaScript 是一种灵活的、动态类型的语言,它使某些类型的代码易于编写且简洁明了。另一方面,同样的灵活性会使 JavaScript 更难在团队环境中使用,因为没有简单的方法可以在整个代码库中自动强制使用一致的约定。诚然,通过大量额外的工作,JavaScript 团队可以坚持要求所有脚本都使用额外的元数据(例如 JSDoc)进行增强,然后使用其他工具来验证所有脚本是否符合约定的约定。这也必然会将开发人员限制在 JavaScript 的静态可分析子集,因为 JavaScript 的一些最具动态性的构造——eval()
和 with
语句就是很好的例子——彻底击败了静态分析。所有这些额外的东西——元数据和验证工具——看起来都很像临时的静态类型系统和编译器前端。
此外,我们非常想要一个 IDE。我们的经验彻底说服我们,IDE 是提高生产力、质量和可维护性的福音。现代 Java IDE 中状态如代码完成、调试、集成单元测试、重构和语法感知搜索等功能对于 JavaScript 来说几乎不存在。原因再次与 JavaScript 的动态性有关。例如,在一般情况下,不可能在 JavaScript 编辑器中提供可靠的代码完成,因为不同的运行时代码路径可能会为相同的符号产生不同的含义。考虑一下这段合法的 JavaScript
function foo(m) { alert(“You called foo with the message: “ + m); } if (dayOfWeek() == “Wednesday”) { foo = 3; } foo(“Hello?”); // [1]
在 [1] 处,静态地判断 foo
是函数还是变量是不可能的,因此 IDE 代码完成只能提供“可能正确”的建议,这是一种乐观的说法,即您必须仔细检查 IDE 的代码完成建议,这反过来可能会削弱从 JavaScript IDE 中实现的预期生产力提升。出于类似的原因,很少看到 JavaScript 的自动化重构工具,即使此类工具在 Java 世界中无处不在。这些观察结果使 JavaScript 作为一种编写大型应用程序的语言显得不那么有吸引力。
我们最终意识到,我们希望用 Java 语言开发我们的源代码,但部署为纯 JavaScript。通过选择 Java 语言作为原始语言,我们可以立即利用 Java 工具的强大生态系统,尤其是出色的 Java IDE。唯一的问题是如何从 Java 源代码输入生成 JavaScript。我们的答案是构建一个 Java 到 JavaScript 的编译器——实际上是一个优化编译器,因为我们认为既然无论如何我们都要费力编写编译器,为什么不确保它生成小而高效的 JavaScript 呢?此外,我们发现,由于 Java 具有静态类型系统,因此它允许进行许多编译时优化,而动态类型的 JavaScript 则不允许。
作为这方面的一个例子,请考虑内联和去虚化(即,删除方法调用中的多态性)之间的交互。在 JavaScript 中,开发人员经常模拟面向对象的构造,例如多态性。例如,如果您想在 JavaScript 中拥有一个简单的 Shape 层次结构,您可以这样编写
function Shape() { } Shape.prototype.getArea = function() { } function Circle(radius) { this.radius = radius; } Circle.prototype = new Shape(); Circle.prototype.getArea = function() { return this.radius * this.radius * Math.PI; } function Square(length) { this.length = length; } Square.prototype = new Shape(); Square.prototype.getArea = function() { return this.length * this.length; } function displayArea(shape) { alert(“The area is “ + shape.getArea()); } function runTest() { var shape1 = new Circle(3); var shape2 = new Square(2); displayArea(shape1); displayArea(shape2); }
用 Java 语言编写的用于 GWT 的相同内容可能如下所示
abstract class Shape { public abstract double getArea(); } class Circle extends Shape { private final double radius; public Circle(double radius) { this.radius = radius; } @Override public double getArea() { return radius * radius * Math.PI; } } class Square extends Shape { private final double length; public Square(double length) { this.length = length; } @Override public double getArea() { return length * length; } } static void displayArea(Shape shape) { Window.alert(“The area is “ + shape.getArea()); } static void runTest() { Shape shape1 = new Circle(3); Shape shape2 = new Square(2); displayArea(shape1); displayArea(shape2); }
这两个示例的源代码看起来几乎相同,除了细微的语法差异、@Override
的使用(这有助于防止错误)以及散布在字段、方法和局部变量上的显式类型名称。
由于额外的类型信息,GWT 编译器能够执行一些优化。GWT 编译器输出的未混淆版本大致如下所示
function runTest() { var shape1, shape2; shape1 = $Circle(new Circle(), 3); shape2 = $Square(new Square(), 2); alert(‘The area is ‘ + shape1.radius * shape1.radius * 3.141592653589793); // [1] alert(‘The area is ‘ + shape2.length * shape2.length); // [2] }
请注意,在上面的 [1] 和 [2] 中,进行了一系列优化。
首先,编译器内联了对 displayArea()
方法的两个调用。这证明是有帮助的,因为它消除了为该方法生成代码的需要。实际上,displayArea()
完全不存在于编译后的脚本中,从而略微缩小了大小。更好的是,内联代码适合在更具体的上下文中使用进一步优化,在这种上下文中,优化器可以获得更多信息。
接下来,优化器注意到 shape1
和 shape2
的类型可以“收紧”为比其原始声明更具体的类型。换句话说,虽然 shape1
被声明为 Shape,但编译器看到它实际上是一个 Circle。同样,shape2
的类型被收紧为 Square。因此,对 [1] 和 [2] 中 getArea()
的调用变得更加具体。前者变成了对 Circle 的 getArea()
的调用,后者变成了对 Square 的 getArea()
的调用。因此,所有方法调用都是静态绑定的,并且所有多态性都被删除。
最后,在删除所有多态性后,优化器将 Circle 的 getArea()
内联到 [1] 中,并将 Square 的 getArea()
内联到 [2] 中。两个 getArea()
方法都从编译后的脚本中消失了,因为它们已被内联。Math.PI
是一个编译时常量,也被简单地内联到 [1] 中。
所有这些优化的好处是速度。GWT 编译器生成的脚本执行速度更快,因为它消除了多个级别的函数调用。
出于显而易见的原因,大型代码库的编写往往侧重于清晰度和可维护性,而不仅仅是纯粹的性能。当涉及到可维护性时,抽象、重用和模块化是绝对的基石。然而,在前面的示例中,可维护性和性能之间存在直接冲突:内联代码速度更快,但没有软件工程师会以这种方式编写它。“可维护性与性能”的二分法当然并非 Java 代码独有。同样真实的是,编写模块化、可维护的 JavaScript 往往会产生比人们期望的更慢、更大的脚本。因此,所有构建复杂 Web 应用程序的开发人员都必须面对这种权衡的现实。关键问题似乎是,一旦您编写了代码库,您的代码库在多大程度上适合优化。在这方面,Java 类型系统提供了很大的杠杆作用,这就是 GWT 编译器能够包含许多类似于此处所示的优化,以帮助减轻您可能最终不得不为设计良好的面向对象代码库支付的“抽象惩罚”的原因。
当然,创建允许开发人员使用 Java 构建基于浏览器的应用程序的环境,只解决了开发周期的一部分。像大多数开发人员一样,我们不会生成完美的代码,因此我们知道我们还必须解决调试 GWT 程序所涉及的问题。
在第一次听到 GWT 时,人们通常假设您会以以下方式使用它
事实上,这根本不是您在 GWT 中的工作方式。您将大部分时间花在 GWT 的托管模式中,该模式允许您像平常一样在普通 Java 调试器(例如,Eclipse)中运行和调试 Java 代码。只有在应用程序编写和调试完成后,您才需要实际将其编译为 JavaScript。因此,每个人对永远无法理解和调试编译后的 JavaScript 的本能恐惧被证明是毫无根据的。
使托管模式成为有效的调试环境的秘诀在于,它不仅仅是在 Java 中调试时模拟浏览器的行为。托管模式直接将真正的 Java 调试与真实的浏览器 UI 和事件系统相结合。托管模式在概念上很简单,并且在单个 JVM(Java 虚拟机)进程中执行
步骤 3,重写 JSNI 方法,是这里真正巧妙的部分。JSNI 是用手写 JavaScript 实现本机 Java 方法的方式,例如
// This is Java! static native Element createDivElement() /*-{ // This is JavaScript! return document.createElement(“div”); }-*/;
CCL 了解 JSNI 并将其重写为如下所示
// A static initializer is introduced in the class. static { hostedBrowser.injectFunc(“createDivElement”, “return document.createElement(\”div\”);”); } // The method becomes all-Java and is no longer native. static Element createDivElement() { return hostedBrowser.invokeInjectedFunc(this, “createDivElement”); };
因此,托管模式 CCL 将 JSNI 方法转换为桩代码,将其调用重定向到托管浏览器的 JavaScript 引擎,而 JavaScript 引擎又驱动真实的浏览器 DOM。
从 JVM 的角度来看,此处描述的所有内容都是纯 Java 字节码,因此可以使用 Java 调试器正常调试。从开发人员的角度来看,他或她可以看到由 Java 源代码驱动的真实浏览器的真实行为,而无需首先将其交叉编译为纯 JavaScript。
这引出了关于托管模式的最令人兴奋的一点:因为它与 Java 代码动态工作,并且不依赖于调用 GWT 交叉编译器(这可能很慢),所以托管模式真的很快。这意味着开发人员可以获得与直接使用 JavaScript 时相同的运行/调整/刷新行为。
因此,GWT 设法将传统优化编译器的优势与动态语言的快速开发周转结合起来。虽然编译技术可能看起来很复杂,但对于优化编译器来说,它实际上是相当标准的。我们在整个过程中遇到的真正技术问题围绕着我们为创建 UI 库所做的努力,这些库同时考虑了特定于浏览器的怪癖,而又不损害大小或速度。换句话说,我们需要提供许多不同的 UI 功能实现——Firefox 的 A 版本,Safari 的 B 版本等等——而不会让编译后的应用程序负担所有变体的联合,从而迫使每个浏览器至少下载一些不相关的代码。我们的解决方案是一种独特的机制,我们称之为延迟绑定,它安排 GWT 编译器生成的不只是一个输出脚本,而是任意数量的输出脚本,每个脚本都针对一组特定的情况进行了优化。
每个编译后的输出都是许多不同实现选择的组合,因此每个脚本都具有(且仅具有)它所需的代码量。值得一提的是,除了处理浏览器差异外,延迟绑定还可以沿着其他轴专门化编译。例如,延迟绑定用于创建每个语言环境的专业化(例如,为什么法国用户必须下载本地化为英语的字符串,反之亦然?)。事实上,延迟绑定是完全开放式的,因此开发人员可以根据自己的需要添加专业化轴。
这种方法确实会创建大量编译后的脚本,但我们认为这是一个受欢迎的权衡:您最终会在廉价的服务器磁盘空间上花费大量优化的脚本,因此,应用程序下载和运行速度更快,使最终用户更快乐。
无论如何,我们在开发 GWT 方面的经验彻底说服我们,没有必要屈服于 Web 开发的典型约束。也就是说,凭借一点创造力和一些专注的努力,我们现在知道确实有可能保留更熟悉的开发环境的丰富性,而不会损害应用程序用户最终享受的体验。问
喜欢它,讨厌它?请告诉我们
[email protected]
Bruce Johnson 在他的母校佐治亚理工学院隔壁的亚特兰大创立了 Google 的工程办公室,目标是生产 Google Web Toolkit 和许多相关工具,旨在使 Web 开发更高效、更有效,并且更有趣。
© 2009 1542-7730/09/0700 $10.00
最初发表于 Queue vol. 7, no. 6—
在 数字图书馆 中评论本文
Pete Hunt, Paul O'Shannessy, Dave Smith, Terry Coatta - React:Facebook 在编写 JavaScript 方面的功能性转变
用户友好的 JavaScript 前端长期存在的讽刺之一是,构建它们通常需要跋涉通过 DOM(文档对象模型),这几乎不以为开发人员友好而闻名。但是现在,由于 Facebook 决定开源其 React 库以构建用户界面组件,开发人员有一种方法可以避免直接与 DOM 交互。
Eric Schrock - 在生产环境中调试 AJAX
JavaScript 语言有着一段奇特的历史。最初只是一个让 Web 开发人员向静态网页添加动态元素的简单工具,后来演变成交付基于 Web 的应用程序的复杂平台的核心。在早期,该语言静默处理失败的能力被视为一种优势。如果图像翻转失败,最好保持无缝的 Web 体验,而不是向用户呈现难看错误对话框。
Jeff Norwalk - 案例研究:转向 AJAX
小型初创公司通常面临着令人眼花缭乱的技术选择:如何交付他们的应用程序,使用哪种语言,是使用现有组件(商业的或开源的)还是自行开发……等等。更重要的是,围绕这些选择的决策通常需要快速做出。本案例研究真实地再现了一家年轻的初创公司在努力在 Web 上部署产品时面临的各种挑战。与许多初创公司一样,这也是一个没有幸福结局的故事。