自从问世以来的二十年里,JavaScript 已经成为 Web 的事实官方语言。在运行时环境的数量方面,JavaScript 胜过所有其他语言。如今市场上几乎所有消费硬件设备都以某种方式支持这种语言。虽然这通常是通过集成 Web 浏览器应用程序来完成的,但许多设备现在也原生支持 Web 视图,作为操作系统 UI(用户界面)的一部分。在大多数平台(手机、平板电脑、电视和游戏机)上,例如,Netflix UI 几乎完全是用 JavaScript 编写的。
尽管 JavaScript 最初只是想成为 Java 的“傻小弟”,4 但它最终成为推动 Web 2.0 演进的关键组成部分。通过 Ajax 的引入,这种演进为 Web 增加了动态元素,创造了现在被认为是理所当然的生机勃勃的社交 Web 概念。今天,随着它通过 Node.js 进入服务器领域,这种语言的影响力继续增长。尽管存在种种缺点,但可以说 JavaScript 已经非常成功地实现了 Sun Microsystems 经常吹捧为 Java 优势之一的“一次编写,到处运行”。
随着越来越多的应用程序逻辑转移到浏览器,开发人员已经开始突破 JavaScript 最初的用途的界限。整个桌面应用程序现在完全用 JavaScript 重建——Google Docs 办公套件就是一个例子。如此庞大的应用程序需要创造性的解决方案来管理加载所需的 JavaScript 文件及其依赖项的复杂性。当引入多变量 A/B 测试时,问题可能会变得更加复杂,而多变量 A/B 测试是 Netflix DNA 的核心概念。多变量测试引入了许多 JavaScript 无法使用原生结构处理的问题,其中之一就是本文的重点:管理条件依赖关系。尽管如此,工程上的独创性使 Netflix 能够以快速且可维护的方式构建高度复杂且引人入胜的 UI。
Netflix 沉浸在 A/B 测试文化中。从电影个性化算法到视频编码,一直到 UI,服务的各个要素都是 A/B 测试的潜在目标。典型的 Netflix 订阅者同时被分配到 30 到 50 个不同的 A/B 测试中是很常见的。以这种规模运行测试提供了灵活性,可以同时尝试完全新颖的方法和多种演进方法。在 UI 中,这一点尤为明显。
虽然许多 A/B 测试是在多个平台和设备上同步启动的,但它们也可以针对特定设备(手机或平板电脑)。这些测试允许在订阅者之间进行截然不同的 UI 体验实验,并且这些测试的活跃生命周期可能从一天到六个月或更长。目标是了解每种设计背后的核心理念的根本差异如何使 Netflix 能够提供更好的用户体验。
Netflix 网站上的 A/B 测试倾向于添加新功能或更改现有功能以增强控制体验。许多网站测试从一开始就被设计为跨分配友好的——换句话说,可以与其他 A/B 测试堆叠。这确保了新引入的功能可以与其他测试共存。因此,虽然一个 Netflix 订阅者的主页表面上可能看起来与另一个订阅者的主页相似,但在整个页面中添加或修改了各种功能片段,使最终产品感觉不同。应该注意的是,测试涵盖了 Netflix UI 的所有部分(HTML、CSS 和 JavaScript),但这里的重点是使用 JavaScript 来缩小问题的范围。
HTTP Archive 估计,2014 年的平均网站包含大约 290 KB 的 JavaScript,分布在 18 个不同的文件中。2 相比之下,今天的 Netflix 主页平均以单个 JavaScript 文件形式交付 150 KB 的有效负载。该文件实际上由 30 到 50 个不同的文件连接在一起组成,它们是否包含在有效负载中取决于 Netflix 推荐算法生成的数百个个性化方面中的一个或多个。这些方面通常可以通过订阅者的 A/B 测试分配、注册国家/地区、观看品味和共享偏好(Facebook 集成)得出,但可以想象得到任何任意逻辑的支持。这些方面充当开关,这是一种可以有效旋转和调整 UI 的方法。这已将网站推向了一个独特的困境:如何以可维护且高性能的方式管理许多不同 UI 的打包和交付。
首先清楚地划清个性化方面及其对 UI 的影响之间的界限是有用的。一个简单的例子可以帮助说明这种关系。假设今天我们要 A/B 测试一个搜索框。对于此测试,我们可能有一个对照组,这是将用户发送到搜索结果页面的传统体验。为了适应用户体验的区域差异,我们还对该对照组进行了轻微的修改,具体取决于订阅者是否位于美国。第一个测试组提供自动完成功能,并且对分配到第 1 组的所有订阅者可用。在这种情况下,分配意味着订阅者被随机选择参与此测试。第二个测试组直接在当前页面上提供搜索结果,方法是在用户键入时显示结果。我们称之为即时搜索,它对分配到第 2 组的所有订阅者可用。这些是三种截然不同的体验或“功能”,每一种体验都由一组非常特定的个性化方面控制。因此,当用户被分配到测试并且他们的方面满足测试的要求时,他们只会看到其中一种搜索体验(见表 1)。页面的其他部分,例如页眉或页脚,可以以类似的方式进行测试,而不会影响搜索框测试。
在这种测试策略下,必须将网站的每个功能部分分隔到称为模块的离散沙盒文件中。模块已成为 JavaScript 中的常见最佳实践,作为一种在离散且有凝聚力的单元中安全地沙盒化和分组相关功能的方法。这对于各种技术原因来说是可取的:它减少了对隐式全局变量的依赖;它可以使用私有/公共方法和属性;并且它允许存在真正的导入/导出系统。导入/导出也为适当的依赖关系管理打开了大门。
在这种情况下,模块背后还有另一个驱动力。它们允许从一个页面到下一个页面的无缝功能可移植性。应该将网页划分为越来越小的部分,直到可以使用现有模块组合新的有效负载。如果必须从以前的模块中分解出功能才能实现这一点,则很可能表明所讨论的模块承担了太多的责任。单元越小,就越容易维护、测试和部署。
最后,使用模块封装功能可以实现在控制 A/B 测试的个性化方面之上构建抽象层。由于测试的资格可以映射到特定功能,而功能又可以映射到模块,因此只需确定订阅者当前活动的每个测试的资格,就可以有效地为给定订阅者解析 JavaScript 有效负载。
模块还允许更先进的技术发挥作用,其中一种技术对于复杂的应用程序至关重要:依赖关系管理。在许多语言中,可以同步导入依赖项,因为运行时环境与请求的依赖项位于同一台机器上。然而,管理浏览器端 JavaScript 依赖项的复杂性在于,运行时环境(浏览器)与其源(服务器)之间被不确定的延迟隔开。网络延迟可以说是当今 Web 应用程序性能中最显着的瓶颈,1 因此挑战在于在给定一组可能因订阅者、请求而异的不确定性约束条件下,找到带宽和延迟之间的平衡。
多年来,Web 社区设计了几种方法来处理这种复杂性,但成功程度各不相同。早期的解决方案只是将所有依赖项都包含在页面上,无论是否会使用该模块。虽然简单且一致,但这会全面惩罚用户,带宽限制通常会加剧已经很长的加载时间。后来的解决方案依赖于浏览器在确定缺少依赖项时向服务器发出多个异步请求。这也有其缺点,因为它会惩罚深度依赖关系树。在这种实现中,具有深度为 N 个节点的依赖关系树的有效负载可能需要多达 N -1 个串行请求才能加载所有依赖项。
最近,AMD(异步模块定义)库(如 RequireJS)的引入允许用户创建模块,然后通过静态分析依赖关系树,在每个页面的基础上抢先生成有效负载。该解决方案结合了之前两种解决方案的优点,通过生成仅包含页面所需内容的特定有效负载,并避免了基于依赖关系树深度的不必要惩罚。更有趣的是,用户还可以完全选择退出静态分析步骤,并退回到异步检索依赖项,或者他们可以采用两者的组合。在图 1 中,名为 foo 的模块有三个依赖项。由于 depC 是异步获取的,因此在页面准备就绪之前会发出 N - 1 个额外的请求(其中 N 是树的深度,在本例中 N=2)。 应用程序的依赖关系树可以使用静态分析工具构建。
AMD 和类似解决方案的问题在于它们假设静态依赖关系树。在运行时环境与源代码位于同一位置的情况下,通常会导入所有可能的依赖项,但仅执行一个代码路径,具体取决于上下文。不幸的是,在浏览器中这样做付出的代价要严重得多,尤其是在大规模情况下。
通过回顾之前的搜索框 A/B 测试,可以更好地可视化该问题,该测试具有三种不同的搜索体验。如果页面标题依赖于搜索框,您如何为给定用户仅加载正确的搜索框体验?可以将所有框添加到有效负载中,然后让父模块添加逻辑,使其能够确定正确的操作过程(参见图 2)。然而,这是不可扩展的,因为它会将 A/B 测试功能的知识泄露到消费父模块中。加载所有可能的依赖项也会增加有效负载大小,从而增加页面加载所需的时间。
第二种选择是及时获取依赖项,但这可能会在 UI 的响应速度中引入任意延迟(参见图 3)。在这种选择中,仅加载所需的模块,但代价是额外的异步请求。如果任何搜索模块还有其他依赖项,则在初始化搜索之前,还会有另一个请求,依此类推。
这两种选择都是不可取的,并且已被证明对用户体验产生重大负面影响。3 它们也没有考虑到某些个性化方面仅在服务器上可用,并且出于安全原因无法暴露给 JavaScript 层。
浏览 Netflix 网站存储库会发现超过 600 个独特的 JavaScript 文件和超过 500 个独特的 CSS(层叠样式表)文件。A/B 测试占这些文件的大部分。可以使用唯一组合公式粗略估计网站处理的不同 JavaScript 有效负载的数量
n!/r!(n-r)!
假设总共有 600 个模块,并估计平均 JavaScript 有效负载包含大约 40 个模块,您将得到以下可能的组合数量
600!/40!(560!) = 433518929550349486086117218185493567650720611537099964754232870
这个数字引人注目,但并非完全诚实。在 600 个不同的模块中,大多数不是独立可选的。其中许多模块依赖于其他常见平台模块,而这些模块又依赖于第三方模块。此外,即使是最大的 A/B 测试通常也影响不到 300 万用户。这似乎是一个庞大的测试人群,但实际上它仍然只占 5000 多万订阅者总数的一小部分。此信息得出了一些初步结论:首先,测试的分配规模不足以均匀分布在整个 Netflix 订阅者群中;其次,独立可选文件的数量极低。这两者都将导致唯一组合的数量显着减少。
与其尝试调整公式,不如分享一些经验数据可能更实用。该网站每周周期部署一个新版本。对于每个构建周期,该网站大约生成 250 万个 JavaScript 和 CSS 有效负载的唯一组合。
鉴于这个庞大的数字,很容易让人想到让浏览器在解析树时获取依赖项。这种解决方案适用于小型代码存储库,因为额外的串行请求可能相对微不足道。然而,如前所述,由于 A/B 测试的规模,网站上的典型有效负载包含 30 到 50 个不同的模块。即使可以利用浏览器的并行资源获取来获得最大效率,潜在的 30 多个请求累积的延迟也足以产生次优体验。在图 4 中,即使是一个深度仅为 5 个节点的显着简化的示例,页面也将在页面准备就绪之前发出四个异步请求。实际生产页面可能很容易有 15 个以上的深度。
由于异步加载依赖项已被排除在此特定情况之外,因此很明显,A/B 测试的规模决定了交付单个 JavaScript 有效负载的选择。如果单个有效负载是解决方案,这可能会给人一种印象,即这些 250 万个唯一有效负载是提前生成的。这将需要在每个部署周期分析所有个性化方面,以便为每种可能的测试组合构建正确的有效负载。然而,如果订阅者和 A/B 测试的增长继续保持正确的轨迹,那么抢先生成有效负载将变得站不住脚。今天的唯一有效负载数量可能是 250 万,但明天可能是 500 万。这根本不是满足 Netflix 长期需求的正确解决方案。
因此,A/B 测试系统需要的是一种在不负面影响用户体验的情况下解析条件依赖关系的方法。在这种情况下,服务器端组件必须介入,以防止客户端 JavaScript 在其自身的复杂性下崩溃。由于我们能够通过静态分析确定所有可能的依赖项,以及触发包含每个依赖项的条件,因此根据我们的要求,最佳解决方案是在生成有效负载时及时解析所有条件依赖关系。
让我们在搜索框测试定义中添加另一列(见表 2)。此表现在代表构建有效负载所需的所有数据的完整抽象。实际上,最后一列映射仅存在于 UI 层中,而不存在于提供 A/B 测试定义的核心服务中。通常,构建此映射取决于测试定义的消费者,因为对于每个设备或平台来说,它很可能是唯一的。然而,就本文而言,将数据可视化在一个地方更容易。
假设有效负载包含图 5 中所示主页的文件。浏览器已请求主页 JavaScript 有效负载。静态分析结果创建了一个依赖关系树,并且有一个表将搜索模块映射到三种可能的实现。由于标题只关心是否包含搜索模块,而不关心其实现,因此我们可以通过确保所有实现都符合特定合同(即公共 API)来插入正确的搜索模块,如图 6 所示。
让单个体验的变体符合类似的公共 API 允许我们通过简单地包含正确的搜索模块来更改底层实现。不幸的是,由于 JavaScript 的弱类型性质,无法强制执行此合同,甚至无法验证任何声称符合所述合同的模块的有效性。做正确事情的责任通常留给创建和使用这些共享模块的开发人员。在实践中,不符合要求的模块不是破坏游戏规则者;前面示例中的“即插即用”替换通常是完全独立的,只有一个入口点,在本例中是暴露的 init() 方法。具有复杂公共 API 的模块往往是共享的通用库,不太可能以这种方式进行 A/B 测试。
还值得注意的是,每个 A/B 体验之间的差异数量通常会驱动是否甚至有可能进行即插即用替换。在某些情况下,新体验的设计旨在有意甚至完全不同,因此公共 API 中存在差异是有意义的。这几乎肯定会增加消费父模块的复杂性,但这是同时运行截然不同的体验的可接受成本。其他策略可以帮助缓解复杂性,例如返回模块存根(参见图 7),而不是尝试真正的即插即用替换。在这种情况下,可以将模块加载器配置为返回带有存根标志的空对象,表明它不是真正的实现。如果所讨论的 A/B 体验几乎没有共同之处,并且从公共 API 中几乎没有或根本没有获益,则此策略可能很有用。
继续以主页有效负载为例,当收到请求主页有效负载的请求时(参见图 8),由于静态分析,我们已经知道订阅者可能收到的所有可能文件。
当我们开始将文件附加到有效负载时,我们可以在搜索框测试表(表 2)中查找该文件是否受到资格要求的支持(即,订阅者是否有资格获得该功能)。此解析将返回一个布尔值,该值用于确定是否附加该文件(图 9)。
通过结合静态分析来构建依赖关系树,然后在请求时使用该树来解析条件依赖关系,我们能够为 Netflix.com 上数百万种独特的体验构建定制的有效负载。重要的是要注意,这只是最终将 JavaScript 交付给最终用户的服务链中的第一步。
出于性能原因,永远不希望通过内联脚本交付整个有效负载。内联脚本无法独立于 HTML 内容进行缓存,因此浏览器端缓存的好处会立即丢失。更可取的是通过指向代表此有效负载的 URL 的脚本标记来交付内容,浏览器可以轻松缓存该 URL。在大多数情况下,这是一个 CDN(内容交付网络)托管的 URL,其源服务器指向生成此有效负载的原始服务器。因此,到目前为止讨论的所有内容都仅仅负责生成有效负载的唯一性。
然而,仅仅使用随机生成的标识符缓存唯一有效负载是不够的。如果服务器有多个实例在运行以进行负载平衡,则这些实例中的任何一个都可能收到对此有效负载的传入请求。如果请求发送到尚未生成(或缓存)该唯一有效负载的实例,则它无法解析该请求。为了解决这个问题,有效负载的 URL 必须是反向可解析的,这一点至关重要;您的服务器的任何实例都必须能够通过简单地查看 URL 来解析唯一有效负载中的文件。这可以通过多种方式解决,最常见的方式是通过直接在 URL 中引用文件名来表示文件,或者通过使用唯一哈希的组合,其中哈希的每个块都可以解析为特定文件。
虽然我们已经针对单个有效负载进行了优化,但仍有可能使用并行浏览器请求来获得额外的性能提升。我们希望避免解包整个有效负载,这迫使我们采取发出 30 多个请求的路线,但我们可以将单个有效负载拆分为两个,第一个包含所有常见的第三方库或共享模块,第二个捆绑包含特定于页面的模块。这将允许浏览器从一个页面到另一个页面缓存通用模块,从而进一步降低用户在网站中移动时页面准备就绪的时间上限。这在 Web 浏览器通常必须处理的带宽和延迟约束之间取得了很好的平衡。
尽管存在种种缺点,但 JavaScript 已成为 Web 的事实语言,并且随着行业的增长,它将继续在无数设备和平台上使用。本文中确定的问题只是冰山一角,尤其是在应用程序规模和复杂性不断增长的情况下。现实情况是,JavaScript 主要仍然是一种客户端语言,其运行时环境主要是浏览器。这意味着大多数旨在解决复杂问题(如条件依赖关系)的库或工具都已从浏览器域入手并尝试解决问题。
将解决问题的方法限制在浏览器内部限制了更丰富的端到端解决方案的可能性。尽管即将到来的 ECMAScript 6 修订版中既有针对原生 JavaScript 模块的规定,也有模块加载器的规定,但它也面临着范围受限的相同问题。即使是当今最完整的模块系统也仅从浏览器域内部解决问题。
正如我们所发现的,通配符约束是浏览器运行时环境与源代码(服务器)的位置“太远”。从历史上看,较大的 Web 开发团队避免开发将服务器和浏览器域紧密集成的解决方案。原因很可能是简单性,或者可能是希望更清楚地分离客户端代码和服务器端代码的关注点。然而,条件依赖关系使这种约束的存在变得痛苦地清晰。任何未能考虑到这一点的解决方案都将不可避免地在性能方面有所损失。因此,对于解析条件依赖关系而言,性能最高的 JavaScript 打包解决方案将需要服务器端组件,至少在可预见的未来是这样。
随着 Node.js 和服务器端 JavaScript 的兴起,我们今天面临的问题很可能会得到更多关注。在许多企业环境中,服务器一直是一个完全独立的领域,由具有不同技能组合的工程师拥有。然而,Node.js 为许多前端工程师打开了通往服务器端的大门,不仅扩展了传统前端工程师的角色,还扩展了可用于解决特定于 UI 的问题的工具集。这种范式转变,以及前端工程师新扩展的角色,确实为未来带来了一些希望。随着前端工程师拥有 UI 服务器,他们可以端到端地控制所有特定于 UI 的问题。
本文讨论的解决方案正是诞生于这种确切的环境,这对于未来来说是一个好兆头。尽管 JavaScript 缺乏原生处理当今 Web 应用程序重型世界中某些问题的约定,但一些创造性的工程设计可以在此期间填补空白。解决诸如条件依赖关系之类的复杂问题使 Netflix 能够继续构建大型、复杂且引人入胜的 UI——所有这些都在 JavaScript 中完成。网站最近采用 Node.js 无疑是对该语言和 Netflix 对开放 Web 的承诺的最好认可。
1. Grigorik, I. 2012. 延迟:新的 Web 性能瓶颈; https://www.igvita.com/2012/07/19/latency-the-new-web-performance-bottleneck/.
2. HTTP Archive. 趋势; http://httparchive.org/trends.php?s=All&minlabel=Nov+15+2010&maxlabel=Jun+15+2014.
3. Nielsen, J. 1993 (2014 年更新)。响应时间:三个重要的限制; http://www.nngroup.com/articles/response-times-3-important-limits/.
4. Severance, C. 2012. JavaScript:10 天内设计一种语言。《计算机》45(2):7-8; http://www.computer.org/csdl/mags/co/2012/02/mco2012020007.html.
喜欢还是讨厌?请告诉我们
Alex Liu 是一位前端工程师,他热衷于在开放 Web 堆栈上构建出色的用户体验。虽然他整个职业生涯都在桌面、浏览器和服务器上构建 JavaScript 应用程序,但他仍然不断地对社区利用 JavaScript 的所有新颖和创造性的方式感到惊讶。他是 Netflix 的高级 UI 工程师,也是领导 Netflix.com 迁移到 Node.js 的核心团队成员。
© 2014 1542-7730/14/0900 $10.00
最初发表于 Queue vol. 12, no. 9—
在 数字图书馆 中评论本文
Shylaja Nukala, Vivek Rau - 为什么 SRE 文档很重要
SRE(站点可靠性工程)是一种职位职能、一种思维模式以及一套工程方法,用于使 Web 产品和服务可靠运行。SRE 在软件开发和系统工程的交叉点运作,以解决运营问题并设计解决方案,从而可扩展、可靠且高效地设计、构建和运行大规模分布式系统。成熟的 SRE 团队可能拥有与许多 SRE 功能相关的明确定义的文档体系。
Taylor Savage - 组件化 Web
当今软件工程中没有哪项任务能像 Web 开发那样艰巨。Web 应用程序的典型规范可能如下:该应用程序必须跨各种浏览器工作。它必须以 60 fps 的速度运行动画。它必须立即响应触摸。它必须符合特定的一组设计原则和规范。它必须在几乎所有可以想象到的屏幕尺寸上工作,从电视和 30 英寸显示器到手机和手表表面。它必须是精心设计且长期可维护的。
Arie van Deursen - 超越页面对象:使用状态对象测试 Web 应用程序
Web 应用程序的端到端测试通常涉及使用诸如 Selenium WebDriver 之类的框架与网页进行复杂的交互。隐藏此类网页的复杂性的推荐方法是使用页面对象,但首先需要回答一些问题:在测试 Web 应用程序时,应该创建哪些页面对象?应该在页面对象中包含哪些操作?给定你的页面对象,应该指定哪些测试场景?
Rich Harris - 消除入门障碍
在 Web 开发领域,一场战争正在进行。一方是工具制造者和工具使用者的先锋,他们热衷于摧毁过时的旧观念(在这个圈子里,“旧”意味着任何在一个多月前在 Hacker News 上首次亮相的东西)以及关于转译器及诸如此类的激烈辩论。