Skip to content

04 - 数据绑定与事件处理

📖 学习目标

通过本章节学习,您将掌握:

  • Angular数据绑定机制
  • 插值绑定
  • 属性绑定
  • 事件绑定
  • 双向绑定
  • 事件处理最佳实践

🎯 核心概念

1. 数据绑定类型

Angular提供四种数据绑定方式:

数据流向         语法                    类型
组件 → 模板      {{ value }}            插值绑定
组件 → 模板      [property]="value"     属性绑定
模板 → 组件      (event)="handler()"    事件绑定
双向绑定         [(ngModel)]="value"    双向绑定

2. 变更检测

Angular使用变更检测来跟踪数据变化:

  • 脏检查:比较当前值和之前值
  • 自动触发:用户交互、HTTP请求、定时器
  • 性能优化:OnPush策略减少检测频率

🔧 插值绑定

1. 基本插值

html
<!-- 基本插值 -->
<h1>{{ title }}</h1>
<p>用户数量:{{ userCount }}</p>

<!-- 表达式插值 -->
<p>总价:{{ price * quantity }}</p>
<p>状态:{{ isActive ? '活跃' : '非活跃' }}</p>

<!-- 方法调用插值 -->
<p>格式化日期:{{ formatDate(createdAt) }}</p>

2. 安全导航操作符

html
<!-- 避免空值错误 -->
<p>用户名:{{ user?.name }}</p>
<p>邮箱:{{ user?.profile?.email }}</p>

<!-- 使用默认值 -->
<p>显示名称:{{ user?.name || '匿名用户' }}</p>

3. 管道使用

html
<!-- 内置管道 -->
<p>价格:{{ price | currency:'CNY':'symbol':'1.2-2' }}</p>
<p>日期:{{ date | date:'yyyy-MM-dd HH:mm:ss' }}</p>
<p>文本:{{ text | uppercase }}</p>
<p>数字:{{ number | number:'1.2-2' }}</p>

<!-- 链式管道 -->
<p>价格:{{ price | currency:'CNY' | lowercase }}</p>

🎨 属性绑定

1. 基本属性绑定

html
<!-- 基本属性 -->
<img [src]="imageUrl" [alt]="imageAlt">
<button [disabled]="isDisabled">按钮</button>
<input [value]="inputValue" [placeholder]="placeholder">

<!-- 类绑定 -->
<div [class.active]="isActive">内容</div>
<div [class]="cssClass">内容</div>
<div [ngClass]="{'active': isActive, 'disabled': isDisabled}">内容</div>

<!-- 样式绑定 -->
<div [style.color]="textColor">文本</div>
<div [style.font-size.px]="fontSize">文本</div>
<div [ngStyle]="{'color': textColor, 'font-size': fontSize + 'px'}">文本</div>

2. 属性绑定示例

typescript
// 组件代码
export class DataBindingComponent {
  // 基本属性
  imageUrl = 'https://example.com/image.jpg';
  imageAlt = '示例图片';
  isDisabled = false;
  inputValue = '初始值';
  placeholder = '请输入内容';

  // 类绑定
  isActive = true;
  cssClass = 'custom-class';
  isDisabled = false;

  // 样式绑定
  textColor = 'blue';
  fontSize = 16;

  // 动态样式对象
  dynamicStyles = {
    'background-color': 'lightblue',
    'padding': '10px',
    'border-radius': '5px'
  };
}
html
<!-- 模板代码 -->
<div class="container">
  <!-- 基本属性绑定 -->
  <img [src]="imageUrl" [alt]="imageAlt" class="responsive-image">
  <button [disabled]="isDisabled" class="btn">点击我</button>
  <input [value]="inputValue" [placeholder]="placeholder" class="form-input">

  <!-- 类绑定 -->
  <div [class.active]="isActive" class="card">
    动态类绑定
  </div>
  
  <div [class]="cssClass">
    完全替换类
  </div>
  
  <div [ngClass]="{'active': isActive, 'disabled': isDisabled}">
    条件类绑定
  </div>

  <!-- 样式绑定 -->
  <div [style.color]="textColor" [style.font-size.px]="fontSize">
    动态样式
  </div>
  
  <div [ngStyle]="dynamicStyles">
    样式对象绑定
  </div>
</div>

⚡ 事件绑定

1. 基本事件绑定

html
<!-- 点击事件 -->
<button (click)="onClick()">点击</button>
<button (click)="onClick($event)">点击(带事件对象)</button>

<!-- 输入事件 -->
<input (input)="onInput($event)" (blur)="onBlur()">
<textarea (input)="onTextareaInput($event)"></textarea>

<!-- 表单事件 -->
<form (submit)="onSubmit($event)">
  <button type="submit">提交</button>
</form>

<!-- 鼠标事件 -->
<div (mouseenter)="onMouseEnter()" (mouseleave)="onMouseLeave()">
  鼠标悬停区域
</div>

2. 键盘事件

html
<!-- 键盘事件 -->
<input (keyup)="onKeyUp($event)" (keydown)="onKeyDown($event)">

<!-- 特定按键 -->
<input (keyup.enter)="onEnter()" (keyup.escape)="onEscape()">
<input (keyup.space)="onSpace()" (keyup.arrowup)="onArrowUp()">

<!-- 组合键 -->
<input (keyup.control.enter)="onCtrlEnter()">

3. 事件处理示例

typescript
// 组件代码
export class EventHandlingComponent {
  // 基本事件处理
  onClick() {
    console.log('按钮被点击');
  }

  onClickWithEvent(event: MouseEvent) {
    console.log('点击位置:', event.clientX, event.clientY);
    event.preventDefault(); // 阻止默认行为
  }

  onInput(event: Event) {
    const target = event.target as HTMLInputElement;
    console.log('输入值:', target.value);
  }

  onBlur() {
    console.log('输入框失去焦点');
  }

  onSubmit(event: Event) {
    event.preventDefault();
    console.log('表单提交');
  }

  // 键盘事件处理
  onKeyUp(event: KeyboardEvent) {
    console.log('按键:', event.key);
  }

  onEnter() {
    console.log('按下了Enter键');
  }

  onEscape() {
    console.log('按下了Escape键');
  }

  // 鼠标事件处理
  onMouseEnter() {
    console.log('鼠标进入');
  }

  onMouseLeave() {
    console.log('鼠标离开');
  }
}

🔄 双向绑定

1. 使用ngModel

html
<!-- 基本双向绑定 -->
<input [(ngModel)]="name" placeholder="请输入姓名">
<textarea [(ngModel)]="description"></textarea>

<!-- 带验证的双向绑定 -->
<input 
  [(ngModel)]="email" 
  type="email" 
  required 
  email
  #emailInput="ngModel">

<!-- 选择框双向绑定 -->
<select [(ngModel)]="selectedOption">
  <option value="">请选择</option>
  <option value="option1">选项1</option>
  <option value="option2">选项2</option>
</select>

2. 自定义双向绑定

typescript
// 自定义组件
@Component({
  selector: 'app-custom-input',
  template: `
    <input 
      [value]="value" 
      (input)="onInput($event)"
      (blur)="onBlur()">
  `
})
export class CustomInputComponent {
  @Input() value: string = '';
  @Output() valueChange = new EventEmitter<string>();

  onInput(event: Event) {
    const target = event.target as HTMLInputElement;
    this.value = target.value;
    this.valueChange.emit(this.value);
  }

  onBlur() {
    // 失去焦点时的处理
  }
}
html
<!-- 使用自定义双向绑定 -->
<app-custom-input [(value)]="customValue"></app-custom-input>

🎮 实践练习

练习1:创建交互式表单

