JavaScript框架/库一直就是百花齐放,最近几年更是层出不穷。回顾这几年,有两个最引人注目的东西,一个是Angular,一个是React。其中,Angular最火的时间是2013年中到2014年末,React从2014年中开始升温,然后又由于ReactNative等周边项目,导致关注度很高。
2014年末,Angular官方宣布了一个大新闻,要完全重写Angular 2.0。这个事情让很多想要使用Angular的人止步不前,也给很多人带来了困惑。
随后,Angular 2.0的开发者之一创建了新的框架Aurelia,整体思路上与Angular相似,有一些细节的差异。那么,我们应当如何看待这些框架呢?
如果不是有重大原因,没有哪个开发者会做出彻底重写,产生很多不兼容变更的决定。对于Angular来说,它面临这么一些原因:
也正是Angular 2.0那篇大新闻,使大家知道了AtScript这样的语言,它在TypeScript的基础上添加了注解等功能。
有很多语言可以转译成JavaScript,比如CoffeeScript,Dart,TypeScript等,从最近的一些事件来看,TypeScript可以算是JavaScript转译领域的最大赢家。
很多人可能会有这样的疑问:为什么我们要用这些东西,而不是直接编写原生的JavaScript?开发语言的选择,很大程度上反映了我们对JavaScript组件化方案抽象度的需求。
比如说,Angular中,可以使用TypeScript来写业务代码,React中,通过JSX来使用组件,这都是具有较高抽象度的方案,能够让业务代码变得更直观。
先不看这些转译语言,来看看ES6,它给我们带来了很多编程的便利,每一次这种语言细节的升级,都引入了一些好用的东西,所以我们当然是期望尽早使用它。但问题是,浏览器的支持程度总是落后的,如果用它写了,在很多浏览器上不支持,比如箭头函数:
this.removeTodo = function(todo) { this.todos = this.todos.filter(item => todo!=item); };
所幸,我们有Babel这样的转换器,可以把这样的代码翻译成ES5代码,它的生成结果就是
this.removeTodo = function(todo) { this.todos = this.todos.filter(function(item) { return todo != item; }); };
这个例子并不明显,如果你使用class之类的东西,就能体会到更大的改变。虽然说class这些只是语法糖,但用起来还是很爽的,可以复用一些传统的设计模式之类。
对于那些只需支持ES5+的项目而言,现在开始选用ES6语法编写代码是非常合适的,因为我们有Babel这样的东西,我们可以享受ES6新语法带来的愉悦编程体验,而无需承担兼容风险。
ES6新语法有很多,想要在生产过程中更好地使用,可以参见百度ecomfe的这篇 使用ES6进行开发的思考
Angular和Aurelia都支持TypeScript,可以直接使用TypeScript编写业务代码。如果选用这样的框架,个人建议直接使用TypeScript。
为什么在类似Angular这样的体系里,我要建议使用TypeScript呢,因为这么几个原因:
很可能在现在这个阶段,你的项目还需要面对一些不支持ES6的浏览器,所以不能直接写ES6代码,但有可能有一天,浏览器支持了,但你的代码还是老的,它基本上还在使用ES5编写,想要迁移到ES6比较麻烦,以后每次迁移都是痛苦的过程。TypeScript就是以生成JavaScript为目标的,所以如果你用它写,只需选择生成参数,比如生成es5,es6就可以了,就算以后es继续升级,也只要改个参数就完事。
TypeScript为代码提示作了很多特殊优化,比如:
ele.on("click", function(e) { // 这里我们是不知道e上面有什么,在编写的时候得不到提示 });
但是如果使用TypeScript编写,因为这个e的类型确定,所以就能有提示。
使用这样的语言也能够更快让非前端方向的人参与项目。
Angular的整体方案,由于分层很清晰,在JavaScript代码中基本就是纯逻辑,这样的代码如果使用TypeScript编写,会更加精炼,更加清晰。
这几年,大家逐渐接受了一个现实,那就是:前端也是需要构建的,所以我们有grunt,gulp这样的构建工具。之前我们不愿意写转译语言,是因为其他环节不需要构建,为了一些语法糖而引入整个构建环节代价太大。现在,既然发布之前的构建环节不可缺少,使用转译语言也不过就是加一段配置而已,这个使用代价已经小很多了。
Angular这样的解决方案,所面向的多数都是重量级产品,这些产品本身就会有构建环节,也基本上会使用IDE,所以,使用TypeScript的代价不大。
当项目变大的时候,我们会面临很大的管理成本,比如对代码的分析,结构调整,模块依赖关系梳理等,在TypeScript上面做,会比在JavaScript上面做更有优势。
最近几年前端领域“工程化”这个词被说得太多,但其实绝大部分说的都只是“工具化”。早在Visual Studio 2005中,就存在很多Factory插件,举例来说,一个普通项目的工作流程可能是这样:
比如说我们做到一半,需要变更模型,也只是需要在ER图那边修改,然后依次一键变更过来。很多时候我们也会有代码的目录调整,批量更名,如果使用约束较强的语言,这部分可靠性会更高。
如果用过angular 1.x,会对它的路由机制印象深刻。有复杂业务需求的人一般都不会使用内置的ng-route,而是会使用第三方的ui-router,这两者的核心差别是子路由的定义。
比如:
A界面有两个选项卡,分别B,C,如果我们想要:
app.html#a/b app.html#a/c
这样的多级路由,在ng-route中想要定义,就比较麻烦,而在ui-router中,允许使用嵌套的ui-view指令,可以比较方便地支持这一功能。
在这两种方式下,路由都是全局配置的,但我们考虑在全组件化的场景下,组件的嵌套会受到这种路由配置的制约。比如,本来我们只是期望把某个组件嵌入到另外一个组件中,就能完成功能,但为了路由,不得不额外在全局路由配置的地方,加一个配置,而且每当组件层级发生变更的时候,这个配置都需要改,这就大幅拖累了我们组件体系的灵活度。
为此,我们可能会期望把路由配置放在每个组件中,比如说,组件A定义自己的路由为a,组件B的路由为b,组件C的路由为c,无需额外的配置,当B和C放在A中作为选项卡的时候,上面那两条路由会自动生效。
在Angular的新路由机制中,就是这样处理的,这也是Angular 2.0和Aurelia的共同路由机制。在这种机制下,如果有一天我们在另外一个更高层的组件D中,引入了组件A,那路由就会自己变成类似:
app.html#d/a/b app.html#d/a/c
这个是非常灵活的,这对于我们构建一个全组件化的系统很有利,另外,这实际上实现了路由的动态配置。
当然,对这个问题,也是有争议的,因为路由不再集中配置,很难有一个地方能查看所有的路由状况了。
此外,由于在Angular 2和Aurelia中都凸显了组件的概念,组件的生命周期被引入了,比如说,组件的四个状态:
这些跟路由进行配合,可以把我们的加载过程,前置、后置条件过程都整理得很清楚。
最近,越来越多的人开始关注Web相关标准的推进,在HTML这个方面,最重要的标准就是Web Components,它主要是提供扩展HTML元素的能力(Custom Elements)。
HTML is great for declaring static documents, but it falters when we try to use it for declaring dynamic views in web-applications. AngularJS lets you extend HTML vocabulary for your application. The resulting environment is extraordinarily expressive, readable, and quick to develop.
这一段来自Angular的官方介绍。扩展HTML的词汇,是Angular的一种愿景,在这个里面,除了包含对元素的扩展,还有属性(Attribute)。
很多时候,仅仅有元素的扩展,是不足以满足需求的。举例说,让某个按钮闪烁,我们有两种方式实现:
其中,前者是特定的解决方案,创建一个自定义元素<blink-button></blink-button>可以达到目的,但闪烁这个动作可以是一种通用行为,我们可能需要让图片闪烁,让链接闪烁,让各种元素都能闪烁,把这种行为扩展到不同的元素上。
如果用jQuery,我们可能会写:
$.fn.blink = function(options) { // 这里对DOM进行处理,添加闪烁功能 };
然后在使用的时候:
$('.some-element').blink();
如果说有自定义属性,可能我们就只要写:
<span blink>aaa</span> <a blink>aaa</a> <button blink>aaa</button>
借助数据绑定,还可以把blink绑定到一个变量上,由这个变量动态控制是否闪烁。
<div blink="hasNewMessage">aaa</div>
在Angular 1.x中,使用指令(directive)来实现自定义元素和自定义属性,这个东西设计得很复杂,所以不太容易上手,在2.0中,这一块改了。
在Angular 2和Aurelia中,使用很简单的标记来表明某个东西是自定义元素还是属性。
@customAttribute('blink') @inject(Element) export class Blink { element:any; constructor(element) { this.element = element; } }
@customElement('my-calendar') export class Calendar { }
自定义属性的理念,在早期IE中实现的HTML Components中有很好的体现,它允许使用JavaScript编写DOM元素相关的代码,然后在css中作为行为附加到选择器上。
对于大型Web应用来说,组件化是必须的,但是如何实现组件化,每个人都有自己的看法,所以组件化这个词就像民主,法制一样,容易谈,难做。
我们所期望的组件化往往是这样:
但实际上,很可能是这样:
实际在用组件,尤其UI组件的时候,会出现很多尴尬的地方,比如说同一个组件在不同场景下形态不一致,所以我们需要多个层次的组件复用级别。
在Angular 1.x中,组件化并不是一个很明确的概念,它的整体思路还是:逻辑层+模板层这样的概念,此外,有一些指令(directive),用于表达对HTML标签、属性的增强。
在2.0版本中,组件成为了一个很清晰的东西。一个常见的组件,包含界面模板片段和逻辑类两个部分。
如果我们经历过Angular 1.2之前的版本,可能会感受到controller的一些变化。比如说,之前我们写一个controller,可能是:
function TestCtrl($scope) { $scope.counter = 0; $scope.inc = function() { $scope.counter++; }; }
然后这样用:
<div ng-controller="TestCtrl"> {{counter}} <button ng-click="inc()">+1</button> </div>
在1.2之后,我们会这样写:
function TestCtrl() { this.counter = 0; this.inc = function() { this.counter++; }; }
然后这样用:
<div ng-controller="TestCtrl as test"> {{test.counter}} <button ng-click="test.inc()">+1</button> </div>
注意TestCtrl的实现,里面没有$scope了,这意味着什么呢?意味着这个“controller”已经不再是controller了,而是view model,这个部分的代码变得更加纯净,每有一个对应的界面片,就实例化一个出来与之对应绑定。
在Angular 2和Aurelia里面,HTML模板与视图模型被视为一体,当做一个组件,而Aurelia的灵活度更高,因为它尽可能地把额外的配置放在HTML模板中,所以视图模型变得更单纯,也存在复用价值了。
Aurelia跟Angular 2有不少细节差异,写法上大致的对比可以从这里看出: Porting an Angular 2.0 App to Aurelia
Angular支持使用pojo作为数据模型,这可以算是它的优点之一,这样,它对模型层的定义就比BackBone和Knockout简洁很多。
但是在2.0时代,我个人是倾向于预定义模型类型的,因为在MVVM这三层中,不宜过于淡化VM和M的分界,分清哪些东西是从属于模型的,哪些东西是从属于视图模型,在很多情况下都会很重要。这会影响我们另外一些工程策略,比如测试环节的处理方式。
在大型应用中,model应当与store视为一体,在比如数据的共享,缓存,防冲突,防脏等方面综合考虑,而view model可以不要考虑得这么复杂。
基于MVVM,我们可以在不同层级复用组件,可以把模板和视图模型当做一个整体复用,也可以只复用视图模型,使用不同的模板。在这一点上,Angular 2显然比Aurelia欠考虑。
Angular的这次升级,最令人不满的是它的不兼容变更。这些变更很多方面来说,是无奈之举,因为前后的差距确实有那么大,想要短期平滑,就得在未来背负更重的历史负担。
但事实上,我们在很多场景下,比如企业应用领域,并没有比它更好的解决方案,所以这时候需要来看看如果想要作一个版本迁移,需要做哪些事情。
如果我们要做从Angular 1.x到2.0的代码迁移,相对最容易,也最值得做迁移的部分是数据模型,但这个问题说难也难,说简单也简单。
很多对分层理解不深的人,很可能把这个代码迁移想得过于复杂。但其实,一个规划良好的Angular 1.x工程,它的代码结构应该是非常有序的,什么东西放在模板里,什么东西放在controller,service,都是非常清楚的,而且,绝大多数controller和service中,是不应有DOM相关的代码的。
比如,service中是什么?主要是数据模型的存取,与服务端的交互,本地缓存,公共方法等,这些东西要迁移到2.0中,是很容易的,只是写法会稍有差别。
接下来往上看看,看这个所谓的controller。在2.0中,不再有controller,service这些东西的区分,一切都是普通的ES类,但是理念还是有的。比如一个含有视图的组件,它的逻辑部分就会是一个ES类,这个也就是视图模型,基本上也就对等于1.x中的controller。
比如最简单的todo:
function TodosCtrl() { this.todos = []; this.newTodo = {}; this.addTodo = function() { this.todos.push(this.newTodo); this.newTodo = {}; }; this.removeTodo = function(todo) { this.todos = this.todos.filter(function(item) { return item != todo; }); }; this.remainingCount = function() { return this.todos.filter(function(item) { return item.finished; }).length; }; }
这代码很简单,就是给一个列表添加移除东西,假设我们要把这个代码移植到2.0,可以说基本没有代价,因为在2.0里你要实现这样的功能,也得这么写。
(注意,下面这段是Aurelia代码,并且不是使用ES6,而是使用TypeScript编写)
export class Todos { public todos: Array<Object> = []; public newTodo: Object = {}; addTodo(): void { this.todos.push(this.newTodo); this.newTodo = { content: "" }; } removeTodo(todo): void { this.todos = this.todos.filter(item => todo != item); } get remainingCount() { return this.todos.filter(item => item["finished"]).length; } }
这么一看,好像也很容易迁移过去,多数情况下是这样,但这里面有坑。坑在什么地方呢?主要是手动添加变更检测的部分。变更检测是个复杂的话题,在本文中先不讲,后面专门写一篇来讲。
现在我们把逻辑层摆平了,来看界面层,这里主要有三个东西,一个是原先的指令,一个是普通的模板,还有一个是过滤器。
指令的问题好办,我们刚才提到的自定义元素,自定义属性,其实对使用者是没什么差别的,也就是实现的人要把代码迁移一下。
我个人并不赞同在一个业务型的项目中封装太多自定义元素,仅仅那种被称为“控件”的东西才有这个必要,其他东西可以直接采用模板加视图模型的方式,具体理由在前一篇的组件化之路中提到过。如果是按照这种理念去实现的业务项目,指令这块迁移成本也不算高。
过滤器也很好办,2.0 有同样类似的机制实现。
普通模板这边,绝大部分都是固定的工作量,比如ng-repeat,ng-click换个写法而已,里面有一些影响,但基本上是可以用批量转换去搞定的。
所以我们发现,迁移的成本并没有想象的那么大,为了更好地拥抱Web标准和更好的性能,这样的事情是比较值得去做的。
这两种东西代表着现代Web前端的两种方法论,前者是以分层和绑定为核心的大一统框架,后者提供了渲染模型多样化,带生命周期的多层组件机制。由于实现理念的不同,用它们分别开发同样的Web应用也会有很大差异。好比我们造一个仿生机器人,用Angular是先造完骨架,把基本运动功能调试完,然后加装肌肉等部件,最后贴皮肤,眉毛,头发,指甲;用React是先造出各种器官,肢体,然后再拼装。
方法论的事情那个很难说对错,只有看场景。比如亚洲农民跟美洲农民种地,理念肯定是不同的,因为他们面临的场景不同,比如亚洲种地普遍很精细化,美国种地很粗放。这也有些像React和Angular的差别。
我个人不赞同在框架的问题上有太多争论,因为天下武功,到底什么厉害,完全是看人的,一阳指在段正淳手里,只能算二流,到了南帝段智兴手里,可与降龙十八掌齐名。聚贤庄一战,乔帮主用最普通的太祖长拳,打得天下英雄落花流水。如果深刻理解了一个技术的优点和缺点所在,扬长避短,则无往而不利。
近年来,各框架是在互相学习的过程,但是每个东西到底有什么不同,最好还是列出需求,分别用代码体现。现在已经有todomvc这么一个库,用各种框架实现todo,但在我看来,这个需求还太小,不足以表达各自的优势。
我倡议,每个框架的熟练使用者能够选出一些典型场景,然后写一些demo,供更多的人学习对比之用。
到目前为止,我们在浏览器中看到系统从规模来说都是中小型的,与传统桌面的大型软件们相比,还很幼小。比如Office的开发团队,千人以上的规模,无论是代码的架构,还是人员的分工协作,都可以算是伟大的工程。
在大型系统中,组件化可以说是立足的基础,但怎样去实践组件化的思想,是一个见仁见智的话题。
还是以Office为例,它除了提供图形化的操作界面,还提供了一套API,可以被VBA这样的嵌入语言调用。
比如说,我们可以在界面上选中一个工作表,然后在某行某列填入数据,也可以在VBA中使用这样的语句去达到同样的目的
这就意味着,对于同一种操作,存在多样化的外围接口。继续分析下去,我们会发现,存在一种叫做Office Object Model的东西,这也就是一个核心数据模型,我们所有的操作其实都是体现在这个模型上的,GUI和VBA分别是这个模型的两个外围表现。
所以可以想象,如果Office的测试团队想要测试功能是否正确,他是有两条路要走:
从这里可以大致感受到,当系统越复杂的时候,独立的模型层越重要,因为必须保持这一层的绝对清晰,才能确保整个系统是正确而稳定的。层层叠加,单向依赖,这使得软件正确性的验证过程变得更加可控。
在业务系统中,又存在另外一些问题。以我曾经从事过的电信行业软件系统为例,整个运营与业务支撑系统由若干个子系统构成,比如:
这些系统基本都已经Web化,如果我们要探讨它们的组件化方式,必须作相当深远的考虑,因为,还可能出现终极杀手——比如呼叫中心系统。
大家打客服电话的时候,有没有注意到,客服人员可以操作的东西,是超过了前台营业员的,这也就说明他实际上能够操作以上某几个系统。可是我们也没有发现他在切换多种功能的时候,花太多时间,说明其实他有一个高度集成的界面入口。
这就来了问题了,如果这里的多数功能是集成其他系统的组件所致,那都该是一些什么样的组件啊?
篇幅所限,不在本文中讨论这些问题。抛出这样的问题来,是为了让大家察觉,在很多不为人知的地方,存在很值得思考的东西。一些新的Web标准是为了解决Web系统的大型化,应用化,但仅仅以这些标准本身而言,还是存在一定的不足,需要更深刻的改变。
我们期望Angular2和Aurelia为代表的新型框架能够给这些领域带来一些灵感,互相碰撞,解放更多人的生产力。
总而言之:
“I think we agree, the past is over.” – George W. Bush