前端project与性能优化(长文)

Posted yxysuanfa

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端project与性能优化(长文)相关的知识,希望对你有一定的参考价值。

原文链接:http://fex.baidu.com/blog/2014/03/fis-optimize/

  每一个參与过开发企业级 web 应用的前端project师也许都曾思考过前端性能优化方面的问题。我们有雅虎 14 条性能优化原则。还有两本非常经典的性能优化指导书:《高性能站点建设指南》、《高性能站点建设指南》经验丰富的project师对于前端性能优化方法耳濡目染。基本都能一一列举出来。这些性能优化原则大概是在 7 年前提出的。对于 web 性能优化至今都有很重要的指导意义。

  然而,对于构建大型 web 应用的团队来说,要坚持贯彻这些优化原则并非一件十分easy的事。由于优化原则中非常多要求与project管理相违背。比方“把 css 放在头部”和“把 js 放在尾部”这两条原则,我们不能让整个团队的project师在写样式和脚本引用的时候都去改动同一份的页面文件。

这会严重影响团队成员间并行开发的效率,尤其是在团队有版本号管理的情况下。每天要花大量的时间进行代码改动合并。这项成本是难以接受的。

因此在前端project界,总会看到周期性的性能优化工作,辛勤的前端project师们每到月圆之夜就会倾巢出动依据优化原则做一次最佳实践。

  本文从一个全新的视角来思考 web 性能优化与前端project之间的关系。通过解读百度前端集成解决方式小组(F.I.S)在打造高性能前端架构并统一百度 40 多条前端产品线的过程中所经历的技术尝试。揭示前端性能优化在前端架构及开发工具设计层面的实现思路。

  性能优化原则及分类

  笔者先如果本文的读者是有前端开发经验的project师,并对企业级 web 应用开发及性能优化有一定的思考。因此我不会反复介绍雅虎 14 条性能优化原则,如果您没有这些前续知识的,请移步这里来学习。

  首先,我们把雅虎 14 条优化原则。《高性能站点建设指南》以及《高性能站点建设进阶指南》中提到的优化点做一次梳理,假设依照优化方向分类能够得到这样一张表格:  

优化方向 优化手段
请求数量 合并脚本和样式表,CSS Sprites,拆分初始化负载。划分主域
请求带宽 开启 GZip。精简 javascript,移除反复脚本,图像优化
缓存利用 使用 CDN。使用外部 JavaScript 和 CSS。加入 Expires 头,降低 DNS 查找。配置 ETag,使 AjaX 可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出
代码校验 避免 CSS 表达式。避免重定向

  眼下大多数前端团队能够利用 yui compressor 或者 google closure compiler 等压缩工具非常easy做到“精简 javascript ”这条原则。相同的,也能够使用图片压缩工具对图像进行压缩,实现“图像优化”原则,这两条原则是对单个资源的处理,因此不会引起不论什么project方面的问题。非常多团队也通过引入代码校验流程来确保实现“避免 css 表达式”和“避免重定向”原则。眼下绝大多数互联网公司也已经开启了服务端的 Gzip 压缩,并使用 CDN 实现静态资源的缓存和高速訪问。一些技术实力雄厚的前端团队甚至研发出了自己主动 CSS Sprites 工具。攻克了 CSS Sprites 在project维护方面的难题。使用“查找 - 替换”思路,我们似乎也能够非常好的实现“划分主域”原则。

  我们把以上这些已经成熟应用到实际生产中的优化手段去除掉,留下那些还没有非常好实现的优化原则,再来回想一下之前的性能优化分类:  

优化方向 优化手段
请求数量 合并脚本和样式表。拆分初始化负载
请求带宽 移除反复脚本
缓存利用 加入 Expires 头。配置 ETag,使 Ajax 可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出

  诚然,不可否认如今有非常多顶尖的前端团队能够将上述还剩下的优化原则也都一一解决,但业界大多数团队都还没能非常好的解决这些问题。因此接下来本文将就这些原则的解决方式做进一步的分析与解说,从而为那些还没有进入前端工业化开发的团队提供一些基础技术建设意见,也借此机会与业界顶尖的前端团队在工业化project化方向上交流一下彼此的心得。

  静态资源版本号更新与缓存

  如表格 2 所看到的,在“缓存利用”分类中保留了“加入 Expires 头”和“配置 ETag ”两项,也许有些人会质疑,明明这两项仅仅要配置了server的相关选项就能够实现。为什么说它们难以解决呢?确实,开启这两项非常easy,但开启了缓存后,我们的项目就開始面临还有一个挑战:怎样更新这些缓存。

  相信大多数团队也找到了类似的答案。它和《高性能站点建设指南》关于“加入 Expires 头”所说的原则一样——修订文件名称。即:

  思路没错。但要怎么改变链接呢?变成什么样的链接才干有效更新缓存。又能最大限度避免那些没有改动过的文件缓存不失效呢?

  先来看看如今一般前端团队的做法:

