菜单 学习猿地 - LMONKEY

VIP

开通学习猿地VIP

尊享10项VIP特权 持续新增

知识通关挑战

打卡带练!告别无效练习

接私单赚外块

VIP优先接,累计金额超百万

学习猿地私房课免费学

大厂实战课仅对VIP开放

你的一对一导师

每月可免费咨询大牛30次

领取更多软件工程师实用特权

入驻
0
0

我们做了大量工作,可自动化 UI 测试依旧实现不了

原创
05/13 14:22
阅读数 377

对开发者而言,测试的重要性不言而喻。在发布新功能前,开发者需要确保已有功能有效,这就需要将每个发布版本给到 QA 团队执行人工回归测试。然后,测试人员或 QA 团队花费数天时间执行脚本以寻找 Bug。本文是 Steven Lemon 所在团队遇到的手工编写自动化 UI 测试的问题总结与反思,希望给广大开发者借鉴和启发

我们做了大量工作,可自动化UI测试依旧实现不了

为什么需要自动化 UI 测试?

随着时间的推移,软件增加的新功能会越来越多,脚本规模越来越大,执行人工测试的时间越来越长。对于人工测试的依赖开始变得棘手,因此,开发人员开始寻找替代方案,自动化 UI 测试开始被注意,这会用自动化框架代替人工继续运行相同的回归测试脚本。毕竟,人工测试的缺点已经非常明显,比如:

  • 人工回归测试是一项繁琐的工作,每个人都乐意看到替代方案;
  • 自动化 UI 测试解放了 QA 团队针对临时的和探索性案例的测试时间;
  • 人工回归测试需要花费很长时间才能完成,很小的延迟就会让发布面临风险。也许需要重新测试,或者开始时间被向后推迟几天,或者回归环境需要同时给 2 个不同的发布版本共享;
  • 发布节奏受到人工回归测试的限制。两天以上的人工回归测试意味着最好的情况下能够一个月发布两次。而且,开发者需要一次性发布所有东西。要么全部发布,要么什么都发布不了,因为需要将所有东西一起测试;
  • 自动化测试是可视化的,可以在特定设备上运行,并展示给用户;
  • 自动化意味着可以一边开发一边进行回归测试,减少等待时间。

当然,即便人工测试有很多缺点,手工编写自动化测试也不是唯一的解决方案,购买一款商业工具也可以创建并管理测试。或者,也许框架自带一个内置的自动化方案。那么,这篇文章不适合你。或者,你可以考虑使用 Selenium 或者 Appium 之类的工具来手写测试。但是,经过数月的尝试,我们团队决定放弃其他的测试方式,因为那些被证明对于我们的测试套件、架构设计和期望来说并不是一个好的选择。在这个过程中,我们吸取了很多教训,遇到了许多本应该提前考虑到的问题。

遇到了哪些问题?

应用程序架构

根据应用程序的组织结构以及增长趋势,你可能会发现自动化需要花费不太合理的时间来设置。

UI 自动化是编写测试步骤的一部分,也是设置测试基础设施的一部分。如果遵循 Page Object Model 模式,那么你会为应用程序中的每个页面和控件创建模型,因而测试可以找页面或控件上的元素并与之交互,需要编写的基础设施代码量取决于项目本身。你是否有一些页面有许多不同的输入,或者许多工作流分布在许多特定的页面上?是否有一个可复用的控件库,还是每个控件都随着 UI 的变化而定制?在此之前,你开发应用程序的方式决定了需要花费多少精力来编写测试基础设施。反过来,这也影响了需要多长时间来编写自动化 UI 测试。

预期效果

在开始之前,要想清楚你需要自动化测试的目的,确定好预期效果。如果是对回归和生产中先前发现的 Bug 进行记录,你期望用自动化 UI 测试捉到其中的多少 Bug 呢?

有许多情况,UI 测试是发现不了的,比如:

  • 不在人工回归脚本路径内的问题。如果不是在测试步骤中显式包含的 Bug,多久会被发现;
  • 功能及其测试都不正确的时候;
  • 边缘情况或者不常见场景的 Bug;
  • 在单元测试和集成测试中能够捕获到的 Bug;
  • 在应用程序中无法看到结果的任何操作。避免只是为了自动化测试而隐藏应用程序中的数据;
  • 可视化错误;
  • 性能问题;
  • 任何太复杂和难以自动化的测试案例。

自动化测试在流程中扮演什么角色?它们是如何支持 QA 团队和回归流程的?也许,你的目的是解放 QA 的时间,而不是发现 Bug。你可以跳过 QA 能够覆盖的区域,执行更详尽的临时性和探索性测试。你期望自动化 UI 测试能够发现的内容,应该告诉你选择包含哪些部分以及计划为多少部分编写测试。

