Ember路由过渡与动画 #

一、路由过渡概述 #

路由过渡是指从一个路由切换到另一个路由的过程。Ember提供了多种方式来处理和增强这个过程。

1.1 过渡生命周期 #

text
当前路由 deactivate
    ↓
新路由 beforeModel
    ↓
新路由 model
    ↓
新路由 afterModel
    ↓
新路由 setupController
    ↓
新路由 activate

二、过渡处理 #

2.1 阻止过渡 #

javascript
import Route from '@ember/routing/route';
import { action } from '@ember/object';

export default class EditRoute extends Route {
  @action
  willTransition(transition) {
    if (this.controller.hasUnsavedChanges) {
      const confirmed = confirm('有未保存的更改,确定离开吗?');
      if (!confirmed) {
        transition.abort();
      }
    }
  }
}

2.2 重试过渡 #

javascript
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class ProtectedRoute extends Route {
  @service session;

  beforeModel(transition) {
    if (!this.session.isAuthenticated) {
      // 保存目标过渡
      this.session.set('attemptedTransition', transition);
      this.transitionTo('login');
    }
  }
}
javascript
// 登录成功后重试
@action
loginSuccess() {
  const transition = this.session.attemptedTransition;
  if (transition) {
    transition.retry();
  } else {
    this.router.transitionTo('home');
  }
}

2.3 过渡目标信息 #

javascript
import Route from '@ember/routing/route';

export default class ApplicationRoute extends Route {
  @action
  willTransition(transition) {
    console.log('从:', transition.from?.name);
    console.log('到:', transition.to.name);
    console.log('参数:', transition.to.params);
    console.log('查询参数:', transition.to.queryParams);
  }
}

三、加载状态 #

3.1 loading子状态 #

javascript
// app/routes/posts.js
import Route from '@ember/routing/route';

export default class PostsRoute extends Route {
  async model() {
    // 模拟慢速加载
    await new Promise((resolve) => setTimeout(resolve, 2000));
    return this.store.findAll('post');
  }
}
handlebars
{{! app/templates/posts-loading.hbs}}
<div class="loading-state">
  <div class="spinner"></div>
  <p>加载中...</p>
</div>

3.2 error子状态 #

javascript
// app/routes/posts.js
import Route from '@ember/routing/route';

export default class PostsRoute extends Route {
  async model() {
    const response = await fetch('/api/posts');
    if (!response.ok) {
      throw new Error('加载失败');
    }
    return response.json();
  }
}
handlebars
{{! app/templates/posts-error.hbs}}
<div class="error-state">
  <h2>出错了</h2>
  <p>{{@model.message}}</p>
  <button {{on "click" this.retry}}>重试</button>
</div>

四、页面过渡动画 #

4.1 使用CSS动画 #

css
/* app/styles/app.css */
.page-enter {
  opacity: 0;
  transform: translateX(20px);
}

.page-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: opacity 300ms, transform 300ms;
}

.page-exit {
  opacity: 1;
  transform: translateX(0);
}

.page-exit-active {
  opacity: 0;
  transform: translateX(-20px);
  transition: opacity 300ms, transform 300ms;
}

4.2 使用ember-animated #

bash
ember install ember-animated
javascript
// app/routes/posts.js
import Route from '@ember/routing/route';
import AnimatedRoute from 'ember-animated/mixins/animated-route';
import { fadeSlide } from '../animations/transition';

export default class PostsRoute extends Route.extend(AnimatedRoute) {
  model() {
    return this.store.findAll('post');
  }

  animation = fadeSlide;
}
javascript
// app/animations/transition.js
import { fadeOut, fadeIn } from 'ember-animated/motions/opacity';
import { move } from 'ember-animated/motions/move';

export function* fadeSlide({ insertedSprites, keptSprites, removedSprites }) {
  for (let sprite of removedSprites) {
    yield fadeOut(sprite);
  }

  for (let sprite of insertedSprites) {
    sprite.startAtPixel({ x: 20 });
    yield fadeIn(sprite);
  }

  for (let sprite of keptSprites) {
    yield move(sprite);
  }
}

4.3 组件级动画 #

javascript
// app/components/animated-list.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import Animated from 'ember-animated/mixins/animated-component';
import { fadeSlide } from '../animations/transition';

export default class AnimatedListComponent extends Component {
  animation = fadeSlide;

