[译] 优化SPA应用的包大小为应用加载提速

175 阅读16分钟

原文地址:https://medium.com/miro-engineering/optimize-spa-bundle-size-to-speed-up-application-loading-c988cef57257

本文聚焦于单页面应用的优化,因此会探索以下几个领域

  • 如何优化一个web应用,以及如何加速其加载过程
  • 优化加载速度的原因
  • 使用什么样的工具对加载效率进行度量、优化以及检验改进措施
  • 在应用中使用可加载模块的收益

降低应用加载时间是一项复杂的工作,这通常需要整个团队的努力。排除对于网络本身的优化,诸如使用HTTP2协议,以及服务端的优化措施,让我们看看前端工程师在优化过程中可以做些什么。

问题

在行动之前,需要严谨的理解我们需要解决的问题是什么:如果应用加载正常,并且工作正常,我们为什么需要去做优化?

众所周知,web应用的加载速度对于用户体验有巨大的影响。如下图所示

1_2f-QgmWG6qhB_YJhGafRhA.jpeg

  • 每增加2秒的加载时间,用户的跳出率就会增加103%。假设你或者你的公司的主要业务就是通过吸引流量来获取收入,那么通过提升加载速度,就可以节省更多的广告费用,并且增加收入最终提升利润。
  • 加载时间每增加1秒钟,都会降低7%的成交转换率。也就是说假设你公司每天挣10万美元,加载时长增加一秒,大约会减少250万美元的年收入。这个数字几乎相当于一家公司的所有开发团队的工资。仅仅是一秒之差。

想要了解这些加载时间都藏在哪里,那我们需要先理解浏览器是如何加载一个单页面应用的。假设我们的应用是一个单体SPA应用,加载过程大致如下:

  1. 获取并加载HTML,然后构建DOM元素。浏览器首先收到一个HTML文档,其中会包含指向静态资源的链接比如JS文件或样式文件,除此以外不含任何其他内容。此时用户看到的只是一个没有内容的白屏页面。
  2. 加载外部资源。在这一阶段,JS和CSS文件会被加载。
  3. 解析CSS并构建CSS对象模型。构建出的样式被解析,但此时用户面对的仍然是一个没有内容的白屏。
  4. 执行Javascript代码
  5. 渲染页面。加载应用的结果在此时才最终向用户呈现。应用内容终于被显示出来了。

加载应用过程的示意图如下图所示:

1_-n7yBxW1kiKKL2IXSzmD9Q.png

通常来说,加载JS和CSS文件可以是同步的,也可以是异步的。在一个典型的单页面应用场景中,所有的加载步骤都是阻塞的,因为对于用户来说,浏览器加载完所有的必须的外部资源之前,用户什么都做不了。在上图中,这一时间点被标记为TTI(Time To Interactive):在此时间点之后,用户才可以开始使用应用。

FCP(First Contentful Paint)时间点,标记了用户看到的网页从白屏变为有内容的那一时刻。FCP时间点可能出现在JS文件加载过程中,也可能出现在所有静态资源加载时间的末尾,紧邻TTI时间点。

应用的加载时间与应用加载过程中需要加载的文件数量和大小是成正比的关系。既然已经了解到加载时间是如何产生的,那么下一步我们需要想办法让加载的文件大小尽可能的降低,加载文件的数量尽可能的减少。

优化页面加载

在深入优化应用包之前,我们先看看加速页面加载的通用方法。

删除不再使用的代码

一种很常见的问题是有一些不会被执行的代码出现在了生产环境的代码包中。其中一些例子如下:

  • mock文件。几年前我在一个项目中仅仅通过删除一个mock数据的json文件,就将应用的代码包大小降低了80%。这个json文件中包含5000名雇员的组织架构信息。那是在服务端API还在开发时,前端工程师为了mock API数据放在工程项目中的。但很显然最终替换成真实的服务端API后,有人忘了删除这个json文件。
  • 老的模块。当开发新的功能时,我们经常会写好几个版本的原型代码,而最终老版本的原型代码并没有使用但也仍然被打包到生产环境中去了。
  • 样式代码。像Bootstrap或者TailwindCSS之类的样式库会引入成千上万的没有必要的样式类。针对这种情况可以使用工具来删除他们,比如PurgeCSS。
  • 库。除了样式库,还有很多种情况是在项目中为了一两个功能而引入了一个非常大型的库。针对这种场景应该使用tree-shaking技术通过仅打包引入的功能而不是打包完整的库文件。

压缩代码

打包JS和CSS文件是通过收集器的机制。这些代码本身还可以通过简写变量名、删除空格和注释做深度压缩。

大多数情况下,由于主流的开发框架都已经默认在生产环境开启代码压缩,因此编译之后的应用代码已经是压缩过的了。但如果你使用了自定义的构建工具配置,需要注意是否已开启了代码压缩的能力。光这一项就可以降低50%-60%的代码大小。

压缩图片

我相信很多人都碰到过那种页面加载需要很长时间的情况。这些页面中的图片可能会消耗掉几兆的下载流量,因为这些图片直接使用了上传时图片的原始状态。

1__zzRvZqhSUKD02xfEmggEg.png

有些工具可以在不造成可见的质量损失的前提下对图片进行几十上百倍的压缩。对于项目中直接引入的图片可以使用Webpack在打包过程中进行压缩。但如果是通过服务端返回的图片,完全可以交给服务端工程师对图片进行压缩,这对他们来说就是几个小时的工作量。

压缩字体文件

有时候字体文件大小可以轻松超过500KB

降低字体文件大小的主要措施首先是选择正确的web字体格式,也就是选择那些最新的,支持所有浏览器并且经过最大化压缩的字体文件。比如WOFF或者WOFF2格式。

另外一个降低字体文件大小的措施是删除字体文件中不使用的字形。这种方法对于仅仅将字体应用于标题或者logo时非常适用。

可以使用专有的应用来移除字体文件中不使用的字形,或者如果你可以使用Google Fonts就更加方便:引用Google Font的连接中,可以传入希望使用的字符作为参数。服务器会根据传入的参数仅返回使用的字形。因此最终需要加载的字体文件大小可以得到数十倍的降低。

<link href=”https://fonts.googleapis.com/css?family=Roboto&text=Miro” rel=”stylesheet” >

应对打包大小

代码分割可以为整个应用代码分块打包,因此可以排除单页面应用启动时不需要执行的代码。

代码分割技术的应用是优化应用打包文件大小的基石。应用代码分割技术可以把主应用的代码分割成若干分块,这样不同分块的打包文件就可以按需进行加载了。而且加载过程还可以在应用程序加载之后再执行。

按路由分割

对单页面应用使用代码分割最流行的方式就是根据路由进行分割。打包工具按照单页面应用中的每个页面创建独立的分块。这些分块可以仅在用户跳转到对应页面的时候再执行加载动作。

1_n0bGriBFRIUTKSvQyvuZJA.jpeg

上面的图示显示了应用是如何按照不同页面分割代码块的,其中main模块用来初始化框架、模块、数据和路由。

当浏览器打开应用时,用户只需要加载一个页面所需的代码包。因此在启动过程中,只需下载两个文件:main模块,和其中一个页面分块。由于一个应用所包含的页面数轻轻松松就可以达到数十个,因此这个方法可以显著的降低应用首页面加载时长。

一次性代码

在单页面应用按照路由分块组织之后,还可以进一步深入查看根模块及其内部页面是如何组织的。首先需要观察的就是那些一次性代码。

这里所说的一次性代码是指应用中用户可能只会看到一次的那些功能。比如:

  • 注册和身份验证表单
  • 新手指引
  • 提示和各种信息框

不常用的代码

这是指那些虽然属于公用功能,但是用户也并不经常会使用的代码。我们可以把这些代码移动到其他分块中,而不需要占用应用初始化的模块大小。比如应用中随处可见的帮助功能。

1_-QP17dMh_0jCIfr-tEeQEQ.png

