首页 > 代码库 > 前端project与性能优化(长文)

前端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="http://www.mamicode.com/a.js?

t=20130825"></script>

  或者

<script type="text/javascript" src="http://www.mamicode.com/a.js?

v=1.0.0"></script>

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

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

<script type="text/javascript" src="http://www.mamicode.com/a.js?v=1.0.0"></script>

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

<script type="text/javascript" src="http://www.mamicode.com/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="http://www.mamicode.com/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="http://www.mamicode.com/a.js"></script>

  可是线上代码是这种:

<script type="text/javascript" src="http://www.mamicode.com/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=http://www.mamicode.com/” path ”><link href=http://www.mamicode.com/” path ”><img src=http://www.mamicode.com/”>、已经 embed、audio、video、object 等具有资源载入功能的标签。

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

技术分享

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

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

技术分享

  因为我们的资源版本是通过对文件内容进行 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 应用,这样的方式有一些非常严重的缺陷,来看一个非常熟悉的样例:

技术分享

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

技术分享

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

技术分享

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

技术分享

  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 代码为:

<html>
    <head>
        <title>hello world</title>
        <link rel="stylesheet" type="text/css" href="bootstrap.css">
        <link rel="stylesheet" type="text/css" href="A/A.css">
        <link rel="stylesheet" type="text/css" href="B/B.css">
        <link rel="stylesheet" type="text/css" href="C/C.css">
    </head>
    <body>
        <div>html of A</div>
        <div>html of B</div>
        <div>html of C</div>
        <script type="text/javascript" src="jquery.js"></script>
        <script type="text/javascript">console.log(A loaded);</script>
    </body>
</html>

  不难看出。我们眼下已经实现了“按需载入”。“将脚本放在底部”,“将样式表放在头部”三项优化原则。

  前面讲到静态资源在上线后须要加入 hash 戳作为版本号标识,那么这样的使用模板语言来收集的静态资源该怎样实现这项功能呢?答案是:静态资源依赖关系表。

如果前面讲到的模板源码所相应的文件夹结构为下图所看到的:

技术分享

  那么我们能够使用工具扫描整个 project 文件夹。然后创建一张资源表,同一时候记录每一个资源的部署路径。能够得到这种一张表:

{
    "res": {
        "A/A.css": {
            "uri": "/A/A_1688c82.css",
            "type": "css"
        },
        "B/B.css": {
            "uri": "/B/B_52923ed.css",
            "type": "css"
        },
        "C/C.css": {
            "uri": "/C/C_6dda653.css",
            "type": "css"
        },
        "bootstrap.css": {
            "uri": "bootstrap_08f2256.css",
            "type": "css"
        },
        "jquery.js": {
            "uri": "jquery_9155343.css",
            "type": "js"
        },
    },
    "pkg": {}
}

  基于这张表。我们就非常easy实现 {require name=” id ”} 这个模板接口了。

仅仅须查表就可以。比方运行{require name=” jquery.js ”},查表得到它的 url 是“/jquery_9151577.js ”,声明一个数组收集起来就好了。这样,整个页面运行完成之后。收集资源载入需求,并替换页面的占位符。就可以实现资源的 hash 定位,得到:

<html>
    <head>
        <title>hello world</title>
        <link rel="stylesheet" type="text/css" href="bootstrap_08f2256.css">
        <link rel="stylesheet" type="text/css" href="A/A_1688c82.css">
        <link rel="stylesheet" type="text/css" href="B/B_52923ed.css">
        <link rel="stylesheet" type="text/css" href="C/C_6dda653.css">
    </head>
    <body>
        <div>html of A</div>
        <div>html of B</div>
        <div>html of C</div>
        <script type="text/javascript" src="jquery_9155343.js"></script>
        <script type="text/javascript">console.log(A loaded);</script>
    </body>
</html>

  接下来。我们讨论怎样在基于表的设计思想上是怎样实现静态资源合并的。也许有些团队使用过 combo 服务,也就是我们在终于拼接生成页面资源引用的时候,并非生成多个独立的 link 标签。而是将资源地址拼接成一个 url 路径。请求一种线上的动态资源合并服务,从而实现降低 HTTP 请求的需求,比方:

<html>
    <head>
        <title>hello world</title>
        <link rel="stylesheet" type="text/css" href="/combo?files=bootstrap_08f2256.css,A/A_1688c82.css,B/B_52923ed.css,C/C_6dda653.css">
    </head>
    <body>
        <div>html of A</div>
        <div>html of B</div>
        <div>html of C</div>
        <script type="text/javascript" src="jquery_9155343.js"></script>
        <script type="text/javascript">console.log(A loaded);</script>
    </body>
</html>

  这个“/combo?files=file1,file2,file3,…”的 url 请求响应就是动态 combo 服务提供的,它的原理非常easy,就是依据 get 请求的 files 參数找到相应的多个文件,合并成一个文件来响应请求。并将其缓存,以加快訪问速度。

  这样的方法非常巧妙。有些server甚至直接集成了这类模块来方便的开启此项服务,这样的做法也是大多数大型 web 应用的资源合并做法。