  @tracked items = [];

  @action
  addItem() {
    this.items = [...this.items, { id: Date.now() }];
  }
}
handlebars
{{! app/components/animated-list.hbs}}
<button {{on "click" this.addItem}}>添加</button>

<AnimatedContainer>
  {{#animated-each this.items use=this.animation as |item|}}
    <div class="item">{{item.id}}</div>
  {{/animated-each}}
</AnimatedContainer>

五、过渡钩子 #

5.1 应用级过渡 #

javascript
// app/routes/application.js
import Route from '@ember/routing/route';
import { action } from '@ember/object';

export default class ApplicationRoute extends Route {
  @action
  willTransition(transition) {
    // 所有路由过渡前执行
    console.log('即将过渡');
  }

  @action
  didTransition() {
    // 过渡完成后执行
    console.log('过渡完成');
    window.scrollTo(0, 0);
  }
}

5.2 路由级过渡 #

javascript
// app/routes/posts.js
import Route from '@ember/routing/route';
import { action } from '@ember/object';

export default class PostsRoute extends Route {
  @action
  willTransition(transition) {
    // 离开posts路由时执行
  }

  @action
  didTransition() {
    // 进入posts路由后执行
  }
}

六、进度指示器 #

6.1 全局加载指示器 #

javascript
// app/services/loading.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class LoadingService extends Service {
  @tracked isLoading = false;
  @tracked progress = 0;

  start() {
    this.isLoading = true;
    this.progress = 0;
  }

  update(value) {
    this.progress = value;
  }

  complete() {
    this.progress = 100;
    setTimeout(() => {
      this.isLoading = false;
    }, 300);
  }
}
javascript
// app/routes/application.js
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

export default class ApplicationRoute extends Route {
  @service loading;

  @action
  willTransition() {
    this.loading.start();
  }

  @action
  didTransition() {
    this.loading.complete();
  }
}
handlebars
{{! app/templates/application.hbs}}
{{#if this.loading.isLoading}}
  <div class="loading-bar" style="width: {{this.loading.progress}}%"></div>
{{/if}}

{{outlet}}

七、页面标题管理 #

7.1 使用ember-page-title #

bash
ember install ember-page-title
handlebars
{{! app/templates/posts/show.hbs}}
<PageTitle>{{@model.title}} - 我的博客</PageTitle>

<h1>{{@model.title}}</h1>

7.2 动态标题 #

javascript
// app/routes/posts/show.js
import Route from '@ember/routing/route';

export default class PostsShowRoute extends Route {
  afterModel(model) {
    document.title = `${model.title} - 我的博客`;
  }
}

八、滚动行为 #

8.1 滚动到顶部 #

javascript
// app/routes/application.js
import Route from '@ember/routing/route';
import { action } from '@ember/object';

export default class ApplicationRoute extends Route {
  @action
  didTransition() {
    window.scrollTo(0, 0);
  }
}

8.2 保持滚动位置 #

javascript
// app/services/scroll-position.js
import Service from '@ember/service';

export default class ScrollPositionService extends Service {
  positions = {};

  save(routeName) {
    this.positions[routeName] = window.scrollY;
  }

  restore(routeName) {
    const position = this.positions[routeName] || 0;
    window.scrollTo(0, position);
  }
}

九、最佳实践 #

9.1 优雅的加载状态 #

handlebars
{{! 好的做法 - 提供有意义的加载状态}}
<div class="loading-state">
  <div class="skeleton-card"></div>
  <div class="skeleton-card"></div>
  <div class="skeleton-card"></div>
</div>

{{! 避免 - 简单的加载文字}}
<div>加载中...</div>

9.2 平滑过渡 #

javascript
// 使用CSS过渡
.page-container {
  transition: opacity 0.3s ease;
}

.page-container.loading {
  opacity: 0.5;
}

9.3 避免闪烁 #

javascript
// 添加最小加载时间
async model() {
  const [data] = await Promise.all([
    this.store.findAll('post'),
    new Promise((resolve) => setTimeout(resolve, 300)),
  ]);
  return data;
}

十、总结 #

路由过渡与动画要点:

技术 用途
loading子状态 加载中UI
error子状态 错误处理UI
ember-animated 动画效果
过渡钩子 过渡控制
页面标题 SEO优化

良好的过渡体验能提升用户满意度。

最后更新:2026-03-28