前端工程——基础篇
最后更新于:2022-04-02 01:21:54
> 原文出处:https://github.com/fouber/blog/issues/10
> 作者:[fouber](https://github.com/fouber)
* * * * *
目录
[TOC]
> 喂喂喂,那个切图的,把页面写好就发给研发工程师套模板吧。
你好,切图仔。
不知道你的团队如何定义前端开发,据我所知,时至今日仍然有很多团队会把前端开发归类为产品或者设计岗位,虽然身份之争多少有些无谓,但我对这种偏见还是心存芥蒂,酝酿了许久,决定写一个系列的文章,试着从工程的角度系统的介绍一下我对前端,尤其是Web前端的理解。
只要我们还把自己的工作看作为一项软件开发活动,那么我相信读过下面的内容你也一定会有所共鸣。
## 前端,是一种GUI软件
现如今前端可谓包罗万象,产品形态五花八门,涉猎极广,什么高大上的基础库/框架,拽炫酷的宣传页面,还有屌炸天的小游戏……不过这些一两个文件的小项目并非是前端技术的主要应用场景,更具商业价值的则是复杂的Web应用,它们功能完善,界面繁多,为用户提供了完整的产品体验,可能是新闻聚合网站,可能是在线购物平台,可能是社交网络,可能是金融信贷应用,可能是音乐互动社区,也可能是视频上传与分享平台……
> 从本质上讲,所有Web应用都是一种运行在网页浏览器中的软件,这些软件的图形用户界面(Graphical User Interface,简称GUI)即为前端。
如此复杂的Web应用,动辄几十上百人共同开发维护,其前端界面通常也颇具规模,工程量不亚于一般的传统GUI软件:
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11d3b09fb.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11d3b09fb.png)
尽管Web应用的复杂程度与日俱增,用户对其前端界面也提出了更高的要求,但时至今日仍然没有多少前端开发者会从软件工程的角度去思考前端开发,来助力团队的开发效率,更有甚者还对前端保留着”如玩具般简单“的刻板印象,日复一日,刀耕火种。
历史悠久的前端开发,始终像是放养的野孩子,原始如斯,不免让人慨叹!
## 前端工程的三个阶段
现在的前端开发倒也并非一无所有,回顾一下曾经经历过或听闻过的项目,为了提升其前端开发效率和运行性能,前端团队的工程建设大致会经历三个阶段:
### 第一阶段:库/框架选型
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11d69da8d.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11d69da8d.png)
前端工程建设的第一项任务就是根据项目特征进行技术选型。
基本上现在没有人完全从0开始做网站,哪怕是政府项目用个jquery都很正常吧,React/Angularjs等框架横空出世,解放了不少生产力,合理的技术选型可以为项目节省许多工程量这点毋庸置疑。
### 第二阶段:简单构建优化
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11d8ad8f0.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11d8ad8f0.png)
选型之后基本上就可以开始敲码了,不过光解决开发效率还不够,必须要兼顾运行性能。前端工程进行到第二阶段会选型一种构建工具,对代码进行压缩,校验,之后再以页面为单位进行简单的资源合并。
前端开发工程化程度之低,常常出乎我的意料,我之前在百度工作时是没有多少概念的,直到离开大公司的温室,去到业界与更多的团队交流才发现,能做到这个阶段在业界来说已然超出平均水平,属于“具备较高工程化程度”的团队了,查看网上形形色色的网页源代码,能做到最基本的JS/CSS压缩的Web应用都已跨入标准互联网公司行列,不难理解为什么很多前端团队对于前端工程构建的认知还仅停留在“压缩、校验、合并”这种程度。
### 第三阶段:JS/CSS模块化开发
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11ddd5414.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11ddd5414.png)
分而治之是软件工程中的重要思想,是复杂系统开发和维护的基石,这点放在前端开发中同样适用。在解决了基本开发效率运行效率问题之后,前端团队开始思考维护效率,模块化是目前前端最流行的分治手段。
> 很多人觉得模块化开发的工程意义是复用,我不太认可这种看法,在我看来,模块化开发的最大价值应该是分治,是分治,分治!(重说三)。
>
> 不管你将来是否要复用某段代码,你都有充分的理由将其分治为一个模块。
JS模块化方案很多,AMD/CommonJS/UMD/ES6 Module等,对应的框架和工具也一大堆,说起来很烦,大家自行百度吧;CSS模块化开发基本都是在less、sass、stylus等预处理器的import/mixin特性支持下实现的。
虽然这些技术由来已久,在如今这个“言必及React”的时代略显落伍,但想想业界的绝大多数团队的工程化落后程度,放眼望去,毫不夸张的说,能达到第三阶段的前端团队已属于高端行列,基本具备了开发维护一般规模Web应用的能力。
然而,做到这些就够了么?Naive!
## 第四阶段
> 前端是一种技术问题较少、工程问题较多的软件开发领域。
当我们要开发一款完整的Web应用时,前端将面临更多的工程问题,比如:
* 大体量:多功能、多页面、多状态、多系统;
* 大规模:多人甚至多团队合作开发;
* 高性能:CDN部署、[缓存控制](https://developers.google.com/speed/docs/insights/LeverageBrowserCaching)、[文件指纹](https://developers.google.com/speed/docs/insights/LeverageBrowserCaching)、缓存复用、请求合并、按需加载、同步/异步加载、移动端[首屏CSS内嵌](https://developers.google.com/speed/docs/insights/mobile)、HTTP 2.0服务端[资源推送](http://chimera.labs.oreilly.com/books/1230000000545/ch12.html#HTTP2_PUSH)。
> 扩展阅读:[大公司里怎样开发和部署前端代码?](https://github.com/fouber/blog/issues/6)
这些无疑是一系列严肃的系统工程问题。
前面讲的三个阶段虽然相比曾经“茹毛饮血”的时代进步不少,但用于支撑第四阶段的多人合作开发以及精细的性能优化似乎还欠缺点什么。
到底,缺什么呢?
## 没有银弹
读过《[人月神话](http://book.douban.com/subject/2230248/)》的人应该都听说过,软件工程 [没有银弹](http://baike.baidu.com/view/277972.htm)。没错,前端开发同样没有银弹,可是现在是连™铅弹都没有的年月!(刚有了BB弹,摔)
前端历来以“简单”著称,在前端开发者群体中,小而美的价值观占据着主要的话语权,甚至成为了某种信仰,想与其他人交流一下工程方面的心得,得到的回应往往都是两个字:太重。
> 重你妹!你的脑容量只有4K吗?
工程方案其实也可以小而美!只不过它的小而美不是指代码量,而是指“规则”。找到问题的根源,用最少最简单明了的规则制定出最容易遵守最容易理解的开发规范或工具,以提升开发效率和工程质量,这同样是小而美的典范!
2011年我有幸参与到 [FIS](http://fis.baidu.com/) 项目中,与百度众多大中型项目的前端研发团队共同合作,不断探索实践前端开发的工程化解决方案,13年离开百度去往UC,面对完全不同的产品形态,不同的业务场景,不同的适配终端,甚至不同的网络环境,过往的方法论仍然能够快速落地,为多个团队的不同业务场景量身定制出合理的前端解决方案。
这些经历让我明悟了一个道理:
> 进入第四阶段,我们只需做好两件事就能大幅提升前端开发效率,并且兼顾运行性能,那就是——组件化开发与资源管理。
### 第一件事:组件化开发
分治的确是非常重要的工程优化手段。在我看来,前端作为一种GUI软件,光有JS/CSS的模块化还不够,对于UI组件的分治也有着同样迫切的需求:
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e05db51.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e05db51.png)
如上图,这是我所信仰的前端组件化开发理念,简单解读一下:
1. 页面上的每个 **独立的** 可视/可交互区域视为一个组件;
2. **每个组件对应一个工程目录**,组件所需的各种资源都在这个目录下**就近维护**;
3. 由于组件具有独立性,因此组件与组件之间可以 **自由组合**;
4. 页面只不过是组件的容器,负责组合组件形成功能完整的界面;
5. 当不需要某个组件,或者想要替换组件时,可以整个目录删除/替换。
其中第二项描述的就近维护原则,是我觉得最具工程价值的地方,它为前端开发提供了很好的分治策略,每个开发者都将清楚的知道,自己所开发维护的功能单元,其代码必然存在于对应的组件目录中,在那个目录下能找到有关这个功能单元的所有内部逻辑,样式也好,JS也好,页面结构也好,都在那里。
组件化开发具有较高的通用性,无论是前端渲染的单页面应用,还是后端模板渲染的多页面应用,组件化开发的概念都能适用。组件HTML部分根据业务选型的不同,可以是静态的HTML文件,可以是前端模板,也可以是后端模板:
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e292bc8.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e292bc8.png)
> 不同的技术选型决定了不同的组件封装和调用策略。
基于这样的工程理念,我们很容易将系统以独立的组件为单元进行分工划分:
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e617541.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e617541.png)
由于系统功能被分治到独立的模块或组件中,粒度比较精细,组织形式松散,开发者之间不会产生开发时序的依赖,大幅提升并行的开发效率,理论上允许随时加入新成员认领组件开发或维护工作,也更容易支持多个团队共同维护一个大型站点的开发。
结合前面提到的模块化开发,整个前端项目可以划分为这么几种开发概念:
| 名称 | 说明 | 举例 |
| --- | --- | --- |
| JS模块 | 独立的算法和数据单元 | 浏览器环境检测(detect),网络请求(ajax),应用配置(config),DOM操作(dom),工具函数(utils),以及组件里的JS单元 |
| CSS模块 | 独立的功能性样式单元 | 栅格系统(grid),字体图标(icon-fonts),动画样式(animate),以及组件里的CSS单元 |
| UI组件 | 独立的可视/可交互功能单元 | 页头(header),页尾(footer),导航栏(nav),搜索框(search) |
| 页面 | 前端这种GUI软件的界面状态,是UI组件的容器 | 首页(index),列表页(list),用户管理(user) |
| 应用 | 整个项目或整个站点被称之为应用,由多个页面组成 | |
以上5种开发概念以相对较少的规则组成了前端开发的基本工程结构,基于这些理念,我眼中的前端开发就成了这个样子:
| 示意图 | 描述 |
| --- | --- |
| [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e8ed52e.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11e8ed52e.png) | 整个Web应用由页面组成 |
| [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11eb1e535.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11eb1e535.png) | 页面由组件组成 |
| [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11edb12b4.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11edb12b4.png) | 一个组件一个目录,资源就近维护 |
| [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11f3127e4.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11f3127e4.png) | 组件可组合,
组件的JS可依赖其他JS模块,
CSS可依赖其他CSS单元 |
综合上面的描述,对于一般中小规模的项目,大致可以规划出这样的源码目录结构:
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11fabbf40.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11fabbf40.png)
如果项目规模较大,涉及多个团队协作,还可以将具有相关业务功能的页面组织在一起,形成一个子系统,进一步将整个站点拆分出多个子系统来分配给不同团队维护,针对这种情况后面我会单开文章详细介绍。
以上架构设计历经许多不同公司不同业务场景的前端团队验证,收获了不错的口碑,是行之有效的前端工程分治方案。
> 吐槽:我本人非常反对某些前端团队将前端开发划分为“JS开发”和“页面重构”两种岗位,更倾向于组件粒度的开发理念,对GUI软件开发的分工规划应该以功能为单位,而不是开发语言;对开发者的技术要求也应该是掌握完整的端内技术。
### 第二件事:“智能”静态资源管理
上面提到的模块化/组件化开发,仅仅描述了一种开发理念,也可以认为是一种开发规范,倘若你认可这规范,对它的分治策略产生了共鸣,那我们就可以继续聊聊它的具体实现了。
很明显,模块化/组件化开发之后,我们最终要解决的,就是模块/组件加载的技术问题。然而前端与客户端GUI软件有一个很大的不同:
> 前端是一种远程部署,运行时增量下载的GUI软件
前端应用没有安装过程,其所需程序资源都部署在远程服务器,用户使用浏览器访问不同的页面来加载不同的资源,随着页面访问的增加,渐进式的将整个程序下载到本地运行,“增量下载”是前端在工程上有别于客户端GUI软件的根本原因。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec120448b3f.gif)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec120448b3f.gif)
上图展示了一款界面繁多功能丰富的应用,如果采用Web实现,相信也是不小的体量,如果用户第一次访问页面就强制其加载全站静态资源再展示,相信会有很多用户因为失去耐心而流失。根据“增量”的原则,我们应该精心规划每个页面的资源加载策略,使得用户无论访问哪个页面都能按需加载页面所需资源,没访问过的无需加载,访问过的可以缓存复用,最终带来流畅的应用体验。
这正是Web应用“免安装”的魅力所在。
由“增量”原则引申出的前端优化技巧几乎成为了性能优化的核心,有加载相关的按需加载、延迟加载、预加载、请求合并等策略;有缓存相关的浏览器缓存利用,缓存更新、缓存共享、非覆盖式发布等方案;还有复杂的BigRender、BigPipe、Quickling、PageCache等技术。这些优化方案无不围绕着如何将增量原则做到极致而展开。
所以我觉得:
> 第四阶段前端开发最迫切需要做好的就是在基础架构中贯彻增量原则。
相信这种贯彻不会随着时间的推移而改变,在可预见的未来,无论在HTTP1.x还是HTTP2.0时代,无论在ES5亦或者ES6/7时代,无论是AMD/CommonJS/UMD亦或者ES6 module时代,无论端内技术如何变迁,我们都有足够充分的理由要做好前端程序资源的增量加载。
正如前面说到的,第三阶段前端工程缺少点什么呢?我觉得是在其基础架构中缺少这样一种“智能”的资源加载方案。没有这样的方案,很难将前端应用的规模发展到第四阶段,很难实现落地前面介绍的那种组件化开发方案,也很难让多方合作高效率的完成一项大型应用的开发,并保证其最终运行性能良好。在第四阶段,我们需要强大的工程化手段来管理”玩具般简单“的前端开发。
在我的印象中,Facebook是这方面探索的伟大先驱之一,早在2010年的[Velocity China大会](http://velocity.oreilly.com.cn/2010/)上,来自Facebook的[David Wei博士](http://davidwei.org/)就为业界展示了他们令人惊艳的[静态网页资源管理和优化](http://velocity.oreilly.com.cn/2010/index.php?func=session&name=%E9%9D%99%E6%80%81%E7%BD%91%E9%A1%B5%E8%B5%84%E6%BA%90%E7%9A%84%E7%AE%A1%E7%90%86%E5%92%8C%E4%BC%98%E5%8C%96)技术。
David Wei博士在当年的交流会上提到过一些关于Facebook的一些产品数据:
> * Facebook整站有10000+个静态资源;
> * 每个静态资源都有可能被翻译成超过100种语言版本;
> * 每种资源又会针对浏览器生成3种不同的版本;
> * 要针对不同带宽的用户做5种不同的打包方法;
> * 有3、4个不同的用户组,用于小批次体验新的产品功能;
> * 还要考虑不同的送达方法,可以直接送达,或者通过iframe的方式提升资源并行加载的速度;
> * 静态资源的压缩和非压缩状态可切换,用于调试和定位线上问题
这是一个状态爆炸的问题,将所有状态乘起来,整个网站的资源组合方式会达到几百万种之多(去重之后统计大概有300万种组合方式)。支撑这么大规模前端项目运行的底层架构正是魏博士在那次演讲中分享的[Static Resource Management System](http://velocity.oreilly.com.cn/2010/ppts/VelocityChina2010Dec7StaticResource.pdf)(静态资源管理系统),用以解决Facebook项目中有关前端工程的3D问题(Development,Deployment,Debugging)。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec12c741d9c.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec12c741d9c.png)
那段时间 [FIS](http://fis.baidu.com/) 项目正好遇到瓶颈,当时的FIS还是一个用php写的task-based构建工具,那时候对于前端工程的认知度很低,觉得前端构建不就是几个压缩优化校验打包任务的组合吗,写好流程调度,就针对不同需求写插件呗,看似非常简单。但当我们支撑越来越多的业务团队,接触到各种不同的业务场景时,我们深刻的感受到task-based工具的粗糙,团队每天疲于根据各种业务场景编写各种打包插件,构建逻辑异常复杂,隐隐看到不可控的迹象。
我们很快意识到把基础架构放到构建工具中实现是一件很愚蠢的事,试图依靠构建工具实现各种优化策略使得构建变成了一个巨大的黑盒,一旦发生问题,定位起来非常困难,而且每种业务场景都有不同的优化需求,构建工具只能通过静态分析来优化加载,具有很大的局限性,单页面/多页面/PC端/移动端/前端渲染/后端渲染/多语言/多皮肤/高级优化等等资源加载问题,总不能给每个都写一套工具吧,更何况这些问题彼此之间还可以有多种组合应用,工具根本写不过来。
Facebook的做法无疑为我们亮起了一盏明灯,不过可惜它并不开源(不是技术封锁,而是这个系统依赖FB体系中的其他方面,通用性不强,开源意义不大),我们只能尝试挖掘相关信息,网上对它的完整介绍还是非常非常少,分析facebook的前端代码也没有太多收获,后来无意中发现了facebook使用的项目管理工具[phabricator](http://phabricator.org/)中的一个静态管理方案[Celerity](https://secure.phabricator.com/book/phabdev/article/celerity/),以及相关的[说明](https://secure.phabricator.com/book/phabflavor/article/soon_static_resources/),看它的描述很像是Facebook静态资源管理系统的一个mini版!
简单看过整个系统之后发现原理并不复杂(小而美的典范),它是通过一个小工具扫描所有静态资源,生成一张资源表,然后有一个PHP实现的资源管理框架(Celerity)提供了资源加载接口,替代了传统的script/link等静态的资源加载标签,最终通过查表来加载资源。
虽然没有真正看过FB的那套系统,但眼前的这个小小的框架给了当时的我们足够多的启示:
> 静态资源管理系统 = 资源表 + 资源加载框架
多么优雅的实现啊!
资源表是一份数据文件(比如JSON),是项目中所有静态资源(主要是JS和CSS)的构建信息记录,通过构建工具扫描项目源码生成,是一种k-v结构的数据,以每个资源的id为key,记录了资源的类别、部署路径、依赖关系、打包合并等内容,比如:
~~~
{
"a.js": {
"url": "/static/js/a.5f100fa.js",
"dep": [ "b.js", "a.css" ]
},
"a.css": {
"url": "/static/css/a.63cf374.css",
"dep": [ "button.css" ]
},
"b.js": {
"url": "/static/js/b.97193bf.js"
},
"button.css": {
"url": "/static/css/button.de33108.js"
}
}
~~~
而资源加载框架则提供一些资源引用的API,让开发者根据id来引用资源,替代静态的script/link标签来收集、去重、按需加载资源。调用这些接口时,框架通过查表来查找资源的各项信息,并递归查找其依赖的资源的信息,然后我们可以在这个过程中实现各种性能优化算法来“智能”加载资源。
根据业务场景的不同,加载框架可以在浏览器中用JS实现,也可以是后端模板引擎中用服务端语言实现,甚至二者的组合,不一而足。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec12d8f0f95.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec12d8f0f95.png)
> 有关加载框架的具体实现我曾写过很多文章介绍,可以扩展阅读:
>
> * [前端工程与性能优化](https://github.com/fouber/blog/issues/3)
> * [前端工程与模块化框架](https://github.com/fouber/blog/issues/4)
这种设计很快被验证具有足够的灵活性,能够完美支撑不同团队不同技术规范下的性能优化需求,前面提到的按需加载、延迟加载、预加载、请求合并、文件指纹、CDN部署、Bigpipe、Quickling、BigRender、首屏CSS内嵌、HTTP 2.0服务端推送等等性能优化手段都可以很容易的在这种架构上实现,甚至可以根据性能日志自动进行优化(Facebook已实现)。
因为有了资源表,我们可以很方便的控制资源加载,通过各种手段在运行时计算页面的资源使用情况,从而获得最佳加载性能。无论是前端渲染的单页面应用,还是后端渲染的多页面应用,这种方法都同样适用。
此外,它还很巧妙的约束了构建工具的职责——只生成资源表。资源表是非常通用的数据结构,无论什么业务场景,其业务代码最终都可以被扫描为相同结构的表数据,并标记资源间的依赖关系,有了表之后我们只需根据不同的业务场景定制不同的资源加载框架就行了,从此彻底告别一个团队维护一套工具的时代!!!
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec12dc1d215.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec12dc1d215.png)
> 恩,如你所见,虽然彻底告别了一个团队一套工具的时代,但似乎又进入了一个团队一套框架的时代。其实还是有差别的,因为框架具有很大的灵活性,而且不那么黑盒,采用框架实现资源管理相比构建更容易调试、定位和升级变更。
深耕静态资源加载框架可以带来许多收益,而且有足够的灵活性和健壮性面向未来的技术变革,这个我们留作后话。
## 总结
回顾一下前面提到过的前端工程三个阶段:
* 第一阶段:库/框架选型
* 第二阶段:简单构建优化
* 第三阶段:JS/CSS模块化开发
现在补充上第四阶段:
* 第四阶段:组件化开发与资源管理
由于先天缺陷,**前端相比其他软件开发,在基础架构上更加迫切的需要组件化开发和资源管理**,而解决资源管理的方法其实一点也不复杂:
> 一个通用的资源表生成工具 + 基于表的资源加载框架
近几年来各种你听到过的各种资源加载优化策略大部分都可以在这样一套基础上实现,而这种优化对于业务来说是完全透明的,不需要重构的性能优化——这不正是我们一直所期盼的吗?正如魏小亮博士所说:我们可以把优秀的人集中起来去优化加载。
如何选型技术、如何定制规范、如何分治系统、如何优化性能、如何加载资源,当你从切图开始转变为思考这些问题的时候,我想说:
你好,工程师!
* * *
> 前端工程其实是一个很大的话题,开发仅是其中的一部分。
';
前端工程——测试篇
最后更新于:2022-04-02 01:21:52
> 原文出处:https://github.com/fouber/blog/issues/7
> 作者:[fouber](https://github.com/fouber)
目录
[TOC=2,2]
> 此文章转自我在 [知乎](http://www.zhihu.com/question/29922082/answer/46141819) 上的同名问答
前端测试是前端工程方面的重要分支,有过一些探索,这里简单分享一下。
首先,还是要强调一点:
> 前端是一种特殊的GUI软件
看过我最近一年内做前端工程方面相关分享的人可能有印象,我总是在强调这一点。前端测试也跟这个理论基础有所关联。
在这里,我还想吐槽一下:
> API测试方法论在测试GUI时并不能解决所有问题。
与很多前端工程师讨论过前端测试,大家更多的还是盯着API测试方法论。诚然,前端有那么一小部分代码是可以用API测试保证质量的,但前端项目中的绝大多数代码是GUI界面,**前端测试应该向传统GUI测试方法论需求解决方案**:[GUI软件测试_百度百科](http://baike.baidu.com/view/5131653.htm) ,这个百科词条介绍的很不错,大家可以感受一下GUI测试相关概念和方法。它的测试用例、覆盖率统计、测试方法等等都与API测试有着很大的不同。
统一了这个认知之后,我们来讨论一下前端GUI测试的特殊性。根据百科词条上的那些介绍,相信大家都能感觉到GUI测试的成本非常高,而前端这种特殊的GUI软件,具有天生的快速迭代特征,这使得case维护成本也变得非常高,经常跟不上迭代速度。
一个标准的互联网应用产品的前端部分,我粗略估计大概有20%的业务基础代码比较稳定,比如通用组件、通用算法和数据模块等,可以针对这些建立复杂一些的API和GUI测试用例来保证质量。剩下80%的部分不是很稳定,每天都在迭代,针对他们维护case的成本非常高。目前业界中号称做了自动化测试的项目,也大多是在做那稳定的20%。
关于稳定部分的单元测试方法我这里就不赘述了, [@貘吃馍香](http://zhi.hu/j9ta) 的答案给出了很多关键字,有兴趣的去搜索就好了。我想讨论的是针对剩下80%不稳定部分的工程化测试方案。据我了解,前端测试面对这些问题还是很无力的,业内大部分团队还是靠堆人解决。
面对这种现状,我其实也没想到过什么好的方法,基本原则就是:
> 以最低的成本建立和维护自动化测试用例。
到目前为止,就想到过两个方案(都不是测试方案,只是回归测试辅助):
## 1\. 不太靠谱的“超级工位”大法。
这个方案可以说根本不是什么技术方案,而是一个办公设施,就是我们准备一个工位,摆上所有我们需要测试的主流设备,然后设备通过某种方式与一台电脑相连接,测试人员坐在工位上,在电脑中输入某个url,就能同步到所有设备中,然后开始逐个的人肉测试。
超级工位大法示意图(应该很多设备的,这里就是随便展示一下而已。。。)
相比现在的前端GUI测试,超级工位已经算是从0到1的飞跃了,虽然没解决什么技术问题,但为测试前的准备工作做好了铺垫。如果把前端测试比作吃屎,超级工位就是为这餐准备了一个好一点的餐桌。。。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec119128545.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec119128545.jpg)
## 2\. 靠谱一些的“页面差异监控”
12年的时候还在百度,当时有同事去美国参加velocity,twitter分享了一下他们的开发流程,其中有一个环节就是页面对比监控,利用了一个叫pdiff的工具,每次提交代码,会自动对比页面之间的差异然后提醒测试人员注意回归。这也是一个典型的GUI测试零成本维护用例的案例。不过pdiff这个工具是基于像素对比的,误报率比较高,所以去年我做了一个这个项目:[fouber/page-monitor · GitHub](https://github.com/fouber/page-monitor) 基于DOM树的diff,这样就能很大程度上自主控制要监控的元素,可以设置监控样式、文本的变化,比起像素diff智能了一些。
其工作原理就是利用phantom或其他headless浏览器访问页面,然后截图,然后执行一段js,遍历整个dom树,获取元素计算样式和元素内文本内容,构造出一个JSON结构,然后每次diff这个json来判断页面差异,并标记在截图上展示。dom树的diff过程有点类似react的虚拟dom树diff。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec119dd0170.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec119dd0170.png)
(react的dom树diff算法示意图)
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a43c448.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a43c448.png)
(react的dom树diff算法示意图)
### DOM树diff我们可以分辨出元素样式修改/内容修改/新增元素/删除元素四种不同的页面差异,我们可以配置选择器来忽略元素。四种页面差异的效果图:
新增元素(绿色区域标记部分,“i am new here”)
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a6819ac.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a6819ac.jpg)
* * *
删除元素(灰色区域标记部分,“你好”)
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a70e8b2.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a70e8b2.jpg)
* * *
内容修改(黄色区域标记部分,“百-度”,“新-浪”)
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a8197a9.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a8197a9.jpg)
* * *
样式修改(红色区域标记的部分)
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a9bb4da.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11a9bb4da.jpg)
* * *
基于这样的页面差异对比监控,我们可以建立一个任务系统,把应用的所有页面url监控起来,这样每次版本迭代提交代码后,系统就能自动告诉我们,哪些页面的元素展现发生了改变,用于确定回归范围。
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11aa2f2b5.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11aa2f2b5.jpg)
(目前我还只是把这个系统用于竞品或者自家产品的运营监控)
[![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11b50fbaa.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11b50fbaa.jpg)
(百度 [@FEX](https://github.com/FEX) 团队开发的基于像素diff的组件监控平台)
用监控的方式确定测试回归范围,是一种“少吃屎”的手段,符合工程化要求,能比较大范围的应用,虽然不能完美解决GUI中的交互问题,但能保证GUI的展现问题已经是不小的进步了。
=====[ 补充 ]=====
经评论中 @貘吃馍香 大大的提醒,这里强调一下:
> 页面差异监控的目的是方便的通知人肉回归范围,这并非测试方案,而是一种辅助测试的手段。
';
大公司里怎样开发和部署前端代码?
最后更新于:2022-04-02 01:21:49
> 原文出处:https://github.com/fouber/blog/issues/6
> 作者:[fouber](https://github.com/fouber)
目录
[TOC]
> 本文搬运自我在知乎上 [同名问题](http://www.zhihu.com/question/20790576/answer/32602154) 中的答案。
这是一个非常有趣的 `非主流前端领域`,这个领域要探索的是如何用工程手段解决前端开发和部署优化的综合问题,入行到现在一直在学习和实践中。
在我的印象中,facebook是这个领域的鼻祖,有兴趣、有梯子的同学可以去看看facebook的页面源代码,体会一下什么叫工程化。
接下来,我想从原理展开讲述,多图,较长,希望能有耐心看完。
* * *
[![一个简单的页面](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec115579535.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec115579535.png)
让我们返璞归真,从原始的前端开发讲起。上图是一个“可爱”的index.html页面和它的样式文件a.css,用文本编辑器写代码,无需编译,本地预览,确认OK,丢到服务器,等待用户访问。前端就是这么简单,好好玩啊,门槛好低啊,分分钟学会有木有!
[![简单页面的网络请求图](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11584b36d.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec11584b36d.png)
然后我们访问页面,看到效果,再查看一下网络请求,200!不错,太™完美了!那么,研发完成。。。。了么?
等等,这还没完呢!对于大公司来说,那些变态的访问量和性能指标,将会让前端一点也不“好玩”。
看看那个a.css的请求吧,如果每次用户访问页面都要加载,是不是很影响性能,很浪费带宽啊,我们希望最好这样:
[![使用304缓存的网络请求图](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1158c8c41.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1158c8c41.png)
利用304,让浏览器使用本地缓存。但,这样也就够了吗?不成!304叫协商缓存,这玩意还是要和服务器通信一次,我们的优化级别是变态级,所以必须彻底灭掉这个请求,变成这样:
[![使用本地缓存的网络请求图](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec115c623a8.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec115c623a8.png)
强制浏览器使用本地缓存(cache-control/expires),不要和服务器通信。好了,请求方面的优化已经达到变态级别,那问题来了:你都不让浏览器发资源请求了,这缓存咋更新?
很好,相信有人想到了办法:**通过更新页面中引用的资源路径,让浏览器主动放弃缓存,加载新资源**。好像这样:
[![使用构建版本号query更新资源](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec115f0521a.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec115f0521a.png)
下次上线,把链接地址改成新的版本,就更新资源了不是。OK,问题解决了么?!当然没有!大公司的变态又来了,思考这种情况:
[![使用构建版本号时上线部署](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec116292bb5.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec116292bb5.png)
页面引用了3个css,而某次上线只改了其中的a.css,如果所有链接都更新版本,就会导致b.css,c.css的缓存也失效,那岂不是又有浪费了?!
重新开启变态模式,我们不难发现,要解决这种问题,必须让url的修改与文件内容关联,也就是说,只有文件内容变化,才会导致相应url的变更,从而实现文件级别的精确缓存控制。
什么东西与文件内容相关呢?我们会很自然的联想到利用 [数据摘要要算法](http://baike.baidu.com/view/10961371.htm) 对文件求摘要信息,摘要信息与文件内容一一对应,就有了一种可以精确到单个文件粒度的缓存控制依据了。好了,我们把url改成带摘要信息的:
[![使用摘要信息更新缓存](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1169a141f.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1169a141f.png)
这回再有文件修改,就只更新那个文件对应的url了,想到这里貌似很完美了。你觉得这就够了么?大公司告诉你:图样图森破!
唉~~~~,让我喘口气
现代互联网企业,为了进一步提升网站性能,会把静态资源和动态网页分集群部署,静态资源会被部署到CDN节点上,网页中引用的资源也会变成对应的部署路径:
[![静态资源分集群部署](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec116c78fde.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec116c78fde.png)
好了,当我要更新静态资源的时候,同时也会更新html中的引用吧,就好像这样:
[![CDN部署过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec116e11f82.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec116e11f82.png)
这次发布,同时改了页面结构和样式,也更新了静态资源对应的url地址,现在要发布代码上线,亲爱的前端研发同学,你来告诉我,咱们是先上线页面,还是先上线静态资源?
1. `先部署页面,再部署资源`:在二者部署的时间间隔内,如果有用户访问页面,就会在新的页面结构中加载旧的资源,并且把这个旧版本的资源当做新版本缓存起来,其结果就是:用户访问到了一个样式错乱的页面,除非手动刷新,否则在资源缓存过期之前,页面会一直执行错误。
2. `先部署资源,再部署页面`:在部署时间间隔之内,有旧版本资源本地缓存的用户访问网站,由于请求的页面是旧版本的,资源引用没有改变,浏览器将直接使用本地缓存,这种情况下页面展现正常;但没有本地缓存或者缓存过期的用户访问网站,就会出现旧版本页面加载新版本资源的情况,导致页面执行错误,但当页面完成部署,这部分用户再次访问页面又会恢复正常了。 好的,上面一坨分析想说的就是:先部署谁都不成!都会导致部署过程中发生页面错乱的问题。所以,访问量不大的项目,可以让研发同学苦逼一把,等到半夜偷偷上线,先上静态资源,再部署页面,看起来问题少一些。
但是,大公司超变态,没有这样的“绝对低峰期”,只有“相对低峰期”。So,为了稳定的服务,还得继续追求极致啊!
这个奇葩问题,起源于资源的 覆盖式发布,用 待发布资源 覆盖 已发布资源,就有这种问题。解决它也好办,就是实现 非覆盖式发布。
[![非覆盖式发布](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1174d81b3.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1174d81b3.png)
看上图,用文件的摘要信息来对资源文件进行重命名,把摘要信息放到资源文件发布路径中,这样,内容有修改的资源就变成了一个新的文件发布到线上,不会覆盖已有的资源文件。上线过程中,先全量部署静态资源,再灰度部署页面,整个问题就比较完美的解决了。
所以,大公司的静态资源优化方案,基本上要实现这么几个东西:
> 1. 配置超长时间的本地缓存 —— 节省带宽,提高性能
> 2. 采用内容摘要作为缓存更新依据 —— 精确的缓存控制
> 3. 静态资源CDN部署 —— 优化网络请求
> 4. 更资源发布路径实现非覆盖式发布 —— 平滑升级
全套做下来,就是相对比较完整的静态资源缓存控制方案了,而且,还要注意的是,静态资源的缓存控制要求在 **前端所有静态资源加载的位置都要做这样的处理** 。是的,所有!什么js、css自不必说,还要包括js、css文件中引用的资源路径,由于涉及到摘要信息,引用资源的摘要信息也会引起引用文件本身的内容改变,从而形成级联的摘要变化,大概示意图就是:
[![多级依赖示意图](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1175be56e.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1175be56e.png)
好了,目前我们快速的学习了一下前端工程中关于静态资源缓存要面临的优化和部署问题,新的问题又来了:这™让工程师怎么写码啊!!!
要解释优化与工程的结合处理思路,又会扯出一堆有关模块化开发、资源加载、请求合并、前端框架等等的工程问题,以上只是开了个头,解决方案才是精髓,但要说的太多太多,有空再慢慢展开吧。
> 总之,前端性能优化绝逼是一个工程问题!
以上不是我YY的,可以观察 百度 或者 facebook 的页面以及静态资源源代码,查看它们的资源引用路径处理,以及网络请中静态资源的缓存控制部分。再次赞叹facebook的前端工程建设水平,跪舔了。
建议前端工程师多多关注前端工程领域,也许有人会觉得自己的产品很小,不用这么变态,但很有可能说不定某天你就需要做出这样的改变了。而且,如果我们能把事情做得更极致,为什么不去做呢?
另外,也不要觉得这些是运维或者后端工程师要解决的问题。如果由其他角色来解决,**大家总是把自己不关心的问题丢给别人**,那么前端工程师的开发过程将受到极大的限制,这种情况甚至在某些大公司都不少见!
妈妈,我再也不玩前端了。。。。5555
## 业界实践
### Assets Pipeline
Rails中的Assets Pipeline完成了以上所说的优化细节,对整个静态资源的管理上的设计思考也是如此,了解rails的人也可以把此答案当做是对rails中assets pipeline设计原理的分析。
rails通过把静态资源变成erb模板文件,然后加入,上线前预编译完成处理,fis的实现思路跟这个几乎完全一样,但我们当初确实不知道有rails的这套方案存在。
相关资料:
* 英文版:[http://guides.rubyonrails.org/asset_pipeline.html](http://guides.rubyonrails.org/asset_pipeline.html)
* 中文版:[http://guides.ruby-china.org/asset_pipeline.html](http://guides.ruby-china.org/asset_pipeline.html)
### FIS的解决方案
用 F.I.S 包装了一个小工具,完整实现整个回答所说的最佳部署方案,并提供了源码对照,可以感受一下项目源码和部署代码的对照。
* 源码项目:[https://github.com/fouber/static-resource-digest-project](https://github.com/fouber/static-resource-digest-project)
* 部署项目:[https://github.com/fouber/static-resource-digest-project-release](https://github.com/fouber/static-resource-digest-project-release)
部署项目可以理解为线上发布后的结果,可以在部署项目里查看所有资源引用的md5化处理。
这个示例也可以用于和assets pipeline做比较。fis没有assets的目录规范约束,而且可以以独立工具的方式组合各种前端开发语言(coffee、less、sass/scss、stylus、markdown、jade、ejs、handlebars等等你能想到的),并与其他后端开发语言结合。
assets pipeline的设计思想值得独立成工具用于前端工程,fis就当做这样的一个选择吧。
';
前端工程与模块化框架
最后更新于:2022-04-02 01:21:47
> 原文出处:https://github.com/fouber/blog/issues/4
> 作者:[fouber](https://github.com/fouber)
目录
[TOC]
本文最先发表在 [DIV.IO](http://div.io/) - 高质量前端社区,欢迎大家围观
> 不要再求验证码了,这个blog目前有800+人订阅,求验证没什么的很影响其他订阅者,可以在div.io上申请,定期会有同学发放的。。。
* * *
一直酝酿着写一篇关于模块化框架的文章,因为模块化框架是前端工程中的 `最为核心的部分` 。本来又想长篇大论的写一篇完整且严肃的paper,但看了 [@糖饼](https://github.com/aui) 在 [DIV.IO](http://div.io/) 的一篇文章 《[再谈 SeaJS 与 RequireJS 的差异](http://div.io/topic/430)》觉得可以借着这篇继续谈一下,加上最近spm3发布,在seajs的官网上又引来了一场 [口水战](https://github.com/seajs/seajs/issues/454) ,我并不想参与到这场论战中,各有所爱的事情不好评论什么,但我想从工程的角度来阐述一下已知的模块化框架相关的问题,并给出一些新的思路,~~其实也不新啦,都实践了2多年了~~。
> 前端模块化框架肩负着 `模块管理`、`资源加载` 两项重要的功能,这两项功能与工具、性能、业务、部署等工程环节都有着非常紧密的联系。因此,模块化框架的设计应该最高优先级考虑工程需要。
基于 [@糖饼](https://github.com/aui) 的文章 《[再谈 SeaJS 与 RequireJS 的差异](http://div.io/topic/430)》,我这里还要补充一些模块化框架在工程方面的缺点:
1. requirejs和seajs二者在加载上都有缺陷,就是模块的依赖要等到模块加载完成后,通过静态分析(seajs)或者deps参数(requirejs)来获取,这就为 `合并请求` 和 `按需加载` 带来了实现上的矛盾:
* 要么放弃按需加载,把所有js合成一个文件,从而满足请求合并(两个框架的官方demo都有这样的例子);
* 要么放弃请求合并,请求独立的模块文件,从而满足按需加载。
2. AMD规范在执行callback的时候,要初始化所有依赖的模块,而CMD只有执行到require的时候才初始化模块。所以用AMD实现某种if-else逻辑分支加载不同的模块的时候,就会比较麻烦了。考虑这种情况:
~~~
//AMD for SPA
require(['page/index', 'page/detail'], function(index, detail){
//在执行回调之前,index和detail模块的factory均执行过了
switch(location.hash){
case '#index':
index();
break;
case '#detail':
detail();
break;
}
});
~~~
在执行回调之前,已经同时执行了index和detail模块的factory,而CMD只有执行到require才会调用对应模块的factory。这种差别带来的不仅仅是性能上的差异,也可能为开发增加一点小麻烦,比如不方便实现换肤功能,factory注意不要直接操作dom等。当然,我们可以多层嵌套require来解决这个问题,但又会引起模块请求串行的问题。
* * *
> 结论:以纯前端方式实现模块化框架 **不能** 同时满足 `按需加载`,`请求合并` 和 `依赖管理` 三个需求。
导致这个问题的根本原因是 `纯前端方式只能在运行时分析依赖关系`。
## 解决模块化管理的新思路
由于根本问题出在 `运行时分析依赖`,因此新思路的策略很简单:不在运行时分析依赖。这就要借助 `构建工具`做线下分析了,其基本原理就是:
> 利用构建工具在线下进行 `模块依赖分析`,然后把依赖关系数据写入到构建结果中,并调用模块化框架的 `依赖关系声明接口` ,实现模块管理、请求合并以及按需加载等功能。
举个例子,假设我们有一个这样的工程:
~~~
project
├ lib
│ └ xmd.js #模块化框架
├ mods #模块目录
│ ├ a.js
│ ├ b.js
│ ├ c.js
│ ├ d.js
│ └ e.js
└ index.html #入口页面
~~~
工程中,`index.html` 的源码内容为:
~~~
...
...
~~~
工程中,`mods/a.js` 的源码内容为(采用类似CMD的书写规范):
~~~
define('a', function(require, exports, module){
console.log('a.init');
var b = require('b');
var c = require('c');
exports.run = function(){
//do something with b and c.
console.log('a.run');
};
});
~~~
## 具体实现过程
1. 用工具在下线对工程文件进行扫描,得到依赖关系表:
~~~
{
"a" : [ "b", "c" ],
"b" : [ "d" ]
}
~~~
2. 工具把依赖表构建到页面或者脚本中,并调用模块化框架的配置接口,`index.html`的构建结果为:
~~~
...
~~~
3. 模块化框架根据依赖表加载资源,比如上述例子,入口需要加载a、e两个模块,查表得知完整依赖关系,配合combo服务,可以发起一个合并后的请求:
[http://www.example.com/??d.js,b.js,c.js,a.js,e.js](http://www.example.com/??d.js,b.js,c.js,a.js,e.js)
## 先来看一下这种方案的优点
1. 采用类似CMD的书写规范(同步require函数声明依赖),可以在执行到require语句的时候才调用模块的factory。
2. 虽然采用CMD书写规范,但放弃了运行时分析依赖,改成工具输出依赖表,因此 `依赖分析完成后可以压缩掉require关键字`
3. 框架并没有严格依赖工具,它只是约定了一种数据结构。不使用工具,人工维护`require.config({...})` 相关的数据也是可以的。对于小项目,文件全部合并的情况,更加不需要deps表了,只要在入口的require.async调用之前加载所有模块化的文件,依赖关系无需额外维护
4. 构建工具设计非常简单,而且可靠。工作就是扫描模块文件目录,得到依赖表,JSON序列化之后插入到构建代码中
5. 由于框架预先知道所有模块的依赖关系,因此可以借助combo服务实现`请求合并`,而不用等到一级模块加载完成才能知道后续的依赖关系。
6. 如果构建工具可以自动包装define函数,那么整个系统开发起来会感觉跟nodejs非常接近,比较舒服。
## 再来讨论一下这种方案的缺点
由于采用require函数作为依赖标记,因此如果需要变量方式require,需要额外声明,这个时候可以实现兼容AMD规范写法,比如
~~~
define('a', ['b', 'c'], function(require, exports, module){
console.log('a.init');
var name = isIE ? 'b' : 'c';
var mod = require(name);
exports.run = function(){
//do something with mod.
console.log('a.run');
};
})
~~~
只要工具把define函数中的 `deps` 参数,或者factory内的require都作为依赖声明标记来识别,这样工程性就比较完备了。
但不管怎样, `线下分析始终依靠了字面量信息`,所以开发上可能会有一定的局限性,但总的来说瑕不掩瑜。
> 希望本文能为前端模块化框架的作者带来一些新的思路。没有必要争论规范,工程问题才是最根本的问题。
';
前端工程与性能优化
最后更新于:2022-04-02 01:21:45
> 原文出处:https://github.com/fouber/blog/issues/3
> 作者:[fouber](https://github.com/fouber)
目录
[TOC]
每个参与过开发企业级web应用的前端工程师或许都曾思考过前端性能优化方面的问题。我们有雅虎14条性能优化原则,还有两本很经典的性能优化指导书:《高性能网站建设指南》、《高性能网站建设进阶指南》。经验丰富的工程师对于前端性能优化方法耳濡目染,基本都能一一列举出来。这些性能优化原则大概是在7年前提出的,对于web性能优化至今都有非常重要的指导意义。
[![高性能网站建设指南](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10b60b094.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10b60b094.jpg)
然而,对于构建大型web应用的团队来说,要坚持贯彻这些优化原则并不是一件十分容易的事。因为优化原则中很多要求是与工程管理相违背的,比如 `把css放在头部` 和 `把js放在尾部` 这两条原则,我们不能让团队的工程师在写样式和脚本引用的时候都去修改一个相同的页面文件。这样做会严重影响团队成员间并行开发的效率,尤其是在团队有版本管理的情况下,每天要花大量的时间进行代码修改合并,这项成本是难以接受的。因此在前端工程界,总会看到周期性的性能优化工作,辛勤的前端工程师们每到月圆之夜就会倾巢出动根据优化原则做一次性能优化。
> 性能优化是一个工程问题
本文将从一个全新的视角来思考web性能优化与前端工程之间的关系,揭示前端性能优化在前端架构及开发工具设计层面的实现思路。
## 性能优化原则及分类
po主先假设本文的读者是有前端开发经验的工程师,并对企业级web应用开发及性能优化有一定的思考,因此我不会重复介绍雅虎14条性能优化原则。如果您没有这些前续知识,请移步 [这里](http://developer.yahoo.com/performance/rules.html) 来学习。
首先,我们把雅虎14条优化原则,《高性能网站建设指南》以及《高性能网站建设进阶指南》中提到的优化点做一次梳理,按照优化方向分类,可以得到这样一张表格:
| 优化方向 | 优化手段 |
| --- | --- |
| 请求数量 | 合并脚本和样式表,CSS Sprites,拆分初始化负载,划分主域 |
| 请求带宽 | 开启GZip,精简JavaScript,移除重复脚本,图像优化 |
| 缓存利用 | 使用CDN,使用外部JavaScript和CSS,添加Expires头,
减少DNS查找,配置ETag,使AjaX可缓存 |
| 页面结构 | 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出 |
| 代码校验 | 避免CSS表达式,避免重定向 |
目前大多数前端团队可以利用 [yui compressor](http://yui.github.io/yuicompressor/) 或者 [google closure compiler](https://code.google.com/p/closure-compiler/) 等压缩工具很容易做到 `精简Javascript` 这条原则;同样的,也可以使用图片压缩工具对图像进行压缩,实现 `图像优化` 原则。这两条原则是对单个资源的处理,因此不会引起任何工程方面的问题。很多团队也通过引入代码校验流程来确保实现`避免css表达式` 和 `避免重定向` 原则。目前绝大多数互联网公司也已经开启了服务端的Gzip压缩,并使用CDN实现静态资源的缓存和快速访问;一些技术实力雄厚的前端团队甚至研发出了自动CSS Sprites工具,解决了CSS Sprites在工程维护方面的难题。使用“查找-替换”思路,我们似乎也可以很好的实现 `划分主域` 原则。
我们把以上这些已经成熟应用到实际生产中的优化手段去除掉,留下那些还没有很好实现的优化原则。再来回顾一下之前的性能优化分类:
| 优化方向 | 优化手段 |
| --- | --- |
| 请求数量 | 合并脚本和样式表,拆分初始化负载 |
| 请求带宽 | 移除重复脚本 |
| 缓存利用 | 添加Expires头,配置ETag,使Ajax可缓存 |
| 页面结构 | 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出 |
有很多顶尖的前端团队可以将上述还剩下的优化原则也都一一解决,但业界大多数团队都还没能很好的解决这些问题。因此,本文将就这些原则的解决方案做进一步的分析与讲解,从而为那些还没有进入前端工业化开发的团队提供一些基础技术建设意见,也借此机会与业界顶尖的前端团队在工业化工程化方向上交流一下彼此的心得。
## 静态资源版本更新与缓存
`缓存利用` 分类中保留了 `添加Expires头` 和 `配置ETag` 两项。或许有些人会质疑,明明这两项只要配置了服务器的相关选项就可以实现,为什么说它们难以解决呢?确实,开启这两项很容易,但开启了缓存后,我们的项目就开始面临另一个挑战: `如何更新这些缓存?`
相信大多数团队也找到了类似的答案,它和《高性能网站建设指南》关于“添加Expires头”所说的原则一样——修订文件名。即:
> 最有效的解决方案是修改其所有链接,这样,全新的请求将从原始服务器下载最新的内容。
思路没错,但要怎么改变链接呢?变成什么样的链接才能有效更新缓存,又能最大限度避免那些没有修改过的文件缓存不失效呢?
先来看看现在一般前端团队的做法:
~~~
hello world
~~~
为了触发用户浏览器的缓存更新,我们需要更改静态资源的url地址,如果采用构建信息(时间戳、版本号等)作为url修改的依据,如上述代码所示,我们只修改了一个a.js文件,但再次构建会让所有请求都更改了url地址,用户再度访问页面那些没有修改过的静态资源的(b.js,b.js,c.js,d.js,e.js)的浏览器缓存也一同失效了。
> 使用构建信息作为静态资源更新标记会导致每次构建发布后所有静态资源都被迫更新,浏览器缓存利用率降低,给性能带来伤害。
此外,采用添加query的方式来清除缓存还有一个弊端,就是 `覆盖式发布` 的上线问题。
[![覆盖式发布](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10c73f08c.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10c73f08c.png)
采用query更新缓存的方式实际上要覆盖线上文件的,index.html和a.js总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。尤其是当页面是后端渲染的模板的时候,静态资源和模板是部署在不同的机器集群上的,上线的过程中,静态资源和页面文件的部署时间间隔可能会非常长,对于一个大型互联网应用来说即使在一个很小的时间间隔内,都有可能出现新用户访问。在这个时间间隔中,访问了网站的用户会发生什么情况呢?
1. 如果先覆盖index.html,后覆盖a.js,用户在这个时间间隙访问,会得到新的index.html配合旧的a.js的情况,从而出现错误的页面。
2. 如果先覆盖a.js,后覆盖index.html,用户在这个间隙访问,会得到旧的index.html配合新的a.js的情况,从而也出现了错误的页面。
这就是为什么大型web应用在版本上线的过程中经常会较集中的出现前端报错日志的原因,也是一些互联网公司选择加班到半夜等待访问低峰期再上线的原因之一。
对于静态资源缓存更新的问题,目前来说最优方案就是 `基于文件内容的hash版本冗余机制` 了。也就是说,我们希望项目源码是这么写的:
~~~
~~~
发布后代码变成
~~~
~~~
也就是a.js发布出来后被修改了文件名,产生一个新文件,并不是覆盖已有文件。其中”_82244e91”这串字符是根据a.js的文件内容进行hash运算得到的,只有文件内容发生变化了才会有更改。由于将文件发布为带有hash的新文件,而不是同名文件覆盖,因此不会出现上述说的那些问题。同时,这么做还有其他的好处:
1. 上线的a.js不是同名文件覆盖,而是文件名+hash的冗余,所以可以先上线静态资源,再上线html页面,不存在间隙问题;
2. 遇到问题回滚版本的时候,无需回滚a.js,只须回滚页面即可;
3. 由于静态资源版本号是文件内容的hash,因此所有静态资源可以开启永久强缓存,只有更新了内容的文件才会缓存失效,缓存利用率大增;
> 以文件内容的hash值为依据生产新文件的非覆盖式发布策略是解决静态资源缓存更新最有效的手段。
虽然这种方案是相比之下最完美的解决方案,但它无法通过手工的形式来维护,因为要依靠手工的形式来计算和替换hash值,并生成相应的文件,将是一项非常繁琐且容易出错的工作,因此我们需要借助工具来处理。
用grunt来实现md5功能是非常困难的,因为grunt只是一个task管理器,而md5计算需要构建工具具有递归编译的能,而不是简单的任务调度。考虑这样的例子:
[![md5计算过程](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10c806257.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10c806257.png)
由于我们的资源版本号是通过对文件内容进行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中的引用。
> 计算index.html中引用的a.css文件的url过程:
> 1\. 压缩a.png后计算其内容的md5值
> 2\. 将a.png的md5写入a.css,再压缩a.css,计算其内容的md5值
> 3\. 将a.css的md5值写入到index.html中
grunt等task-based的工具是很难在task之间协作处理这样的需求的。
在解决了基于内容hash的版本更新问题之后,我们可以将所有前端静态资源开启永久强缓存,每次版本发布都可以首先让静态资源全量上线,再进一步上线模板或者页面文件,再也不用担心各种缓存和时间间隙的问题了!
## 静态资源管理与模块化框架
解决了静态资源缓存问题之后,让我们再来看看前面的优化原则表还剩些什么:
| 优化方向 | 优化手段 |
| --- | --- |
| 请求数量 | 合并脚本和样式表,拆分初始化负载 |
| 请求带宽 | 移除重复脚本 |
| 缓存利用 | 使Ajax可缓存 |
| 页面结构 | 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出 |
很不幸,剩下的优化原则都不是使用工具就能很好实现的。或许有人会辩驳:“我用某某工具可以实现脚本和样式表合并”。嗯,必须承认,使用工具进行资源合并并替换引用或许是一个不错的办法,但在大型web应用,这种方式有一些非常严重的缺陷,来看一个很熟悉的例子 :
[![第一天](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10c9cd219.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10c9cd219.png)
某个web产品页面有A、B、C三个资源
[![第二天](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10caed0bc.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10caed0bc.png)
工程师根据“减少HTTP请求”的优化原则合并了资源
[![第三天](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10cc51606.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10cc51606.png)
产品经理要求C模块按需出现,此时C资源已出现多余的可能
[![第四天](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10cd5c37b.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10cd5c37b.png)
C模块不再需要了,注释掉吧!代码1秒钟搞定,但C资源通常不敢轻易剔除
[![后来](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10ce9b88c.png%3Fv)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10ce9b88c.png%3Fv)
不知不觉中,性能优化变成了性能恶化……
> 这个例子来自 [Facebook静态网页资源的管理和优化@Velocity China 2010](http://velocity.oreilly.com.cn/2010/index.php?func=session&name=%E9%9D%99%E6%80%81%E7%BD%91%E9%A1%B5%E8%B5%84%E6%BA%90%E7%9A%84%E7%AE%A1%E7%90%86%E5%92%8C%E4%BC%98%E5%8C%96)
事实上,使用工具在线下进行静态资源合并是无法解决资源按需加载的问题的。如果解决不了按需加载,则必会导致资源的冗余;此外,线下通过工具实现的资源合并通常会使得资源加载和使用的分离,比如在页面头部或配置文件中写资源引用及合并信息,而用到这些资源的html组件写在了页面其他地方,这种书写方式在工程上非常容易引起维护不同步的问题,导致使用资源的代码删除了,引用资源的代码却还在的情况。因此,在工业上要实现资源合并至少要满足如下需求:
1. 确实能减少HTTP请求,这是基本要求(合并)
2. 在使用资源的地方引用资源(就近依赖),不使用不加载(按需)
3. 虽然资源引用不是集中书写的,但资源引用的代码最终还能出现在页面头部(css)或尾部(js)
4. 能够避免重复加载资源(去重)
将以上要求综合考虑,不难发现,单纯依靠前端技术或者工具处理是很难达到这些理想要求的。
接下来我会讲述一种新的模板架构设计,用以实现前面说到那些性能优化原则,同时满足工程开发和维护的需要,这种架构设计的核心思想就是:
> 基于依赖关系表的静态资源管理系统与模块化框架设计
考虑一段这样的页面代码:
~~~
page
page
page
page
~~~
而最终在模板解析的过程中,资源收集与去重、页面script收集、占位符替换操作,最终从服务端发送出来的html代码为:
~~~
page
page
page
page
page
~~~
很明显,`dialog.js` 这个文件我们不需要在初始化的时候就加载,因此它应该在后续的交互中再加载,但文件都加了md5戳,我们如何能在浏览器环境中知道加载的url呢?
> 答案就是:把静态资源表的一部分输出在页面上,供前端模块化框架加载静态资源。
我就不多解释代码的执行过程了,大家看到完整的html输出就能理解是怎么回事了:
~~~
page
';
hello world
~~~ > ps: 也有团队采用构建版本号为静态资源请求添加query,它们在本质上是没有区别的,在此就不赘述了。 接下来,项目升级,比如页面上的html结构发生变化,对应还要修改 `a.js` 这个文件,得到的构建结果如下: ~~~ content of module a
content of module b
content of module c
~~~
根据资源合并需求中的第二项,我们希望资源引用与使用能尽量靠近,这样将来维护起来会更容易一些,因此,理想的源码是:
~~~
content of module a
content of module b
content of module c
~~~
当然,把这样的页面直接送达给浏览器用户是会有严重的页面闪烁问题的,所以我们实际上仍然希望最终页面输出的结果还是如最开始的截图一样,将css放在头部输出。这就意味着,页面结构需要有一些调整,并且有能力收集资源加载需求,那么我们考虑一下这样的源码(以php为例):
~~~
content of module a
content of module b
content of module c
~~~
在页面的头部插入一个html注释 `` 作为占位,而将原来字面书写的资源引用改成模板接口 `require_static` 调用,该接口负责收集页面所需资源。
require_static接口实现非常简单,就是准备一个数组,收集资源引用,并且可以去重。最后在页面输出的前一刻,我们将require_static在运行时收集到的 `a.css`、`b.css`、`c.css` 三个资源拼接成html标签,替换掉注释占位 ``,从而得到我们需要的页面结构。
经过实践总结,可以发现模板层面只要实现三个开发接口,就可以比较完美的实现目前遗留的大部分性能优化原则,这三个接口分别是:
1. require_static(res_id):收集资源加载需求的接口,参数是静态资源id。
2. load_widget(wiget_id):加载拆分成小组件模板的接口。你可以叫它为widget、component或者pagelet之类的。总之,我们需要一个接口把一个大的页面模板拆分成一个个的小部分来维护,最后在原来的页面中以组件为单位来加载这些小部件。
3. script(code):收集写在模板中的js脚本,使之出现的页面底部,从而实现性能优化原则中的 `将js放在页面底部` 原则。
实现了这些接口之后,一个重构后的模板页面的源代码可能看起来就是这样的了:
~~~
content of module a
content of module b
content of module c
~~~
不难看出,我们目前已经实现了 `按需加载`,`将脚本放在底部`,`将样式表放在头部` 三项优化原则。
前面讲到静态资源在上线后需要添加hash戳作为版本标识,那么这种使用模板语言来收集的静态资源该如何实现这项功能呢?
> 答案是:静态资源依赖关系表。
考虑这样的目录结构:
~~~
project
├── widget
│ ├── a
│ │ ├── a.css
│ │ ├── a.js
│ │ └── a.php
│ ├── b
│ │ ├── b.css
│ │ ├── b.js
│ │ └── b.php
│ └── c
│ ├── c.css
│ ├── c.js
│ └── c.php
├── bootstrap.css
├── bootstrap.js
├── index.php
└── jquery.js
~~~
如果我们可以使用工具扫描整个project目录,然后创建一张资源表,同时记录每个资源的部署路径,得到这样的一张表:
~~~
{
"res" : {
"widget/a/a.css" : "/widget/a/a_1688c82.css",
"widget/a/a.js" : "/widget/a/a_ac3123s.js",
"widget/b/b.css" : "/widget/b/b_52923ed.css",
"widget/b/b.js" : "/widget/b/b_a5cd123.js",
"widget/c/c.css" : "/widget/c/c_03cab13.css",
"widget/c/c.js" : "/widget/c/c_bf0ae3f.js",
"jquery.js" : "/jquery_9151577.js",
"bootstrap.css" : "/bootstrap_f5ba12d.css",
"bootstrap.js" : "/bootstrap_a0b3ef9.js"
},
"pkg" : {}
}
~~~
基于这张表,我们就很容易实现 `require_static(file_id)`,`load_widget(widget_id)` 这两个模板接口了。以load_widget为例:
~~~
function load_widget($id){
//从json文件中读取资源表
$map = load_map();
//查找静态资源
$filename = 'widget/' . $id . '/' . $id;
//查找js文件
$js = $filename . '.js';
if(isset($map['res'][$js])) {
//如果有对应的js资源,就收集起来
collect_js_static($map['res'][$js]);
}
//查找css文件
$css = $filename . '.css';
if(isset($map['res'][$css])) {
//如果有对应的css资源,就收集起来
collect_css_static($map['res'][$css]);
}
include $filename . '.php';
}
~~~
利用查表来解决md5戳的问题,这样,我们的页面最终送达给用户的结果就是这样的:
~~~
content of module a
content of module b
content of module c
~~~
接下来,我们讨论基于表的设计思想上是如何实现静态资源合并的。或许有些团队使用过combo服务,也就是我们在最终拼接生成页面资源引用的时候,并不是生成多个独立的link标签,而是将资源地址拼接成一个url路径,请求一种线上的动态资源合并服务,从而实现减少HTTP请求的需求,比如前面的例子,稍作调整即可得到这样的结果:
~~~
content of module a
content of module b
content of module c
~~~
这个 `/??file1,file2,file3,…` 的url请求响应就是动态combo服务提供的,它的原理很简单,就是根据url找到对应的多个文件,合并成一个文件来响应请求,并将其缓存,以加快访问速度。
这种方法很巧妙,有些服务器甚至直接集成了这类模块来方便的开启此项服务,这种做法也是大多数大型web应用的资源合并做法。但它也存在一些缺陷:
1. 浏览器有url长度限制,因此不能无限制的合并资源。
2. 如果用户在网站内有公共资源的两个页面间跳转访问,由于两个页面的combo的url不一样导致用户不能利用浏览器缓存来加快对公共资源的访问速度。
3. 如果combo的url中任何一个文件发生改变,都会导致整个url缓存失效,从而导致浏览器缓存利用率降低。
对于上述第二条缺陷,可以举个例子来看说明:
* 假设网站有两个页面A和B
* A页面使用了a,b,c,d四个资源
* B页面使用了a,b,e,f四个资源
* 如果使用combo服务,我们会得:
* A页面的资源引用为:/??`a,b,c,d`
* B页面的资源引用为:/??`a,b,e,f`
* 两个页面引用的资源是不同的url,因此浏览器会请求两个合并后的资源文件,跨页面访问没能很好的利用a、b这两个资源的缓存。
很明显,如果combo服务能聪明的知道A页面使用的资源引用为 /??`a,b` 和 /??`c,d`,而B页面使用的资源引用为 /??`a,b` 和 /??`e,f`就好了。这样当用户在访问A页面之后再访问B页面时,只需要下载B页面的第二个combo文件即可,第一个文件已经在访问A页面时缓存好了的。
基于这样的思考,我们在资源表上新增了一个字段,取名为 `pkg`,就是资源合并生成的新资源,表的结构会变成:
~~~
{
"res" : {
"widget/a/a.css" : "/widget/a/a_1688c82.css",
"widget/a/a.js" : "/widget/a/a_ac3123s.js",
"widget/b/b.css" : "/widget/b/b_52923ed.css",
"widget/b/b.js" : "/widget/b/b_a5cd123.js",
"widget/c/c.css" : "/widget/c/c_03cab13.css",
"widget/c/c.js" : "/widget/c/c_bf0ae3f.js",
"jquery.js" : "/jquery_9151577.js",
"bootstrap.css" : "/bootstrap_f5ba12d.css",
"bootstrap.js" : "/bootstrap_a0b3ef9.js"
},
"pkg" : {
"p0" : {
"url" : "/pkg/lib_cef213d.js",
"has" : [ "jquery.js", "bootstrap.js" ]
},
"p1" : {
"url" : "/pkg/lib_afec33f.css",
"has" : [ "bootstrap.css" ]
},
"p2" : {
"url" : "/pkg/widgets_22feac1.js",
"has" : [
"widget/a/a.js",
"widget/b/b.js",
"widget/c/c.js"
]
},
"p3" : {
"url" : "/pkg/widgets_af23ce5.css",
"has" : [
"widget/a/a.css",
"widget/b/b.css",
"widget/c/c.css"
]
}
}
}
~~~
相比之前的表,可以看到新表中多了一个pkg字段,并且记录了打包后的文件所包含的独立资源。这样,我们重新设计一下 require_static、load_widget 这两个模板接口,实现这样的逻辑:
> 在查表的时候,如果一个静态资源有pkg字段,那么就去加载pkg字段所指向的打包文件,否则加载资源本身。
比如执行`require_static('bootstrap.js')`,查表得知bootstrap.js被打包在了`p1`中,因此取出p1包的url`/pkg/lib_cef213d.js`,并且记录页面已加载了 `jquery.js` 和 `bootstrap.js` 两个资源。这样一来,之前的模板代码执行之后得到的html就变成了:
~~~
content of module a
content of module b
content of module c
~~~
虽然这种策略请求有4个,不如combo形式的请求少,但可能在统计上是性能更好的方案。由于两个lib打包的文件修改的可能性很小,因此这两个请求的缓存利用率会非常高,每次项目发布后,用户需要重新下载的静态资源可能要比combo请求节省很多带宽。
> 性能优化既是一个工程问题,又是一个统计问题。优化性能时如果只关注一个页面的首次加载是很片面的。还应该考虑全站页面间跳转、项目迭代后更新资源等情况下的优化策略。
此时,我们又引入了一个新的问题:如何决定哪些文件被打包?
从经验来看,项目初期可以采用人工配置的方式来指定打包情况,比如:
~~~
{
"pack" : {
"lib.js" : [ "jquery.js", "bootstrap.js" ],
"lib.css" : "bootstrap.css",
"widgets.js" : "widget/**.js",
"widgets.css" : "widget/**.css"
}
}
~~~
但随着系统规模的增大,人工配置会带来非常高的维护成本,此时需要一个辅助系统,通过分析线上访问日志和静态资源组合加载情况来自动生成这份配置文件,系统设计如图:
[![静态资源分析系统](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10d097544.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10d097544.png)
至此,我们通过基于表的静态资源管理系统和三个模板接口实现了几个重要的性能优化原则,现在我们再来回顾一下前面的性能优化原则分类表,剔除掉已经做到了的,看看还剩下哪些没做到的:
| 优化方向 | 优化手段 |
| --- | --- |
| 请求数量 | 拆分初始化负载 |
| 缓存利用 | 使Ajax可缓存 |
| 页面结构 | 尽早刷新文档的输出 |
`拆分初始化负载` 的目标是将页面一开始加载时不需要执行的资源从所有资源中分离出来,等到需要的时候再加载。工程师通常没有耐心去区分资源的分类情况,但我们可以利用组件化框架接口来帮助工程师管理资源的使用。还是从例子开始思考,如果我们有一个js文件是用户交互后才需要加载的,会怎样呢:
~~~
content of module a
content of module b
content of module c
~~~
dialog.js不会在页面以script src的形式输出,而是变成了资源注册,这样,当页面点击触发require.async执行的时候,async函数才会查表找到资源的url并加载它,加载完毕后触发回调函数。
> 以上框架示例我实现了一个java-jsp版的,有兴趣的同学请看这里:[https://github.com/fouber/fis-java-jsp](https://github.com/fouber/fis-java-jsp)
到目前为止,我们又以架构的形式实现了一项优化原则(拆分初始化负载),回顾我们的优化分类表,现在仅有两项没能做到了:
| 优化方向 | 优化手段 |
| --- | --- |
| 缓存利用 | 使Ajax可缓存 |
| 页面结构 | 尽早刷新文档的输出 |
剩下的两项优化原则要做到并不容易,真正可缓存的Ajax在现实开发中比较少见,而 `尽早刷新文档的输出` 原则facebook在2010年的velocity上 [提到过](http://velocity.oreilly.com.cn/2010/index.php?func=session&name=Facebook%E7%BD%91%E7%AB%99%E7%9A%84Ajax%E5%8C%96%E3%80%81%E7%BC%93%E5%AD%98%E5%92%8C%E6%B5%81%E6%B0%B4%E7%BA%BF),就是BigPipe技术。当时facebook团队还讲到了Quickling和PageCache两项技术,其中的PageCache算是比较彻底的实现Ajax可缓存的优化原则了。由于篇幅关系,就不在此展开了,后续还会撰文详细解读这两项技术。
## 总结
其实在前端开发工程管理领域还有很多细节值得探索和挖掘,提升前端团队生产力水平并不是一句空话,它需要我们能对前端开发及代码运行有更深刻的认识,对性能优化原则有更细致的分析与研究。在前端工业化开发的所有环节均有可节省的人力成本,这些成本非常可观,相信现在很多大型互联网公司也都有了这样的共识。
本文只是将这个领域中很小的一部分知识的展开讨论,抛砖引玉,希望能为业界相关领域的工作者提供一些不一样的思路。
前端开发体系建设日记
最后更新于:2022-04-02 01:21:42
> 原文出处:https://github.com/fouber/blog/issues/2
> 作者:[fouber](https://github.com/fouber)
目录
[TOC=2,2]
# 前端开发体系建设日记
上周写了一篇 [文章](https://github.com/fouber/blog/issues/1) 介绍前端集成解决方案的基本理论,很多同学看过之后大呼不过瘾。
> 干货 ~~fuck things~~ 在哪里!
本打算继续完善理论链,形成前端工程的知识结构。但鉴于如今的快餐文化,po主决定还是先写一篇实战介绍,让大家看到前端工程体系能为团队带来哪些好处,调起大家的胃口再说。
> ps: 写完才发现这篇文章真的非常非常长,涵盖了前端开发中的很多方面,希望大家能有耐心看完,相信一定会有所斩获。。。
## 2014年02月12日 - 晴
新到松鼠团队的第二天,小伙伴 [@nino](https://github.com/ninozhang) 找到我说
> nino: 视频项目打算重新梳理一下,希望能引入新的技术体系,解决现有的一些问题。
po主不禁暗喜,好机会,这是我专业啊,蓝翔技校-前端集成解决方案学院-自动化系-打包学专业的文凭不是白给的,于是自信满满的对nino说,有什么需求尽管提!
> nino: 我的需求并不多,就这么几条~~
1. 模块化开发。最好能像写nodejs一样写js,很舒服。css最好也能来个模块化管理!
2. 性能要好。模块那么多,得有按需加载,请求不能太多。
3. 组件化开发。一个组件的js、css、模板最好都在一个目录维护,维护起来方便。
4. 用 [handlebars](http://handlebarsjs.com/) 作为前端模板引擎。这个模板引擎不错,logic-less(轻逻辑)。
5. 用 [stylus](http://learnboost.github.io/stylus/) 写css挺方便,给我整一个。
6. 图片base64嵌入。有些小图可能要以base64的形式嵌入到页面、js或者css中使用。嵌入之前记得压缩图片以减小体积。
7. js/css/图片压缩应该都没问题吧。
8. 要能与公司的ci平台集。工具里最好别依赖什么系统库,ci的机器未必支持。
9. 开发体验要好。文件监听,浏览器自动刷新([livereload](http://www.livereload.com/))一个都不能少。
10. 我们用nodejs作为服务器,本地要能预览,最好再能抓取线上数据,方便调试。
我倒吸一口凉气,但表面故作镇定的说:恩,确实不多,让我们先来看看第一个需求。。。
还没等我说完,nino打断我说
> nino: 桥豆麻袋(稍等),还有一个最重要的需求!
~~~
松鼠公司的松鼠浏览器你知道吧,恩,它有很多个版本的样子。
我希望代码发布后能按照版本部署,不要彼此覆盖。
举个例子,代码部署结构可能是这样的:
release/
- public/
- 项目名
- 1.0.0/
- 1.0.1/
- 1.0.2/
- 1.0.2-alpha/
- 1.0.2-beta/
让历史浏览器浏览历史版本,没事还能做个灰度发布,ABTest啥的,多好!
此外,我们将来会有多个项目使用这套开发模式,希望能共用一些组件或者模
块,产品也会公布一些api模块给第三方使用,所以共享模块功能也要加上。
~~~
总的来说,还要追加两个部署需求:
1. 按版本部署,采用非覆盖式发布
2. 允许第三方引用项目公共模块
> nino: 怎么样,不算复杂吧,这个项目很赶,`3天`搞定怎么样?
我凝望着会议室白板上的这些需求,正打算争辩什么,一扭头发现nino已经不见了。。。正在沮丧之际,小伙伴 [@hinc](https://github.com/hinc) 过来找我,跟他大概讲了一下nino的需求,正想跟他抱怨工期问题时,hinc却说
> hinc: 恩,这正是我们需要的开发体系,不过我这里还有一个需求。。。
1. 我们之前积累了一些业务可以共用的模块,放在了公司内的gitlab上,采用 [component](http://component.io/) 作为发布规范,能不能再加上这个组件仓库的支持?
3天时间,13项前端技术元素,靠谱么。。。
## 2014年02月13日 - 多云
一觉醒来,轻松了许多,但还有任务在身,不敢有半点怠慢。整理一下昨天的需求,我们来做一个简单的划分。
* 规范
* 开发规范
* 模块化开发,js模块化,css模块化,像nodejs一样编码
* 组件化开发,js、css、handlebars维护在一起
* 部署规范
* 采用nodejs后端,基本部署规范应该参考 [express](http://expressjs.com/) 项目部署
* 按版本号做非覆盖式发布
* 公共模块可发布给第三方共享
* 框架
* js模块化框架,支持请求合并,按需加载等性能优化点
* 工具
* 可以编译stylus为css
* 支持js、css、图片压缩
* 允许图片压缩后以base64编码形式嵌入到css、js或html中
* 与ci平台集成
* 文件监听、浏览器自动刷新
* 本地预览、数据模拟
* 仓库
* 支持component模块安装和使用
这样一套规范、框架、工具和仓库的开发体系,服从我之前介绍的 [前端集成解决方案](https://github.com/fouber/blog/issues/1) 的描述。前端界每天都团队在设计和实现这类系统,它们其实是有规律可循的。百度出品的 [fis](https://github.com/fis-dev/fis/wiki) 就是一个能帮助快速搭建前端集成解决方案的工具。使用fis我应该可以在3天之内完成这些任务。
> ps: 这不是一篇关于fis的软文,如果这样的一套系统基于grunt实现相信会有非常大量的开发工作,3天完成几乎是不可能的任务。
不幸的是,现在fis官网所介绍的 **并不是** fis,而是一个叫 [fis-plus](https://github.com/fex-team/fis-plus) 的项目,该项目并不像字面理解的那样是fis的加强版,而是在fis的基础上定制的一套面向百度前端团队的解决方案,以php为后端语言,跟smarty有较强的绑定关系,有着 `19项` 技术要素,密切配合百度现行技术选型。绝大多数非百度前端团队都很难完整接受这19项技术选型,尤其是其中的部署、框架规范,跟百度前端团队相关开发规范、部署规范、以及php、smarty等有着较深的绑定关系。
因此如果你的团队用的不是 `php后端` && `smarty模板` && `modjs模块化框架` && `bingo框架` 的话,请查看 [fis的文档](https://github.com/fis-dev/fis/wiki),或许不会有那么多困惑。
> ps: fis是一个构建系统内核,很好的抽象了前端集成解决方案所需的通用工具需求,本身不与任何后端语言绑定。而基于fis实现的具体解决方案就会有具体的规范和技术选型了。
言归正传,让我们基于 [fis](https://github.com/fis-dev/fis/) 开始实践这套开发体系吧!
### 0\. 开发概念定义
前端开发体系设计第一步要定义开发概念。开发概念是指针对开发资源的分类概念。开发概念的确立,直接影响到规范的定制。比如,传统的开发概念一般是按照文件类型划分的,所以传统前端项目会有这样的目录结构:
* js:放js文件
* css:放css文件
* images:放图片文件
* html:放html文件
这样确实很直接,任何智力健全的人都知道每个文件该放在哪里。但是这样的开发概念划分将给项目带来较高的维护成本,并为项目臃肿埋下了工程隐患,理由是:
1. 如果项目中的一个功能有了问题,维护的时候要在js目录下找到对应的逻辑修改,再到css目录下找到对应的样式文件修改一下,如果图片不对,还要再跑到images目录下找对应的开发资源。
2. images下的文件不知道哪些图片在用,哪些已经废弃了,谁也不敢删除,文件越来越多。
> ps: 除非你的团队只有1-2个人,你的项目只有很少的代码量,而且不用关心性能和未来的维护问题,否则,以文件为依据设计的开发概念是应该被抛弃的。
以我个人的经验,更倾向于具有一定语义的开发概念。综合前面的需求,我为这个开发体系确定了3个开发资源概念:
* 模块化资源:js模块、css模块或组件
* 页面资源:网站html或后端模板页面,引用模块化框架,加载模块
* 非模块化资源:并不是所有的开发资源都是模块化的,比如提供模块化框架的js本身就不能是一个模块化的js文件。严格上讲,页面也属于一种非模块化的静态资源。
> ps: 开发概念越简单越好,前面提到的fis-plus也有类似的开发概念,有组件或模块(widget),页面(page),测试数据(test),非模块化静态资源(static)。有的团队在模块之中又划分出api模块和ui模块(组件)两种概念。
### 1\. 开发目录设计
基于开发概念的确立,接下来就要确定目录规范了。我通常会给每种开发资源的目录取一个有语义的名字,三种资源我们可以按照概念直接定义目录结构为:
~~~
project
- modules 存放模块化资源
- pages 存放页面资源
- static 存放非模块化资源
~~~
这样划分目录确实直观,但结合前面hinc说过的,希望能使用component仓库资源,因此我决定将模块化资源目录命名为`components`,得到:
~~~
project
- components 存放模块化资源
- pages 存放页面资源
- static 存放非模块化资源
~~~
而nino又提到过模块资源分为项目模块和公共模块,以及hinc提到过希望能从component安装一些公共组件到项目中,因此,一个components目录还不够,想到nodejs用node_modules作为模块安装目录,因此我在规范中又追加了一个 `component_modules` 目录,得到:
~~~
project
- component_modules 存放外部模块资源
- components 存放项目模块资源
- pages 存放页面资源
- static 存放非模块化资源
~~~
nino说过今后大多数项目采用nodejs作为后端,express是比较常用的nodejs的server框架,express项目通常会把后端模板放到 `views` 目录下,把静态资源放到 `public` 下。为了迎合这样的需求,我将page、static两个目录调整为 `views` 和 `public`,规范又修改为:
~~~
project
- component_modules 存放外部模块资源
- components 存放项目模块资源
- views 存放页面资源
- public 存放非模块化资源
~~~
考虑到页面也是一种静态资源,而`public`这个名字不具有语义性,与其他目录都有概念冲突,不如将其与`views`目录合并,views目录负责存放页面和非模块化资源比较合适,因此最终得到的开发目录结构为:
~~~
project
- component_modules 存放外部模块资源
- components 存放项目模块资源
- views 存放页面以及非模块化资源
~~~
### 2\. 部署目录设计
托nino的福,咱们的部署策略将会非常复杂,根据要求,一个完整的部署结果应该是这样的目录结构:
~~~
release
- public
- 项目名
- 1.0.0 1.0.0版本的静态资源都构建到这里
- 1.0.1 1.0.1版本的静态资源都构建到这里
- 1.0.2 1.0.2版本的静态资源都构建到这里
...
- views
- 项目名
- 1.0.0 1.0.0版本的后端模板都构建到这里
- 1.0.1 1.0.1版本的后端模板都构建到这里
- 1.0.2 1.0.2版本的后端模板都构建到这里
...
~~~
由于还要部署一些可以被第三方使用的模块,public下只有项目名的部署还不够,应改把模块化文件单独发布出来,得到这样的部署结构:
~~~
release
- public
- component_modules 模块化资源都部署到这个目录下
- module_a
- 1.0.0
- module_a.js
- module_a.css
- module_a.png
- 1.0.1
- 1.0.2
...
- 项目名
- 1.0.0 1.0.0版本的静态资源都构建到这里
- 1.0.1 1.0.1版本的静态资源都构建到这里
- 1.0.2 1.0.2版本的静态资源都构建到这里
...
- views
- 项目名
- 1.0.0 1.0.0版本的后端模板都构建到这里
- 1.0.1 1.0.1版本的后端模板都构建到这里
- 1.0.2 1.0.2版本的后端模板都构建到这里
...
~~~
由于 `component_modules` 这个名字太长了,如果部署到这样的路径下,url会很长,这也是一个优化点,因此最终决定部署结构为:
~~~
release
- public
- c 模块化资源都部署到这个目录下
- 公共模块
- 版本号
- 项目名
- 版本号
- 项目名
- 版本号 非模块化资源都部署到这个目录下
- views
- 项目名
- 版本号 后端模板都构建到这个目录下
~~~
插一句,并不是所有团队都会有这么复杂的部署要求,这和松鼠团队的业务需求有关,但我相信这个例子也不会是最复杂的。每个团队都会有自己的运维需求,前端资源部署经常牵连到公司技术架构,因此很多前端项目的开发目录结构会和部署要求保持一致。这也为项目间模块的复用带来了成本,因为代码中写的url通常是部署后的路径,迁移之后就可能失效了。
> 解耦开发规范和部署规范是前端开发体系的设计重点。
好了,去吃个午饭,下午继续。。。
### 3\. 配置fis连接开发规范和部署规范
我准备了一个样例项目:
~~~
project
- views
- logo.png
- index.html
- fis-conf.js
- README.md
~~~
`fis-conf.js`是fis工具的配置文件,接下来我们就要在这里进行构建配置了。虽然开发规范和部署规范十分复杂,但好在fis有一个非常强大的 [roadmap.path](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#roadmappath) 功能,专门用于分类文件、调整发布结构、指定文件的各种属性等功能实现。
> 所谓构建,其核心任务就是将文件按照某种规则进行分类(以文件后缀分类,以模块化/非模块化分类,以前端/后端代码分类),然后针对不同的文件做不同的构建处理。
闲话少说,我们先来看一下基本的配置,在 `fis-conf.js` 中添加代码:
~~~
fis.config.set('roadmap.path', [
{
reg : '**.md', //所有md后缀的文件
release : false //不发布
}
]);
~~~
> 以上配置,使得项目中的所有md后缀文件都不会发布出来。release是定义file对象发布路径的属性,如果file对象的release属性为false,那么在项目发布阶段就不会被输出出来。
在fis中,roadmap.pah是一个数组数据,数组每个元素是一个对象,必须定义 `reg` 属性,用以匹配项目文件路径从而进行分类划分,reg属性的取值可以是路径通配字符串或者正则表达式。fis有一个内部的文件系统,会给每个源码文件创建一个 [fis.File](https://github.com/fex-team/fis-kernel/blob/master/lib/file.js) 对象,创建File对象时,按照roadmap.path的配置逐个匹配文件路径,匹配成功则把除reg之外的其他属性赋给File对象,fis中各种处理环节及插件都会读取所需的文件对象的属性值,而不会自己定义规范。有关roadmap.path的工作原理可以看[这里](https://github.com/fex-team/fis-kernel/blob/master/lib/file.js#L156) 以及 [这里](https://github.com/fex-team/fis-kernel/blob/master/lib/uri.js#L48-L70)。
ok,让md文件不发布很简单,那么views目录下的按版本发布要求怎么实现呢?其实也是非常简单的配置:
~~~
fis.config.set('roadmap.path', [
{
reg : '**.md', //所有md后缀的文件
release : false //不发布
},
{
//正则匹配【/views/**】文件,并将views后面的路径捕获为分组1
reg : /^\/views\/(.*)$/i,
//发布到 public/proj/1.0.0/分组1 路径下
release : '/public/proj/1.0.0/$1'
}
]);
~~~
roadmap.path数组的第二元素据采用正则作为匹配规则,正则可以帮我们捕获到分组信息,在release属性值中引用分组是非常方便的。正则匹配 + 捕获分组,成为目录规范配置的强有力工具:
[![roadmap.path](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10705d574.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10705d574.png)
在上面的配置中,版本号被写到了匹配规则里,这样非常不方便工程师在迭代的过程中升级项目版本。我们应该将版本号、项目名称等配置独立出来管理。好在roadmap.path还有读取其他配置的能力,修改上面的配置,我们得到:
~~~
//开发部署规范配置
fis.config.set('roadmap.path', [
{
reg : '**.md', //所有md后缀的文件
release : false //不发布
},
{
reg : /^\/views\/(.*)$/i,
//使用${xxx}引用fis.config的其他配置项
release : '/public/${name}/${version}/$1'
}
]);
//项目配置,将name、version独立配置,统管全局
fis.config.set('name', 'proj');
fis.config.set('version', '1.0.0');
~~~
fis的配置系统非常灵活,除了 [文档](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI) 中提到的配置节点,其他配置用户可以随便定义使用。比如配置的roadmap是系统保留的,而name、version都是用户自己随便指定的。fis系统保留的配置节点只有6个,包括:
1. [project](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#projectcharset)(系统配置)
2. [modules](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#modulesparser)(构建流程配置)
3. [settings](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#settings)(插件配置)
4. [roadmap](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#roadmappath)(目录规范与域名配置)
5. [deploy](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#deploy)(部署目标配置)
6. [pack](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#pack)(打包配置)
完成第一份配置之后,我们来看一下效果。
~~~
cd project
fis release --dest ../release
~~~
进入到项目目录,然后使用fis release命令,对项目进行构建,用 `--dest ` 参数指定编译结果的产出路径,可以看到部署后的结果:
[![fis release](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1072735f1.png%3Fv)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1072735f1.png%3Fv)
> ps: fis release会将处理后的结果发布到源码目录之外的其他目录里,以保持源码目录的干净。
fis系统的强大之处在于当你调整了部署规范之后,fis会识别所有资源定位标记,将他们修改为对应的部署路径。
[![资源定位替换](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1073e6817.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec1073e6817.png)
fis的文件系统设计决定了配置开发规范的成本非常低。fis构建核心有三个超级正则,用于识别资源定位标记,把用户的开发规范和部署规范通过配置完整连接起来,具体实现可以看[这里](https://github.com/fex-team/fis-kernel/blob/master/lib/compile.js#L376-L465)。
> 不止html,fis为前端三种领域语言都准备了资源定位识别标记,更多文档可以看这里:[在html中定位资源](https://github.com/fex-team/fis/wiki/%E5%AE%9A%E4%BD%8D%E8%B5%84%E6%BA%90#%E5%9C%A8html%E4%B8%AD%E5%AE%9A%E4%BD%8D%E8%B5%84%E6%BA%90),[在js中定位资源](https://github.com/fex-team/fis/wiki/%E5%AE%9A%E4%BD%8D%E8%B5%84%E6%BA%90#%E5%9C%A8js%E4%B8%AD%E5%AE%9A%E4%BD%8D%E8%B5%84%E6%BA%90),[在css中定位资源](https://github.com/fex-team/fis/wiki/%E5%AE%9A%E4%BD%8D%E8%B5%84%E6%BA%90#%E5%9C%A8css%E4%B8%AD%E5%AE%9A%E4%BD%8D%E8%B5%84%E6%BA%90)
接下来,我们修改一下项目版本配置,再发布一下看看效果:
~~~
fis.config.set('version', '1.0.1');
~~~
再次执行:
~~~
cd project
fis release --dest ../release
~~~
得到:
[![资源定位替换2](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107721e0d.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107721e0d.png)
至此,我们已经基本解决了开发和部署直接的目录规范问题,这里我需要加快一些步伐,把其他目录的部署规范也配置好,得到一个相对比较完整的结果:
~~~
fis.config.set('roadmap.path', [
{
//md后缀的文件不发布
reg : '**.md',
release : false
},
{
//component_modules目录下的代码,由于component规范,已经有了版本号
//我将它们直接发送到public/c目录下就好了
reg : /^\/component_modules\/(.*)$/i,
release : '/public/c/$1'
},
{
//项目模块化目录没有版本号结构,用全局版本号控制发布结构
reg : /^\/components\/(.*)$/i,
release : '/public/c/${name}/${version}/$1'
},
{
//views目录下的文件发布到【public/项目名/版本】目录下
reg : /^\/views\/(.*)$/,
release : '/public/${name}/${version}/$1'
},
{
//其他文件就不属于前端项目了,比如nodejs的后端代码
//不处理这些文件的资源定位替换(useStandard: false)
//也不用对这些资源进行压缩(useOptimizer: false)
reg : '**',
useStandard : false,
useOptimizer : false
}
]);
fis.config.set('name', 'proj');
fis.config.set('version', '1.0.2');
~~~
我构造了一个相对完整的目录结构,然后进行了一次构建,效果还不错:
[![项目构建](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107846f37.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107846f37.png)
不管部署规则多么复杂都不用担心,有fis强大的资源定位系统帮你在开发规范和部署规范之间建立联系,设计开发体系不在受制于工具的实现能力。
> 你可以尽情发挥想象力,设计出最优雅最合理的开发规范和最高效最贴合公司运维要求的部署规范,最终用fis的roadmap.path功能将它们连接起来,实现完美转换。
>
> fis的roadmap功能实际上提供了项目代码与部署规范解耦的能力。
从前面的例子可以看出,开发使用相对路径即可,fis会在构建时会根据fis-conf.js中的配置完成开发路径到部署路径的转换工作。这意味着在fis体系下开发的模块将具有天然的可移植性,既能满足不同项目的不同部署需求,又能允许开发中使用相对路径进行资源定位,工程师再不用把部署路径写到代码中了。
愉快的一天就这么过去了,睡觉!
## 2014年02月14日 - 阴转多云
每到周五总是非常惬意的感觉,不管这一周多么辛苦,周五就是一个解脱,更何况今天还是个特别的日子——情人节!
昨天主要解决了开发概念、开发目录规范、部署目录规范以及初步的fis-conf.js配置。今天要进行前端开发体系设计的关键任务——模块化框架。
> 模块化框架是前端开发体系中最为核心的环节。
模块化框架肩负着模块管理、资源加载、性能优化(按需,请求合并)等多种重要职责,同时它也是组件开发的基础框架,因此模块化框架设计的好坏直接影响到开发体系的设计质量。
很遗憾的说,现在市面上已有的模块化框架都没能很好的处理模块管理、资源加载和性能优化三者之间的关系。这倒不是框架设计的问题,而是由前端领域语言特殊性决定的。框架设计者一般在思考模块化框架时,通常站在纯前端运行环境角度考虑,基本功能都是用原生js实现的,因此一个模块化开发的关键问题不能被很好的解决。这个关键问题就是`依赖声明`。
以 [seajs](http://seajs.org/) 为例(无意冒犯),seajs采用运行时分析的方式实现依赖声明识别,并根据依赖关系做进一步的模块加载。比如如下代码:
~~~
define(function(require) {
var foo = require("foo");
//...
});
~~~
当seajs要执行一个模块的factory函数之前,会先分析函数体中的`require`书写,具体代码在[这里](https://github.com/seajs/seajs/blob/master/src/module.js#L286)和[这里](https://github.com/seajs/seajs/blob/master/src/util-deps.js),大概的代码逻辑如下:
~~~
Module.define = function (id, deps, factory) {
...
//抽取函数体的字符串内容
var code = factory.toString();
//设计一个正则,分析require语句
var reg = /\brequire\s*\(([.*]?)\)/g;
var deps = [];
//扫描字符串,得到require所声明的依赖
code.replace(reg, function(m, $1){
deps.push($1);
});
//加载依赖,完成后再执行factory
...
};
~~~
由于框架设计是在“纯前端实现”的约束条件下,使得模块化框架对于依赖的分析必须在模块资源加载完成之后才能做出识别。这将引起两个性能相关的问题:
1. require被强制为关键字而不能被压缩。否则factory.toString()的分析将没有意义。
2. 依赖加载只能串行进行,当一个模块加载完成之后才知道后面的依赖关系。
第一个问题还好,尤其是在gzip下差不多多少字节,但是要配置js压缩器保留require函数不压缩。第二个问题就比较麻烦了,虽然seajs有seajs-combo插件可以一定程度上减少请求,但仍然不能很好的解决这个问题。举个例子,有如下seajs模块依赖关系树:
[![seajs依赖树](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107a6159d.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107a6159d.png)
> ps: 图片来源 [@raphealguo](http://weibo.com/guozhiw)
采用seajs-combo插件之后,静态资源请求的效果是这样的:
1. [http://www.example.com/page.js](http://www.example.com/page.js)
2. [http://www.example.com/a.js,b.js](http://www.example.com/a.js,b.js)
3. [http://www.example.com/c.js,d.js,e.js](http://www.example.com/c.js,d.js,e.js)
4. [http://www.example.com/f.js](http://www.example.com/f.js)
工作过程是
1. 框架先请求了入口文件page.js
2. 加载完成后分析factory函数,发现依赖了a.js和b.js,然后发起一个combo请求,加载a.js和b.js
3. a.js和b.js加载完成后又进一步分析源码,才发现还依赖了c.js、d.js和e.js,再发起请求加载这三个文件
4. 完成c、d、e的加载之后,再分析,发现f.js依赖,再请求
5. 完成f.js的请求,page.js的依赖全部满足,执行它的factory函数。
虽然combo可以在依赖层级上进行合并,但完成page.js的请求仍需要4个。很多团队在使用seajs的时候,为了避免这样的串行依赖请求问题,会自己实现打包方案,将所有文件直接打包在一起,放弃了模块化的按需加载能力,也是一种无奈之举。
原因很简单
> 以纯前端方式来实现模块依赖加载不能同时解决性能优化问题。
归根结底,这样的结论是由前端领域语言的特点决定的。前端语言缺少三种编译能力,前面讲目录规范和部署规范时其实已经提到了一种能力,就是“资源定位的能力”,让工程师使用开发路径定位资源,编译后可转换为部署路径。其他语言编写的程序几乎都没有web这种物理上分离的资源部署策略,而且大多具都有类似'getResource(path)'这样的函数,用于在运行环境下定位当初的开发资源,这样不管项目怎么部署,只要getResource函数运行正常就行了。可惜前端语言没有这样的资源定位接口,只有url这样的资源定位符,它指向的其实并不是开发路径,而是部署路径。
这里可以简单列举出前端语言缺少三种的语言能力:
* 资源定位的能力:使用开发路径进行资源定位,项目发布后转换成部署路径
* 依赖声明的能力:声明一个资源依赖另一个资源的能力
* 资源嵌入的能力:把一个资源的编译内容嵌入到另一个文件中
以后我会在完善前端开发体系理论的时候在详细介绍这三种语言能力的必要性和原子性,这里就暂时不展开说明了。
> fis最核心的编译思想就是围绕这三种语言能力设计的。
要兼顾性能的同时解决模块化依赖管理和加载问题,其关键点在于
> 不能运行时去分析模块间的依赖关系,而要让框架提前知道依赖树。
了解了原因,我们就要自己动手设计模块化框架了。不要害怕,模块化框架其实很简单,思想、规范都是经过很多前辈总结的结果,我们只要遵从他们的设计思想去实现就好了。
参照已有规范,我定义了三个模块化框架接口:
* 模块定义接口:`define(id, factory);`
* 异步加载接口:`require.async(ids, callback);`
* 框架配置接口:`require.config(options);`
利用构建工具建立模块依赖关系表,再将关系表注入到代码中,调用`require.config`接口让框架知道完整的依赖树,从而实现`require.async`在异步加载模块时能提前预知所有依赖的资源,一次性请求回来。
以上面的page.js依赖树为例,构建工具会生成如下代码:
~~~
require.config({
deps : {
'page.js' : [ 'a.js', 'b.js' ],
'a.js' : [ 'c.js' ],
'b.js' : [ 'd.js', 'e.js' ],
'c.js' : [ 'f.js' ],
'd.js' : [ 'f.js' ]
}
});
~~~
当执行`require.async('page.js', fn);`语句时,框架查询config.deps表,就能知道要发起一个这样的combo请求:
> [http://www.example.com/f.js,c.js,d.js,e.js,a.js,b.js,page.js](http://www.example.com/f.js,c.js,d.js,e.js,a.js,b.js,page.js)
从而实现`按需加载`和`请求合并`两项性能优化需求。
根据这样的设计思路,我请 [@hinc](https://github.com/hinc) 帮忙实现了这个框架,我告诉他,deps里不但会有js,还会有css,所以也要兼容一下。hinc果然是执行能力非常强的小伙伴,仅一个下午的时间就搞定了框架的实现,我们给这个框架取名为 [scrat.js](https://github.com/scrat-team/scrat.js/blob/master/scrat.js),仅有393行。
前面提到fis具有资源依赖声明的编译能力。因此只要工程师按照fis规定的书写方式在代码中声明依赖关系,就能在构建的最后阶段自动获得fis系统整理好的依赖树,然后对依赖的数据结构进行调整、输出,满足框架要求就搞定了!fis规定的资源依赖声明方式为:[在html中声明依赖](https://github.com/fex-team/fis/wiki/%E5%9C%A8html%E4%B8%AD%E5%A3%B0%E6%98%8E%E4%BE%9D%E8%B5%96),[在js中声明依赖](https://github.com/fex-team/fis/wiki/%E5%9C%A8js%E4%B8%AD%E5%A3%B0%E6%98%8E%E4%BE%9D%E8%B5%96),[在css中声明依赖](https://github.com/fex-team/fis/wiki/%E5%9C%A8css%E4%B8%AD%E5%A3%B0%E6%98%8E%E4%BE%9D%E8%B5%96)。
接下来,我要写一个配置,将依赖关系表注入到代码中。fis构建是分流程的,具体构建流程可以看[这里](https://github.com/fex-team/fis/wiki/%E8%BF%90%E8%A1%8C%E5%8E%9F%E7%90%86)。fis会在`postpackager`阶段之前创建好完整的依赖树表,我就在这个时候写一个插件来处理即可。
编辑`fis-conf.js`
~~~
//postpackager插件接受4个参数,
//ret包含了所有项目资源以及资源表、依赖树,其中包括:
// ret.src: 所有项目文件对象
// ret.pkg: 所有项目打包生成的额外文件
// reg.map: 资源表结构化数据
//其他参数暂时不用管
var createFrameworkConfig = function(ret, conf, settings, opt){
//创建一个对象,存放处理后的配置项
var map = {};
//依赖树数据
map.deps = {};
//遍历所有项目文件
fis.util.map(ret.src, function(subpath, file){
//文件的依赖数据就在file对象的requires属性中,直接赋值即可
if(file.requires && file.requires.length){
map.deps[file.id] = file.requires;
}
});
console.log(map.deps);
};
//在modules.postpackager阶段处理依赖树,调用插件函数
fis.config.set('modules.postpackager', [createFrameworkConfig]);
~~~
我们准备一下项目代码,看看构建的时候发生了什么:
[![依赖声明](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107b47c7c.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec107b47c7c.png)
执行fis release查看命令行输出,可以看到consolog.log的内容为:
~~~
{
deps: {
'components/bar/bar.js': [
'components/bar/bar.css'
],
'components/foo/foo.js': [
'components/bar/bar.js',
'components/foo/foo.css'
]
}
}
~~~
可以看到js和同名的css自动建立了依赖关系,这是fis默认进行的依赖声明。有了这个表,我们就可以把它注入到代码中了。我们为页面准备一个替换用的钩子,比如约定为`__FRAMEWORK_CONFIG__`,这样用户就可以根据需要在合适的地方获取并使用这些数据。模块化框架的配置一般都是写在非模块化文件中的,比如html页面里,所以我们应该只针对views目录下的文件做这样的替换就可以。所以我们需要给views下的文件进行一个标记,只有views下的html或js文件才需要进行依赖树数据注入,具体的配置为:
~~~
fis.config.set('roadmap.path', [
{
reg : '**.md',
release : false
},
{
reg : /^\/component_modules\/(.*)$/i,
release : '/public/c/$1'
},
{
reg : /^\/components\/(.*)$/i,
release : '/public/c/${name}/${version}/$1'
},
{
reg : /^\/views\/(.*)$/,
//给views目录下的文件加一个isViews属性标记,用以标记文件分类
//我们可以在插件中拿到文件对象的这个值
isViews : true,
release : '/public/${name}/${version}/$1'
},
{
reg : '**',
useStandard : false,
useOptimizer : false
}
]);
var createFrameworkConfig = function(ret, conf, settings, opt){
var map = {};
map.deps = {};
fis.util.map(ret.src, function(subpath, file){
if(file.requires && file.requires.length){
map.deps[file.id] = file.requires;
}
});
//把配置文件序列化
var stringify = JSON.stringify(map, null, opt.optimize ? null : 4);
//再次遍历文件,找到isViews标记的文件
//替换里面的__FRAMEWORK_CONFIG__钩子
fis.util.map(ret.src, function(subpath, file){
//有isViews标记,并且是js或者html类文件,才需要做替换
if(file.isViews && (file.isJsLike || file.isHtmlLike)){
var content = file.getContent();
//替换文件内容
content = content.replace(/\b__FRAMEWORK_CONFIG__\b/g, stringify);
file.setContent(content);
}
});
};
fis.config.set('modules.postpackager', [createFrameworkConfig]);
//项目配置
fis.config.set('name', 'proj'); //将name、version独立配置,统管全局
fis.config.set('version', '1.0.3');
~~~
我在`views/index.html`中写了这样的代码:
~~~
hello
~~~
执行 `fis release -d ../release` 之后,得到构建后的内容为:
~~~
hello
~~~
在调用 `require.async('components/foo/foo.js')` 之际,模块化框架已经知道了这个foo.js依赖于bar.js、bar.css以及foo.css,因此可以发起两个combo请求去加载所有依赖的js、css文件,完成后再执行回调。
现在模块的id有一些问题,因为模块发布会有版本号信息,因此模块id也应该携带版本信息,从前面的依赖树生成配置代码中我们可以看到模块id其实也是文件的一个属性,因此我们可以在roadmap.path中重新为文件赋予id属性,使其携带版本信息:
~~~
fis.config.set('roadmap.path', [
{
reg : '**.md',
release : false,
isHtmlLike : true
},
{
reg : /^\/component_modules\/(.*)$/i,
//追加id属性
id : '$1',
release : '/public/c/$1'
},
{
reg : /^\/components\/(.*)$/i,
//追加id属性,id为【项目名/版本号/文件路径】
id : '${name}/${version}/$1',
release : '/public/c/${name}/${version}/$1'
},
{
reg : /^\/views\/(.*)$/,
//给views目录下的文件加一个isViews属性标记,用以标记文件分类
//我们可以在插件中拿到文件对象的这个值
isViews : true,
release : '/public/${name}/${version}/$1'
},
{
reg : '**',
useStandard : false,
useOptimizer : false
}
]);
~~~
重新构建项目,我们得到了新的结果:
~~~
hello
~~~
you see?所有id都会被修改为我们指定的模式,这就是以文件为中心的编译系统的威力。
> 以文件对象为中心构建系统应该通过配置指定文件的各种属性。插件并不自己实现某种规范规定,而是读取file对象的对应属性值,这样插件的职责单一,规范又能统一起来被用户指定,为完整的前端开发体系设计奠定了坚实规范配置的基础。
接下来还有一个问题,就是模块名太长,开发中写这么长的模块名非常麻烦。我们可以借鉴流行的模块化框架中常用的缩短模块名手段——别名(alias)——来降低开发中模块引用的成本。此外,目前的配置其实会针对所有文件生成依赖关系表,我们的开发概念定义只有components和component_modules目录下的文件才是模块化的,因此我们可以进一步的对文件进行分类,得到这样配置规范:
~~~
fis.config.set('roadmap.path', [
{
reg : '**.md',
release : false,
isHtmlLike : true
},
{
reg : /^\/component_modules\/(.*)$/i,
id : '$1',
//追加isComponentModules标记属性
isComponentModules : true,
release : '/public/c/$1'
},
{
reg : /^\/components\/(.*)$/i,
id : '${name}/${version}/$1',
//追加isComponents标记属性
isComponents : true,
release : '/public/c/${name}/${version}/$1'
},
{
reg : /^\/views\/(.*)$/,
isViews : true,
release : '/public/${name}/${version}/$1'
},
{
reg : '**',
useStandard : false,
useOptimizer : false
}
]);
~~~
然后我们为一些模块id建立别名:
~~~
var createFrameworkConfig = function(ret, conf, settings, opt){
var map = {};
map.deps = {};
//别名收集表
map.alias = {};
fis.util.map(ret.src, function(subpath, file){
//添加判断,只有components和component_modules目录下的文件才需要建立依赖树或别名
if(file.isComponents || file.isComponentModules){
//判断一下文件名和文件夹是否同名,如果同名则建立一个别名
var match = subpath.match(/^\/components\/(.*?([^\/]+))\/\2\.js$/i);
if(match && match[1] && !map.alias.hasOwnProperty(match[1])){
map.alias[match[1]] = file.id;
}
if(file.requires && file.requires.length){
map.deps[file.id] = file.requires;
}
}
});
var stringify = JSON.stringify(map, null, opt.optimize ? null : 4);
fis.util.map(ret.src, function(subpath, file){
if(file.isViews && (file.isJsLike || file.isHtmlLike)){
var content = file.getContent();
content = content.replace(/\b__FRAMEWORK_CONFIG__\b/g, stringify);
file.setContent(content);
}
});
};
fis.config.set('modules.postpackager', [createFrameworkConfig]);
~~~
再次构建,在注入的代码中就能看到alias字段了:
~~~
require.config({
"deps": {
"proj/1.0.5/bar/bar.js": [
"proj/1.0.5/bar/bar.css"
],
"proj/1.0.5/foo/foo.js": [
"proj/1.0.5/bar/bar.js",
"proj/1.0.5/foo/foo.css"
]
},
"alias": {
"bar": "proj/1.0.5/bar/bar.js",
"foo": "proj/1.0.5/foo/foo.js"
}
});
~~~
这样,代码中的 `require('foo');` 就等价于 `require('proj/1.0.5/foo/foo.js');`了。
还剩最后一个小小的需求,就是希望能像写nodejs一样开发js模块,也就是要求实现define的自动包裹功能,这个可以通过文件编译的 [postprocessor](https://github.com/fex-team/fis/wiki/%E9%85%8D%E7%BD%AEAPI#modulespostprocessor) 插件完成。配置为:
~~~
//在postprocessor对所有js后缀的文件进行内容处理:
fis.config.set('modules.postprocessor.js', function(content, file){
//只对模块化js文件进行包装
if(file.isComponents || file.isComponentModules){
content = 'define("' + file.id +
'", function(require,exports,module){' +
content + '});';
}
return content;
});
~~~
所有在components目录和component_modules目录下的js文件都会被包裹define,并自动根据roadmap.path中的id配置进行模块定义了。
最煎熬的一天终于过去了,睡一觉,拥抱一下周末。
## 2014年02月15日 - 超晴
周末的天气非常好哇,一觉睡到中午才起,这么好的天气写码岂不是很loser?!
## 2014年02月16日 - 小雨
居然浪费了一天,剩下的时间不多了,今天要抓紧啊!!!
让我们来回顾一下已经完成了哪些工作:
* 规范
* 开发规范
* ~~模块化开发,js模块化,css模块化,像nodejs一样的模块化开发~~
* ~~组件化开发,js、css、~~handlebars维护在一起
* 部署规范
* ~~采用nodejs后端,基本部署规范应该参考 [express](http://expressjs.com/) 项目部署~~
* ~~按版本号做非覆盖式发布~~
* ~~公共模块可发布给第三方共享~~
* 框架
* ~~js模块化框架,支持请求合并,按需加载等性能优化点~~
* 工具
* 可以编译stylus为css
* 支持js、css、图片压缩
* 允许图片压缩后以base64编码形式嵌入到css、js或html中
* 与ci平台集成
* 文件监听、浏览器自动刷新
* 本地预览、数据模拟
* 仓库
* 支持component模块安装和使用
剩下的几个需求中有些是fis默认支持的,比如base64内嵌功能,图片会先经过编译流程,得到压缩后的内容fis再对其进行base64化的内嵌处理。由于fis的内嵌功能支持任意文件的内嵌,所以,这个语言能力扩展可以同时解决前端模板和图片base64内嵌需求,比如我们有这样的代码:
~~~
project
- components
- foo
- foo.js
- foo.css
- foo.handlebars
- foo.png
~~~
无需配置,既可以[在js中嵌入资源](https://github.com/fex-team/fis/wiki/%E5%9C%A8js%E4%B8%AD%E5%B5%8C%E5%85%A5%E8%B5%84%E6%BA%90),比如 foo.js 中可以这样写:
~~~
//依赖声明
var bar = require('../bar/bar.js');
//把handlebars文件的字符串形式嵌入到js中
var text = __inline('foo.handlebars');
var tpl = Handlebars.compile(text);
exports.render = function(data){
return tpl(data);
};
//把图片的base64嵌入到js中
var data = __inline('foo.png');
exports.getImage = function(){
var img = new Image();
img.src = data;
return img;
};
~~~
编译后得到:
~~~
define("proj/1.0.5/foo/foo.js", function(require,exports,module){
//依赖声明
var bar = require('proj/1.0.5/bar/bar.js');
//把handlebars文件的字符串形式嵌入到js中
var text = " release output destination
-m, --md5 [level] md5 release option
-D, --domains add domain name
-l, --lint with lint
-t, --test with unit testing
-o, --optimize with optimizing
-p, --pack with package
-w, --watch monitor the changes of project
-L, --live automatically reload your browser
-c, --clean clean compile cache
-r, --root set project root
-f, --file set fis-conf file
-u, --unique use unique compile caching
--verbose enable verbose output
~~~
这些参数是可以随意组合的,比如我们想文件监听、自动刷新,则使用:
~~~
fis release -wL
~~~
压缩、打包、文件监听、自动刷新、发布到output目录,则使用:
~~~
fis release -opwLd output
~~~
> 构建工具不需要那么多命令,或者develop、release等不同状态的配置文件,应该从命令行切换编译参数,从而实现开发/上线构建模式的切换。
另外,fis是命令行工具,各种内置的插件也是完全独立无环境依赖的,可以与ci平台直接对接,并在各个主流操作系统下运行正常。
利用fis的内置的各种编译功能,我们离目标又近了许多:
* 规范
* 开发规范
* ~~模块化开发,js模块化,css模块化,像nodejs一样的模块化开发~~
* ~~组件化开发,js、css、handlebars维护在一起~~
* 部署规范
* ~~采用nodejs后端,基本部署规范应该参考express项目部署~~
* ~~按版本号做非覆盖式发布~~
* ~~公共模块可发布给第三方共享~~
* 框架
* ~~js模块化框架,支持请求合并,按需加载等性能优化点~~
* 工具
* ~~可以编译stylus为css~~
* ~~支持js、css、图片压缩~~
* ~~允许图片压缩后以base64编码形式嵌入到css、js或html中~~
* ~~与ci平台集成~~
* ~~文件监听、浏览器自动刷新~~
* 本地预览、数据模拟
* 仓库
* 支持component模块安装和使用
剩下两个,我们可以通过扩展fis的命令行插件来实现。fis有11个编译流程扩展点,还有一个命令行扩展点。要扩展命令行插件很简单,只要我们将插件安装到与fis同级的node_modules目录下即可。比如:
~~~
node_modules
- fis
- fis-command-say
~~~
那么执行 `fis say` 这个命令,就能调用到那个fis-command-say插件了。剩下的这个component模块安装,我就利用了这个扩展点,结合component开源的 [component-installer](https://www.npmjs.org/package/component-installer) 包,我就可以把component整合当前开发体系中,这里我们需要创建一个npm包来提供扩展,而不能直接在fis-conf.js中扩展命令行,插件代码我就不贴了,可以看 [这里](https://github.com/scrat-team/scrat-command-install/blob/master/index.js)。
眼前我们有了一个差不多100行的fis-conf.js文件,还有几个插件,如果我把这样一个零散的系统交付团队使用,那么大家使用的步骤差不多是这样的:
1. 安装fis,`npm install -g fis`
2. 安装component安装用的命令行插件,`npm insatll -g fis-command-component`
3. 安装stylus编译插件,`npm install -g fis-parser-stylus`
4. 下载一份配置文件,fis-conf.js,修改里面的name、version配置
这种情况让团队用起来会有很多问题。首先,安装过程太过麻烦,其次如果项目多,那么fis-conf.js不能同步升级,这是非常严重的问题。grunt的gruntfile.js也是如此。如果说有一个项目用了某套grunt配置感觉很爽,那么下个项目也想用这套方案,复制gruntfile.js是必须的操作,项目用的多了,同步gruntfile的成本就变得非常高了。
因此,fis提供了一种“包装”的能力,它允许你将fis作为内核,包装出一个新的命令行工具,这个工具内置了一些fis的配置,并且把所有命令行调用的参数传递给fis内核去处理。
我准备把这套系统打包为一个新的工具,给它取名为 `scrat`,也是一只[松鼠](http://image.baidu.com/i?tn=baiduimage&ct=201326592&lm=-1&cl=2&fr=ala1&word=scrat)。这个新工具的目录结构是这样的:
~~~
scrat
- bin
- scrat
- node_modules
- fis
- fis-parser-handlebars
- fis-lint-jshint
- scrat-command-install
- scrat-command-server
- scrat-parser-stylus
- index.js
- package.json
~~~
其中,index.js的内容为:
~~~
//require一下fis模块
var fis = module.exports = require('fis');
//声明命令行工具名称
fis.cli.name = 'scrat';
//定义插件前缀,允许加载scrat-xxx-xxx插件,或者fis-xxx-xxx插件,
//这样可以形成scrat自己的插件系统
fis.require.prefixes = [ 'scrat', 'fis' ];
//把前面的配置都写在这里统一管理
//项目中就不用再写了
fis.config.merge({...});
~~~
将这个npm包发布出来,我们就有了一个全新的开发工具,这个工具可以解决前面说的13项技术问题,并提供一套完整的集成解决方案,而你的团队使用的时候,只有两个步骤:
1. 安装这个工具,npm install -g scrat
2. 项目配置只有两项,name和version
使用新工具的命令、参数几乎和fis完全一样:
~~~
scrat release [options]
scrat server start
scrat install [options]
~~~
而scrat这个工具所内置的配置将变成规范文档描述给团队同学,这套系统要比grunt那种松散的构建系统组成方式更容易被多个团队、多个项目同时共享。
> 熬了一个通宵,基本算是完成了。。。
## 2014年02月17日 - 多云
终于到了周一,交付了一个新的开发工具——[scrat](https://github.com/scrat-team/scrat/),及其使用 [文档](https://github.com/scrat-team/scrat/#%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E)。
然而,过去的三天,为了构造这套前端开发体系,都写了哪些代码呢?
* 基于fis的一套[规范及插件配置](https://github.com/scrat-team/scrat/blob/master/index.js),274行;
* scrat install命令行[插件](https://github.com/scrat-team/scrat-command-install/blob/master/index.js),用于安装component模块,74行;
* scrat server命令行[插件](https://github.com/scrat-team/scrat-command-server/blob/master/index.js),用于启动nodejs的服务器,203行
* 编译stylus的[插件](https://github.com/scrat-team/scrat-parser-stylus/blob/master/index.js),10行
* 编译handlebars的[插件](https://github.com/fouber/fis-parser-handlebars/blob/master/index.js),6行
* 一个模块化框架 [scrat.js](https://github.com/scrat-team/scrat.js/blob/master/scrat.js),393行
一共 `960行` 代码,用了4人/天。
## 总结
> 不可否认,为大规模前端团队设计集成解决方案需要花费非常多的心思。
如果说只是实现一个简单的编译+压缩+文件监+听自动刷新的常规构建系统,基于fis应该不超过1小时就能完成一个,但要实践完整的前端集成解决方案,确实需要点时间。
如之前一篇 [文章](https://github.com/fouber/blog/issues/1) 所讲,前端集成解决方案有8项技术要素,除了组件仓库,其他7项对于企业级前端团队来说,应该都需要完整实现的。即便暂时不想实现,也会随着业务发展而被迫慢慢完善,这个完善过程是普适的。
对于前端集成解决方案的实践,可以总结出这些设计步骤:
1. 设计开发概念,定义开发资源的分类(模块化/非模块化)
2. 设计开发目录,降低开发、维护成本(开发规范)
3. 根据运维和业务要求,设计部署规范(部署规范)
4. 设计工具,完成开发目录和部署目录的转换(开发-部署转换)
5. 设计模块化框架,兼顾性能优化(开发框架)
6. 扩展工具,支持开发框架的构建需求(框架构建需求)
7. 流程整合(开发、测试、联调、上线等流程接入)
我们可以看看业界已有团队提出的各种解决方案,无不以这种思路来设计和发展的:
* [seajs开发体系](http://seajs.org/),支付宝团队前端开发体系,以 [spm](https://github.com/spmjs/spm) 为构建和包管理工具
* [fis-plus](https://github.com/fex-team/fis-plus),百度绝大多数前端团队使用的开发体系,以fis为构建工具内核,以[lights](https://www.npmjs.org/package/lights)为包管理工具
* [edp](https://github.com/ecomfe/edp),百度ecomfe前端开发体系,以 [edp](https://github.com/ecomfe/edp) 为构建和包管理工具
* [modjs](http://madscript.com/modjs/),腾讯AlloyTeam团队出品的开发体系
* [yeoman](http://yeoman.io/),google出品的解决方案,以grunt为构建工具,bower为包管理工具
纵观这些公司出品的前端集成解决方案,深入剖析其中的框架、规范、工具和流程,都可以发现一些共通的影子,设计思想殊途同归,不约而同的朝着一种方向前进,那就是前端集成解决方案。尝试将前端工程孤立的技术要素整合起来,解决常见的领域问题。
> 或许有人会问,不就是写页面么,用得着这么复杂?
在这里我不能给出肯定或者否定的答复。
因为单纯从语言的角度来说,html、js、css(甚至有人认为css是数据结构,而非语言)确实是最简单最容易上手的开发语言,不用模块化、不用工具、不用压缩,任何人都可以快速上手,完成一两个功能简单的页面。所以说,在一般情况下,前端开发非常简单。
> 在规模很小的项目中,前端技术要素彼此不会直接产生影响,因此无需集成解决方案。
但正是由于前端语言这种灵活松散的特点,使得前端项目规模在达到一定规模后,工程问题凸显,成为发展瓶颈,各种技术要素彼此之间开始出现关联,要用模块化开发,就必须对应某个模块化框架,用这个框架就必须对应某个构建工具,要用这个工具,就必须对应某个包管理工具……这个时候,完整实践前端集成解决方案就成了不二选择。
> 当前端项目达到一定规模后,工程问题将成为主要瓶颈,原来孤立的技术要素开始彼此产生影响,需要有人从比较高的角度去梳理、寻找适合自己团队的集成解决方案。
所以会出现一些框架或工具在小项目中使用的好好的,一旦放到团队里使用就非常困难的情况。
前端入门虽易工程不易,且行写珍惜!
';
{{title}}
"; var tpl = Handlebars.compile(text); exports.render = function(data){ return tpl(data); }; //把图片的base64嵌入到js中 var data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoA...'; exports.getImage = function(){ var img = new Image(); img.src = data; return img; }; }); ~~~ 支持stylus也非常简单,fis在 [parser](https://github.com/fouber/blog/issues/2) 阶段处理非标准语言,这个阶段可以把非标准的js(coffee/前端模板)、css(less/sass/stylus)、html(markdown)语言转换为标准的js、css或html。处理之后那些文件还能和标准语言一起经历预处理、语言能力扩展、后处理、校验、测试、压缩等阶段。 所以,要支持stylus的编译,只要在fis-conf.js中添加这样的配置即可: ~~~ //依赖开源的stylus包 var stylus = require('stylus'); //编译插件只负责处理文件内容 var stylusParser = function(content, file, conf){ return stylus(content, conf).render(); }; //配置编译流程,styl后缀的文件经过编译插件函数处理 fis.config.set('modules.parser.styl', stylusParser); //告诉fis,styl后缀的文件,被当做css处理,编译后后缀也是css fis.config.set('roadmap.ext.styl', 'css'); ~~~ 这样我们项目中的*.styl后缀的文件都会被编译为css内容,并且会在后面的流程中被当做css内容处理,比如压缩、csssprite等。 文件监听、自动刷新都是fis内置的功能,fis的release命令集合了所有编译所需的参数, ~~~ fis release -h Usage: release [options] Options: -h, --help output usage information -d, --dest浅谈前端集成解决方案
最后更新于:2022-04-02 01:21:40
> 原文出处:https://github.com/fouber/blog/issues/1
> 作者:[fouber](https://github.com/fouber)
目录
[TOC]
> 每个前端团队都在打造自己的前端开发体系,这通常是一个东拼西凑,逐渐磨合的过程,在技术发展日新月异的今天,这样的过程真的是不可抽象和复制的么?本文希望能够通过系统的拆解前端开发体系为大家提供体系设计思路参考。
# 什么是前端集成解决方案
> 前端集成解决方案,英文翻译为 Front-end Integrated Solution,缩写fis,发音[fɪs]
前端集成解决方案并不是一个新词汇,将这个词拆开来看,我们能得到:
* 前端:指前端领域,即web研发中常用的浏览器客户端相关技术,比如html、js、css等
* 集成:将一些孤立的事物或元素通过某种方式改变原有的分散状态集中在一起,产生联系,从而构成一个有机整体的过程。[[1](http://baike.baidu.com/link?url=aGjwHq7-F5eplgGcJPKy7-q5x_5j5eJgfSwuM6WDm0NEEuJekRotYn1Au9GmwRem)]
* 解决方案:针对某些已经体现出的,或者可以预期的问题,不足,缺陷,需求等等,所提出的一个解决问题的方案,同时能够确保加以有效的执行。[[2](http://baike.baidu.com/view/1038216.htm)]
总结来说,前端集成解决方案就是:
> 将前端研发领域中各种分散的技术元素集中在一起,并对常见的前端开发问题、不足、缺陷和需求,所提出的一种解决问题的方案。
# 前端领域有哪些技术元素
前端行业经历了这么长时间的发展,技术元素非常丰富,这里列举出一般web团队需要用到的技术元素:
[![前端开发体系技术元素及其关联关系](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10291ffe4.jpg)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-06_55ec10291ffe4.jpg)
1. `开发规范`:包括开发、部署的目录规范,编码规范等。不要小瞧规范的威力,可以极大的提升开发效率,真正优秀的规范不会让使用者感到约束,而是能帮助他们快速定位问题,提升效率。
2. `模块化开发`:针对js、css,以功能或业务为单元组织代码。js方面解决独立作用域、依赖管理、api暴露、按需加载与执行、安全合并等问题,css方面解决依赖管理、组件内部样式管理等问题。是提升前端开发效率的重要基础。现在流行的模块化框架有requirejs、seajs等。
3. `组件化开发`:在模块化基础上,以页面小部件(component)为单位将页面小部件的js、css、html代码片段放在一起进行开发、维护,组件单元是资源独立的,组件在系统内可复用。比如头部(header)、尾部(footer)、搜索框(searchbar)、导航(menu)、对话框(dialog)等,甚至一些复杂的组件比如编辑器(editor)等。通常业务会针对组件化的js部分进行必要的封装,解决一些常见的组件渲染、交互问题。
4. `组件仓库`:有了组件化,我们希望将一些非常通用的组件放到一个公共的地方供团队共享,方便新项目复用,这个时候我们就需要引入一个组件仓库的东西,现在流行的组件库有bower、component等。团队发展到一定规模后,组件库的需求会变得非常强烈。
5. `性能优化`:这里的性能优化是指能够通过工程手段保证的性能优化点。由于其内容比较丰富,就不在这里展开了,感兴趣的同学可以阅读我的这两篇文章 [[1](http://infoq.com/cn/articles/front-end-engineering-and-performance-optimization-part1)] [[2](http://infoq.com/cn/articles/front-end-engineering-and-performance-optimization-part2)]。性能优化是前端项目发展到一定阶段必须经历的过程。这部分我想强调的一点是 `性能优化一定是一个工程问题和统计问题`,不能用工程手段保证的性能优化是不靠谱的,优化时只考虑一个页面的首次加载,不考虑全局在宏观统计上的优化提升也是片面的。
6. `项目部署`:部署按照现行业界的分工标准,虽然不是前端的工作范畴,但它对性能优化有直接的影响,包括静态资源缓存、cdn、非覆盖式发布等问题。合理的静态资源资源部署可以为前端性能带来较大的优化空间。
7. `开发流程`:完整的开发流程包括本地开发调试、视觉效果走查确认、前后端联调、提测、上线等环节。对开发流程的改善可以大幅降低开发的时间成本,工作这些年见过很多独立的系统(cms系统、静态资源推送系统)将开发流程割裂开,对前端开发的效率有严重的阻碍。
8. `开发工具`:这里说的工具不是指IDE,而是工程工具,包括构建与优化工具、开发-调试-部署等流程工具,以及组件库获取、提交等相关工具,甚至运营、文档、配置发布等平台工具。前端开发需要工具支持,这个问题的根本原因来自前端领域语言特性(未来我会单独写一篇文章介绍前端领域语言缺陷问题)。前端开发所使用的语言(js、css、html)以及前端工程资源的加载与定位策略决定了前端工程必须要工具支持。由于这些工具通常都是独立的系统,要想把它们串联起来,才有了yeoman这样的封装。前面提到的7项技术元素都直接或间接的对前端开发工具设计产生一定的影响,因此能否串联其他技术要素,使得前端开发形成一个连贯可持续优化的开发体系,工具的设计至关重要。
以上8项,1-3是技术和业务相关的开发需求,4是技术沉淀与共享需求,5-8是工程优化需求。
经过这些年的工程领域实践,个人觉得以上8项技术元素应该成为绝大多数具有一定规模的前端开发团队的标配。各位读者可以对照自己团队现状来思考一下团队开发体系还有哪些环节需要完善。
# 攒一套前端集成解决方案
> 不难发现,其实其他领域工程也基本需要解决上述这些问题。前端由于其领域语言的独特性,使得前端工程在解决这些问题上跟其他工程有很大区别,因此至今也没有形成一套比较好的理论体系指导团队实践前端工程。
仔细观察过一些团队的技术体系形成过程,大家都在努力拼凑上述8项技术元素的具体解决方案。单独观察每一项技术点,你或许会觉得都能各自找到已有的实现,但我要说,`把所有8项技术点无缝的串联起来,是一项非常有挑战的工作`,你信么?相信真正经历过这样事情的同学能明白我说的串联成本问题。
假设我们希望实践一套完整的前端集成解决方案,好了,如果我们单独去看每一项技术点,都可能会找来一两个现成的东西,假设我们东拼西凑的找全了所有8项技术要素对应的具体实现。接下来要用了,它们能很完整流程的跑起来么?
正如前面的贴图展示的那样,所有的技术点都有一定的内在联系:
* 模块化开发涉及到性能优化、对构建工具又有一定的配套实现要求,也会影响开发规范的定制
* 组件化开发应该基于模块化框架来加载其他依赖的组件,如果组件化框架自带模块管理功能,那么就可能导致工程性的性能优化实现困难
* 组件库应该与组件化开发配套,组件仓库中的组件都应该按照相同的标准来实现,否则下载的组件不具有可复用性、可移植性,就是去了仓库的意义
* 我们设计的开发规范工具是否能很容易的实现,如果部署上有特殊要求,工具是否能很容易的做出调整,而不是修改规范。
* 工具是否能提供接入公司已有流程中的接口,比如命令调用,如果工具需要一些系统环境支持,公司的ci流程中是否能支持等问题。
前端领域语言的特点决定了攒一套集成解决方案有很高的实现成本。因为前端语言缺少包、导入、模块等开发概念,这使得各个技术点的解决方案在设计的时候都是考虑`被独立使用的情况下如何工作`,因此或多或少的会延伸自己的职责。比如模块化框架要附属构建工具,甚至要求后端服务(比如combo),组件化框架自带模块化框架,构建工具自带部署规范等,这就大大提高了将各个技术要素融合起来的成本。
总之,前述的8项技术要素之间有许多联系,这就为打造一套完整连贯的前端集成解决方案带来了较大的挑战。如何兼顾规范、性能、框架、流程、部署等问题,就不是东拼西凑那么简单的事了。后面我会单独撰文介绍如何实现一套集成解决方案。
';