期望的回报

比较编写测试的时间和可能节省的时间是比较容易的。比如自动化一个功能可能会花费 200 个小时,而这为每次发布带来的时间节省可能是 20 分钟。所以,在开始之前,开发人员需要想清楚期望得到的回报,以及愿意为此花费的时间。

负责编写测试的人员

你可能会期望通过使用 Page Object Model 模式,开发者可以编写测试基础设施来给 QA 用来编写测试。我们的经验并不是这样的,开发者需要同时编写基础设施和测试。

  • 测试基础设施可能并不能在多个测试用例间复用。如果不复用,你会需要一边编写支持代码一边编写测试代码;
  • 编写测试可能需要对应用程序做许多更新;
  • 自动化框架并没有提供足够多的信息来知道测试失败是由于基础设施还是测试用例;
  • 如果 QA 团队缺乏编码或自动化经验,你可能很难让这个框架易用;
  • 测试用例需要太多应用程序内部的知识;
  • 测试的脆弱性导致开发人员需要不断修复测试基础设施;

当践行自动化测试理念时,请让所有有意扩展和维护测试的人参与进来。确保你正在做的事情适合他们的技术栈并且理解你的应用程序。

干净的,用于测试的数据集

刚开始的时候,你可能使用平时开发所用的同一个数据库。然而,不久之后,你就会花费越来越多的时间来处理数据集。

  • 你需要发现先决条件而不是预先设置好,或者它们应该很容易创建;
  • 随着更多的数据被创建,你的 UI 会发生变化。例如,额外的数据将一个元素挤出页面,测试用例会失败,因为它们不能与之交互;
  • 同一个测试可能会在同一分钟内重复运行。你需要检查一个元素是由当前测试创建的还是由先前测试创建的;
  • 测试用户遇到了预料之外的状态而导致测试用例失败,需要人工干预或者测试用例预先筛选处于每个无效状态的用户;
  • 对数据集的彻底更改会改变你一直使用的数据。例如,你可能定期清除开发数据库或者从另一个系统导入数据来刷新;
  • 并行的测试运行或者开发者使用同一个数据库导致的意外交互和测试失败;
  • 针对多个环境运行测试用例。在开发时,针对 dev 数据库而在验收时针对回归环境。

每一个测试都有各种需要设置的先决条件。依赖自动化测试来安装它们的先决条件会将每一个测试转变成一条长动作链。这些额外的步骤不仅会使测试的编写和运行速度更慢,还会使测试更加脆弱,而且使跟踪失败点更困难。使用干净的数据集,可以拥有已知的测试条件和测试用户,类似于如何在单元测试中使用母对象。

开发人员想要一个可以重置并填充固定数据的数据库。如果还没有这种数据库,那么将需要大量新的基础设施:一个新的数据、一个填充合法测试数据的工具、指向新数据库的 API 以及用于部署这个环境的构建管道。

UI 框架和组件

每一个测试都需要考虑 UI 组件能做的所有事情,比如,不能在测试期间重置数据库等。

  • 我们点中某个元素,则测试通过;
  • 后续运行向这个列表增加更多选项,将目标挤出屏幕,我们需要更新测试在点击之前跳到这个元素;
  • 这个列表变得如此长以至于 UI 虚拟化,目标不再存在于页面上,不能再跳到这个元素,而是需要缓慢滚动来在列表中搜索目标;
  • 列表中的重复显示,需要指出哪个元素是当前测试的目标;
  • 另一个元素变大,使整个列表处于屏幕之外。在与之交互前,需要先滚动到这个列表;
  • 之前运行的测试由于失败没有完成,而留下测试实体处于隐藏整个列表的状态。

自动化测试经常失败

自动化测试经常失败,而且通常,并不知道失败的原因,因为可能的情况有很多,比如:

  • 使用的自动化框架不能获取屏幕上的元素;
  • 自动化框架不能识别应用程序是否已经启动;
  • 测试驱动不能连接;
  • 遇到了一个 UI 组件的边缘情况;
  • 一个元素被挤出屏幕,自动化框架不能与之交互;
  • 测试在不同的屏幕尺寸和分辨率上的运行不同,因为不同的元素可能在屏幕上,也可能不在屏幕上;
  • 每次测试运行,没有一个干净的、独立的数据实例而导致前面提到的所有问题。

我们使用 Appium 和 WinAppDriver,而对于大部分失败测试,我们得不到有用的错误信息,没有日志和异常跟踪栈,比如因为一个元素找不到而导致测试失败,但是我们没办法知道是哪个元素找不到。更糟糕的是,由于失败是断断续续的,而且可以是特定设备或环境,因此需要花费很长的时间来确定失败原因。

解决测试不稳定性的一种方案是运行每个测试直到它通过。这引起了一些问题:测试周期更长;更难从测试中及时获取反馈等。其次,这使得编写新的测试用例更困难,而且可能要等待 10 分钟或更久来测试单个变化。理想情况下,持续跟踪测试用例,并将收到的模糊错误信息分组。当没有可用日志时,知道测试用例什么时候开始是一个很关键的线索。

为了跟踪测试的不稳定性,我们维护了一长串可能导致错误的因素,这包括测试套件和应用程序中所有的边缘测试用例和 UI 交互。创建这个列表不仅花费很长时间并需要很多尝试和错误,而且还增加了其它开发者共享测试套件的学习曲线。

重构困难

自动化 UI 测试很难重构。测试运行可能需要数个小时,使得很难获取反馈并进行修正。一些测试可能严重依赖精心安排的时间点,一旦偏离则可能发生任何事情。

由于自动化测试对团队来说可能比较陌生,你需要面临由于开发者尝试找出且争取在测试用例中应用的最佳策略而导致的多种不同方案。拥有不同的方案使得参与项目的新人很难分辨哪个是最佳方案。每当对应用程序的 UI 做出改变时,都会产生后果。你可能发现自己需要修改大量自动化 UI 测试,每一个都需要不同的实现。

人的因素

当引入一个新工具、技术或者流程到一个团队,需要考虑各种人的因素:

  • 使用新工具的体验如何?是沮丧还是缓慢?
  • 有人愿意成为新技术的捍卫者吗?他们离开了,谁来接管?
  • 当工具延迟时怎么办?当你没有时间时,自动化测试会被放弃么?业务能容忍为了给新功能增加自动化测试的额外时间吗?
  • 如果这个工具在团队中的声誉不好怎么办?
  • 每一个人都认同编写自动化测试的价值,还是认为这是在浪费时间?

正如已经总结的,关于这些测试的价值有很多潜在痛点和问题。如果没有解决办法,你的测试套件不可能持续很久。

注意事项

也许,创建自动化 UI 测试看起来不是一个有吸引力的选项。然而,你也不想花费很长时间来进行人工回归测试,那么有哪些其它选项吗?

不要尝试自动化所有人工回归脚本

事实上,对于自动化测试所能涵盖的范围,并不是原来所用的全部人工回归套件,因为有些组件太过复杂或者太耗时间,是不值得进行自动化的,比如:

  • 长链动作无法拆分。UI 测试的不可靠性使得通过一次运行完成所有测试具有挑战性;
  • 测试与其他的应用程序交互;
  • 检查 PDF 和其它生成文件的输出;
  • 与 Windows 系统或者 Windows 文件系统交互的测试任务;
  • 后续运行将有不同结果的测试:测试可能会被之前运行的测试结果影响。人工回归可能两周发生一次,而自动化 UI 测试可能一分钟或一小时重试同一个测试许多次,增加了冲突的机会。
  • 如果测试运行失败或者半途崩溃可能导致应用程序处于不一致的状态。这时,或许需要人工进行干预来修复。
  • 如果对应用程序某个部分中显示的数据没有足够的控制,则很难设置测试的先决条件。测试人员是否必须在应用程序中寻找匹配的数据,而不是创建数据或者直接导航到相应的场景?

注意:别太教条,没必要强制在不适合的场景中使用 UI 自动化测试。这些测试不仅很难编写,也很不可靠并很难维护。在你开始之前,最好认识清楚哪些部分可以自动化,任何将测试自动化的开发者都有说“不”的自由。

先补充测试金字塔的其它部分

任何单种测试不能提供百分百的覆盖率。你想要各种级别特异性和独立性的测试,金字塔底层的众多特定独立的测试。然后,越来越少的测试变得更不具体且覆盖的应用程序更多。单元测试在底层,然后是集成测试,再然后是端对端测试。

金字塔的每一层都协调配合,拥有不同的优势和弱势。

如果可能,我们将在单元测试和集成测试覆盖尽可能多的部分。这些测试易于编写,提供更多详细反馈,而且可以在开发期间运行。单元测试更适合覆盖边缘测试案例和错误场景。根据应用程序,自动化 UI 测试可以覆盖 UI 逻辑,而单元测试可能不能覆盖。自动化 UI 测试还可以保证应用程序中的多个部分如预期那样工作。

没有一种测试可以提供完全的测试覆盖。如果已经有单元测试和集成测试,可能已经覆盖了人工回归测试脚本的步骤。编写完全的自动化 UI 测试来覆盖已经覆盖的东西是价值不大的。与其用自动化 UI 测试完全取代人工回归测试,为什么不用其他各种类型的测试的结合来取代它们呢?

其他选择

你是编写 UI 自动化来测试 UI,还是用来促进端对端测试?如果不需要测试 UI 层,那么“皮下”测试(subcutaneous testing,指在 UI 层之下执行的测试)可能是一个更好的选择。这个方案允许在 UI 层之下执行端对端测试。与其点击按钮或者填写文本框,开发者可以调用事件处理器并直接在视图模型上设定公开属性。这个方案避免了与 UI 交互和使用自动化框架的困难。这个方案的缺点是,根据应用程序使用的技术,可能没有太多特定的指南。我们的应用程序是用 UWP 编写的,因此不得不自己找方法在模拟 UI 的测试框架中运行。一旦开始生效,它会被证明比自动化 UI 测试更快且更易用。

结束语

UI 自动化测试的潜在优势令人兴奋:发现 Bug,解放 QA 时间,消除人工回归测试,开发者在过程中获取反馈。然而,与任何新技术一样,需要一些提前调查。在开始手动编写自动化人工回归测试套件之前,上面已经提出了一些问题。期望发现的回归 Bug、应用程序的架构或期望编写和维护测试用例的人可能都不是很适合。这个过程存在一些挑战:处理不可靠数据、UI 可能做的工作要比预期多、自动化框架的不稳定性和糟糕的错误信息,谁负责编写测试用例以及谁在进展不顺利时提供支持。最后,是否已经将手工编写测试与其它商业产品和写更多集成测试、单元测试的方式进行了比较,或者与编写“皮下”测试进行了比较。

原文链接: Our team’s troubles with hand-written automated UI tests> 本篇文章主要讲述 V8 如何选择 JavaScript 值在内存中表现形式的优化方式,以及解释 React core 在 V8 中出现的性能断崖。

在这之前,我们讨论过 JavaScript 引擎如何通过使用内联缓存 (Inline Caches) 和形状 (Shapes) 优化 object 和数组的访问, 然后我们还特别展开讲解了引擎是如何加快原型属性的访问速度。这篇文章主要讲述 V8 如何选择 JavaScript 值在内存中的表现形式的优化方式, 和这些优化是如何影响 Shape 机制的——这有助于解释近期发生的一个 React core 在 V8 中出现的性能断崖 (performance cliff) 。

看文吃瓜:React遭遇V8性能崩溃的故事

JavaScript 类型

每个 JavaScript 值的类型都一定是 8 个不同类型中的一个: Number, String, Symbol, BigInt, Boolean, Undefined, Null, 和 Object。

除了一个显著的例外,这些类型都可以通过 typeof 操作符来查看:

复制代码

typeof 42;// → 'number'typeof 'foo';// → 'string'typeof Symbol('bar');// → 'symbol'typeof 42n;// → 'bigint'typeof true;// → 'boolean'typeof undefined;// → 'undefined'typeof null;// → 'object' ?typeof { x: 42 };// → 'object'

typeof null 返回了’object’,并不是 ‘null’, 尽管 Null 他自己就是一个类型。为了理解其中的缘由,我们可以先考虑把 Javascript 中的类型分成两组:

  • 对象 (i.e. the Object type)。
  • 基本类型 (i.e. 所有非对象的值)。

就此来说,null 意味着 " 不存在的对象 " 的值, 而 undefined 代表着 " 不存在 " 的值。

看文吃瓜:React遭遇V8性能崩溃的故事

跟着这条思路,Brendan Eich 按照 Java 的精神将 JavaScript 中 typeof 运算设计为任何值都返回’object’,比如所有的对象和 null。这就是为何尽管规范中有个单独的 Null 类型,但是 typeof null === 'object’依然成立。

看文吃瓜:React遭遇V8性能崩溃的故事

类型表达

JavaScript 引擎必须能在内存中表达任意的 JavaScript 值。然而,有一点值得注意的地方,那就是 JavaScript 值的类型和值本身在 JavaScript 引擎中是分开表达的。

比如 42 这个值,在 JavaScript 中是一个 number 类型。

复制代码

typeof 42;// → 'number'

我们有很多种方法在内存中表达 42 这个整形数值:

看文吃瓜:React遭遇V8性能崩溃的故事

ECMAScript 将 number 数据标准化位 64 位浮点数,通常叫 双精度浮点数 和 Float64。然而这并不代表 JavaScript 引擎将 number 类型的数据一直都按照 Float64 的形式存储 – 这样做的话会非常的低效!引擎可以选择其他的内部表达形式,直到确定需要 Float64 特性的情况出现。