<script type="text/javascript" src="a.js?

t=20130825"></script>

  或者

<script type="text/javascript" src="a.js?

v=1.0.0"></script>

  大家会採用加入 query 的形式改动链接。这样做是比較直观的解决方式。但在訪问量较大的站点,这么做可能将面临一些新的问题。

  通常一个大型的 web 应用差点儿每天都会有迭代和更新,公布新版本号也就是公布新的静态资源和页面的过程。以上述代码为例。如果如今线上执行着 index.html 文件。而且使用了线上的 a.js 资源。index.html 的内容为:

<script type="text/javascript" src="a.js?v=1.0.0"></script>

  这次我们更新了页面中的一些内容。得到一个 index.html 文件,并开发了新的与之匹配的 a.js 资源来完毕页面交互。新的 index.html 文件的内容因此而变成了:

<script type="text/javascript" src="a.js?

v=1.0.1"></script>

  好了,如今要開始将两份新的文件公布到线上去。

能够看到,a.html 和 a.js 的资源实际上是要覆盖线上的同名文件的。

无论如何。在公布的过程中,index.html 和 a.js 总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。

对于一个大型互联网应用来说即使在一个非常小的时间间隔内,都有可能出现新用户訪问,而在这个时间间隔中訪问了站点的用户会发生什么情况呢:

  1. 假设先覆盖 index.html。后覆盖 a.js,用户在这个时间间隙訪问。会得到新的 index.html 配合旧的 a.js 的情况,从而出现错误的页面。
  2. 假设先覆盖 a.js,后覆盖 index.html,用户在这个间隙訪问,会得到旧的 index.html 配合新的 a.js 的情况,从而也出现了错误的页面。

  这就是为什么大型 web 应用在版本号上线的过程中常常会较集中的出现前端报错日志的原因。也是一些互联网公司选择加班到半夜等待訪问低峰期再上线的原因之中的一个。此外,因为静态资源文件版本号更新是“覆盖式”的,而页面须要通过改动 query 来更新,对于使用 CDN 缓存的 web 产品来说。还可能面临 CDN 缓存攻击的问题。

我们再来观察一下前面说的版本号更新手段:

<script type="text/javascript" src="a.js?

v=1.0.0"></script>

  我们不难预測,a.js 的下一个版本号是“ 1.0.1 ”。那么就能够刻意构造一串这种请求“ a.js?

v=1.0.1 ”、“ a.js?v=1.0.2 ”、……让 CDN 将当前的资源缓存为“未来的版本号”。这样当这个页面所用的资源有更新时。即使更改了链接地址。也会由于 CDN 的原因返回给用户旧版本号的静态资源,从而造成页面错误。即便不是刻意制造的攻击,在上线间隙出现訪问也可能导致区域性的 CDN 缓存错误。

  此外。当版本号有更新时,改动全部引用链接也是一件与project管理相悖的事,至少我们须要一个能够“查找 - 替换”的工具来自己主动化的解决版本号号改动的问题。

  对付这个问题,眼下来说最优方案就是基于文件内容的 hash 版本号冗余机制 了。也就是说,我们希望project师源代码是这么写的:

<script type="text/javascript" src="a.js"></script>

  可是线上代码是这种:

<script type="text/javascript" src="a_8244e91.js"></script>

  当中”_82244e91 ”这串字符是依据 a.js 的文件内容进行 hash 运算得到的。仅仅有文件内容发生变化了才会有更改。因为版本号序列是与文件名称写在一起的。而不是同名文件覆盖,因此不会出现上述说的那些问题。那么这么做都有哪些优点呢?

  1. 线上的 a.js 不是同名文件覆盖,而是文件名称 +h ash 的冗余。所以能够先上线静态资源,再上线 html 页面。不存在间隙问题;
  2. 遇到问题回滚版本号的时候,无需回滚 a.js,仅仅须回滚页面就可以;
  3. 因为静态资源版本号号是文件内容的 hash。因此全部静态资源能够开启永久强缓存,仅仅有更新了内容的文件才会缓存失效,缓存利用率大增;
  4. 改动静态资源后会在线上产生新的文件,一个文件相应一个版本号,因此不会受到构造 CDN 缓存形式的攻击

  尽管这种方案是相比之下最完美的解决方式,但它无法通过手工的形式来维护,由于要依靠手工的形式来计算和替换 hash 值并生成对应的文件将是一项很繁琐且easy出错的工作。

