微信扫描登录

聚划算前端工具体系

本篇文章整理自阿里聚划算前端工程师马立铭5月31日在『ITA1024前端技术精英群』里的分享实录:聚划算前端工具体系。

enter image description here

大家好!很高兴能在互联网技术联盟(ITA1024)与各位分享讨论关于前端工程化和工具体系建设方面的话题。

本人就职于阿里巴巴集团的聚划算事业部,我们的前端工程师团队规模不大,但却支撑起了聚划算、俪人购、淘抢购、淘清仓、淘金币等众多业务,在高效支撑业务发展的同时,也积累起了一套前端工具体系,今天非常有幸在这个平台上与大家做一个交流。

今天的内容我们主要从以下几个方面来介绍:

  1. 前端工具体系(Clam)概要
  2. 思路分析与模块介绍
  3. 调试期与构建期的一致性保证
  4. 移动端调试工具

前端工具体系(Clam)概要

聚划算的前端工具体系我们给它取了一个名字叫“Clam”,中文译为“蚌”,与“棒”同音。我们的建设思路主要围绕Web领域最为基础和朴素的“请求/响应”网络模型架构,通过“干预请求-接收请求-层层处理-做出响应”,以辅助前端工程师进行开发调试。

enter image description here

如上图所示,基础环境部分是一个支持插件机制的容器(Server),采用插件的形式来组织前端开发环境,在灵活性方面也较有优势,开发者可以根据工程项目的特点,自由地选择插件并进行配置,以满足特定项目的开发要求。

当一个请求进入容器后,会依次经过各个插件,若被其中一个插件命中拦截,则由该插件负责处理并做出响应。各插件会根据请求URL的路径或后缀进行匹配拦截,拦截顺序是异步接口、页面和assets,分别由mockx、essi和flex-combo三个插件(npm模块)进行处理,也就是说mockx负责AJAX异步请求的响应,essi负责页面的渲染,flex-combo负责JavaScript、CSS等assets的处理。

此外,gulp作为自动化构建的基础,在执行资源编译任务时,与开发调试期flex-combo插件中的处理逻辑保持一致。

移动开发环境所做的工作核心是请求的转发,除了众所周知的应用层代理转发之外,还实现了网络层NAT转发(需要硬件配合)。在此基础上,延伸出了mock和console等功能。

下面将分模块介绍各个部分的功能以及在建设时所考虑的问题。

思路分析与模块介绍

基础环境——plug-base

前端工程师要在本地进行项目开发,最核心的基础设施应该就是一个合适的容器(Server)了。当年我就是从安装XAMPP开始学习Web开发的,然而随着前端专业化程度和线上真实环境的复杂度的加深,通用型的容器(如Apache、Ngix)开始不再能够满足开发调试阶段的需求,而且上述容器的安装配置也并不“环保”。

前端开发所需的容器首先要求能够逼真模拟出真实的线上环境,虽然可以舍弃一些诸如高并发方面的特性,但需要提供比线上环境更灵活的动态特性以方便调试,且保持体积的轻盈。

最初我们有一个全局安装的开发环境Clam,但在实际项目开发过程中发现,每个项目对开发环境的需求并不能用一个大一统的环境来满足,开发环境写在工程里跟着项目走或许是更好的办法。

因此我们把Clam进行了拆分,容器部分形成了plug-base模块,顾名思义该模块就像“插座”一样,提供了一个支持前端工程调试期所需的基础容器环境,在其之上可以插入各种插件来满足业务开发的需求。

enter image description here

如上图所示,plug-base在启动前会做一些初始化工作,并且容器本身兜底实现了静态资源访问能力,也就是说在没有插入任何插件的时候,plug-base本身约等于一个静态服务器,且支持HTTP和HTTPS协议。

hosts相关工作

在开发调试过程中,为实现域名环境切换或者本地资源映射,少不了修改hosts文件,例如为了调试一个线上URL对应的js代码,我们会把URL所属域名通过hosts文件指向本地(127.0.0.1),然后由本地容器提供服务,URL没有变化,然而对应的代码已经从线上切到了本地。

不过hosts文件改起来还是比较麻烦的,而且hosts文件修改后的生效并不实时,有时需要“简单粗暴”地重启浏览器来让hosts生效。因此,plug-base集成了修改hosts文件以及强制使其立即生效的功能,其工作在plug-base启动前期:

  1. 从项目配置文件中获取需要切至本地的一组域名,通过查询DNS记录下这些域名所指向的真实IP(以备后用)。 修改hosts将这组域名指向本地,并通过命令清除系统DNS cache以使其立即生效。
  2. 域名与IP的真实对应关系(原始DNS信息)将被传递到每个插入plug-base的插件中,供各插件在需要的时候使用。