但它也存在一些缺陷:

  1. 浏览器有 url 长度限制。因此不能无限制的合并资源。
  2. 假设用户在站点内有公共资源的两个页面间跳转訪问,因为两个页面的 combo 的 url 不一样导致用户不能利用浏览器缓存来加快对公共资源的訪问速度。

  对于上述第二条缺陷。能够举个样例来看说明:

  • 如果站点有两个页面 A 和 B
  • A 页面使用了 a,b。c。d 四个资源
  • B 页面使用了 a,b。e。f 四个资源
  • 如果使用 combo 服务。我们会得:
  • A 页面的资源引用为:/combo?files=a,b,c,d
  • B 页面的资源引用为:/combo?

    files=a,b,e,f

  • 两个页面引用的资源是不同的 url,因此浏览器会请求两个合并后的资源文件。跨页面訪问没能非常好的利用 a、b 这两个资源的缓存。

  非常明显。假设 combo 服务能聪明的知道 A 页面使用的资源引用为“/combo?files=a,b ”和“/combo?files=c,d ”,而 B 页面使用的资源引用为“/combo?files=a,b ”,“/combo?files=e,f ”就好了。

这样当用户在訪问 A 页面之后再訪问 B 页面时,仅仅须要下载 B 页面的第二个 combo 文件就可以。第一个文件已经在訪问 A 页面时缓存好了的。

  基于这种思考。fis 在资源表上新增了一个字段。取名为“ pkg ”,就是资源合并生成的新资源,表的结构会变成:

{
    "res": {
        "A/A.css": {
            "uri": "/A/A_1688c82.css",
            "type": "css"
        },
        "B/B.css": {
            "uri": "/B/B_52923ed.css",
            "type": "css"
        },
        "C/C.css": {
            "uri": "/C/C_6dda653.css",
            "type": "css"
        },
        "bootstrap.css": {
            "uri": "bootstrap_08f2256.css",
            "type": "css"
        },
        "jquery.js": {
            "uri": "jquery_9155343.css",
            "type": "js"
        },
    },
    "pkg": {
        "p0": {
            "uri": "/pkg/utils_b967346.css",
            "type": "css",
            "has": ["bootstrap.css", "A/A.css"]
        },
        "p1": {
            "uri": "/pkg/others_0d4552a.css",
            "type": "css",
            "has": ["B/B.css", "C/C.css"]
        }
    }
}

  相比之前的表,能够看到新表中多了一个 pkg 字段,而且记录了打包后的文件所包括的独立资源。

这样,我们又一次设计一下{require name=” id ”}这个模板接口:在查表的时候,假设一个静态资源有 pkg 字段,那么就去载入 pkg 字段所指向的打包文件,否则载入资源本身。比方运行{require name=” bootstrap.css ”}。查表得知 bootstrap.css 被打包在了“ p0 ”中。因此取出 p0 包的 url “/pkg/utils_b967346.css ”,而且记录页面已载入了“ bootstrap.css ”和“ A/A.css ”两个资源。

这样一来,之前的模板代码运行之后得到的 html 就变成了:

<html>
    <head>
        <title>hello world</title>
        <link rel="stylesheet" type="text/css" href="pkg/utils_b967346.css">
        <link rel="stylesheet" type="text/css" href="pkg/others_0d4552a.css">
    </head>
    <body>
        <div>html of A</div>
        <div>html of B</div>
        <div>html of C</div>
        <script type="text/javascript" src="jquery_9155343.js"></script>
        <script type="text/javascript">console.log(A loaded);</script>
    </body>
</html>

  css 资源请求数由原来的 4 个降低为 2 个。这种打包结果是怎么来的呢?答案是配置得到的。

我们来看一下带有打包结果的资源表的 fis 配置:

fis.config.set(‘pack‘, {
    ‘pkg/util.css‘: [ ‘bootstrap.css‘, ‘A/A.css‘],
    ‘pkg/other.css‘: [ ‘**.css‘ ]
});

  我们将“ bootstrap.css ”、“ A/A.css ”打包在一起。其它 css 另外打包。从而生成两个打包文件,当页面须要打包文件里的资源时,模块框架就会收集并计算出最优的资源载入结果。从而解决静态资源合并的问题。

  这样做的原因是为了弥补 combo 在前面讲到的两点技术上的不足而设计的。但也不难发现这样的打包策略是须要配置的。这就意味着维护成本的添加。

但好在它有两个优势能够一定程度上弥补这个问题:

  1. 打包的资源仅仅是原来独立资源的备份。打包与否不会导致资源的丢失,最多是没有合并的非常好而已。

  2. 配置能够由project师依据经验人工维护。也能够由统计日志生成,这为性能优化自适应站点设计提供了非常好的基础。

  关于第二点。fis 有这样辅助系统来支持自适应打包算法:

技术分享

  至此,我们通过基于表的静态资源管理系统和三个模板接口实现了几个重要的性能优化原则,如今我们再来回想一下前面的性能优化原则分类表,剔除掉已经做到了的。看看还剩下哪些没做到的:  

