2天时间,聊今年最热的 Agent、上下文工程、AI 产品创新等话题。2025 年最后一场~ 了解详情
写点什么

Dropbox 瘦身攻略:我们如何把 JavaScript 包缩小三分之一

作者 | Umair Nadeem,Rich Hong

  • 2023-09-19
    北京
  • 本文字数:4622 字

    阅读完需:约 15 分钟

大小:2.45M时长:14:16
Dropbox 瘦身攻略:我们如何把JavaScript包缩小三分之一

不知道各位朋友是否还记得,上一次正打算点击网站上的按钮、结果页面突然变化导致你点上了错误的位置是什么时候。或者说,上一次你因为实在忍受不了缓慢的加载速度而愤然点叉又是在什么时候?

 

这些问题在如今内容愈发丰富、交互度越来越高的应用场景中被无限放大。为了支持更复杂的功能,我们不得不编写出更多的前端代码,导致浏览器端需要接收、解析和执行的字节更多,最终性能自然变得更差。

 

在 Dropbox,我们深深了解此等糟糕体验是多么令人崩溃。所以过去一年来,我们的 Web 性能工程团队抽丝剥茧、将性能问题溯源到了一个常常被忽视的元素身上:模块捆绑器。

 

米勒定律认为,人脑在任何给定的时间内只能容纳一定量的信息,所以大部分现代代码库(包括我们 Dropbox 的代码库)才会被拆分成一个个更小的模块。模块捆绑器负责把应用程序中的各类组件(例如 JavaScript 和 CSS)合并成捆绑包,并在页面加载时由浏览器下载这些捆绑包。最常见的处理方式就是将捆绑包保存为最小 JavaScript 文件的形式,用以存放 Web 应用程序中的大部分逻辑。

 

Dropbox 模块捆绑器的首次迭代设计于 2014 年,当时以性能为先的模块捆绑方法才刚刚兴起(分别在 2012 年和 2015 年由 Webpack 和 Rollup 率先提出)。但毕竟年代久远,那时候的方案跟现代设计比起来还是太过简陋。我们的模块捆绑器并没多少性能优化,使用起来比较繁琐,既影响用户体验又会拖慢开发速度。

 

随着捆绑器逐渐显露老态,我们决定面向未来做好性能优化、全面替换掉这位应当功成身退的老将。当前也是替换的最佳时机,因为我们正好在着手将页面迁移至 Edison(我们的全新 Web 服务栈),统筹规划有望一箭双雕。替换之后,我们的静态资产管线也将迎来更现代的捆绑器,在架构层面让集成更为简单。

现有架构


虽然我们原本的捆绑器拥有相对较快的构建速度,但也存在着不少短板,包括捆绑包太过臃肿、工程师们感到难以维护等等。工程师们只能手动定义把哪些脚本跟包捆绑在一起,而且我们之前只简单提供页面渲染所需要的包,但几乎未做任何性能优化。随着时间推荐,这种粗糙的方案也带来了以下几大显著问题。

 

问题一:捆绑代码有好几个版本

 

直到不久前,我们还在使用名为 Dropbox Web Server(DWS)的自定义 Web 架构。简单来讲,每个页面都由多个小页(pagelet,即页面中的子部分)组成,因此导致每个页面都有多个 JS 入口点,而各 servlet 也由后端处对应的控制器提供服务。虽然这种部署在多个团队同时处理同一页面时速度更快,但也往往导致 pagelet 指向不同的后端代码版本。这就要求 DWS 能支持在同一页面上交付不同版本的打包代码,而这经常会引发一致性问题(例如在同一页面上加载相同单例的多个实例)。我们向 Edison 的迁移将消除这种 pagelet 架构,从而更灵活地采取更符合行业标准的捆绑方案。

 

问题二:需要手动分割代码

 

所谓代码分割,就是把 JS 包分割成更小的块的过程,这样浏览器就能只加载当前页面所需要的代码库部分。例如,假设用户先访问 dropbox.com/home,而后访问 dropbox.com/recent,那么如果不进行代码分割,则浏览器会下载整个 bundle.js,这无疑将显著减慢页面的初始导航速度。



所有页面的全部代码均通过单一文件提供

 