因此。我们须要借助工具。有了这种思路,我们以下就来了解一下 fis 是怎样完毕这项工作的。

  首先。之所以有这样的工具需求,全然是由于 web 应用执行的根本机制决定的:web 应用所需的资源是以字面的形式通知浏览器下载而聚合在一起执行的。

这样的资源载入策略使得 web 应用从本质上差别于传统桌面应用的版本号更新方式。也是大型 web 应用须要工具处理的最根本原因。

为了实现资源定位的字面量替换操作。前端构建工具理论上须要识别全部资源定位的标记。当中包含:

  • css 中的@import url(path)、background:url(path)、backgournd-image:url(path)、filter 中的 src
  • js 中的自己定义资源定位函数,在 fis 中我们将其规定为__uri(path)。
  • html 中的<script src=” path ”><link href=” path ”><img src=” path ”>、已经 embed、audio、video、object 等具有资源载入功能的标签。

  为了project上的维护方便。我们希望project师在源代码中写的是相对路径。而工具能够将其替换为线上的绝对路径,从而避免相对路径定位错误的问题(比方 js 中须要定位图片路径时不能使用相对路径的情况)。

image2

  fis 有一个很棒的资源定位系统,它是依据用户自己的配置来指定资源公布后的地址。然后由 fis 的资源定位系统识别文件里的定位标记,计算内容 hash,并依据配置替换为上线后的绝对 url 路径。

  要想实现具备 hash 版本号生成功能的构建工具不是“查找 - 替换”这么简单的,我们考虑这样一种情况:

image3

  因为我们的资源版本是通过对文件内容进行 hash 运算得到,如上图所看到的,index.html 中引用的 a.css 文件的内容事实上也包括了 a.png 的 hash 运算结果。因此我们在改动 index.html 中 a.css 的引用时。不能直接计算 a.css 的内容 hash,而是要先计算出 a.png 的内容 hash。替换 a.css 中的引用。得到了 a.css 的终于内容,再做 hash 运算,最后替换 index.html 中的引用。

  这意味着构建工具须要具备“递归编译”的能力,这也是为什么 fis 团队不得不放弃 gruntjs 等 task-based 系统的根本原因。

针对前端项目的构建工具必须是具备递归处理能力的。此外,因为文件之间的交叉引用等原因,fis 构建工具还实现了构建缓存等机制。以提升构建速度。

  在攻克了基于内容 hash 的版本号更新问题之后,我们能够将全部前端静态资源开启永久强缓存。每次版本号公布都能够首先让静态资源全量上线,再进一步上线模板或者页面文件,再也不用操心各种缓存和时间间隙的问题了!

  静态资源管理与模板框架

  让我们再来看看前面的优化原则表还剩些什么:  

优化方向 优化手段
请求数量 合并脚本和样式表,拆分初始化负载
请求带宽 移除反复脚本
缓存利用 使 Ajax 可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出

  非常不幸。剩下的优化原则都不是使用工具就能非常好实现的。也许有人会辩驳:“我用某某工具能够实现脚本和样式表合并”。

嗯,必须承认。使用工具进行资源合并并替换引用也许是一个不错的办法。但在大型 web 应用,这样的方式有一些非常严重的缺陷,来看一个非常熟悉的样例:

image4

  某个 web 产品页面有 A、B、C 三个资源

image5

  project师依据“降低 HTTP 请求”的优化原则合并了资源

image6

  产品经理要求 C 模块按需出现,此时 C 资源已出现多余的可能

image7

  C 模块不再须要了,凝视掉吧!但 C 资源通常不敢轻易剔除

  不知不觉中。性能优化变成了性能恶化……

  其实,使用工具在线下进行静态资源合并是无法解决资源按需载入的问题的。