支持HTTPS

网站HTTPS化对于防止流量劫持、保护用户隐私具有十分重要的作用,不少互联网公司开始逐步将站点过渡到HTTPS上。不过HTTPS化后对本地开发调试环境也提出了新的挑战,如果线上的一个HTTPS地址通过修改hosts文件切到了本地,那么本地开发环境就需要能够“接盘”提供HTTPS服务,与之而来的是证书信任等问题需要在工具层面解决。

HTTPS服务是与证书配套的,真正的线上证书不太可能给到我们工程师用于开发调试,因此我们制作了一张“自签名”根证书,在plug-base启动时注入开发者电脑让系统信任它。此外证书是与域名相关的,plug-base还实现了SNI(Server Name Indication),支持针对不同域名,用“自签名”根证书签发各自对应的域名证书,“以一当百”地模拟出真实线上多服务器、多域名的环境。

接口模拟插件——mockx

在互联网公司内通常会搭建一个数据接口平台,服务于接口的约定、模拟和校验,例如淘系有一个DIP平台,它是一个基于JSON-Schema规范而构建的用于描述数据接口格式的文档与工具平台。

聚划算前端团队正在接入该平台以规范开发流程,在DIP平台中使用JSON-Schema描述完接口后,便会生成一个URL承载mock数据,在工具层面,我们开发的mockx模块负责对接该平台并做出了一些扩展,以更方便地辅助开发。

需要指出的是,mockx在没有DIP的环境中也是可以使用的,因为该模块把对接DIP平台抽象为获取远程数据(DIP平台进阶使用方式除外)。

根据待模拟接口所需的严谨程度,我们划分了三种接口模拟处理方式:

  1. 从本地静态JSON格式的文件中获取模拟数据
  2. 通过本地JavaScript逻辑动态生成模拟数据
  3. 从远程数据接口平台获取模拟数据

enter image description here

异步接口URL匹配规则和处理方式的选择,均在配置文件中指定,配置文件还支持设置接口的延迟时间、转化为jsonp接口等配置项。

enter image description here

例子:

enter image description here

页面模板插件——essi

工具对模板的支持程度可以从“面向前端”和“面向后端”两个角度考虑。因为在讨论前后端职责定义时,对于“模板归谁管”这个问题一直存在不同的现实划分。

就聚划算的应用来说,大部分采用的是淘系webx框架,模板语言为velocity,如果前端工程师不能在自己的环境中跑起这一后端模板语言的话,那么“模板归谁管”的答案就是显而易见的:“前端出demo,后端套页面”似乎成为了唯一的选项。

其缺点在于工作成果转换到生产环境总是要经过人工翻译的过程,效率上不经济、引入缺陷的风险大,且前端不能控制HTML结构,不利于前端对业务的持续跟进。为了改变这一情况,工具“面向后端”支持了后端模板语 言velocity。

工具“面向前端”进行模板支持则比较容易理解,在一些并非强依赖后端的业务中,前端工程师独立开发页面,当然会选择一款对自己友好的模板语言。聚划算前端选择了Juicer,它是一个高效、轻量的前端模板引擎,我们还对前端模板(Juicer)做出了一些扩展:

类SSI语法支持

SSI(Server Side Includes)提供了一种动态引用页面模块的HTML片段复用方法。在essi中支持了类SSI的语法,并支持向HTML片段传递变量。

<!--#include file="path/to/foo.html"-->

<!--#include file="path/to/foo.html" data='{"name":"${name}"}'-->

资源路径变换

资源文件最终会被发布到CDN上,线上页面引用资源时一般使用的是CDN完整URL路径,而在本地开发时,通过相对路径进行引用则更为方便。

essi允许在书写代码时使用相对路径引用资源(脚本/样式),经过essi处理后会将所有相对路径引用的资源自动添加(由配置项“cdnPath”和“version”指定)前缀变成完整的URL路径。

例如在配置项中定义了

{
    "cdnPath": "http://g.alicdn.com/ju/home",
    "version": "1.2.3"
}

在工程目录中有src/pages/a.js和src/b.js两个资源文件,以及HTML文件src/index.html,在HTML中引入上述两个资源文件。

<script src="pages/a.js"></script>
<script src="b.js"></script>

处理后即变为:

 <script src="http://g.alicdn.com/ju/home/1.2.3/pages/a.js"></script>
    <script src="http://g.alicdn.com/ju/home/1.2.3/b.js"></script>

页面性能优化

一些被广泛接受的页面性能优化原则,也落地在了essi中。

资源combo

资源combo源于《高性能网站建设指南》中提到的“减少HTTP请求”原则,在URL中合并描述所要请求的多个文件,在生产环境中combo功能有很多实现,例如阿里的Tengine要求请求URL中用两个问号(??)作为标识,后面用逗号分割指定所要请求的多个文件,例如: http://g.alicdn.com/ju/home/1.2.3/??pages/a.js,b.js

出于方便代码维护与管理的目的,在书写代码时还是希望能分散引用各资源文件,经过essi处理后,便会对指定的资源进行合并。接着上面“资源路径变换”的例子,如果在资源引入的标签中加上fe-group属性,属性值只要求相同即可:

<script src="pages/a.js" fe-group="jhs"></script>
<script src="b.js" fe-group="jhs"></script>

那么处理后即变为:

<script src="http://g.alicdn.com/ju/home/1.2.3/??pages/a.js,b.js"></script>

资源位置抽离

“将样式表置于页面前部、将脚本置于页面尾部”也是《高性能网站建设指南》中提到的性能优化原则。在essi中,如果在资源引入的标签中加上fe-move属性(值为head或bottom),就可以指定该标签抽离移动到页面前部或尾部。

fe-move="head":抽离移动到HTML标签 </head>之前的位置  

 fe-move="bottom":抽离移动到HTML标签</body>之前的位置  

assets处理插件——flex-combo

随着前端技术和周边基础设施的发展,事实上增加了本地开发调试的难度,且主要体现在了assets的处理上。在前端刀耕火种的时期,资源文件(.js、.css)就是一堆静态文件,并没有编译的概念,而当下为了优化代码组织结构以及提高开发效率。

我们希望提前使用ES6来编写代码,从而需要引入Babel对代码进行转化;为了让样式代码更易维护,我们开始使用CSS预处理语言(例如less),从而需要在assets处理层面支持对源文件进行编译。再比如资源合并请求就要求本地开发环境能够支持解析combo格式URL。

flex-combo作为assets处理插件,其主线处理流程按顺序分为以下几步:

URL解析

将URL中所携带的资源路径信息转换为一个数组(如果有资源合并请求的情况则该数组有多个元素)。以下举例说明。 http://g.alicdn.com/ju/home/1.2.3/??a-min.css,b.less.css 会分解为:

[
    "/ju/home/1.2.3/a-min.css",
    "/ju/home/1.2.3/b.less.css"
]

flex-combo的filter机制会对以上数组元素进行正则替换。 例如以下filter配置项:

{
  "filter": {
    "[\\.\\-]min\\.css$": ".css"
  }
}

可以抹除-min,从而使数组变为:

[
  "/ju/home/1.2.3/a.css",
  "/ju/home/1.2.3/b.less.css"
]

以下filter配置项:

{
  "filter": {
    "[\\.\\-]min\\.css$": ".css",
    "/\\d+\\.\\d+\\.\\d+": "",
    "/ju/home": ""
  }
}

可以抹除-min、版本号和地址前缀,从而使数组变为:

[
  "/a.css",
  "/b.less.css"
]

资源查找与处理

在上一步解析得到数组的基础上,按顺序获取并处理数组中每一个元素所对应的资源文件并响应。针对每一个资源文件,又将按顺序尝试以下四个动作:

enter image description here

资源实时编译

资源实时编译动作是否执行,是通过资源后缀及配置文件来决定的。例如在上例中,.less.css后缀的资源可以触发less引擎对~/PRJ_DIR/src/b.less文件进行编译。若匹配到动态引擎规则并成功处理的,后续动作将略过。

本地

接上例,flex-combo会尝试从~/PRJ_DIR/src/a.css获取资源文件,若文件存在则后续动作将略过,若不存在则继续动作。

缓存

接上例,flex-combo会尝试在缓存中获取文件,若缓存目录下存在目标文件则后续动作将略过,若不存在则继续动作。

远程

若以上三步均未匹配到相应的资源文件,则会尝试从远程服务器获取。

综上所述,资源查找与处理的流程是依次尝试执行“资源实时编译”→“本地”→“缓存”→“远程”四种动作以获取资源文件的最终响应形式。

针对“资源实时编译”需要多说几句。在当下的前端工程实践中,出现了很多需要我们对一份原始代码进行处理、包装,编译输出为另一种形态目标代码的需求。

在“资源编译”中加上“实时”两个字的意义在于,在编写代码的同时,我们不再需要开启一个watchbuild进程,不断监听文件改动并执行编译的动作,因为这样做不符合“环保”的标准。资源实时编译的实现,将开发调试期编译动作触发的时机,从“文件改动”变为“请求发起”。

动手搭建环境

讲完了基础环境plug-base和mockx、essi和flex-combo等插件后,让我们动手搭一个环境出来吧。

聚划算前端内部我们有一个脚手架工具来初始化项目,主要是根据需求生成一份gulpfile.js文件,里面会把构建任务准备好,同时再创建一个任务用于启动调试环境。在这里我演示一下如何手动将发布于npm上的plug-base、mockx、essi和flex-combo等模块组合起来搭建环境。

enter image description here

如上图所示,就完成了一个名为“clam”的gulp task,用于启动调试环境,是不是很简单!

调试期与构建期的一致性保证

对于一个前端工程,在开发调试阶段是以工程师为中心的,追求的是开发效率;而构建的目的则是将代码转换成为对机器友好的形式(以机器为中心),追求的是运行效率。目标的差异性决定了服务于这两个阶段的工具有着不同的侧重点,不过这两个阶段当然不会是割裂的,其中共性的功能可以共用一套处理逻辑。

在上面的分析中,我们提出了不推荐在开发调试期开启watch build,而是由工具提供实时编译能力的观点。在构建期对资源的编译也应由工具提供,而且与开发调试期共享相同的编译逻辑以保证一致性。

我们选择了gulp作为自动化构建的基础。首先一点就是其使用体验比grunt好太多,grunt的配置简直让人发狂!此外,其通用插件也比较丰富,常用的rename、uglify、minify等插件应有尽有,而且其利用了Node.js的文件流处理能力,可以快速构建项目并减少频繁的IO操作,执行效率也比grunt要来得高。

enter image description here

如上图所示,我们认为开发调试期与构建期(gulp)的处理模型在本质上是相似的,因此在工具层面资源编译逻辑若被分别封装为“容器插件”和“构建工具插件”两种形式,就可以统一开发调试期与构建期的资源编译并保证一致性。

举一个简单的例子,对less文件的处理,假设工程根目录有一个style.less文件。

  • 在开发调试期,通过访问URL为http://foo.com/style.less.css 这样的地址,容器中的编译插件拦截.less.css后缀的请求并实时编译对应的less文件做出响应。
  • 在构建期,首先使用gulp-rename插件将.less后缀的文件重命名为.less.css后缀,然后再交给下一层pipe,也就是资源编译插件,与开发调试期一样也会拦截.less.css后缀的文件流,并做出一致的处理。

enter image description here

与传统的做法相比,构建期并不需要引入gulp-less这样的编译插件,而是统一交给了flexCombo.engine方法去处理,其背后与开发调试期的编译逻辑完全一致。

移动端调试工具

关于移动端调试工具,我们独立开发了一个基于NW.js的PC客户端,命名为“么么哒”,取自“默默达”的谐音,寓意默默地将用户请求转达到该去的地方。该工具主要功能有以下几点:

请求转发

为实现移动端调试时的请求转发,通常要比PC端更为复杂,因为移动设备的hosts文件需要越狱或root权限才能修改。为解决这一困境,可以让移动设备连接到代理服务器,通过修改代理服务器的hosts文件,以实现移动端的请求转发。

因此,代理工具是移动端工具建设的必要组成部分。

当然在客户端和服务端中间的除了应用层代理,还有整个网络链路中的路由器等设备,这些设备所对应的 网络层次比传统代理所在的应用层更为底层,通过网络层路由器的控制,可以实现无需在移动终端上设置代理参数(也就是无感知中间人)。

enter image description here

上图展示的是“么么哒”的一个基础界面,可以查看与其绑定的移动设备所发出的网络请求情况,这应该说是代理工具的标配。不过“么么哒”实现了“双模”请求转发,也就是分别在应用层和网络层实现了请求转发。

应用层模式即为传统代理的方式,通过在移动端输入代理服务器的IP和端口完成设置,而网络层模式则是一个软硬件结合的方案,我们采用了基于Router OS的路由器,通过程序控制路由器的NAT配置参数,达到控制移动端请求的目的。

enter image description here

enter image description here

请求mock

请求mock功能基于上面提到的mockx模块,通过可视化界面定义需要进行mock的URL匹配规则,以及响应的数据,请求响应类型可为“JSON数据”、“文本数据”或“文件”。

enter image description here

简单控制台

移动端比PC端调试困难还体现在console信息的输出,移动浏览器或WebView不具备控制台功能,而USB调试方法受设备系统的限制,而且iOS和Android的调试方法又不统一,相信不少人最终选择了“alert大法”。alert一些普通字符串信息尚能接受,如果输出一些复杂的对象的话就无法正常显示了。

