YUI3在美团的实践

美团网在2010年引爆了团购行业,并在2012年销售额超过55亿,实现了全面盈利。在业务规模不断增长的背后,作为研发队伍中和用户最接近的前端团队承担着非常大的压力,比如用户量急剧上升带来的产品多样化,业务运营系统的界面交互日益复杂,代码膨胀造成维护成本增加等等。面对这些挑战,我们持续改进前端技术架构,在提升用户体验和工作效率的同时,成功支撑了美团业务的快速发展,这一切都得益于构建在YUI3框架之上稳定高效的前端代码。在应用YUI3的过程中,我们团队积累了一些经验,这里总结成篇,分享给大家。

为什么选择YUI3

使用什么前端基础框架是建立前端团队最重要的技术决策之一。美团项目初期因为要加快开发进度,选择了当时团队最熟悉的YUI2(前框架时代杰出的类库),保证美团能够更快更早地上线,抢占市场先机。不久由于前端技术发展很快,YUI2的缺点逐渐凸显,例如开发方式落后、影响工作效率等等,于是我们开始考虑基础库的迁移。

经过一段时间对主流前端库、框架的反复考量,我们认为YUI3是最适合我们团队使用的基础框架。

首先,国内的开源框架及其社区刚开始起步,在代码质量、架构设计和理念创新上还难以跟YUI3比肩,所以基本排除在外。其次,国外像YUI3这样面向用户产品、文档丰富、扩展性良好的成熟框架屈指可数,例如ExtJS和Dojo则更适合业务复杂的传统企业级开发。最后,使用jQuery这种类库构建同YUI3一样强大的框架对创业团队来说并不可取,美团快速发展、竞争多变的业务特点决定了我们必须把主要精力放在更高一层的业务开发上,而不是去重复发明一个蹩脚的YUI。

YUI3成为最终选择有以下几个直接的原因:

  • 非常优秀,是真正的框架,真正的重型武器,具有强劲的持续开发能力,可以应对业务的快速发展。不管是规模不断增长的用户产品,还是交互日趋复杂的业务系统(美团有超过100个业务系统作全电子化的运营支撑),YUI3都游刃有余。
  • 代码整齐规范,容易维护,适合有洁癖的工程师,同时能够显著提高团队协作时的开发效率。因为人手紧缺,后端工程师也需要参与前端开发,一致的代码风格使前后端配合轻松简单。
  • 有出色的架构设计,是很好的框架范本,通过研究学习可以帮助工程师成长,培养良好的工程思维。人是美团最重要的产品。

随着团队成长,我们最后引入了YUI3,在迁移过程中,遇到了很多技术上的和工程上的挑战,但是我们一直在前进,一直在行进中开火。从结果来看,YUI3为我们团队提供了先进生产力,为快速开发、快速部署、快速迭代提供了源源不断的力量。

YUI3的优秀主要表现在模块和组件框架的出色设计,下面我们着重介绍这两方面的一些实践经验。

改变一切的模块

前端开发日益复杂化,代码组织成为一个显著的问题。受到后端代码普遍采用的模块机制启发,很多前端模块机制应运而生。目前比较著名的有CommonJS和AMD。但早在2008年8月13日,YUI3 Preview Release 1中就已经给出了YUI团队的解决方案,并在2009年9月29日YUI3正式版发布时定型。

以下是使用YUI3进行模块化开发的简单例子

// 定义模块
YUI.add('greeting', function (Y) {
    Y.sayHello = function () {
        console.log('Hello, world!');
    };
});

// 调用模块
YUI().use('greeting', function (Y) {
    Y.sayHello(); // output 'Hello, world!'
});

模块的引入,使得更细粒度的按功能进行代码组织成为可能,也为方便的进行扩展和分层提供了基础,自底向上的彻底改变了YUI3。一套完整的模块机制,还包括解决关系依赖、自动加载的Loader和提高加载效率的Combo。

面对如此彻底的改变,我们需要解决很多挑战:

  • 如何将原来的功能划分为模块?
  • 如何管理模块元信息?
  • 如何高效的获取模块?

划分模块

经过两年来不断的实践和总结,我们归纳了如下几条划分模块的原则:

  • 抽象与应用脱离。更通用的功能放在更低的层级,应用层完全面向实际问题,在解决的过程中调用抽象出来的方法。
  • 职责单一。保持每个模块的足够简单和专一,方便维护和可持续开发。
  • 粒度得当。有了Combo,我们可以不必担心粒度太小,文件过多导致的速度问题。但是,从可维护的角度来考虑,粒度应该适当而不宜过小,避免海底捞针的情形出现。
  • 海纳百川。我们的模块体系应该是开放的,不符合YUI规范的第三方模块,可以借鉴整合进来,使我们的基础框架更加完善,更加性感。

美团前端架构

按照模块的层次划分,美团的JS框架可以分为四个层次:

  • 最底层交给强悍的YUI3,为我们提供跨浏览器兼容的API和良好的框架设计。
  • 第二层是我们二次开发的核心方法、组件(Component)和控件(Widget)。现已独立为前端核心库,为美团所有系统提供前端支持。核心库的种子文件中定义了全局变量M,除了对YUI3进行封装的代码以外,还包含了对语言层面的扩展,以及一些基础工具类。核心库有一个非常重要的组成部分,就是我们功能丰富的控件集合,比如常用的自动完成、排序表格、气泡提示、对话框等基础控件。除了这些,核心库还包含了常用的基础组件、插件(Plugin)、扩展(Extension)以及单元测试代码。
  • 第三层包含各个系统的一些通用模块。例如www-base模块包含美团主站(www)的消息系统、用户行为追踪系统等通用功能。这一层更加接近应用。
  • 最上面一层,应用模块。这些模块的方法都是用来解决实际业务问题。例如www-deal用来处理美团主站所有deal相关功能的交互,finance-pay用来处理财务系统中付款相关的交互。一些零碎的应用方法我们放在对应系统的misc模块中,避免模块碎片化。

这套框架仍在不断演变,以便更好的支撑业务需求。其中一个明显的方向是,在第二层和第三层之间,出现一个为了更好整合所有内部业务系统前端通用资源的中间层。

管理模块元信息

模块元信息主要包括模块名称、路径、依赖关系等内容。其中最为重要的是依赖关系,这决定了有哪些模块需要加载。为了实现自动加载,需要将所有模块的元信息提供给YUI的Loader。

最初,为了更快的从YUI2迁移到YUI3,模块元信息放在PHP中进行维护。随着时间的推移,渐渐显示出很多弊端。首先,在定义模块的js文件中已经包含模块名称、依赖关系等信息,和PHP中内容重复。其次,这些元信息最终直接输出到html中,没有有效利用缓存。

随后,我们使用NodeJS开发了一系列脚本,收集所有模块元信息,保存为独立js文件,并实现了自动化。为了防止出错,在Git Hooks和上线脚本中都加入了校验过程。工程师需要做的,只是修改模块定义中的元信息。

最近一段时间,我们的精力主要放在两个方面:

  • 自动生成依赖。随着模块粒度细化和模块数量的增长,依赖关系日益复杂,依靠人工配置经常出现过多依赖或过少依赖等问题。我们准备开发一套自动扫描模块引用API,并确定依赖关系的机制。
  • 自动打包依赖模块。如果在代码发布时,就已根据页面模块调用计算好所有依赖模块,并进行打包,可以避免引用全部模块元信息、Loader计算依赖等过程,提高网站性能。

Combo

Combo可以一次请求多个文件,能够有效解决多个模块加载带来的性能问题。Yahoo提供了Combo服务,但只能提供YUI3模块,而且速度在国内并不理想。为了提供更好的体验,让用户访问速度更快,我们最终考虑搭建自己的Combo服务,并把Combo发布到CDN上。

以下是一个Combo请求的例子:

http://c.meituan.net/combo/?f=mt-yui-core.v3.5.1.js;fecore/mt/js/base.js

为了节约时间,我们最开始采用了开源的minify,经过一些修改和配置,就可以在生产和开发环境提供Combo服务。使用一段时间后,发现minify过于复杂,以至于添加一些定制功能相当困难。我们需要的只是简单的文件合并功能,在明确需求和开发量后,着手开发自己的Combo程序。从最初的仅支持文件合并,后来陆续添加了服务器/浏览器端缓存、文件集别名、调试模式、CSS图片相对路径转URI、错误日志等特性,全部代码仅有300多行。经过两年时间以及每天几千万PV的考验,服务一直非常稳定。

灵活健壮的组件框架

YUI3之所以成为纯粹的框架,真正的原因在于提供了一套灵活、健壮的组件框架。借助这套框架,可以轻松的将业务场景进行解耦、分层,并持续的进行改进。通过不断的实践,我们越发认为这是YUI3的精髓所在。

从YUI3定义的开发范式和源代码中可以看出,YUI团队非常重视AOP(Aspect Oriented Programming)和OOP(Object Oriented Programming),这一点可以在接下来的介绍中有所体会。

EventTarget、Attribute和Base

在介绍组件框架之前,有必要首先了解下EventTarget。YUI3创建了一套类似DOM事件的自定义事件体系,支持冒泡传播、默认行为等功能。EventTarget提供了操作自定义事件的接口,可以让任意一个对象拥有定义、监听、触发、注销自定义事件的功能。YUI组件框架中的所有类,以及在此框架之上开发的所有组件,都继承了EventTarget。

Attribute是组件框架中最底层的类,实现了数据和逻辑的完美解耦。为什么说是完美呢?存储在attribute(Attribute提供的数据存取接口)中的数据发生变化时,会触发相应的事件,为相关的逻辑处理提供了便捷的接口。从下面这个简单的例子可以感受到这一点:

// 在name属性变化时,触发nameChange事件
this.on('nameChange', function (e) {
    console.log(e.newVal);
});

// 修改name属性
this.set('name', 'meituan'); // output 'meituan'

实践中发现,妥善处理属性的分类非常重要。供实例进行操作的属性适合作为attribute,例如表单验证组件FormChecker的fields属性,方便应用层进行表单项的增删改。在类方法内部使用的一些属性可以作为私有属性,例如计时器、监听器句柄。供所有类的实例使用的一些常量适合作为类的静态属性,例如一些模板、样式类。

Base是组件框架的核心类。它模拟了C++、Java等语言的经典继承方式和生命周期管理,借助Attribute来实现数据与逻辑的分离,并提供扩展、插件支持,从而获得了良好的扩展性以及强大的可持续开发能力。YUI团队通过多年来对业务实践的抽象,最终演化而成一种开发范式,这,就是一切组件的基石——Base,实至名归。

依照这种范式,我们开发了一系列组件,例如之前提到的FormChecker,以及延迟加载器LazyLoader、地图的封装Map等。最显著的体会是,开发思路更为清晰,代码结构更有条理,维护变得简单轻松。

// 构造方法
FormChecker.prototype.initializer = function () {
    var form = this.get('form');
    this._handle = form.on('submit', function (e) {
        // check fields
    });
};
// 析构方法
FormChecker.prototype.destructor = function () {
    this._handle.detach();
};

// 创建实例时,自动执行构造方法
var checker = new FormChecker({ form: Y.one('#buy-form') });
// 销毁实例时,自动执行析构方法
checker.destroy();

Extension和Plugin

Extension(扩展)是为了解决多重继承,以一种类似组合的方式在类上添加功能的模式,它本身不能创建实例。这种设计非常像Ruby等语言中的Mixin。Plugin(插件)的作用是在对象上添加一些功能,这些功能也可以很方便的移除。

它们有什么区别呢?简单来说,Extension是在类上加一些功能,所有类的实例都拥有这些功能。Plugin只是在某些类的实例中添加功能。举两个典型的例子:一些节点需要使用动画效果,这个功能适合作为Plugin。气泡提示控件需要支持多种对齐方式,所有实例都需要此功能,因此使用YUI3的WidgetPositionAlign扩展。

// 传统的函数方式实现动画
Effect.fadeIn(nodeTip);

// 插件方式实现动画
nodeTip.plug(NodeEffect);
nodeTip.effect.fadeIn();

Extension和Plugin很好的解决了我们遇到的诸多功能重用问题。我们开发了提供全屏功能的WidgetFullScreen、自动对齐对话框的DialogAutoAlign等扩展,以及进行异步查询的AsyncSearch、提供动画效果的NodeEffect等插件。将这些偏重OOP的编程思想应用在前端开发中,比较深刻的体会是:有更多的概念清晰、定位明确的开发模式可以选择。

Widget体系

Widget(控件)建立在Base之上,主要增加了UI层面的功能,例如renderUIbindUIsyncUI等生命周期方法,HTML_PARSER等渐进增强功能,以及样式类、HTML结构和DOM事件的统一管理。Widget提供了控件开发的通用范式。

由于前端资源相对紧张,我们倾向于大量使用控件,尤其在业务系统这样更注重功能的场景。主要出于两点考虑:

  • 减少不必要的重复劳动,提高产出。通过将交互、业务逻辑合理抽象,一次解决一类问题,One Shot One Kill。
  • 节约前端工程师资源。通过自动加载和初始化控件、封装简单易用的后端方法、制作Demo和使用手册等措施,降低使用门槛,后端工程师只需要知道参数的数据结构就可以轻松调用,提高了开发效率。

以下是一个自动加载控件的例子

// 页面初始化时,会扫描所有带有data-widget属性的节点,自动加载对应控件,并根据data-params数据进行初始化
<a href="…" data-widget="bubbleTip" data-params='{ "tip": "全新改版,支持随时退款" }'>下载手机版</a>

目前,我们已经构建了一个包含近30个控件的Widget体系,为所有系统提供丰富、便捷、集成的解决方案。

行进中开火

在整个YUI3的实践中,我们犯过很多错误,例如全局只有一个YUI实例、Combo的CSS图片依赖等等,但这些并没有成为放弃的理由。从今天回过头来看,YUI3带给我们团队的,不只是更高的开发效率、更好的可持续开发能力,还有它本身的设计思路、源码书写、辅助工具等诸多方面潜移默化的影响。这些回报的价值,比起较高的使用门槛、犯过的一些错误,要贵重百倍。

指导这一切的,是我们始终坚持的 “行进中开火”。在互联网这个高速发展的行业里,对于我们这种小规模的创业团队,一天不前进,就意味落后。做事不应该准备太多,一定要先做起来,然后发现不足并不断改进,宁可十年不将军,不可一日不拱卒。每天都做得更好一点,日积月累,我们才会在激烈的竞争中占据越来越大的优势。

YUI3并非完美,存在着学习成本高、对社区不够开放等问题。我们所做的更远非完美,但经过不断的尝试和经验的积累,已经渐渐摸索出一条明确的路线,并会坚持不懈的继续走下去。

关于YUI3和我们团队更多的信息请关注 http://fe.meituan.com

Proudly powered by Express. Designed by Spring.