动态加载Angular 1.x所缺乏的重大功能之一是代码的动态加载。如果你想在运行中添加新的指令或者控制器,非常困难,或者就做不到。它没有被支持。在2.0中,我们从开始设计东西的时候,就把异步放在心里。所以,当你开始编译一个模板的时候,实际上它是个异步过程。 现在我需要详细讨论上面一笔带过的模板编译了。当你编译一个模板的时候,你并不仅仅为编译器提供了一个模板,也同时提供了一个Component的定义。我们稍微深入一点。在模板中使用的时候,Component的定义就包含了什么指令啊,过滤器啊之类的元数据。这确保了在模板被编译器处理之前,所有必要的依赖项都已加载。由于我们的代码架设在ES6 module规范的基础上,只需简单地在Component定义中引用依赖项,如果他们尚未加载,module加载器就会加载它们。因此,通过这种结合ES6 module的方式,我们不费事就得到了各种东西的动态加载。 指令在我们深入模板的语法之前,需要先看一看指令——Angular用于扩展HTML自身的方式。在Angular 1.x中,使用指令定义对象(DDO,Directive Definition Object)来创建指令。这好像是很多Angular开发人员巨大痛苦的来源之一。 如果我们能把指令弄简单点,会怎样呢? 我们已经讨论过模块、类和注解了,如果我们能用这些核心建筑来构建指令会怎样呢?好吧,我们当然就是这么干的。 在Angular 2.0中,有三种指令类型。 - 组件指令(Component Directive)创建一个组合了View和Controller的自定义组件,你可以把它当成一个自定义HTML元素。路由也可以映射到组件。
- 装饰指令(Decorator Directive)使用附加行为来装饰一个已有的HTML元素,一个经典的例子是ng-show。
- 模板指令(Template Directive)把HTML转换成一个可复用的模板。指令的作者可以控制模板何时、怎样初始化,并且插入DOM中。示例包括ng-if和ng-repeat。
你可能听说过在Angular 2.0里面,Controller没了。好吧,不完全正确。其实,Controller成为了我们称之为Component的一部分。Component拥有一个View和一个Controller。View就是你的HTML模板,Controller就是你的JavaScript行为。不像在1.x中那样,要用显式的或者非标准的API来注册控制器,在2.0中,只需创建一个普通的带一些注解的类。这里是选项卡容器组件的控制器的一个部分(稍后会看到它的视图): @ComponentDirective({
selector:'tab-container',
directives:[NgRepeat]
})
export class TabContainer {
constructor(panes:Query<Pane>) {
this.panes = panes;
}
select(selectedPane:Pane) { ... }
}
这里有几个特性值得注意。 首先,组件的控制器只是一个类。它的构造函数会被自动注入其依赖项。因为使用了子注射器,它可以获得沿DOM树向上所有服务的访问,还包括从属于自己元素的本地服务。比如说,这里它就被注入了一个Query,这是一个特殊的集合,会自动跟子Pane元素保持同步,让你获知何时出现新增或者移除。同时,你也被注入了Element自身,这能让你处理与Angular 1.x中$link回调相同的逻辑,但却是通过类构造函数,用一种更一致的方式来处理的。 现在,看一看@ComponentDirective注解吧。它把类标识为一个Component,并且提供了编译器所需用于挂接的元数据。比如说,selector:'tab-container'是一个CSS选择器,会被用于匹配HTML。任何匹配这个选择器的元素都会被转换成一个TabContainer。同时,directives:[NgRepeat]表明了这个组件的模板的依赖项。到现在还没给你们看过,马上讲语法的时候就会看到了。 一个重要的需要注意的细节是,模板将会直接绑定到这个类上,意味着类的任何属性和方法都能直接在模板上访问。这根Angular 1.2中的“controller as”语法很相似。在类和模板之间,不再有$scope了。结果就是Angular内部得到了简化,开发人员也得到了更简单的语法,那种在$scope对象上搞来搞去的事情变少了。 接下来,我们来看看Decorator Directive。一个简单的NgShow是怎样的呢? @DecoratorDirective({
selector:'[ng-show]',
bind: { 'ngShow': 'ngShow' },
observe: {'ngShow': 'ngShowChanged'}
})
export class NgShow {
constructor(element:Element) {
this.element = element;
}
ngShowChanged(newValue){
if(newValue){
this.element.style.display = 'block';
}else{
this.element.style.display = 'none';
}
}
}
这里,我们可以看到指令的更多方面。我们又写了个带注解的类,构造函数注入了装饰器要附加到的HTML元素。因为有DecoratorDirective,编译器知道这是一个装饰器,也知道把它添加到任意匹配于selector:'[ng-show]' CSS选择器的元素上。 在这个注解上,还有其他一些奇怪的属性。 bind: { 'ngShow': 'ngShow' }用于把类属性映射到HTML attribute。不是所有的类属性都直接暴露成HTML的attribute的,如果你想要让属性在HTML中可绑定,需要在bind元数据中指定它。observe: {'ngShow': 'ngShowChanged'}告诉绑定系统,你想要在每次ngShow属性变更的时候得到通知,并且使用ngShowChanged方法作为回调。注意ngShowChanged回调响应变更的方式是,改变附加到的HTML元素的display。(注意,这只是个非常幼稚的实现,仅作演示之用)。 好了,那Template Directive长什么样呢?为什么不看看NgIf呢? @TemplateDirective({
selector: '[ng-if]',
bind: {'ngIf': 'ngIf'},
observe: {'ngIf': 'ngIfChanged'}
})
export class NgIf {
constructor(viewFactory:BoundViewFactory, viewPort:ViewPort) {
this.viewFactory = viewFactory;
this.viewPort = viewPort;
this.view = null;
}
ngIfChanged(value) {
if (!value && this.view) {
this.view.remove();
this.view = null;
}
if (value) {
this.view = this.viewFactory.createView();
this.view.appendTo(this.viewPort);
}
}
}
希望你能理解TemplateDirective注解。它注册了这个指令,并且提供了一些必要的元数据用于设置属性和观测,就像NgShow示例那样。这就是一个TemplateDirective,它能够访问一些特殊的服务,这些服务可以被注入它的构造函数。第一个是ViewFactory,之前我提到,Template Directive把它附加到的HTML转换为模板,模板被自动编译,然后你就能在模板指令中访问视图工厂了。调用工厂的createView API会初始化模板自身。你也可以访问ViewPort,这代表了模板从DOM中提取的位置,你可以用它在DOM上添加或者移除模板的实例。注意ngIfChanged回调是怎样响应变更,初始化模板,添加到viewport,或者从viewport上移除的。如果你在实现类似于NgRepeat的东西,可以把模板实例化很多次,甚至给createView API提供一个指定的数据项,然后可以把多个实例添加到viewport上。基本就是这样。 现在,你已经看到三种类型指令的一些典型例子了。我希望这能够大致说明了如何使用新的行为来扩展HTML编译器。 不过,还有一个重要的东西我尚未充分解释:Controllers。 怎样为应用创建一个控制器呢?设想你要建立一个路由,导航到一个控制器,然后显示它的视图。怎么做到这个呢?简单的回答就是使用一个Component Directive来做。 在Angular 1.x中,Directive和Controller是两种不同的东西,API不同,功能也不同。在Angular 2.0中,既然我们已经去掉了DDO,把Directive变成了基于类的,我们可以把Directive和Controller统一成Component模型。所以,现在可以一箭双雕,当建立路由的时候,只要把路由映射到一个ComponentDirective(本质上由一个视图和控制器构成,就像之前一样)。 所以呢,如果你创建一个假想的客户编辑控制器,可能会是这样: @ComponentDirective
export class CustomerEditController {
constructor(server:Server) {
this.server = server;
this.customer = null;
}
activate(customerId) {
return this.server.loadCustomer(customerId)
.then(response => this.customer = response.customer);
}
}
真没什么新东西,我们就是在注入假想的服务端服务,当被路由激活的时候,使用它加载客户。有意思的是,你不需要使用选择器或者是其他任何元数据,因为这个组件不是被当作自定义元素来使用的。它是被路由动态创建,然后动态渲染到DOM中的。总之,不要太在意细节了。 那么,如果你明白了怎样创建ComponentDirective,你就明白了怎样创建等同于Angular 1.x中使用路由创建的控制器。在Angular 1.x中,很难把这些统一起来,但鉴于我们在Angular 2.0中有了这么帅的类和元数据驱动系统,指令就可以很显著地简化了,用这种方式创建你的“控制器”也变得很容易了。 注意:我想指出,上面的指令代码示例基于早期的原型代码和较新的设计文档规范,它们应当被解读为一种解释的工具,而不是指令的准确语法,那东西还在不断变化。模板编译器和绑定语言现在是Angular 2.0中最不稳定的部分,设计的变更非常频繁。
模板语法至此,你已经对编译过程有了一个概要的认识了:知道它可以异步加载代码,如何编写指令,它们是怎样装配的,控制器怎么适应这些东西的。但我们尚未看一下真正的模板。我们现在来看看刚才假想的TabContainer的模板吧。为了方便起见,把指令代码再贴一遍: @ComponentDirective({
selector:'tab-container',
directives:[NgRepeat]
})
export class TabContainer {
constructor(panes:Query<Pane>) {
this.panes = panes;
}
select(selectedPane:Pane) { ... }
}
<template>
<div class="border">
<div class="tabs">
<div [ng-repeat|pane]="panes" class="tab" (^click)="select(pane)">
<img [src]="pane.icon"><span>${pane.name}</span>
</div>
</div>
<content></content>
</div>
</template>
当你看到这个语法的时候,不要害怕。是啊,这是符合规范的HTML,但不是我们最后的绑定语法。不过,还是用它作例子吧,这样我们能有个比较丰富的讨论的起点。 理解数据绑定语法的关键是属性定义的左侧,考虑到这点,我们先来看一下image标签。 <img [src]="pane.icon"><span>${pane.name}</span>
当你看到一个属性名称被[]包围的时候,它意思是右侧的属性值有一个绑定表达式。 当你看到一个表达式被${}包围的时候,它意思是这是一个表达式,应当被当作字符串插入到内容中(这跟ES6用来做字符串插值的语法相同)。 从模型/控制器到视图的绑定都是单向的。 现在让我们看看这可怕的div: <div [ng-repeat|pane]="panes" class="tab" (^click)="select(pane)">
ng-repeat是一个TemplateDirective,你可以看出我们正在使用一个表达式来绑定它,因为它外面有[]。不过,它里面还有个 | ,还有一个单词“pane”,这表明在模板中使用的局部变量名称为“pane”。 现在看看(^click),使用括号表明我们把这个表达式作为一个事件处理函数。如果在括号里还有个 ^ ,就意味着不把处理函数直接附加到DOM节点上,而是让它冒泡,在文档的级别处理它。 在这个和模板部分的其他东西上,我暂不表达自己的意见,到下面的评注章节再说。现在先不管你我第一次看到这个的想法,先讨论为什么会选择这样的语法。 Web Components改变了所有东西。这是另外一个Web的变化影响框架的例子。多数基于数据绑定的框架假定了HTML元素的一个固定集合,并且已经预知了一些特定元素的行为,比如说input等等。可是,在Web Components的世界里,没有什么可以假设。一个开发人员,不针对Angular,可以编写一个自定义的元素,带有任意数量的属性,高兴加什么事件就加什么事件。不幸的是,没有办法检测Web Component来收集有关这些元数据,驱动绑定系统需要这些数据。比如说,没有办法知道实际触发了什么事件。看这个例子: <x-foo bar="..." baz="..."></x-foo>
看看bar和baz,你能知道哪个是事件,哪个是属性?不……不幸的是,Angular也不知道,因为Web Components规范没有包含自描述组件的概念。这很不幸,因为它意味着一个数据绑定系统没法知道:它是不是需要连接一个绑定表达式,或者是不是需要添加一个事件处理函数来调用表达式。为了解决这个问题,我们需要一个通用的数据绑定系统,语法能够让开发人员区分哪个是事件,哪个是属性绑定。 这还不是唯一的困难。此外,所提供的信息还必须以这么一种不打破Web Component自身的方式。我这话的意思是,不能让Web Component看到这些表达式,那会破坏这个组件,它只应当看到表达式执行后的结果。实际上这不仅仅影响到Web Components,也会影响原生元素。考虑一下这个: <img src="{{some.expression}}">
这个代码会产生一个错误的http请求,企图寻找“some.expression”这个图。这压根就不是我们想要的,我们根本不想img看到这个表达式,只希望它看到值。AngularJS 1.x解决这问题的方式是使用ng-src,一个自定义指令。现在,我们回到Web Components……如果你要给任意Web Compoents的每个属性都创建一个自定义指令,会是一场灾难,是不是?我觉得不能这样,所以需要在绑定系统中更普遍地解决这个问题。 要完成这事,你有两个选择。第一个是在模板编译期间,从DOM上移除属性。这能够阻止Web Component碰到这个表达式的文本。可是,这么做就意味着检测DOM的话,跟踪不到属性上绑定表达式的执行。这会让调试更加困难。另外一个选择是把属性名编码,这样Web Component就“认不出”它。这样可以让Angular看到这些表达式,但是Web Components却看不到。我们也可以在编译后把属性留在元素上,这样检测DOM的时候可以看得到。在调试的时候,当然就好得多了。 从以上可以看到,Angular团队目前支持的是编码属性的方法,这种编码也需要区分属性和事件。上面所示的语法是完成此事的多种可选项之一。 Angular团队已经在这一块严重争论了几个月。上述语法并未获得一致同意,但被多数人认同。当制订绑定语法的时候,也有过大量的考虑。如果你对这些感兴趣,我建议你读一下关于此主题的相当广泛【丰富】的文档。 好吧,现在我们终于介绍完了模板、绑定和指令是怎么混到一起的…… 评注关于刚才这些,我有太多话要说了……真不知道从何说起…… 先从编译器自身说起吧。 我们是从一个较高层次看待模板的编译过程的。虽然在这一块,还有大量的实现要做,我对编译器的设计非常满意了。在这里面有一些挺好的东西,保持小的内存占用,减少了垃圾,并且使得模板的实例化超快。这些都很伟大,当然还需要改进,但已经很稳定了。虽然我们尚未谈及脏检测(数据绑定表达式更新的机制),它的实现也有一些新的不错的想法,可能会让模板实例化和脏检测自身的性能都有所提升。当然,能够动态加载任意东西太可怕了,这是Angular 1.x非常缺乏的一个特性,对大型应用却很关键。所以,它能作为核心需求来设计,我很高兴。 现在我们来讨论指令吧。 使用类、更好的依赖注入和注解来创建指令的新机制非常棒,它比Angular 1.x所需要的要简单多了。不幸的是,对于1.x开发人员而言,这是一个相当大的不兼容变更,如果你限于使用ES5,而不能或者不想使用ES6,TypeScript或者AtScript的话,写起来也会有些困难。本文的前面部分,我提到过提供小型库用于在ES5中更方便地创建注解类,这样的API可能会搞得像DDO对象那样,也许这能让从1.x到2.0的移植过程简单点。或许我们现在就应当开始构建它了,这样你可以在1.3里面使用,然后为2.0提供一个不同的实现……一种迁移抽象层。我也不确定,我想听听你们关于这块的想法,我知道很多人很关心这个。 关于指令,还有另外一件让我很困扰的事:注解有些冗长。回顾一下NgShow指令,你看到文本‘ngShow’或者它的某种变体重复了多少次?这对我来说显得有些傻。再看看CustomerEditController,我们要ComponentDirective干什么啊?既然路由都知道它是什么了,我们只写个普通类不行吗? 在内部我说了很多约定优于配置的想法,这也是Rails流行并影响很多现代框架的原因,我认为这是一种积极的方式。我想看到一些用于创建指令的约定能把样板消除。没有它们的话,我会认为新的指令系统并未把指令简化到应有的程度,感觉就像是把DDO的一些复杂性放到另外一个地方,也就是注解中去了。你会怎么想呢?喜欢约定吗?觉得这能让指令简单吗(假设你一直选择明确 这个地方怎么翻译啊)? (assuming you always had the option to be explicit and override the conventions with annotations)? 不幸的是,这些都还不是真正的大问题。在Component Directive中有一个严重的问题,希望你已经看到了。注意到它们破坏了展现分离(Separated Presentation)原则吗?再看看我的TabContainer示例: @ComponentDirective({
selector:'tab-container',
directives:[NgRepeat]
})
export class TabContainer {
constructor(panes:Query<Pane>) {
this.panes = panes;
}
select(selectedPane:Pane) { ... }
}
有没有看到TabContainer必须在其元数据中,列出其模板使用到的所有指令?这是TabContainer(控制器)到其视图实现细节的直接耦合。之前我提到过,对编译器而言,这是有必要的,因为它在编译模板之前,需要知道要加载什么,但是,这抵消了使用MVC,MVVM或者其他展现分离模式所带来的主要优势。为了避免你觉得我在纸上谈兵,我来指出一些后果吧: - 没办法实现ng-include了。为了编译HTML,编译器需要ComponentDirective,因此你就没法编译它自己上面的HTML,并且将其包含到一个View里。
- 如果你想要为同一个组件创建多个潜在的视图,会很痛苦。想象一下,你有一个组件,但是想要在手机和电脑上显示不同的视图,需要聚合所有的指令、过滤器之类,这些东西你在所有视图都要用,还要确保在单个组件的元数据里把它们都列出来。这个维护起来太可怕了。如果不检测所有的视图,从依赖列表中删任何东西都是不可靠的,也有可能会忘记加东西。
- 对同一个组件而言,是不可能拥有多种运行时视图的。想象一下,你配置了一些路由,某些路由使用了相同的“控制器”,但是你需要不同的视图。不好意思,真做不到。
- 完全不可能实现展现层的临时组合,最起码这会让数据驱动的UI构建更加复杂。你没法简单地组合视图和控制器(视图模型)来渲染,这抑制了UI的组合方式,也限制了可复用性,它迫使你为了得到不同的视图,把控制器拆分成子类。
幸好,设计还是会有不少变化的,我提了个建议来解决这个问题,非常简单:通过让模板指定自己的imports,使得它能够完全自包含。把元数据移出指令,放到HTML模板中。可以这样使用一个自定义元素: <ng-import src="ngRepeat"></ng-import>
编译器可以很容易找到他们,并且确保编译模板内容的时候,所有东西都加载完成了。就这样,刚才我提到的所有问题都解决了。 好了,现在我们说完编译器和指令了…… 我感觉接下来是不是该说模板语法了? 老实说,很多人看到这个模板语法的时候可能会吐了,不是所有人,但有不少。我个人是不太喜欢这种语法的,但这是多数人投票的结果(为了理解为什么它还是个草案,你需要看看这篇文档)。别怕!已经有一些技术性的问题让这种语法变不成事实,更不用说社区的反对之声了。Angular团队已经回头继续讨论最佳语法了,很多社区成员也加入了,并且提出了自己的见解,很棒。我会把我自己的推荐放在这里,这样每个人都可以评论。 这是我提议的基本语法: property="{{expression}}" - 从模型到元素属性的单向绑定,使用{{}}标识 on-event="{{expression}}" - 给事件添加处理函数,执行表达式,使用on-前缀标识 ${expression} - HTML内容和属性中的字符串插值(基于ES6语法) 就这样。然后,在实现的时候,我们需要把表达式从DOM移除,以避免各种Web Component的问题之类。仅在调试模式,我们可以通过一个前缀,比如bind-,把它们加回来,这样可以在不影响Web Components的情况下,通过检测DOM的方式看到它们。这使得你所写的和在DOM检测器中看到的东西不太对称,但我觉得为了清晰、更加标准的绑定语法起见,这是一种合理的权衡。不是所有人都同意我,你觉得呢? 这个提议解决了绑定的技术问题,也对向后的兼容性有所帮助。可能我们能够对向后兼容性做很多的事情。我们能够允许在HTML内容中使用{{expression}}来做字符串插值,但是你可能会对此有选择余地。它可能会计划在20xx年被淘汰。推荐的方式可能会是${expression},但这可以对模板提供一个更平缓的升级路径。也有可能创建一套可选的指令用于支持ng-click之类,同样将于20xx年废弃。此外,我们可以提供文档,对照显示新旧的差异,帮助人们在“截断日期”之前,逐步地转换模板。 这就是我提案的基本内容,也有其他的提案,当然我是有倾向的,我也想知道你们的看法。向后兼容对你来说重要吗?你是更倾向于使用{{}}这样的语法,还是用某种方式把属性名进行编码?有太多选择了。 嗯,现在我们的问题都解决了。没,还有个超大的。 看看双向绑定! 我不知道你注意到没有,整篇文章连一个双向绑定的例子都没有。其实,我上面解释过的所有语法都不能用于指定各种绑定选项,如:方向性,触发器,防反跳等等。那,怎样绑定一个input元素,把数据推送到模型中呢?怎样绑定一个需要更新模型的自定义Web Component呢? 在Angular 2.0是否需要双向的数据绑定,Angular团队中产生了激烈的辩论。如果你读过公开的设计文档(包括这篇文档),或者看过ngEurope关于Angular 2.0核心的演讲或Q&A,你可能会发现这一点。我强烈支持保留双向数据绑定,在我看来,这是Angular灵魂的一部分。我尚未看到哪个建议能提供一个优雅的替代,在我能提出之前,还是会认同支持保留双向绑定。 你可能想知道这到底为了什么。 我听到过一些有关为数据流执行DAG的解释。这个思路是最近被ReactJS搞得火起来的。但是坦率地说,你不能完全执行它。我只要用一个事件聚合器就足以把它搞挂,这是一个在复合应用中非常常见的模式。我觉得你应当教给人们有关DAG的事情,帮助他们在合适的情况下使用,但不能强求。这会使他们的工作变得困难。 我听说过另外一个论点,主要围绕校验能力的不足,但这不是一个移除双向绑定的理由。你可以很容易在底层放双向绑定功能,把校验系统放在它的上层。 我认为最大问题来自Angular用于实现绑定的脏检测。因为脏检测,你每做一次检测,其实是做了两次。原因在于,如果第一次检测导致了变化,作为一种副作用,它可能导致其他变化。所以,为了确认,你一定还要再检测一次。然后,如果第二次检测之后,又变化了,还得检测第三次……等等。这个事情就称为模型的稳定化。是啊,这是脏检测系统的痛苦,但是移除双向绑定并不能解决这个问题。你还需要移除所有的监控器,这样一个表达式的变化不会导致它们中的任意一个产生变更。很明显,这也就是也需要考虑移除监控器的原因。可是这样也还是不能解决问题,因为一个事件聚合器就能绕过它……坦白地说,有时候你是需要这样的。数据绑定是一个很强大的工具,人也是会犯错的,但我认为我们能解决它。我知道你们中的很多人都可以的。 可能你不同意我的观点,你觉得“good riddance to two-way binding.”,持这种观点的人肯定很多。不过,我怀疑多数Angular,Durandal,Knockout,Ember等框架的用户会认同我。所幸的是,Angular团队在此事上尚未下定决心,他们在尝试考虑所有的可能性。所以,没必要担心。不过,如果你爱双向绑定的话,要来帮我,我觉得,如果Angular团队的其他成员能听到你们有多爱双向绑定的话,就太好了。 另一方面,如果你认为双向绑定是个坏主意,请你帮我们调查替代品。到目前为止,我尚未见到一个差不多好的替代方式,但也许你有比较好的想法呢。如果是这样的话,我请你来跟我们分享一下。如果我们能一起想出一些更好的东西……那就太棒了。 |