Instagram网站性能优化之路:完结篇

932 阅读8分钟

近年来,Instagram发布了许多功能-我们推出了故事,过滤器,创建工具,通知和消息直递,以及许多其他功能和优化。 但是,随着产品功能的增长,一个不幸的副作用是我们的网络性能开始下降。 在过去的一年中,我们有意识地努力来改善这一状况。 到目前为止,我们的不懈努力已使Feed页的加载时间累计提升了近50%。 这一系列博客文章将概述我们为实现这些改进所做的一些工作。

在第1部分中,我们介绍了数据和资源预加载,在第2部分中,我们介绍了通过直接向客户端推送数据而不是等待客户端请求数据来提高性能,在第3部分中,我们介绍了缓存优先渲染。

代码大小和执行优化

在第1-3部分中,我们介绍了各种方法,这些方法可以优化关键路径的静态资源和数据查询的加载模式。 但是,还有一个我们尚未涉及的关键领域,对于提高Web应用程序的性能至关重要,尤其是在低端设备上-向用户交付更少的代码,尤其是更少的JavaScript。

这看起来似乎很明显,但是这里有几点需要考虑。 业界普遍认为,通过网络下载的JavaScript的大小是重要的(即压缩后的大小),但是我们发现真正重要的是压缩前的大小,因为即使在本地缓存,也需要在用户设备上进行解析和执行。

如果您的站点有很多重复用户(较高的浏览器缓存命中率)或在移动设备上访问您的站点的用户,则尤其如此。 在这些情况下,JavaScript在CPU上的解析和执行性能成为主要的限制因素,而不是网络下载时间。

例如,当我们为JavaScript资产实施Brotli压缩时,我们发现整个网络的压缩后大小减少了近20%,但是从最终用户的角度来看,总体页面加载时间没有统计上的显著减少。

另一方面,我们发现压缩前JavaScript尺寸的减小始终可以提高性能。 在关键路径上执行的JavaScript和主页完成后动态导入的JavaScript之间也应加以区分。

理想情况下,减少应用程序中的JavaScript总量虽然很不错,但短期内要进行优化的关键是关键路径上执行的JavaScript数量(我们使用称为“每条路由的关键字节数”的指标进行跟踪) )。

延迟加载的动态导入JavaScript通常不会对页面加载性能产生重大影响,因此,将不可见或与交互相关的UI组件从初始页面包中移出并动态导入包是一种有效的策略。

从长远来看,重构我们的UI以减少关键路径上的脚本数量对于提高性能至关重要,但这是一项艰巨的任务,需要时间。 在短期内,我们进行了许多项目,以对产品开发人员透明的方式提高现有代码的大小和执行效率,并且几乎不需要重构现有产品代码。

内联引用

我们使用Metro(与React Native使用的捆绑器)打包前端Web资产,因此我们可以直接访问内联引用。 内联需求将需求/导入模块的成本在实际使用时首次转移。

这意味着您可以避免为未使用的功能付出执行成本(尽管您仍将支付下载和解析它们的成本),并且可以在应用程序启动时更好地摊销执行成本,而不是进行大量的前期计算。

const config = {
  transformer: {
    getTransformOptions: () => {
      return {
        transform: { inlineRequires: true },
      };
    },
  },
};

module.exports = config;

我们可以看下下面这个例子:

const foo = require('foo');
const bar = require('bar');

module.exports = function baz() {
    foo();
}