现实中 JavaScript 应用的大部分 number 类型都是有效的 ECMAScript 数组下标,比如说在 0 到 2³²−2 之间的整数。

复制代码

array[0]; // Smallest possible array index.array[42];array[2**32-2]; // Greatest possible array index.

JavaScript 引擎可以为这类 number 选择一个在内存中最佳的表达方式来优化根据下标访问数组元素操作的性能。对于处理器的访问内存操作来说,数组下标必须是一个能用补码形式表达的数字。用 Float64 的方式来表达数组下标是非常浪费的,因为引擎在每次访问数组元素时不得不在 Float64 和补码之间反复转换。

32 位补码表达形式不只在数组操作中很实用。一般来说,处理器执行整型操作要比浮点型操作快非常多。这就是下面这个例子中,第一个循环要比第二个循环快 2 倍的原因。

复制代码

for (let i = 0; i < 1000; ++i) {  // fast ?} for (let i = 0.1; i < 1000.1; ++i) {  // slow ?}

这种情况在运算操作中也一样。在下面这个例子中,取模运算的性能取决于你的操作数是否为一个整型数据。

复制代码

const remainder = value % divisor;// Fast ? if `value` and `divisor` are represented as integers,// slow ? otherwise.

如果所有的操作数都是整型,CPU 可以非常高效地计算出结果。当除数为 2 的指数时,V8 还有个额外的优化。如果操作数是浮点类型,这个计算将会复杂很多并且花费更长时间。

因为整型操作一般执行速度比浮点型要快非常多,看起来引擎应该一直使用补码形式来表达所有的整型数据和整型数据的运算结果。不幸的是,这样是违反 ECMAScript 规范的!ECMAScript 是用 Float64 来标准化的,所以 某些整型操作的结果实际上是浮点型。在下面的例子中,这点对 JS 引擎能产出正确结果很重要。

复制代码

// Float64 的安全整型范围为 53 位,// 超过这个范围你将丢失精度。2**53 === 2**53+1;// → true // Float64 支持表达 -0,所以 -1 * 0 必须等于 -0// 但在补码形式中 -0 是没办法表达的。-1*0 === -0;// → true // Float64 可以表达因为除 0 而产生的 Infinity。1/0 === Infinity;// → true-1/0 === -Infinity;// → true // Float64 还能表达 NaN。0/0 === NaN;

虽然等号左边的值都是整数,但等号右边的全是浮点数。这就是使用 32 位二进制补码无法正确执行上述操作的原因。JavaScript 引擎不得不特殊处理以确保整型计算能适当地回落到复杂的浮点结果。

对于小于 31 位的有符号整型,V8 有个被称为 Smi 的特别的表达方式。任何非 Smi 的数据将会被表达为 HeapObject,即一些在内存中的实体的地址。对于 number 来说,我们使用一个特殊的 HeapObject,或者叫 HeapNumber,来表达不在 Smi 范围内的 number 数据。

复制代码

-Infinity // HeapNumber-(2**30)-1 // HeapNumber  -(2**30) // Smi       -42 // Smi        -0 // HeapNumber         0 // Smi       4.2 // HeapNumber        42 // Smi   2**30-1 // Smi     2**30 // HeapNumber  Infinity // HeapNumber       NaN // HeapNumber

正如上面例子所展示,一些 JavaScriptnumber 被表达为 Smi,而其他的表达为 HeapNumber。V8 对 Smi 做了特殊的优化,因为在现实的 JavaScript 程序中小整型数据实在是太常用了。Smi 不需要在内存中为其分配专门的实体,而且通常可以使用快速的整型运算。

这里最重要的一点是,作为一个优化点,即便是一样的 JavaScript 类型但是在内存中表达形式可以完全不一样。

Smi vs. HeapNumber vs. MutableHeapNumbe

接下来说下这具体是如何执行的。首先你有如下的一个对象:

复制代码

const o = {  x: 42, // Smi  y: 4.2, // HeapNumber};

x 的值 42 可以被编码为 Smi,所以它可以被存储在对象自身中。而 y 的 4.2 需要一个分开的实体来保存这个值,然后这个对象指向那个实体。

看文吃瓜:React遭遇V8性能崩溃的故事

现在,我们执行下接下来的 JavaScript 片段:

复制代码

o.x += 10;// → o.x is now 52o.y += 1;// → o.y is now 5.2

在这个例子中,由于新值 52 也是 Smi,所以 x 的值可以直接被替换。

看文吃瓜:React遭遇V8性能崩溃的故事

另一方面,y=5.2 的新值不属于 Smi,而且和之前的 4.2 也不同,所以 V8 分配了一个新的 HeapNumber 实体并将地址赋值给 y。

看文吃瓜:React遭遇V8性能崩溃的故事

HeapNumber 是无法被修改的,因为这样可以进行某些优化。举个例子,如果我们把 y 赋值给 x:

复制代码

o.x = o.y;// → o.x is now 5.2

那么我们现在只需要指向相同的 HeapNumber 而不必为相同的值分配一个新的对象。

看文吃瓜:React遭遇V8性能崩溃的故事

HeapNumber 不可变机制不好的一面是频繁修改非 Smi 范围内的属性将会变得缓慢。就像下面这个例子:

复制代码

// Create a `HeapNumber` instance.const o = { x: 0.1 }; for (let i = 0; i < 5; ++i) {  // Create an additional `HeapNumber` instance.  o.x += 1;}

第一行代码将会创建一个 HeapNumber 实例并初始化其值为 0.1。循环体将其改为 1.1,2.1,3.1,4.1 直到 5.1,总共创建了 6 个 HeapNumber 实例,其中 5 将会在循环结束后成为内存垃圾。

看文吃瓜:React遭遇V8性能崩溃的故事

为了避免这个问题,V8 提供了一个优化更新非 Smi 的 number 字段的方法。当一个 number 字段保存了一个不再 Smi 范围内的值时,V8 在该对象的 shape 中将其标记为 Double 字段,并且分配一个被称为 MutableHeapNumber 的对象以 Float64 编码形式保存其值。

看文吃瓜:React遭遇V8性能崩溃的故事

当该字段变化时,V8 不再需要去重新分配一个新的 HeapNumber,而是只需要更新 MutableHeapNumber 中的值即可。

看文吃瓜:React遭遇V8性能崩溃的故事

但是,这种方法也有个问题。因为 MutableHeapNumber 的值可以修改,所以它们不应该被传递出去。

看文吃瓜:React遭遇V8性能崩溃的故事

举个例子,如果你将 o.x 赋值给另外一个变量 y,你不会希望 y 值的改变也带来 x.o 的改变 – 这是违反 JavaScript 规范的!所以当 o.x 被访问时,这个数字必须得重新装箱成一个正常的 HeapNumber,然后再赋值给 y。

对于浮点数来说,V8 在幕后完成了上面提到的所有“装箱”操作。但是因为小整型数据也使用 MutableHeapNumber 机制是非常浪费的,因此 Smi 是一个更加有效的表达方式。

复制代码

const object = { x: 1 };// → no “boxing” for `x` in object object.x += 1;// → update the value of `x` inside object

为了避免低效,我们为了小整型数字所要做的事情就是将 shape 上的字段标记为 Smi 表达,然后只要满足小整型范围的更新就只执行数值替换。

看文吃瓜:React遭遇V8性能崩溃的故事

Shape 的弃用和整合

那么如果一个字段一开始存的是 Smi 数据,但是后面又被更新成了一个小整数范围之外的数据该怎么办?比如下面这个例子,2 个结构相同的对象,其中 x 都为 Smi 表达的初始值:

复制代码

const a = { x: 1 };const b = { x: 2 };// → objects have `x` as `Smi` field now b.x = 0.2;// → `b.x` is now represented as a `Double` y = a.x;

那么一开始这两个对象都指向同一个 shape,其中 x 被标记为 Smi 表达。

看文吃瓜:React遭遇V8性能崩溃的故事

当 b.x 修改为 Double 表达时,V8 分配了一个新的 shape 而且其中的 x 被指定为 Double 表达,并指向空 shape。V8 也会为属性 x 分配一个 MutableHeapNumber 来保存这个新的值 0.2。然后当再更新对象 b 指向这个新的 shape,并更改对象中的槽以指向偏移 0 处的先前分配的 MutableHeapNumber。最后,我们将旧的 shape 标记为废弃的并且将其从转变树 (transition tree) 中摘除。这是通过’x’从空 shape 到新创建的 shape 的转变 (transition) 来完成的。

看文吃瓜:React遭遇V8性能崩溃的故事

此时我们还不能完全移除旧的 shape,因为它还在被 a 所使用,而且遍历内存去寻找所有指向了旧 shape 的对线并立刻更新他们的将是非常昂贵的。相反,V8 使用了一个偷懒的办法:任何对 a 的属性访问或者赋值都会先将其迁移到新的 shape 上。这个思路最终将使得废弃的 shape 变得不可抵达然后被垃圾回收器删除。

看文吃瓜:React遭遇V8性能崩溃的故事

如果更改表示的字段不是链中的最后一个字段,则会出现更棘手的情况:

复制代码

const o = {  x: 1,  y: 2,  z: 3,}; o.y = 0.1;

在这个例子中,V8 需要去寻找一个被称为 分离 shape(split shape) 的 shape,即指相关属性引入之前链中的最后一个 shape。在这里我们修改了 y,所以我们需要找到最后一个没有包含 y 的 shape,在我们这个例子中就是引入了 x 的那个 shape。

看文吃瓜:React遭遇V8性能崩溃的故事

从分离 shape 开始,我们为 y 创建了一个可以重放所有之前的转变的新转变链 (transition chain),但是其中’y’被标记成 Double 表达。然后我们使用这个新的转变链并将旧的子树标记为废弃的。在最后一步我们把实例 o 迁移到了新的 shape,并使用了 MutableHeapNumber 来保存 y 的值。这样,新的对象就不会使用老的路径,而且一旦旧 shape 的引用小时,树中废弃的 shape 的那部分就会消失。

扩展性和完整性级别的转换

Object.preventExtensions() 可以阻止将新属性添加到对象上。如果你尝试去这么做,它将会抛出一个异常。(如果你不在严格模式下,异常不会抛出但也不会发生任何修改)

复制代码

const object = { x: 1 };Object.preventExtensions(object);object.y = 2;// TypeError: Cannot add property y;// object is not extensible

Object.seal 和 Object.preventExtensions 作用相同,但是它还会将所有属性标记为不可配置,意味着你不能删除它们,或者改变它们的可枚举性,可以配置性或者可写性。

复制代码

const object = { x: 1 };Object.seal(object);object.y = 2;// TypeError: Cannot add property y;// object is not extensibledelete object.x;// TypeError: Cannot delete property x

Object.freeze 也和 Object.seal 作用相同,但是它还会通过将属性标记为不可写来阻止现有属性被修改。

复制代码

const object = { x: 1 };Object.freeze(object);object.y = 2;// TypeError: Cannot add property y;// object is not extensibledelete object.x;// TypeError: Cannot delete property xobject.x = 3;// TypeError: Cannot assign to read-only property x

让我们考虑下这个具体的例子,两个对象都有一个属性 x,然后我们阻止任何对第二个对象进一步的扩展。

复制代码

const a = { x: 1 };const b = { x: 2 }; Object.preventExtensions(b);

如我们之前所知,一切从空 shape 转变到一个包含属性’x’(以 Smi 形式表达) 的新 shape 开始。当我们阻止了对 b 的扩展,我们对新的 shape 进行了一个特殊的转变 – 将其标记为不可扩展。这个特殊的转变没有引入任何新的属性 – 它实际上只是个标记。

看文吃瓜:React遭遇V8性能崩溃的故事

注意我们为何不能直接更新包含 x 的 shape,因为它被另外一个对象 a 所引用,而且依然是可扩展的。

React 的性能问题

让我们把所前面提到的东西放到一起,用我们所学的东西去理解这个 issue 。当 React 团队对一个真实的应用进行性能测试的时候,他们发现了一个影响 React 核心的奇怪的 V8 性能悬崖。这里有个简单的 bug 重现:

复制代码

const o = { x: 1, y: 2 };Object.preventExtensions(o);o.y = 0.2;

我们有个包含了 2 个 Smi 表达的字段。我们阻止了所有其他对这个对象的扩展,然后最终强制第二个字段变成 Double 表达。

如我们之前所学,它大致创造了以下配置:

看文吃瓜:React遭遇V8性能崩溃的故事

所有属性都被表达为 Smi 形式,而且最终的转变是将这个属性标记为不可扩展的扩展性转变。

现在我们需要将 y 修改为 Double 表达,意味着我们需要重新开始找到分离 shape。在本例中,这是引入了 x 的那个 shape。但是现在 V8 有点困惑,因为分离 shape 是可扩展的但当前 shape 是被标记成了不可扩展的,而且 V8 不能确切地知道如何正确地重放转变。所以 V8 实际上直接放弃理解这件事,与此相反地创建了一个和现有的 shape 树没有任何关联的独立 shape,也不会共享给任何其他对象。把它想象成孤立的 shape:

看文吃瓜:React遭遇V8性能崩溃的故事

你可以想象到如果有大量的这样的对象出现这种情况将是非常糟糕的,因为这会使整个 shape 系统变得无用。

这 React 的例子中,实际上发生的是:每个 FiberNode 有几个字段,用来在统计性能时保存一些时间戳。

复制代码

class FiberNode {  constructor() {    this.actualStartTime = 0;    Object.preventExtensions(this);  }} const node1 = new FiberNode();const node2 = new FiberNode();

这些字段(比如说 actualStartTime) 被初始化为 0 或者 -1,因此一开始按照 Smi 表达。但是后面实际上存进来的是从 performance.now() 返回的浮点型时间戳,导致这些字段变成 Double 表达,因为这些数据不满足 Smi 表达的要求。最重要的是,React 还阻止了对 FiberNode 实例的扩展。

将上面的例子简化如下:

看文吃瓜:React遭遇V8性能崩溃的故事

这里有 2 个实例共享一个 shape 树,一切运转如我们所想。但是接下来,当你储存这个真实的时间戳,V8 开始困惑于寻找分离 shape:

看文吃瓜:React遭遇V8性能崩溃的故事

V8 指派了一个新的孤立 shape 给 node1,然后稍后 node2 也发生了同样的情况,导致了两个孤岛,每个孤岛都有着自己不相交的 shape。很多真实的 React 应用不止有 2 个,而是有超过成千上万个 FiberNodes。如你所想,这种情况对 V8 的性能来说不是什么好事。

幸运的是,我们已经在 V8 v7.4 中修复了这个性能悬崖,而且我们正在想办法让字段表达的改变更加高效来消除任何潜在的性能悬崖。在这个 fix 后,V8 现在做了正确的事:

看文吃瓜:React遭遇V8性能崩溃的故事

这两个 FiberNode 实例指向了不可扩展且 actualStartTime 为 Smi 表达的 shape。当第一个对 node1.actualStartTime 的赋值发生时,一个新的转变链被创建并且之前的转变链被标记为废弃的:

看文吃瓜:React遭遇V8性能崩溃的故事

注意为何扩展性转变现在会正确的在新链中重放。

看文吃瓜:React遭遇V8性能崩溃的故事

在对 node2.actualStartTime 赋值后,所有的节点引用了新的 shape,而且转变树中废弃的部分可以被垃圾回收器清理。

注意:也许你会认为 shape 的废弃 / 迁移很复杂,那你是对的。实际上,我们怀疑这个机制导致的问题(在性能,内存占用和复杂度上)比它带来的帮助要多,尤其是因为使用指针压缩,我们将无法再使用它来把 double-valued(双精度?) 字段内联到对象中。所以,我们希望完全移除掉 V8 的 shape 废弃机制。You could say it’s puts on sunglasses being deprecated. YEEEAAAHHH…(不知道该怎么翻译了 - -)

React 团队在他们那边也通过确保 FiberNode 的所有的时间和持续时间字段都被初始化为 Double 表达来规避这个问题。

复制代码

class FiberNode {  constructor() {    // 从一开始就强制 w 诶 `Double` 表达    this.actualStartTime = Number.NaN;    // 然后你依然 k 恶意将这个值初始化为任何你想要的值    this.actualStartTime = 0;    Object.preventExtensions(this);  }} const node1 = new FiberNode();const node2 = new FiberNode();

不只是 Number.NaN,任何不在 Smi 范围的浮点值都可以使用。比如说 0.000001,Number.MIN_VALUE,-0,Infinity。

值得指出的的是这个 React 的 Bug 是 V8 规范导致的,开发者不应该为一个特定的 JavaScript 引擎做优化。尽管如此,当事情运转不正常时有个解决方案还是挺不错的。

记住 JavaScript 引擎会在幕后做一些 magic 的优化,而你可以通过尽可能避免类型混用来有效的帮助它执行这些优化。举个例子,不要用 null 来初始化 number 类型的字段,这不仅能避免使得所有字段表达跟踪带来收益全部失效,还能让你的代码变得更可读:

复制代码

// Don’t do this!class Point {  x = null;  y = null;} const p = new Point();p.x = 0.1;p.y = 402;

换句话说,写可读的代码,然后性能自然就会提升。

最后总结

我们在这次深入探讨中涵盖了以下内容:

  • JavaScript 对“基本类型”和“对象”的区分,而且 typeof 是个骗子。
  • 即使具有相同 JavaScript 类型的值也可以在幕后具有不同的表示。
  • 在你的 JavaScript 程序中,V8 会尝试为每个属性寻找最佳的表达方式。
  • 我们讨论了 V8 如何处理 shape 废弃和迁移,包含了扩展性和转变的一些内容。

基于这些知识,我们可以得出一些能帮助提升性能的 JavaScript 编码实用提示:

  • 永远用同样的方式初始化你的对象,这样 shape 机制可以更有效。
  • 使用合理的值来初始化你的字段,这样可以帮助 JavaScript 引擎更好地选择表达方式。

发表评论

0/200
0 点赞
0 评论
收藏
为你推荐 换一批