“么么哒”实现简单控制台的功能,原理是页面请求经过代理时,么么哒会向页面自动植入一段代码,由这段代码负责与简单控制台进行双向联系,将console信息传输到简单控制台上,并能将简单控制台上输入的JavaScript脚本植入到移动页面中去执行。

enter image description here

如上图所示,页面中的console信息被输出到了“么么哒”客户端中。

总结

以上就是聚划算的前端工具体系,正如一开始所讲的,我们的建设思路主要围绕Web领域最为基础和朴素的“请求/响应”网络模型架构,无论是PC端还是移动端都是建立在“请求/响应”的模型之上的,我们甚至还把构建时的gulp任务也归为了广义的“请求/响应”模型,因此论证了调试期与构建期可以共享编译逻辑并保证一致性,由此可以抛弃watch build的开发模式。

希望我们的思路可以抛砖引玉为大家所用。

谢谢大家。

Q&A

问:缓存更新的策略是什么?

马立铭:flex-combo的缓存,其实缓存的是本地找不到而去抓取线上的文件,然后如果每次都去线上抓取的话,会比较慢,因此考虑了缓存。由于是开发调试时用的,所以具体的策略比较简单,对线上url进而md5之后存在cache目录中,尝试读取缓存也是根据md5后的结果去找。

问:那线上资源更新后,本地cache是不是自动更新呢?什么时机更新?

马立铭:线上资源更新一般url也会变化,我们采取的是非覆盖式发布,因此不会担心cache的更新问题。

问:用么么哒需要怎么配置?

马立铭:在没有我上面所说的硬件环境下,么么哒的使用与charles比较像,么么哒支持“双模”,就是指可以与传统的代理工具一样,手机上设置IP+端口的形式,也可以在有硬件支持下,不设置IP+端口来实现代理。

问:马老师好,对于顶替watch build的,请求编译方式,我有点没看明白。比如,使用jade模板编写html,通常情况下是保存瞬间编译成了html,如果没有watch的话,何时才能编译成html?

马立铭:jade模板我倒是不熟,不过应该也是有编译的接口可以调用,访问时编译应该也能实现,只是需要自己来开发。

问:您指的访问编译的模式是否适合指线上调试阶段?

马立铭:本地调试阶段。

问:嗯,比如我页面已经打开了,同时编辑了代码

马立铭:刷新之后又发起请求,然后编译。

问:是指刷新页面访问的时候才编译吗

马立铭:是的,模板编译是一块,另一块是资源文件的编译,就像我上面说的less的例子。

问:也就是在脚本里会有刷新的行为,那这个刷新是判断何时刷新的?比如每秒刷新一次还是?

马立铭:这个刷新是人来控制的,比如你写了一段代码,要看效果了,就去浏览器上刷新一下。这个就是时机,在这个时机触发了动态编译,惰性的。只有当你要看效果时触发,而且动态编译的也就是这个页面所涉及到的资源。

问:嗯,也就是把我们常用的保存编译操作回归到了,保存刷新再编译的操作?

马立铭:对,调试期是针对分散的资源,触发后编译一下,输出一个结果供你查看效果,构建期用相同的编译逻辑,根据gulp指定的src路径,全量的遍历一遍编译输出到build目录。

问:不知道我理解的对不对,你们的开发方式倾向于把线上环境抓下来或者映射到本地进行?(不知道理解的对不对),我们的开发方式是拷贝一份一模一样的线上环境作为线下沙盒调试,你觉得哪种方式更好?

马立铭:基本上是这样的,而且有些时候解决线上问题,通过映射到本地会比较快。而且在阿里,就线上环境来说,好多是java环境,前端在本地搭起那样的环境很重,也比较困难,通过hosts切到本地,然后对url进行一些变换处理最终在本地找到文件,直接来解决线上问题。

问:阿里似乎很喜欢用nodejs,对于前端在本地搭环境重,用nodejs做中间层通过ral访问固定线下沙盒的java服务如何?

马立铭:这个倒也是可以考虑的。

问:对于cdn的文件合并功能,对commonjs规范打包的方式该如何处理。比如a和b文件都用commonjs规范打包,同时引用了c,那么c其实会被打包到a和b中,这个时候如果a b文件合并了,会引入两个c。所以看上去还没有把a b c一起打包后引一个文件好。

马立铭:cdn的文件合并功能和具体的某个规范是不冲突的,根据某个规则打包的动作发生在构建的时期,而cdn的文件合并是在构建完成正式发布之后,如果在构建时打包进去了就ok了,cdn只是做纯粹的文件concat,怎么运用还是看具体项目的设计。