但在代码分割之后,浏览器只需要下载页面所需要的各个代码块。由于浏览器下载的代码量更少,所以 dropbox.omc/home 的初始导航速度将大大提升。此外,代码分割可以保证先加载关键脚本,而后再异步加载、解析和执行非关键脚本。共享代码片段也将被浏览器缓存下来,进一步减少用户在不同页面间移动时所需下载的 JS 代码量。所有这些,都将大大减少 Web 应用程序的加载时间。



仅下载页面所需的新代码块

 

由于我们现在的捆绑器没有任何内置的代码分割工具,所以工程师只能手动对包做定义。具体来讲,我们的打包 map 是个 6000 多行的庞大字典,具体指定了哪些模块该放进哪个包中。

 

可以想见,随着时间推移这样一套架构的维护工作将变得异常复杂。为了避免非优打包,我们强制执行了一套严格的测试(打包测试),但因为每次变更都可能打乱原本的模块排列,所以工程师们变得神经紧张、苦不堪言。

 

这也导致我们的实际代码量比页面所需要多得多。例如,假定我们有以下包 map:

 

{  "pkg-a": ["a", "b"],  "pkg-c": ["c", "d"],}
复制代码

 

如果页面依赖于模块 a、b 和 c,则浏览器只须进行两次 HTTP 调用(分别获取 pkg-a 和 pkg-b),而非对各模块各进行一次(共三次)调用。这虽然会减少 HTTP 调用的开销,但同时也会加载不必要的模块——在本示例中就是模块 d。由于缺乏摇树优化,我们不但加载了不必要的代码,还加载了页面不需要的整个模块,因此会拖慢整体用户体验。

 

问题三:缺少摇树优化

 

摇树是一种包优化技术,能够消除未使用的代码来帮助捆绑包瘦身。假设我们的应用程序需要导入包含多个模块的第三方库,如果没有摇树优化,则实际加载的大部分捆绑代码其实都毫无用处。



无论是否实际使用,所有代码都会被捆绑进来

 

通过摇树优化,我们可以分析代码的静态结构,并删除一切未被其他代码直接引用的代码。这样最终的捆绑包就能更加精简小巧。



只捆绑要使用的代码

 

因为我们之前的捆绑器不太完善,所以其中没有任何摇树功能。生成的包往往包含大量未使用代码,特别是来自第三方库的代码,这会导致页面加载无用内容、延长等待时间。此外,因为我们使用 protobuf 定义来实现从前端到后端的高效数据传输,所以在检测某些可观察性指标时往往要引入高达几 MB 的未使用代码!

为何选择 Rollup


多年来我们其实考虑过不少解决方案,并最终把核心需求梳理了出来:我们真正需要的,就只有自动代码分割、摇树优化,以及可以进一步优化捆绑管线的可选插件。Rollup 就是当前最成熟、也能灵活融入到我们现有构建管线的工具,于是最终成为我们的首选解决方案。

 

另一个原因是:有助于降低工程开销。因为我们已经在使用 Rollup 捆绑我们的 NPM 模块,所以继续扩大 Rollup 的使用范畴肯定比再引入新工具要划算得多。此外,这也意味着跟其他捆绑器相比,我们已经在之前的运营中掌握了更多关于 Rollup 特性的工程专业知识,能有效降低用不下去的可能性。最后,我们还算了一笔账,发现跟深入集成 Rollup 相比,在原有模块捆绑器中重现 Rollup 的功能需要投入更多工程资源。

不负众望的 Rollup


我们都知道,安全、分步推出模块捆绑器绝非易事,毕竟我们在期间需要同时可靠支持两种模块捆绑器(并生成两种对应的捆绑包)。我们主要关心的问题包括如何保证捆绑代码稳定、无 bug,如何增加构建系统和 CI 的负载,还有怎样激励团队接受在其页面中使用 Rollup 捆绑包。

考虑到可靠性和可扩展性等问题,我们把发布过程分成了四个阶段。


  • 开发者预览阶段:允许工程师在开发环境中选择加入 Rollup 捆绑包。这样我们就能让开发者尽早发现 Rollup 捆绑包引发的任何意外,借此推动行之有效的众包 QA 测试。认真收集相关信息后,我们将有充足的时间解决 bug、适应范围变更。

  • 面向 Dropbox 员工的内部预览阶段将全面推广 Rollup 捆绑包,借此收集早期性能数据并进一步获取关于应用程序行为变化的实践反馈。

  • 通用阶段,即逐步向所有 Dropbox 用户(包括内部和外部用户)推出 Rollup 捆绑包。在此之前,我们已经对 Rollup 包做过彻底测试并确定其稳定性已经达到较高水平。

  • 维护阶段,强调解决项目中遗留的所有技术债,再通过迭代让 Rollup 进一步优化性能和开发者体验。我们意识到,如此规模的大体量项目将不可避免地积累下一些技术债,我们应计划在某个阶段将其解决,而不能假装债务不存在。

 

为了有效支持各个阶段,我们混合使用了基于 cookie 的门控和内部功能门控系统。以往,Dropbox 的大多数部署都纯粹借助我们的内部功能门控系统得以完成。但这一次,我们决定允许基于 cookie 的门控在 Rollup 和旧捆绑包间快速切换,从而加快调试速度。每个发布阶段都以交替形式分步推出,包括从 1%、10%、25%,到 50%乃至最终的 100%。这让我们能够灵活地收集早期性能与稳定性结果,当发现问题时进行无缝回滚,同时尽可能降低对内、外部用户造成的影响。

 

因为我们需要迁移大量页面,所以除了建立安全可靠的 Rollup 切换策略之外,还得激励页面所有者主动执行切换。由于我们的 Web 栈将配合 Edison 进行一波重大改造,所以这应该是个可以一箭双雕的绝佳时机。如果把 Rollup 塑造成 Edison 所支持的独特功能,那开发团队应该会更愿意同时接受 Rollup 和 Edison,我们也能借此将 Rollup 的迁移策略跟 Edison 升级紧密绑定起来。

 

Edison 也有望借此提高自己的性能和开发速度。我们认为,将 Edison 与 Rollup 相结合,会在整个公司内产生强烈的转型协同效应。

挑战与障碍


我们早就做好了迎接意外挑战的准备,但事实证明将一种构建系统(Rollup)跟另一种构建系统(基于 Bazel 的原有基础设施)进行复杂对接,其挑战性要远远大于我们的任何想象。

 

首先,我们发现同时运行两种不同模块捆绑器,所消耗的资源要远超我们的估计。Rollup 的摇树算法虽然相当成熟,但仍需要将所有模块都先加载到内存中,之后生成分析关系并摇出代码所需的抽象语法树。此外,我们将 Rollup 集成到 Bazel 中的作法,限制了我们缓存中间构建结果的能力。也就是说,我们需要持续集成以重建并重新缩小每个构建上的全部 Rollup 块。这导致我们的持续集成构建因内存耗尽而超时,显著拖慢了部署节奏。

 

我们还发现了 Rollup 摇树算法中的几个 bug,这会导致摇树优化过于激进。值得庆幸的是问题不大,我们在开发者预览阶段就将其修复,所以最终用户并未受到影响。此外,我们发现旧版捆绑程序会提供来自第三方库的某些代码,而这些代码与 JS 严格模式并不兼容。一旦将这些代码提交给采用严格模式的新捆绑器,则会在浏览器中引发极为严重的运行时错误。这就要求我们对整个代码库、特别是与严格模式不兼容的补丁代码,开展一轮全面审计。

 

最后,在 Dropbox 内部员工预览阶段,我们发现 Rollup 和旧版捆绑器之间的 A/B 遥测指标并未体现出符合预期的 TTVC 性能提升。我们最终意识到,这是因为 Rollup 生成的代码块比旧版捆绑器生成的代码块要多得多。尽管我们最初假设 HTTP2 的多路复用能消除大量代码块引发的性能下降,但事实证明代码块过多还是会导致浏览器耗费更长的时间来获取页面所需的各模块。再有,模块数量的增加也会拉低压缩效率,因为 Zlib 等压缩算法使用的是滑动窗口方法执行压缩,就是说单一大文件的压缩效率要明显好于多个小文件。

最终结果


在向全体 Dropbox 用户推出 Rollup 之后,我们发现新项目将 JavaScript 包缩小了约三分之一,JS 脚本总量减少了 15%,TTVC 也实现了适度改进。我们还通过自动代码分割显著提高了前端开发速度,开发人员现在不必在每次变量时都手动调整捆绑包定义。最后,也可能是最重要的一点在于,我们完成了捆绑基础设施的现代化改造,削减了自 2014 年以来积累的大量技术债,显著减轻了未来的项目维护负担。

 