假设解决不了按需载入。则势必会导致资源的冗余。此外,线下通过工具实现的资源合并一般会使得资源载入和使用的分离,比方在页面头部或配置文件里写资源引用及合并信息,而用到这些资源的 html 组件写在了页面其它地方,这样的书写方式在project上很easy引起维护不同步的问题,导致使用资源的代码删除了,引用资源的代码却还在的情况。因此。在工业上要实现资源合并至少要满足例如以下需求:

  1. 确实能降低 HTTP 请求。这是基本要求(合并)
  2. 在使用资源的地方引用资源(就近依赖),不使用不载入(按需)
  3. 尽管资源引用不是集中书写的,但资源引用的代码终于还能出如今页面头部(css)或尾部(js)
  4. 可以避免反复载入资源(去重)

  将以上要求综合考虑。不难发现,单纯依靠前端技术或者工具处理的是非常难达到这些理想要求的。现代大型 web 应用所展示的页面绝大多数都是使用服务端动态语言拼接生成的。有的产品使用模板引擎,比方 smarty、velocity,有的则干脆直接使用动态语言,比方 php、python。不管使用哪种方式实现。前端project师开发的 html 绝大多数终于都不是以静态的 html 在线上执行的,接下来我会讲述一种新的模板架构设计,用以实现前面说到那些性能优化原则。同一时候满足project开发和维护的须要,这样的架构设计的核心思想就是:

  考虑一段这种页面代码:

<html>
    <head>
        <title>hello world</title>
        <link rel="stylesheet" type="text/css" href="A.css">
        <link rel="stylesheet" type="text/css" href="B.css">
        <link rel="stylesheet" type="text/css" href="C.css">
    </head>
    <body>
        <div>html of A</div>
        <div>html of B</div>
        <div>html of C</div>
    </body>
</html>

  依据资源合并需求中的第二项,我们希望资源引用与使用能尽量靠近,这样将来维护起来会更easy一些。因此,理想的源代码是:

<html>
    <head>
        <title>hello world</title>
    </head>
    <body>
        <link rel="stylesheet" type="text/css" href="A.css"><div>html of A</div>
        <link rel="stylesheet" type="text/css" href="B.css"><div>html of B</div>
        <link rel="stylesheet" type="text/css" href="C.css"><div>html of C</div>
    </body>
</html>

  当然,把这种页面直接送达给浏览器用户是会有严重的页面闪烁问题的,所以我们实际上仍然希望终于页面输出的结果还是如最開始的截图一样,将 css 放在头部输出。

这就意味着,页面结构须要有一些调整,而且有能力收集资源载入需求。那么我们考虑一下这种源代码:

<html>
    <head>
        <title>hello world</title>
        <!--[CSS LINKS PLACEHOLDER]-->
    </head>
    <body>
        {require name="A.css"}<div>html of A</div>
        {require name="B.css"}<div>html of B</div>
        {require name="C.css"}<div>html of C</div>
    </body>
</html>

  在页面的头部插入一个 html 凝视“<!--[CSS LINKS PLACEHOLDER]-->”作为占位,而将原来字面书写的资源引用改成模板接口(require)调用。该接口负责收集页面所需资源。require 接口实现很easy,就是准备一个数组,收集资源引用。而且能够去重。

最后在页面输出的前一刻,我们将 require 在执行时收集到的“ A.css ”、“ B.css ”、“ C.css ”三个资源拼接成 html 标签。替换掉凝视占位“<!--[CSS LINKS PLACEHOLDER]-->”,从而得到我们须要的页面结构。

  经过 fis 团队的总结,我们发现模板层面仅仅要实现三个开发接口,既能够比較完美的实现眼下遗留的大部分性能优化原则,这三个接口各自是:

  1. require(String id):收集资源载入需求的接口。參数是资源 id。

  2. widget(String template_id):载入拆分成小组件模板的接口。你能够叫它为 load、component 或者 pagelet 之类的。总之,我们须要一个接口把一个大的页面模板拆分成一个个的小部分来维护,最后在原来的大页面以组件为单位来载入这些小部件。
  3. script(String code):收集写在模板中的 js 脚本。使之出现的页面底部,从而实现性能优化原则中的“将 js 放在页面底部”原则。

  实现了这些接口之后,一个重构后的模板页面的源码可能看起来就是这种了:

<html>
    <head>
        <title>hello world</title>
        <!--[CSS LINKS PLACEHOLDER]-->
        {require name="jquery.js"}
        {require name="bootstrap.css"}
    </head>
    <body>
        {require name="A/A.css"}{widget name="A/A.tpl"}
        {script}console.log(\'A loaded\'){/script}
        {require name="B/B.css"}{widget name="B/B.tpl"}
        {require name="C/C.css"}{widget name="C/C.tpl"}
        <!--[SCRIPTS PLACEHOLDER]-->
    </body>
</html>

  而终于在模板解析的过程中,资源收集与去重、页面 script 收集、占位符替换操作。终于从服务端发送出来的 html 代码为: