尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Angular 2模板语法全解析:从数据绑定到结构型指令

Angular 2模板语法全解析:从数据绑定到结构型指令 Angular 2 正式发布那会儿不少从 AngularJS也就是 1.x过来的老前端第一反应是翻文档第二反应是怀疑人生。原来用ng-click点一下就完事了现在要写(click)原来一个ng-repeat就能循环渲染现在又是*ngFor又是let item of items多了一层东西到底图什么我当时也是这么过来的。等真正用 Angular 2 写了几个项目、接手过别人留下的代码之后才慢慢意识到Angular 2 的模板语法不是换了个写法而是换了一套数据流的思考方式。方括号、圆括号、星号这些符号本质上都是在给数据流向做标注。搞懂了这套符号背后的逻辑再看模板的时候脑子里会自动浮现出一条条组件数据往 DOM 流、用户操作往组件流的箭头写起来顺手排查起问题来也更直接。所以这篇文章不打算只列语法规则我想把为什么这样设计和实际开发里到底怎么用一起讲清楚。适合刚上手 Angular 2 想系统过一遍模板语法的读者也适合已经写过一段时间但有些语法只知道能用、不知道什么时候该用、甚至踩过坑的开发者。1. 入门之前Angular 2 模板语法的设计逻辑比我预想的重要1.1 从 AngularJS 到 Angular 2模板语法不是换写法是换思维AngularJS 时代我们写模板最常见的方式是往 HTML 里塞一堆ng-开头的指令ng-repeat做循环ng-click绑事件ng-show/ng-hide控制显隐ng-bind输出数据ng-model做双向绑定。功能上没毛病但有一个问题指令各自为政语法形态不统一也没有表达出数据的流向。Angular 2 把这个体系整个推倒重来了。新的模板语法只用三类符号就覆盖了绝大多数场景语法含义数据流方向[属性]表达式属性绑定组件 → DOM(事件)语句事件绑定DOM → 组件[(x)]属性双向绑定双向*指令结构型指令控制 DOM 增减这个设计的精妙之处在于模板里的 HTML 不再只是一堆静态标签而是变成了数据流的可视化表达。方括号像是一个输入管道圆括号像是一个输出管道组合起来的香蕉括号banana in a box就是双向管道。你不需要看到组件的 TypeScript 代码光读模板就能大致推断出组件上有哪些属性、会触发哪些方法。1.2 模板到底是什么带标记的 HTML如何变成视图Angular 2 的模板形式上还是 HTML但里面掺了 Angular 的标记语法。浏览器不认识这些方括号和圆括号所以 Angular 编译器会先把模板编译成组件视图定义再实例化成真实的 DOM。这里的重点在于模板中的绑定表达式会在每个变更检测周期被重新求值。Angular 通过 Zone.js 拦截了浏览器的事件、定时器、Promise 回调等异步操作在它们执行完之后自动触发变更检测从组件树的根部往下检查所有绑定表达式的值是否发生变化变了就更新 DOM。这也是为什么你在模板里写的每一个绑定都相当于给框架递了一个监听清单。框架知道哪个属性被模板用了值一变就会去更新对应的节点。这跟我们以前用 jQuery 手动$(el).text(xxx)更新 DOM 是完全不同的两个时代的方法论。1.3 模板表达式的边界哪些写法被禁止了模板里可以写表达式但不是所有 JavaScript 表达式都能用。很多初学者会踩这个坑把模板里当 JS 环境来写结果编译报错。Angular 2 模板表达式的限制包括不支持语句if、for、while这些都不能用。不支持new运算符不能在模板里new Date()。不支持自增自减i、i--都不行。不支持位运算|有特殊含义管道符、这些也别想用。不支持赋值操作、、-都是非法语法事件绑定里的赋值除外比如(click)count count 1是允许的。不能引用全局变量window、document、Math、console这些全都不行。为什么 Angular 要这么限制原因很实际模板表达式必须保持简单、可预测、无副作用。如果允许在模板里Math.random()或修改变量每个变更检测周期执行结果都不一样框架没办法做可靠的依赖追踪调试会变成噩梦。那如果确实需要Date、Math这类能力怎么办正确做法是在组件类里封装成属性或方法再暴露给模板。这其实是 Angular 推荐的习惯模板只做展示和简单计算逻辑都往组件类里放。2. 插值表达式与属性绑定最容易混淆的一组语法2.1 插值{{}}是属性绑定的语法糖{{ }}插值表达式大概是所有人接触 Angular 2 模板语法时第一个见到的写法pHello {{ name }}/p它的作用是把组件属性的值插入到文本节点中。但很多人不知道的是插值表达式本质上就是属性绑定的一个语法糖。上述写法经过编译器处理等效于p [textContent]nameHello/p或者说插值表达式最终会编译成一个针对textContent的属性绑定。所以概念上插值{{ }}和属性绑定[property]最终走的是同一套变更检测机制只是插值写起来更方便适合内联在文本中。比如拼接字符串p用户 {{ user.name }} 在 {{ user.lastLogin }} 登录过/p这种场景用插值非常自然而属性绑定[textContent]写起来反而很别扭。2.2[disabled]false为什么必须用属性绑定这是我在新项目里经常看到的问题。有人写button disabled{{ isDisabled }}提交/button当isDisabled为false时按钮依然处于禁用状态。原因在于HTML 布尔属性的特征是只要属性存在值就是 true。插值表达式永远输出的是字符串所以当isDisabled为false时上面的写法实际上是disabledfalse而浏览器看到disabled属性存在就认为是禁用。改用属性绑定就正常了button [disabled]isDisabled提交/button属性绑定传的是真正的布尔值falseAngular 会调用Renderer将属性的存在与否和值对应起来。类似的布尔属性还有checked、readonly、selected、multiple等只要涉及这些属性一律用属性绑定别用插值。2.3 组件输入属性的类型问题另一个常见坑发生在自定义组件上。假设你有个组件Component({ selector: app-price, template: span{{ price }}/span }) export class PriceComponent { Input() price: number; }如果你用插值传值app-price price{{ 199 }}/app-priceprice拿到的会是字符串199如果里面做了price.toFixed(2)直接报错。正确做法是app-price [price]199/app-price这样就传入了真正的数字类型。这个问题的本质是插值输出的永远是字符串属性绑定保留表达式的实际求值类型。所以凡是给组件传对象、数组、数字、布尔值一律用属性绑定。2.4 模板表达式里常见的编译错误写模板时最容易出现的报错第一是表达式里用了全局对象{{ Math.floor(price) }} !-- 报错Math is not defined --第二是在模板里尝试做赋值或自增{{ count }} !-- 表达式不能包含自增操作 --第三是把管道符和位运算混了{{ value | 2 }} !-- 管道符后面要接管道名 --遇到这些错误别慌这是 Angular 在提醒你这里不该放逻辑。去组件类里写个方法或计算属性模板就干净了。3. 事件绑定与双向绑定把数据流的方向搞清楚3.1(click)背后发生了什么事件绑定的语法是(事件名)模板语句button (click)save()保存/button input (input)onInput($event)和属性绑定方向相反事件绑定处理的是从 DOM 到组件的数据流。Angular 编译时会为这个按钮挂载原生的click监听器事件触发后执行组件方法。这里有个关键点事件触发后Angular 会自动执行变更检测。这是事件绑定和手动addEventListener最大的区别。我见过有人写了一个自定义指令在内部用原生的element.addEventListener(click, ...)更新组件数据结果界面怎么都不刷新就是因为脱离了 Angular 的 Zone 监听范围变更检测没有被触发。所以能用(click)、(input)、(change)这类语法绑定的绝对不要用原生监听。如果确实需要处理原生事件也要在回调里手动触发变更检测用ChangeDetectorRef或NgZone.run但这属于万不得已的兜底方案。3.2 用$event和原生事件对象打交道Angular 2 的事件绑定没有像 React 那样做合成事件包装$event就是浏览器原生的Event对象。以输入框为例input (input)onInput($event)onInput(event: Event) { const value (event.target as HTMLInputElement).value; this.searchText value; }需要阻止冒泡或取消默认行为时直接在模板语句里调用a href/download (click)onDownload($event)下载/aonDownload(event: Event) { event.preventDefault(); event.stopPropagation(); // 自定义下载逻辑 }也可以把这两步直接在模板里写掉a href/download (click)$event.preventDefault(); download()下载/a不过这种写法会让模板变得拥挤我更建议逻辑多的场景放到组件方法里做。3.3[(ngModel)]不是黑魔法拆开看就两行Angular 2 的双向绑定[(ngModel)]看起来像魔法其实是一个语法糖完全可以用属性绑定加事件绑定展开!-- 等价写法一 -- input [value]name (input)name $event.target.value !-- 等价写法二Angular 2 内部的实际做法 -- input [ngModel]name (ngModelChange)name $eventngModel指令做的事情是把组件属性映射到input的 value同时监听input事件把新值通过ngModelChange事件抛出去。所以[(ngModel)]是[ngModel]和(ngModelChange)的组合。理解这个拆解后你就能解释两件事为什么用[(ngModel)]需要FormsModule因为ngModel是FormsModule提供的指令没在NgModule的imports里引入它模板根本识别不了。为什么只写[ngModel]时输入框的值不会随用户的输入变化没有(ngModelChange)去更新数据属性绑定只是单向的。还有一种常见的变体需求用户输入后想要实时过滤同时给值加个 trim。可以拆开写input [ngModel]keyword (ngModelChange)onKeywordChange($event)onKeywordChange(value: string) { this.keyword value.trim(); }这样既保留了双向绑定的便捷又能在中间插入处理逻辑。3.4 自定义组件怎么支持[(value)]双向绑定[(ngModel)]是 Angular 内置的但如果你想让自己的组件也支持双向绑定约定的语法是一样的核心就是Input 和 Output 命名要成对。比如做一个自己封装的计数器组件Component({ selector: app-counter, template: button (click)decrement()-/button{{ count }}button (click)increment()/button }) export class CounterComponent { Input() count: number; Output() countChange new EventEmitternumber(); increment() { this.count; this.countChange.emit(this.count); } decrement() { this.count--; this.countChange.emit(this.count); } }父组件这样用app-counter [(count)]total/app-counter规则很简单Input() xxx和Output() xxxChange成对出现模板里就能用[(xxx)]双向绑定。这个约定被官方称为 banana-in-a-box因为它长得像方括号包圆括号[()]。如果你拆开来看app-counter [count]total (countChange)total $event/app-counter就一目了然了。4. 结构型指令DOM 的加减法比你想的讲究4.1*号是怎么来的ng-template 展开结构型指令的特点是有星号*比如*ngIf、*ngFor、*ngSwitchCase。加星号是为了让模板写起来更像 HTML但本质上它是个语法糖。*ngIf其实展开成ng-template [ngIf]isLogged p欢迎回来/p /ng-templateng-template是一个虚拟容器它本身不会渲染任何 DOM只负责包裹需要条件渲染的内容。手动写ng-template最大的价值在于你可以更灵活地组织多个结构型指令尤其是ngIf的else分支div *ngIfisLogged; else loginBox欢迎回来/div ng-template #loginBox p请先登录/p /ng-template这里#loginBox是模板引用变量指向一个ng-template的引用else语句通过变量名找到它。4.2 ngIf 与 hidden销毁还是隐藏这是个问题很多人在做条件显隐时纠结用*ngIf还是[hidden]。它们的区别不只是语法而是 DOM 处理方式完全不同对比项*ngIf[hidden]DOM 是否保留条件为 false 时移除保留元素仅添加display: none组件生命周期每次创建/销毁都会触发ngOnInit、ngOnDestroy不触发生命周期图片/接口请求条件为 false 时不创建不请求元素存在图片通常会加载性能开销频繁切换时创建/销毁开销大无创建/销毁开销但要一直维护 DOM适用场景条件变化不频繁、初始化有成本的内容高频切换、轻量 UI 元素我的实际建议是绝大多数场景用*ngIf。因为它能保证组件在不可见时不占用资源而且状态可控。但如果有一段内容在用户操作下会非常高频地切换显隐比如点击展开收起一个面板而且面板内部没有图片、没有组件要初始化用[hidden]会让交互流畅不少因为每次切换不需要重新创建 DOM。还有一点容易踩坑[hidden]的样式优先级问题。如果元素上同时有类似.active { display: block }的自定义样式display: none会被覆盖导致hidden失效。遇到这种情况要么用*ngIf要么加上!important之类的补救措施。4.3 ngFor 的真正威力index/even/odd 和 trackBy 性能优化*ngFor最基础的用法是循环渲染li *ngForlet item of items{{ item.name }}/li但它提供的上下文变量非常实用div *ngForlet item of items; index as i; first as isFirst; last as isLast; even as isEven; odd as isOdd span{{ i }}: {{ item.name }}/span span *ngIfisFirst最新/span /div这些在列表展示里很常用比如给偶数行加背景色[class.row-even]isEven。trackBy是ngFor里最容易被忽略的性能优化项。默认情况下Angular 不用 trackBy 时遇到数组元素引用变化会销毁所有列表项 DOM 再重建。如果列表数据是通过接口轮询或刷新获得的每次返回的都是新数组即使内容完全一样也会触发全量重建结果就是输入框丢焦点、滚动位置丢失、图片重新请求。加上 trackBy 可以解决trackById(index: number, item: any) { return item.id; }li *ngForlet item of items; trackBy: trackById{{ item.name }}/li这样 Angular 会根据item.id判断哪些项需要更新相同 id 的 DOM 直接复用。我之前做过一个聊天列表后端每 3 秒推一次消息数据数组整体替换。刚开始没加 trackBy用户正在输入框打字消息到达后输入框直接失焦加了trackBy: trackById后问题立刻消失。4.4 手写一个结构型指令感受 ViewContainerRef 的力量理解了*ngIf的展开之后自己写一个结构型指令就有了基础。比如实现一个*appUnless条件为 false 时显示和*ngIf相反import { Directive, Input, TemplateRef, ViewContainerRef } from angular/core; Directive({ selector: [appUnless] }) export class UnlessDirective { private hasView false; constructor( private templateRef: TemplateRefany, private viewContainer: ViewContainerRef ) {} Input() set appUnless(condition: boolean) { if (!condition !this.hasView) { this.viewContainer.createEmbeddedView(this.templateRef); this.hasView true; } else if (condition this.hasView) { this.viewContainer.clear(); this.hasView false; } } }用法div *appUnlessisLoading内容加载完成/div这里核心的两个依赖TemplateRef指向模板内容ViewContainerRef是视图容器负责把模板实例化并插入视图或者清空删除。理解这两个类结构型指令的原理基本就通了。5. 样式与属性绑定改了类名改样式再进一步5.1 ngClass 与 ngStyle 什么时候用、什么时候别用动态控制样式最常用的两个属性型指令是ngClass和ngStyle。ngClass支持三种写法!-- 对象形式key 是类名value 是布尔条件 -- div [ngClass]{ active: isActive, disabled: isDisabled }菜单项/div !-- 数组形式 -- div [ngClass][active, highlighted]内容/div !-- 字符串形式 -- div [ngClass]active highlighted内容/divngStyle的写法div [ngStyle]{ font-size: fontSize px, backgroundColor: bgColor }内容/div使用建议是能用 class 解决的就别用 style。样式应该尽量收敛在 CSS 里模板里出现一坨内联样式会让样式表和模板的关系变得混乱。但是遇到需要动态计算的值比如拖拽的坐标、进度条的宽度百分比、随机颜色等ngStyle是合理的工具div classprogress-bar div classprogress-bar-inner [ngStyle]{ width: progress % }/div /div5.2 单类/单样式绑定的原生简洁写法如果只是给一个元素动态加一个类名用[ngClass]反而显得重。Angular 为单一类名和单一样式提供了更简洁的写法!-- 单个类名 -- div [class.active]isActive链接/div !-- 单个样式 -- div [style.color]isError ? red : green状态/div !-- 带单位 -- div [style.width.px]progressWidth进度/div[style.width.px]这种带单位的写法很实用——Angular 会自动给属性值拼上px单位省去了手动拼接字符串。这种写法在 AngularJS 时代是没有的用起来真的很顺手。需要添加多个类名时可以并列写多条div [class.active]isActive [class.highlighted]isHighlighted内容/div这样可读性比一个[ngClass]对象更好推荐在类名条件独立、逻辑清晰时使用。如果条件之间有关联比如互斥状态用[ngClass]的对象形式更合适。5.3 写一个属性型指令高亮、权限、水印都能做属性型指令是模板语法的延伸常见的应用是封装样式和行为的组合逻辑。做一个高亮指令import { Directive, HostBinding, HostListener, Input } from angular/core; Directive({ selector: [appHighlight] }) export class HighlightDirective { Input() appHighlight yellow; HostBinding(style.backgroundColor) backgroundColor: string; HostListener(mouseenter) onMouseEnter() { this.backgroundColor this.appHighlight; } HostListener(mouseleave) onMouseLeave() { this.backgroundColor null; } }使用p [appHighlight]lightblue鼠标移上来背景变色/p这里HostBinding负责绑定宿主元素的属性这里是style.backgroundColorHostListener负责监听宿主元素的事件。HostBinding和HostListener这两个装饰器实现的效果和直接在模板里写[style.xxx]、(mouseenter)是等价的只是位置从模板挪到了指令类里。实际项目里权限指令也常用这个模式按钮上标注需要的权限码指令初始化时检查当前用户权限没有则把按钮从 DOM 里移除或置灰。6. 模板引用变量、安全导航与管道让模板更健壮更省心6.1#模板引用变量的作用域和典型用法模板引用变量用#声明可以在模板里拿到元素、组件实例或指令实例input #nameInput button (click)alert(nameInput.value)获取值/button典型用法有三类操作原生 DOM#nameInput拿到的是HTMLInputElement可以拿.value、.focus()等。拿到组件实例给子组件打一个引用变量可以在模板里直接访问子组件的公开属性和方法。拿到指令的暴露实例最典型的场景是模板驱动表单的ngFormform #myFormngForm (ngSubmit)onSubmit(myForm) input nameusername ngModel required button [disabled]myForm.invalid提交/button /form这里#myFormngForm不是拿 DOM 元素而是把ngForm指令实例暴露给变量这样模板里就能直接用myForm.invalid、myForm.value这些表单状态。关于作用域要特别注意模板引用变量只能在它声明所在的模板范围内使用不能跨组件。你不能在一个组件的模板里访问另一个组件内部的引用变量。对于结构型指令内部的变量作用域还会进一步限制在指令的模板范围内。还有个小提醒引用变量名尽量不要和组件类里的属性重名否则在模板里变量的引用优先级更高容易出现出乎意料的取值结果。6.2 安全导航操作符?.不是偷懒工具而是防御策略在模板里渲染嵌套对象时最常见的报错就是Cannot read property xxx of undefined。比如接口返回的数据结构是user.profile.nickname但profile可能为null直接渲染就会报错。Angular 提供了安全导航操作符p{{ user?.profile?.nickname }}/p当user为null或undefined时整个表达式返回null不会继续往下访问模板显示为空。不过我想提醒一句安全导航符是防御手段不是逃避手段。如果一个字段的值正常来说必然存在出现了undefined往往意味着上游数据结构出问题了。到处滥用?.会让错误被静默吞掉排查时更难发现根因。更好的实践是在数据源头接口层或数据初始化处就保证结构完整比如用默认值初始化this.user data.user || { profile: { nickname: 匿名用户 } };这样模板可以放心使用user.profile.nickname一旦真的拿不到也能看到兜底值不容易整页崩溃。Angular 2 还支持空值合并操作符??和 TS 里的行为一致p{{ user?.nickname ?? 匿名用户 }}/p这个组合很适合做显示层兜底。6.3 管道内置管道、参数、链式调用与纯管道陷阱管道Pipe是模板语法里处理显示格式的高效工具语法是value | 管道名: 参数。常用的内置管道有这些管道作用示例date日期格式化{{ birthdayuppercase/lowercase大小写转换{{ namecurrency货币格式化{{ pricepercent百分比{{ ratiojsonJSON 输出{{ objslice截取数组/字符串{{ itemsasync异步数据自动解析{{ data$管道可以链式调用前一个的输出是后一个的输入p{{ birthday | date: yyyy-MM-dd | uppercase }}/p这里date管道输出的字符串再经过uppercase变成大写。有个坑值得一说默认情况下管道是纯管道pure: true只有当输入值的引用发生变化时才会重新执行。如果输入是数组或对象你在组件里直接修改了它的某个属性而不是替换整个引用管道不会重新执行显示结果不会更新。一个真实的例子我写过一个过滤管道接收一个数组和一个关键字结果点击清空筛选后列表纹丝不动因为数组引用没变。解决方式有两个要么在组件里每次赋值时创建新的数组this.items [...this.items]要么把管道标记为非纯Pipe({ name: myFilter, pure: false }) export class MyFilterPipe implements PipeTransform { // ... }非纯管道会在每次变更检测时都执行更新及时但代价是性能开销变大因为即使输入没变也会重新计算。所以我的建议是优先保证输入引用更新实在做不到再考虑纯管道和非纯管道的取舍而且管道内逻辑要尽量轻量。6.4 async 管道处理异步数据最省事的办法async管道在我个人看来是 Angular 模板语法里最能省代码的设计。它可以接收Observable或Promise在模板中自动订阅、自动解包并在组件销毁时自动退订。组件类data$: Observablestring[] this.service.getData();模板ul li *ngForlet item of data$ | async{{ item }}/li /ul同样在*ngIf里使用 async 管道还能配合as语法避免重复订阅div *ngIfuser$ | async as user p{{ user.name }}/p p{{ user.email }}/p /div这里$event | async只订阅了一次结果通过as user存成局部变量后面的模板可以直接复用。这个写法极大简化了异步数据的处理逻辑——不用在组件里手动写subscribe也不用担心忘了退订导致内存泄漏。不过也要注意一点async管道订阅的数据流如果不是冷 Observable每次重新订阅可能会触发新的副作用。需要复用同一份数据的场景更稳妥的做法是在组件里shareReplay(1)或用BehaviorSubject再在模板里配合async使用。收个尾我实际踩过的模板语法相关的坑写了这么多最后分享一个我在真实项目中印象最深的教训模板里[style.width.px]这种带单位的绑定值必须是数字。我当时图省事直接绑定了一个字符串50结果样式一直没有生效排查了很久才发现是类型问题。Angular 的模板其实有很多这样的小约定平时不觉得遇到问题会耗掉你不少时间。另外提一句做模板语法相关的工作时尽量保持每个表达式的逻辑足够简单。模板写得太复杂比如在模板里调用一个包含大量计算的方法每次变更检测都会重新执行页面卡顿往往悄无声息地吃在这些地方。正确做法是把计算结果缓存到组件属性里。Angular 2 的模板语法学起来不难但真正要熟练需要在项目里反复碰壁才能体会各个语法的边界和取舍。这篇文章能帮你在入门时少走一些弯路那就值了。
返回列表