创建一个包含以下功能的表单:

  • 姓名输入(双向绑定)
  • 邮箱输入(带验证)
  • 年龄选择(数字输入)
  • 爱好选择(复选框组)
  • 实时预览用户输入

练习2:实现动态样式切换

创建一个组件,支持:

  • 点击切换主题颜色
  • 动态调整字体大小
  • 切换显示/隐藏内容
  • 添加/移除CSS类

📚 详细示例

完整的用户信息组件

typescript
// user-info.component.ts
import { Component } from '@angular/core';

export interface User {
  name: string;
  email: string;
  age: number;
  hobbies: string[];
  theme: string;
}

@Component({
  selector: 'app-user-info',
  templateUrl: './user-info.component.html',
  styleUrls: ['./user-info.component.css']
})
export class UserInfoComponent {
  // 用户数据
  user: User = {
    name: '',
    email: '',
    age: 0,
    hobbies: [],
    theme: 'light'
  };

  // 可用选项
  availableHobbies = ['编程', '阅读', '运动', '音乐', '旅行'];
  themes = [
    { value: 'light', label: '浅色主题' },
    { value: 'dark', label: '深色主题' },
    { value: 'blue', label: '蓝色主题' }
  ];

  // 状态
  isEditing = false;
  showPreview = true;

  // 事件处理
  onNameChange(event: Event) {
    const target = event.target as HTMLInputElement;
    this.user.name = target.value;
  }

  onHobbyToggle(hobby: string, isChecked: boolean) {
    if (isChecked) {
      this.user.hobbies.push(hobby);
    } else {
      const index = this.user.hobbies.indexOf(hobby);
      if (index > -1) {
        this.user.hobbies.splice(index, 1);
      }
    }
  }

  onThemeChange(theme: string) {
    this.user.theme = theme;
  }

  toggleEditing() {
    this.isEditing = !this.isEditing;
  }

  togglePreview() {
    this.showPreview = !this.showPreview;
  }

  saveUser() {
    console.log('保存用户信息:', this.user);
    this.isEditing = false;
  }

  resetUser() {
    this.user = {
      name: '',
      email: '',
      age: 0,
      hobbies: [],
      theme: 'light'
    };
  }

  // 计算属性
  get userDisplayName(): string {
    return this.user.name || '匿名用户';
  }

  get isFormValid(): boolean {
    return !!(this.user.name && this.user.email && this.user.age > 0);
  }

  get themeClass(): string {
    return `theme-${this.user.theme}`;
  }
}
html
<!-- user-info.component.html -->
<div class="user-info-container" [ngClass]="themeClass">
  <div class="header">
    <h2>用户信息管理</h2>
    <div class="controls">
      <button (click)="toggleEditing()" class="btn btn-primary">
        {{ isEditing ? '取消编辑' : '编辑信息' }}
      </button>
      <button (click)="togglePreview()" class="btn btn-secondary">
        {{ showPreview ? '隐藏预览' : '显示预览' }}
      </button>
    </div>
  </div>

  <div class="content">
    <!-- 编辑表单 -->
    <div class="edit-form" *ngIf="isEditing">
      <div class="form-group">
        <label for="name">姓名:</label>
        <input 
          id="name"
          type="text" 
          [(ngModel)]="user.name"
          placeholder="请输入姓名"
          class="form-control">
      </div>

      <div class="form-group">
        <label for="email">邮箱:</label>
        <input 
          id="email"
          type="email" 
          [(ngModel)]="user.email"
          placeholder="请输入邮箱"
          class="form-control">
      </div>

      <div class="form-group">
        <label for="age">年龄:</label>
        <input 
          id="age"
          type="number" 
          [(ngModel)]="user.age"
          min="1"
          max="120"
          class="form-control">
      </div>

      <div class="form-group">
        <label>爱好:</label>
        <div class="checkbox-group">
          <label *ngFor="let hobby of availableHobbies" class="checkbox-label">
            <input 
              type="checkbox" 
              [value]="hobby"
              [checked]="user.hobbies.includes(hobby)"
              (change)="onHobbyToggle(hobby, $event.target.checked)">
            {{ hobby }}
          </label>
        </div>
      </div>

      <div class="form-group">
        <label for="theme">主题:</label>
        <select 
          id="theme"
          [(ngModel)]="user.theme"
          class="form-control">
          <option *ngFor="let theme of themes" [value]="theme.value">
            {{ theme.label }}
          </option>
        </select>
      </div>

      <div class="form-actions">
        <button 
          (click)="saveUser()" 
          [disabled]="!isFormValid"
          class="btn btn-success">
          保存
        </button>
        <button (click)="resetUser()" class="btn btn-warning">
          重置
        </button>
      </div>
    </div>

    <!-- 预览区域 -->
    <div class="preview" *ngIf="showPreview">
      <h3>用户信息预览</h3>
      <div class="user-card">
        <div class="user-avatar">
          {{ userDisplayName.charAt(0) }}
        </div>
        <div class="user-details">
          <h4>{{ userDisplayName }}</h4>
          <p><strong>邮箱:</strong>{{ user.email || '未设置' }}</p>
          <p><strong>年龄:</strong>{{ user.age || '未设置' }}</p>
          <p><strong>爱好:</strong>{{ user.hobbies.join(', ') || '无' }}</p>
          <p><strong>主题:</strong>{{ user.theme }}</p>
        </div>
      </div>
    </div>
  </div>
</div>
css
/* user-info.component.css */
.user-info-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  border-radius: 8px;
  transition: all 0.3s ease;
}

.theme-light {
  background-color: #ffffff;
  color: #333333;
}

.theme-dark {
  background-color: #333333;
  color: #ffffff;
}

.theme-blue {
  background-color: #e3f2fd;
  color: #1565c0;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 2px solid #e0e0e0;
}

.controls {
  display: flex;
  gap: 10px;
}

.content {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

.edit-form {
  background: #f8f9fa;
  padding: 20px;
  border-radius: 8px;
}

.form-group {
  margin-bottom: 15px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: 500;
}

.form-control {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.checkbox-group {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 8px;
}

.checkbox-label {
  display: flex;
  align-items: center;
  font-weight: normal;
  cursor: pointer;
}

.checkbox-label input {
  margin-right: 8px;
}

.form-actions {
  display: flex;
  gap: 10px;
  margin-top: 20px;
}

.preview {
  background: #ffffff;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.user-card {
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 8px;
}

.user-avatar {
  width: 60px;
  height: 60px;
  background: #007bff;
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  font-weight: bold;
}

.user-details h4 {
  margin: 0 0 10px 0;
  color: #333;
}

.user-details p {
  margin: 5px 0;
  color: #666;
}

.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s ease;
}

.btn-primary {
  background: #007bff;
  color: white;
}

.btn-primary:hover {
  background: #0056b3;
}

.btn-secondary {
  background: #6c757d;
  color: white;
}

.btn-secondary:hover {
  background: #545b62;
}

.btn-success {
  background: #28a745;
  color: white;
}

.btn-success:hover {
  background: #1e7e34;
}

.btn-warning {
  background: #ffc107;
  color: #333;
}

.btn-warning:hover {
  background: #e0a800;
}

.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

@media (max-width: 768px) {
  .content {
    grid-template-columns: 1fr;
  }
  
  .header {
    flex-direction: column;
    gap: 10px;
  }
  
  .controls {
    width: 100%;
    justify-content: center;
  }
}

✅ 学习检查

完成本章节后,请确认您能够:

  • [ ] 使用插值绑定显示数据
  • [ ] 使用属性绑定设置元素属性
  • [ ] 使用事件绑定处理用户交互
  • [ ] 使用双向绑定实现数据同步
  • [ ] 处理各种类型的事件
  • [ ] 实现动态样式和类绑定

🚀 下一步

完成本章节学习后,请继续学习05-指令与管道