优化方向 优化手段
请求数量 拆分初始化负载
请求带宽 拆分初始化负载
缓存利用 使 Ajax 可缓存
页面结构 尽早刷新文档的输出

  “拆分初始化负载”的目标是将页面一開始载入时不须要运行的资源从全部资源中分离出来,等到须要的时候再载入。project师通常没有耐心去区分资源的分类情况,但我们能够利用组件化框架接口来帮助project师管理资源的使用。还是从样例開始思考:

<html>
<head>
    <title>hello world</title>
    {require name="jquery.js"}
</head>
<body>
    <button id="myBtn">Click Me</button>
    {script}
        $(‘#myBtn‘).click(function(){
            var dialog = require(‘dialog/dialog.js‘);
            dialog.alert(‘you catch me!‘);
        });
    {/script}
    <!--[SCRIPTS PLACEHOLDER]-->
</body>
</html>

  在 fis 给百度内部团队开发的架构中,假设这样书写代码,页面终于的运行结果会变成:

<html>
<head>
    <title>hello world</title>
</head>
<body>
    <button id="myBtn">Click Me</button>
    <script type="text/javascript" src="/jquery_9151577.js"></script>
    <script type="text/javascript" src="/dialog/dialog_ae8c228.js"></script>
    <script type="text/javascript">
    $(#myBtn).click(function(){
        var dialog = require(dialog/dialog.js);
        dialog.alert(you catch me!);
    });
    </script>
    <!--[SCRIPTS PLACEHOLDER]-->
</body>
</html>

  fis 系统会分析页面中 require(id)函数的调用,并将依赖关系记录到资源表相应资源的 deps 字段中,从而在页面渲染查表时能够载入依赖的资源。

但此时 dialog.js 是以 script 标签的形式同步载入的,这样会在页面初始化时出现资源的浪费。因此。fis 团队提供了 require.async 的接口。用于异步载入一些资源。源代码改动为:

<html>
<head>
    <title>hello world</title>
    {require name="jquery.js"}
</head>
<body>
    <button id="myBtn">Click Me</button>
    {script}
        $(‘#myBtn‘).click(function() {
            require.async(‘dialog/dialog.js‘, function( dialog ) {
                dialog.alert(‘you catch me!‘);
            });
        });
    {/script}
    <!--[SCRIPTS PLACEHOLDER]-->
</body>
</html>

  这样书写之后。fis 系统会在表里以 async 字段来标准资源依赖关系是异步的。fis 提供的静态资源管理系统会将页面输出的结果改动为:

<html>
<head>
    <title>hello world</title>
</head>
<body>
    <button id="myBtn">Click Me</button>
    <script type="text/javascript" src="/jquery_9151577.js"></script>
    <script type="text/javascript" src="/dialog/dialog_ae8c228.js"></script>
    <script type="text/javascript">
    $(#myBtn).click(function() {
        require.async(dialog/dialog.js, function( dialog ) {
            dialog.alert(you catch me!);
        });
    });
    </script>
    <!--[SCRIPTS PLACEHOLDER]-->
</body>
</html>

  dialog.js 不会在页面以 script src 的形式输出。而是变成了资源注冊。这样,当页面点击button触发 require.async 运行的时候,async 函数才会查表找到资源的 url 并载入它,载入完成后触发回调函数。

  到眼下为止,我们又以架构的形式实现了一项优化原则(拆分初始化负载),回想我们的优化分类表,如今仅有两项没能做到了:  

优化方向 优化手段
缓存利用 使 Ajax 可缓存
页面结构 尽早刷新文档的输出

  剩下的两项优化原则要做到并不easy。真正可缓存的 Ajax 在现实开发中比較少见。而尽早刷新文档的输出的情况 facebook 在 2010 年的 velocity 上提到过。就是 BigPipe 技术。

当时 facebook 团队还讲到了 Quickling 和 PageCache 两项技术,当中的 PageCache 算是比較彻底的实现 Ajax 可缓存的优化原则了。fis 团队也曾与某产品线合作基于静态资源表、模板组件化等技术实现了页面的 PipeLine 输出、以及 Quickling 和 PageCache 功能。但终于效果没有达到理想的性能优化预期,因此这两个方向尚在探索中,相信在不久的将来会有新的突破。

  总结

  事实上在前端开发project管理领域还有非常多细节值得探索和挖掘,提升前端团队生产力水平并非一句空话,它须要我们能对前端开发及代码执行有更深刻的认识。对性能优化原则有更仔细的分析与研究。fis 团队一直致力于从架构而非经验的角度实现性能优化原则;解决前端project师开发、调试、部署中遇到的project问题。提供组件化框架,提高代码复用率;提供开发工具集,提升project师的开发效率。

在前端工业化开发的全部环节均有可节省的人力成本,这些成本非常可观。相信如今非常多大型互联网公司也都有了这种共识。本文仅仅是将这个领域中非常小的一部分知识的展开讨论,抛砖引玉。希望能为业界相关领域的工作者提供一些不一样的思路。欢迎关注fis项目,对本文有不论什么意见或建议都能够在 fis 开源项目中进行反馈和讨论。


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