Facebook如何为新的Facebook.com网站重建技术堆栈

2020-05-09 00:29:58

facebook.com成立于2004年,最初是一个简单的、服务器呈现的PHP网站。随着时间的推移,我们增加了一层又一层的新技术,以提供更多的交互功能。每一项新功能和新技术都会逐渐降低网站的速度,并使其更难维护。这使得引入新体验变得更加困难。像暗模式和在News Feed中保存您的位置这样的功能没有直接的技术实现。我们需要退一步重新思考我们的架构。

当我们考虑如何构建一款新的网络应用程序时,我们意识到我们现有的技术堆栈不能支持我们需要的类似应用程序的感觉和性能。这款应用程序是为今天的浏览器设计的,具有人们对Facebook的期望功能。完全重写是极其罕见的,但在这种情况下,由于过去十年来网络发生了如此多的变化,我们知道这是我们实现业绩和可持续未来增长目标的唯一途径。今天,我们将分享在使用Reaction(用于构建用户界面的声明性JavaScript库)和Relay(用于Reaction的GraphQL客户端)重新构建Facebook.com时所学到的教训。

我们知道我们希望Facebook.com快速启动、快速响应,并提供高度互动的体验。虽然服务器驱动的应用程序可以提供快速的启动时间,但我们不相信我们能让它像客户端驱动的应用程序那样具有交互性和愉悦性。然而,我们相信我们可以构建一个客户端驱动的应用程序,启动时间快得有竞争力。

但从头开始,客户至上的应用程序带来了一系列新的问题。我们需要快速重建网站,同时还要解决速度和其他用户体验问题--我们需要以这样一种方式做到这一点,这样它在未来几年都是可持续的。

越少越好,越早越好。我们应该只提供我们需要的资源,我们应该努力让它们在我们需要之前就到达。

服务于用户体验的工程经验。我们发展的最终目标是所有使用我们网站的人。当我们考虑我们网站上的UX挑战时,我们可以调整我们的开发经验来指导工程师在默认情况下做正确的事情。

我们应用了同样的原则来改进站点的四个主要元素:CSS、JavaScript、数据和导航。

首先,我们通过改变编写和构建样式的方式,将主页上的CSS减少了80%。在新站点上,我们编写的CSS与发送到浏览器的CSS不同。当我们在与组件相同的文件中编写熟悉的类似CSS的JavaScript时,构建工具会将这些样式拆分成单独的、优化的捆绑包。因此,新网站附带的CSS更少,支持可访问性的深色模式和动态字体大小,并提高了图像渲染性能-所有这些都让工程师更容易使用。

在我们的老站点上,当加载主页时,我们加载了超过400KB的压缩CSS(2MB未压缩),但其中只有10%实际用于初始渲染。我们一开始并没有那么多CSS;它只是随着时间的推移而增长,很少会减少。这在一定程度上是因为每个新功能都意味着添加新的CSS。

我们通过在构建时生成原子CSS来解决这个问题。原子CSS具有对数增长曲线,因为它与唯一样式声明的数量成正比,而不是与我们编写的样式和特性的数量成正比。这使我们可以将从整个站点生成的原子CSS组合成一个小的共享样式表。因此,新主页的下载量不到旧网站下载量的20%。

我们的CSS随时间增长的另一个原因是,很难确定各种CSS规则是否仍在使用。原子CSS有助于减轻这方面的性能影响,但是独特的样式仍然会增加不必要的字节,并且源代码中未使用的CSS会增加工程开销。现在,我们将样式与组件放在一起,以便可以同时删除它们,并且仅在构建时将它们拆分成单独的捆绑包。

我们还解决了我们面临的另一个问题:CSS的优先级取决于排序,当使用随时间变化的自动打包时,这一点尤其难以管理。以前,一个文件中的更改可能会在作者没有意识到的情况下中断另一个文件中的样式。取而代之的是,我们现在使用一种受Reaction Native Style API启发的熟悉语法来创作样式:我们保证以稳定的顺序应用样式,并且不支持CSS后代选择器。