使用内联引用可以将其转换为如下所示(您可以通过在浏览器开发人员工具的Instagram JS源代码中搜索r(d[来找到这些内联要求))

module.exports = function baz() {
    require('foo')();
}

如我们所见,它实际上是通过将对所需模块的本地引用替换为需要该模块的函数调用而起作用的。 这意味着除非实际使用该模块中的代码,否则永远不需要该模块(因此也就不会执行该模块)。 在大多数情况下,这非常有效,但是要注意会导致问题的一些极端情况,即具有副作用的模块。 例如:

// Module A

window.globalState = { 'foo': 'bar' };

// Module B

module.exports = function() {
   console.log(window.globalState);
}

// Module C

const A = require('A');
const B = require('B');

B();

没有内联引用,模块C将输出{'foo':'bar'},但是当我们启用内联引用时,它将输出undefined,因为B对A具有隐式依赖性。这是一个人为的示例,但还有其他示例。

在现实世界中,这种情况可能会产生影响,例如,如果模块在其初始化过程中进行了一些日志记录,该怎么办-启用内联请求可能导致该日志记录停止发生。

通过linters来检查在模块作用域级别立即执行的代码,这在大多数情况下是可以避免的,但是我们必须从此优化中将某些文件列入黑名单,例如需要立即执行的运行时polyfills。 在整个代码库中尝试启用内联需求之后,我们发现Feed TTI(互动时间)和Display Done分别提高了12%和9.8%,并认为处理这些次要情况对于提高性能是值得的。

现代浏览器代码优化

推动采用诸如Babel之类的编译器/编译器工具的主要驱动器之一,是允许开发人员使用现代的JavaScript编码习惯,同时在缺乏支持的浏览器中可以运行。

从那时起,出现了这些工具的许多其他重要场景,包括诸如Typescript和ReasonML之类的JS编译语言,诸如JSX和Flow类型注释之类的语言扩展以及针对诸如国际化之类的时间AST操纵。

因此,这个额外的编译步骤不太可能很快在前端开发工作流程中消失。不过,话虽如此,但值得回顾的是,在2019年是否仍然有达到此目的的原始目的(跨浏览器兼容性)。

大多数主要浏览器的最新版本现在都很好地支持ES2015和更新功能(例如async / await),因此绝对有可能直接提供包含这些更新功能的JavaScript -但是我们必须首先回答两个关键问题:

  • 如果有足够的用户能够利用这一点,因为这个将增加额外的构建复杂性(因为您仍然需要维护旧版浏览器的旧版转换步骤)
  • 如果直接提供ES2015+的代码是否对性能有帮助

要回答第一个问题,我们首先必须确定要在不进行转译的情况下提供哪些功能,以及我们要为不同的浏览器支持多少个构建变体。 我们选择了两个版本,一个版本需要支持ES2017语法,另一个版本可以移植回ES5(此外,我们还添加了一个可选的polyfill捆绑包,该捆绑包仅适用于缺少运行时支持的旧版浏览器。 最近的DOM API)。

通过在服务器端进行一些基本的用户代理嗅探来检测对这些组的支持,从而确保从客户端检测要加载的捆绑软件起,没有运行时成本或额外的往返时间。

考虑到这一点,我们确定了instagram.com的56%的用户可以在不进行任何代码转换或运行时polyfill的情况下获得ES2017版本的服务,并且考虑到该百分比只会随着时间的推移而增加– 考虑到能够使用它的用户数量,似乎值得支持两个版本。

至于第二个问题-直接交付ES2017的性能优势是什么-首先让我们看一下Babel在将一些常见的构造转换回ES5方面的实际作用。 左列是ES2017代码,右列是已编译的ES5兼容版本。

Class (ES2017 vs ES5)

Async/Await (ES2017 vs ES5)

Arrow functions (ES2017 vs ES5)

Destructuring assignment (ES2017 vs ES5)

从中我们可以看到,在编译这些语法时有相当大的开销(即使您在较大的代码库上分摊了某些运行时帮助程序函数的成本)。 对于Instagram,当我们从构建中删除所有ES2017转码插件时,我们看到核心JavaScript包的大小减少了5.7%。

在测试中,我们发现使用ES2017捆绑包的用户与未使用ES2017捆绑包的用户相比,提要页面的端到端加载时间缩短了3%。

写在最后

尽管到目前为止取得的进展令人印象深刻,但到目前为止,我们所做的工作只是开始。 在Redux存储/ Reducer模块化,更好的代码拆分,将更多JavaScript执行移出关键路径,优化滚动性能,适应不同的带宽条件等方面,还有大量的改进空间。

『奶爸码农』从事互联网研发工作10+年,经历IBM、SAP、陆金所、携程等国内外IT公司,目前在美团负责餐饮相关大前端技术团队,定期分享关于大前端技术、投资理财、个人成长的思考与总结