但请注意,在上图的右边是浏览器中的开发者工具,显示着加载了一系列JS文件。打开帮助功能时触发了一系列的下载动作,而不仅仅是一个独立文件。这是过度优化时发生的副作用。举例来说,如果你将一个代码块放到一个独立的分块中,然后又将这个代码块中引用的一堆组件都移动到独立的分块中,这样会引起独立分块之间的循环引用。结果当然也是导致完整加载这个组件需要更长的时间。

不想引发这样的过度优化,那么好的实践方式就是不要设法分割所有东西。为了提升性能,建议的需要分块的文件阈值是100KB。

隐藏的代码块

除了应用中路由的分块,你也可以尝试为页面中的tabs内容之类的隐藏性页面进行分块。对于这种隐藏页面可以在页面内嵌入内部路由进行分块处理。

1_br6ydtXriHRIN4jQN4y7rg.png

在实践中,基于对用户使用习惯的了解,我们可以将页面中不常使用的tab内容做分块打包处理。

上面的屏幕截图显示了页面内容是按照tabs的形式组织的,也就是同一时刻仅有一个tab内容会被显示。因此我们可以让其他tab页的JS代码在显示时再去加载。

除了首屏优化,单页面应用的分割处理还带来了另外一个显著的好处,那就是缓存。浏览器下载应用文件之后,会把他们存储在本地以便后续使用。一个用户想要重新打开以前访问过的页面时,页面对应的分块不会再从服务器下载,而是从本地内存中加载。这会让用户体验感受到的是瞬时打开了页面。更重要的是,当你更新了应用的版本,对于那些没有更新的模块浏览器会直接使用本地缓存。

对话框

我们会把重要的对话框内容组织成一个组件,以便用户在任一页面都可以访问到这个对话框。通常来说这种组件都会被打包在根模块中。这也就意味着应用在初始加载时会去加载这个对话框组件。

这些对话框在应用初始化阶段几乎没有什么用处,因为除非被用户触发,否则对话框不会默认显示。因此他们也可以被移动到自己的分块中去。

在大型项目中,对话框可能占用大量的代码包大小。比如说下图的下单组件大小在350KB左右;甚至有一个独立的开发团队在负责这个模块。

1_cyn8BNusR-9c__iz-Jj6Vw.png

使用可加载模块有以下优势:

  • 源代码结构:通过将组件移动到独立模块,可以改进源代码的组织结构,和项目中的文件结构。
  • 基于组件的独立开发:独立的组件模块更易于不同的小组进行协作。
  • A/B测试:对于组件的动态加载可以按需替换不同版本的组件。这也让AB测试更易进行。
  • 便于迁移或者替换一个老旧的模块。

本地化

对于本地化解决方案的优化其原理也是类似的。对于不同语言的用户仅加载对应的语言包,不适用的语言包不进行加载。

解决方案的技术细节

截至目前,我们已经了解到了解决方案的偏产品的一侧;知道了需要优化什么和以什么样的顺序进行优化。现在可以深入细节看看如何做这些优化。

import(“./module”)

关键是动态引用。它看起来是一个函数,但实际不是。我们不能向其传递一个参数然后做点别的什么事情。调用执行会返回一个Promise,当Promise成功时会返回引用的那个模块。

import(“./foo”).then(foo => console.log(foo.default));
const module = await import(“./foo”)

在本地化的场景中,动态引用允许加载静态服务器上的JSON格式文件而不需要后端开发参与。文件本身仍然保留在项目源代码内,也更便于开发和debug。

const language = getUserLanguage();
import(“./locale/${language}.json”).then(locale => {/* … */})

上面的示例展示了这一语法的强大功能。import接受一个字符串,所以我们可以动态的改变这个字符串,虽然会得到一个小警告:我们不能把常量传入函数内,因为收集器不知道应该对哪些文件进行分块。在本例中,代码是可用的。

在React中,可以轻松地动态引用整个组件和页面。有一个特殊的方法:

const App = () => (
<Suspence fallback={<div>Loading…</div>} >
  <Component />
</Suspence>
)

使用Suspence,当需要的组件正在被加载时,允许显示其他组件作为降级处理。这允许我们对于加载流程做统一处理。与此同时,这样的交互显然也在告诉用户当前某些必须的模块正在被加载。

下面的代码展示了最流行的分块处理方式:

const Home = React.lazy(() => import(“./Home”))
const Profile = React.lazy(() => import(“./Profile”))const App = () => (
<Router>
  <Suspence fallback={<div>Loading…</div>} >
    <Switch>
      <Route exact path=”/” component={Home} />
      <Route path=”/profile” component={Profile} />
    </Switch>
  </Suspence>
</Router>
)

React.lazy有一个缺陷,在SSR是无法使用的。因此在SSR场景下可以使用Loadable Components应对这一问题。

与此同时,React也引入了Server Components或者叫zero-bundle components。这类组件不会在编译过程中引入,他们在服务端执行,执行结果会通过服务器以虚拟DOM的形式返回。由于Server Components还未正式发布,我们可以在最新的测试版本的React中或者最新的Next.js中试用。

Next.js

作为一款SSR的工具,我强烈推荐在单页面应用中试用Next.js。因为很多对于加载速度的优化方案都已经集成在其中,可以开箱即用。其中包括:

SSG(静态站点生成)

静态站点生成可以在构建过程中渲染每一个页面。所以应用中的每一个页面在第一次加载时,浏览器接收到的是已经填入内容的HTML文件,所以浏览器可以即时进行渲染。也就是说无论JS代码是否被加载完成,用户都能看到页面内容。

根据路由预读取

Next.js内置文件形式的路由功能。这使得项目文件结构干净清晰,并且能够自动根据页面进行分块操作。

该路由的主要特性是内置的预读取能力。这一能力允许预先加载那些用户暂时还没有访问的页面。当内部资源的链接引用出现在浏览器视口中,或者当用户的鼠标悬浮在这些链接上时,内部资源会开始下载,当用户打开这些链接时感受到的是秒开。

Image, font, 和script 的优化

对于图片,字体和脚本的优化是内置的。你不需要进行任何配置,直接使用对应的API即可。

业务逻辑和状态管理

对于大型应用来说,项目内极有可能含有一个用于中心化管理状态的工具,比如Redux和Redux Saga。

我们会在应用的最顶层注册状态管理工具。所以也别忘记这些独立的业务逻辑对应的状态管理代码。

1_WwOfd_wgdVSyY4vxRYthAw.png

对于Redux用户,有很多用于代码分割的工具。如果你使用的是Mobx,那更加幸运,分割能力是开箱即用的,你只需要关照好状态管理的代码架构。

对于其他框架的代码分割能力,可以参考其对应的文档。以上所描述的方法对于其他技术栈都可用。

度量和控制应用大小

本文的最后,让我们大概了解一下可以帮助我们度量和控制应用大小的工具。

Lighthouse

1_pTRjk7pXjU2obvcVSCu-EQ.png

Lighthouse用于分析web应用,是内置于Chrome浏览器的。它会以100为满分来显示一些关键指标的得分。其输出的报告包括一些提示信息以及改进建议。

Lighthouse也有一个命令行版本,可以集成到CI/CD系统中,在每一次构建中自动检查新版本的性能表现。

Webpack Bundle Analyzer

这款Webpack插件可以用来分析应用构建的最终产物,分析结果以网页的形式显示组成应用的分块和组件。

1_SRMaUEr5eB_6haTRKgEFfA.gif

网页显示的分析结果有助于识别大模块,以及模块的优化顺序。

Source-map-explorer

1_8bfc4MWefPVznuFn3CO7jQ.png

最后一个工具是source-map-explorer。

这个工具有助于理解分块是由哪些组件构成的。与Webpack Bundle Analyzer相比,它提供了另外一个稍显不同的视角来了解应用的组成:它通过解析map文件,并且以项目结构的方式来呈现分析结果。