我们还利用离线构建步骤进行了辅助功能更新。在今天的许多网站上,人们使用浏览器的缩放功能来放大文本。这可能会意外地触发平板电脑或移动设备的布局,或者增加他们不需要放大的东西的大小,比如图像。

通过使用REMS,我们可以尊重用户指定的默认值,并且能够提供用于自定义字体大小的控件,而无需更改样式表。但是,设计通常是使用CSS像素值创建的。手动转换到REMS增加了工程开销和潜在的bug,因此我们让构建工具为我们执行此转换。

常量样式=样式.create({phasis:{fontWeight:';bold';,},text:{fontSize:';16px&39;,fontWeight:';Normal&39;,},});函数MyComponent(Props){return<;span className={style(';text&39;,pros.isEmphasize&;';emphasis&。

.c0{font-weight:粗体;}.c1{font-weight:Normal;}.c2{font-size:0.9rem;}

函数MyComponent(Props){return<;span className={(propss.isEmphasize?';c0';:';c1';)+';c2';}/>;;}。

在旧站点上,我们过去常常尝试通过将类名添加到Body元素,然后使用该类名用具有更高专用性的规则覆盖现有样式来应用主题。这种方法有问题,而且它不再适用于我们新的原子CSS-in-JavaScript方法,所以我们切换到CSS变量进行主题化。

CSS变量在类下定义,当该类应用于DOM元素时,其值将应用于其DOM子树中的样式。这使我们可以将主题组合到单个样式表中,这意味着切换不同的主题不需要重新加载页面,不同的页面可以具有不同的主题,而无需下载额外的CSS,并且不同的产品可以在同一页面上并排使用不同的主题。

.light-主题{--card-bg:#eee;}.暗-主题{--card-bg:#111;}.card{背景色:var(--card-bg);}。

这使得主题的性能影响与调色板的大小成正比,而不是与组件库的大小或复杂性成正比。单个原子CSS包还包括暗模式实现。

为了防止图标在其余内容之后出现时闪烁,我们使用REACT将SVG内联到HTML中,而不是将SVG文件传递给<;img>;标记。因为这些SVG现在实际上是JavaScript,所以可以将它们与其周围的组件捆绑在一起交付,以实现干净的一遍呈现。我们发现,在加载JavaScript的同时加载这些代码的好处要大于SVG绘制性能的代价。通过内联,之后弹出的图标不会闪烁。

函数MyIcon(Props){return(<;svg{.props}className={style({/*.*/})}>;<;path d=";m17.5.25.479Z";/>;<;/svg>;);}。

此外,这些图标可以在运行时平滑地更改颜色,而无需进一步下载。我们可以根据图标的道具设置图标样式,并使用CSS变量为特定类型的图标创建主题,特别是单色图标。

代码大小是基于JavaScript的单页面应用程序最大的问题之一,因为它对页面加载性能有很大影响。我们知道,如果我们想要一个用于Facebook.com的客户端反应应用程序,我们需要解决这个问题。我们引入了几个新的API,它们与我们的“尽可能少,越早越好”的口号一致。

当有人在等待页面加载时,我们的目标是通过呈现页面外观的UI“骨架”来提供即时反馈。这个框架需要最少的资源,但是如果我们的代码打包在单个捆绑包中,我们就不能提早呈现它,所以我们需要根据页面的显示顺序将代码拆分成捆绑包。然而,如果我们天真地这样做(即,通过使用在渲染期间获取的动态导入),我们可能会损害性能,而不是帮助它。这是我们的JavaScript加载层的代码拆分设计的基础:我们使用一个声明性的、可静态分析的API将初始加载所需的JavaScript拆分成三层。

第1层是显示上述内容(包括初始加载状态的UI骨架)的第一个绘画所需的基本布局。

第2层包括完全呈现所有上述内容所需的所有JavaScript。在第2层之后,屏幕上的任何内容都应该不会因为代码加载而在视觉上发生变化。

一旦遇到了portForDisplay,它和它的依赖项就会移到第2层。这会返回一个基于承诺的包装器,以便在模块加载后访问它。

第3层包括仅在显示后才需要的、不会影响屏幕上当前像素的所有内容,包括日志记录代码和实时更新数据的订阅。

import ForAfterDisplay ModuleC从';ModuleC';;//.函数onClick(E){ModuleCDeferred.onReady(ModuleC=>;{ModuleC.log(';Click Happed!';,e);});}。

一旦遇到portForAfterDisplay,它和它的依赖项就会移到第3层。这将返回一个基于承诺的包装器,以便在模块加载后访问该模块。

一个500 KB的JavaScript页面在第一层可以变成50 KB,在第二层可以变成150 KB,在第三层可以变成300 KB。通过减少达到每个里程碑所需下载的代码量,这种拆分代码的方式使我们能够缩短首次绘制和可视化完成的时间。因为第三层不影响屏幕上的像素,所以它并不是真正的渲染,并且最终的绘画完成得更早。最重要的是,加载屏幕能够更早地呈现。

我们经常需要呈现同一UI的两个变体,例如,在A/B测试中。要做到这一点,最简单的方法是为所有人下载两个版本,但这意味着我们经常下载从未执行过的代码。稍好一点的方法是在渲染时使用动态导入,但这可能会很慢。

相反,为了与我们的“尽可能少,尽可能早”的口头禅保持一致,我们构建了一个声明性API,它可以提早提醒我们这些决策,并将它们编码到我们的依赖关系图中。当页面加载时,服务器能够检查实验并仅发送所需版本的代码。

当我们拆分的条件(如A/B测试、区域设置或设备类)在此人的页面加载之间是静态的时,这很有效。

跨页面加载不是静态的代码分支怎么办?例如,为News Feed帖子发送所有不同类型和组件组合的所有呈现代码会大大增加页面的JavaScript大小。

这些依赖关系是在运行时根据从后端返回的数据决定的。这允许我们使用Relay的新功能来表示需要哪种呈现代码,具体取决于返回的数据类型。如果帖子有特殊附件,如照片,我们描述需要PhotoComponent才能呈现该照片。

..。在帖子{.。在PhotoPost{@module(';PhotoComponent.js&39;)PHOTO_DATA}上.。在VideoPost上{@module(';VideoComponent.js&39;)video_data}}。

我们将呈现每个帖子类型所需的依赖项表示为查询的一部分。

更棒的是,PhotoComponent本身准确地描述了它需要将照片附件类型上的哪些数据作为片段,这意味着我们甚至可以拆分查询逻辑。

层和条件依赖关系帮助我们只交付每个阶段所需的代码,但我们还需要确保每个层的大小随着时间的推移保持在可控范围内。为了管理这一点,我们引入了每个产品的JavaScript预算。

我们根据性能目标、技术限制和产品考虑因素设置预算。我们分配页面级预算,并根据产品边界和团队边界细分页面。共享基础设施被添加到精心策划的列表中,并给出了自己的预算。共享基础设施计入所有页面的预算,但其中的模块供产品团队免费使用。对于延迟、有条件加载或交互加载的代码,我们也有预算。

依赖关系图工具可以更容易地理解字节的来源,并找出减小代码大小的机会。

作为这次重建的一部分,我们对我们在网络上获取数据的基础设施进行了现代化改造。虽然旧站点的一些功能使用Relay和GraphQL来获取数据,但大多数获取的数据是作为其服务器端PHP呈现的一部分。有了这个新网站,我们能够标准化我们的移动应用程序,并确保所有数据获取都通过GraphQL。由于Relay和GraphQL已经为我们处理了“尽可能少”的工作,我们只需要做一些更改来支持尽早获得我们需要的数据。

许多Web应用程序需要等到下载并执行完所有JavaScript后才能从服务器获取数据。使用Relay,我们静态地知道页面需要什么数据。这意味着一旦我们的服务器收到页面请求,它就可以立即开始准备必要的数据,并将其与所需的代码并行下载。我们在此数据变得可用时将其与页面一起流式传输,以便客户端可以避免额外的往返行程,并更快地呈现最终的页面内容。

在初始加载Facebook.com时,某些内容最初可能会隐藏或渲染到视口之外。例如,大多数屏幕可以容纳一到两个News Feed帖子,但是我们事先不知道有多少可以容纳。此外,用户很可能会滚动,在一次连续往返行程中逐个获取每个故事都需要时间。另一方面,我们在一个查询中提取的故事越多,查询的速度就越慢,这会导致查询时间更长,甚至第一个故事的视觉完成时间也会更长。

为了解决这个问题,我们使用内部GraphQL扩展@stream将提要连接流式传输到客户端,以便在滚动上进行初始加载和后续分页。这允许我们在每个提要故事准备就绪后立即逐个发送,只需一个查询操作。

某些查询的不同部分比其他查询需要更长的计算时间。例如,当查看个人资料时,获取人名和个人资料照片相对较快,但获取他们的时间线的内容则需要更长的时间。

要使用单个查询获取这两种类型的数据,我们使用@Defer,它允许响应的不同部分在准备好后立即流式传输。这使我们可以尽可能快地使用初始数据呈现大部分UI,并呈现其余部分的加载状态。有了反应悬念,这就更容易了,因为我们可以显式地定制我们的加载状态,以确保流畅的、自上而下的页面加载体验。

快速导航是单页应用程序的重要功能。当导航到新路线时,我们需要从服务器获取各种代码和数据来呈现目标页面。为了减少加载新页面时所需的网络往返次数,客户端需要提前知道每条路由需要哪些资源。我们称其为路由图,每个条目都称为路由定义。

对于Facebook来说,这张路线图太大了,无法一次发送所有内容。取而代之的是,我们在会话期间动态地将路由定义添加到路由图中,因为呈现了新的链路。路由图和路由器位于应用程序的最顶端,允许当前应用程序和路由器状态的组合推动应用级状态决策,例如基于当前路由的顶部导航栏或聊天选项卡的行为。

客户端应用程序通常等到Reaction呈现页面后才下载该页面所需的代码和数据。这通常是使用React.lazy或类似的原语来完成的。因为这会使页面导航变慢,所以我们甚至在单击链接之前就开始了对一些必要资源的第一次请求:

为了提供比在导航时只显示空白屏幕更流畅的体验,我们使用反应悬念转换来继续呈现上一条路线,直到下一条路线完全呈现或挂起到具有下一页UI骨架的“良好”加载状态。这就不那么刺耳了,而且它模仿了标准的浏览器行为。

我们在新站点上执行了大量延迟加载代码,但是如果我们延迟加载某个路由的代码,并且该路由的数据获取代码位于该代码中,那么我们最终将以串行加载告终。

为了解决这个问题,我们提出了EntryPoints,它是包装代码拆分点并将输入转换为查询的文件。这些文件非常小,并且对于任何可到达的代码拆分点都会提前下载。

GraphQL查询仍然与视图并置,但是入口点封装了何时需要该查询以及如何将输入转换为正确的变量。该应用程序使用这些EntryPoint自动决定何时获取资源,确保默认情况下发生正确的事情。这还有一个额外的好处,即创建单个JavaScript函数,该函数包含应用程序中任何给定点的所有数据获取需求,该函数可用于前面讨论的服务器预加载。

我们在这里讨论的许多变化并不是Facebook所特有的。这些概念和模式可以应用于使用任何框架或库的任何客户端应用程序。通过标准化我们的技术堆栈,我们能够重新思考如何以高性能、可持续的方式引入人们想要的功能-即使我们在工程和产品规模上运营也是如此。

工程体验的改善和用户体验的改善必须齐头并进,性能和可访问性不能被视为对运输功能的征税。凭借出色的API、工具和自动化,我们可以帮助工程师更快地移动,同时交付更好、更高性能的代码。为提高新的Facebook.com的性能所做的工作是广泛的,我们希望很快就能分享更多关于这项工作的信息。要查看重新设计的内容,请从您的桌面访问facebook.com。它正在逐步推出,很快就会向每个人开放。