除了令人眼前一亮的实践表现之外,Rollup 项目还帮助我们发现了现有架构中的几个瓶颈:例如多个渲染会阻塞 RPC,对第三方库的函数调用过多,以及浏览器加载模块依赖性 map 效率太低等。凭借 Rollup 丰富的插件生态系统,解决原有代码库中此类瓶颈正变得越来越简单。

总而言之,全面采用 Rollup 作为模块捆绑器不仅给性能和生产力带来立竿见影的提升,也将在未来帮助 Dropbox 实现更为显著的性能改进。

 

原文链接:

https://dropbox-tech.translate.goog/frontend/how-we-reduced-the-size-of-our-javascript-bundles-by-33-percent

相关阅读:

最佳的 18 个 JAVASCRIPT 前端开发框架和库

新一波 JavaScript Web 框架

JavaScript 框架大战已结束,赢家只有一个

跨过四个时代,JavaScript框架终于可以与原生应用SDK竞争了

2023-09-19 12:577358

评论

发布
暂无评论
发现更多内容

闲置计费 | Serverless 冷启动与成本间的最优解

阿里巴巴云原生

阿里云 Serverless 云原生 函数计算

虎符交易所上线量化网格交易 同步开启活动三重奏

区块链前沿News

活动 虎符交易所

聊聊 C 语言和 ABAP 这两门编程语言的关系

汪子熙

编程语言 C语言 SAP abap 5月月更

《对线面试官》Java注解

Java3y

Java 程序员 面试 编程语言 5月月更

Nacos源码系列—关于服务注册的那些事

牧小农

源码 nacos

聊聊 Kafka:Kafka 消息丢失的场景以及最佳实践

老周聊架构

kafka 4月月更 5月月更

MySQL存储过程批量生成假用户电话号码

芝士味的椒盐

MySQL MySQL 数据库 5月月更

MongoDB 入门教程系列之三:使用 Restful API 操作 MongoDB

汪子熙

数据库 mongodb 分布式数据库 分布式数据库mongodb 5月月更

ssh常用命令总结

入门小站

SSH

Django Model 如何返回空的 QuerySet

AlwaysBeta

django

Flutter/Dart:生成最小值和最大值之间的随机数

坚果

5月月更

从零构建物联网平台-给个理由先

老任物联网杂谈

物联网平台

MongoDB 入门教程系列之二:使用 Spring Boot 操作 MongoDB

汪子熙

node.js 数据库 mongodb 分布式数据库 5月月更

SpringSecurity认证流程分析

急需上岸的小谢

5月月更

低代码实现探索(四十一)未实现小目标

零道云-混合式低代码平台

python进阶-迭代器和生成器

AIWeker

Python 人工智能 5月月更

Docker下的Spring Cloud三部曲之一:极速体验

程序员欣宸

Java Spring Cloud 5月月更

C语言_Linux基本命令与C语言基础

DS小龙哥

5月月更

时序数据库在水电站领域的应用

CnosDB

IoT 时序数据库 开源社区 CnosDB infra

一“碳”究竟:碳交易的生意经与飞轮“燃料”

脑极体

Global Tensor和实习总结|OneFlow学习笔记

OneFlow

深度学习 学习笔记 分布式训练 Global Tensor

CleanMyMac2022免费版Mac电脑清理软件功能

茶色酒

CleanMyMac2022 CleanMyMac

【Go实现】实践GoF的23种设计模式:建造者模式

元闰子

Go 设计模式 建造者模式

网站开发进阶(二十六)JavaScript 实现页面刷新方法汇总

No Silver Bullet

JavaScript 页面刷新 5月月更

五、高可用之全链路压测

穿过生命散发芬芳

5月月更

无需修改代码,用 fcapp.run 运行你的 REST 应用

阿里巴巴云原生

阿里云 Serverless 云原生 函数计算

在线时间戳格式化转换工具

入门小站

工具

喜报!阿里云首个通过应用多活“先进级”能力评估

阿里巴巴云原生

阿里云 云原生 应用多活

IntelliJ IDEA 如何增加运行时候的内存

HoneyMoose

Nginx 如何将所有 HTTP 的流量转移到 HTTPS

HoneyMoose

10-2 5-2 查询至少生产两种不同的计算机(PC或便携式电脑)且机器速度至少为133的厂商 (20 分)(思路加详解+测试用例

爱好编程进阶

程序员 后端开发

Dropbox 瘦身攻略:我们如何把JavaScript包缩小三分之一_架构_InfoQ精选文章