如何在 Ember.Router 中限制特定 routes 的访问
结论
不要在 serialize() 中做访问验证,也不需要用 setTimeout 修正 URL。更合适的做法是集中管理 checkout 的步骤状态,并在目标 route 的 beforeModel() 中检查前置条件。如果条件不满足,就用 replaceWith() 跳转到最早尚未完成的步骤。
无论用户正常点击、手动输入 URL,还是刷新深层 route,都会经过同一套验证逻辑。hash 和 history 只负责管理 URL,不参与流程判断。
为什么使用 beforeModel()
这些 hook 各有用途:
serialize()负责把 model 转换成 URL 参数,不适合做权限或流程验证。model()负责加载当前 route 的数据。afterModel()或redirect()适合处理依赖当前 route model 的判断。beforeModel()适合检查登录状态、checkout 前置步骤等不依赖当前 route model 的条件。
如果验证过程需要读取服务端、localStorage 或其他异步数据,可以直接在 beforeModel() 中等待 Promise。Ember 会暂停当前 transition,无须额外添加 timeout。
集中定义步骤顺序
不要让每个 route 各自判断应该退回哪个步骤。可以使用 service 统一保存步骤顺序和完成状态:
// app/services/checkout-flow.js
import Service, { service } from '@ember/service';
export default class CheckoutFlowService extends Service {
@service checkoutSession;
steps = [
{
route: 'checkout.billing.address',
isComplete: () => Boolean(this.checkoutSession.billingAddress)
},
{
route: 'checkout.delivery.address',
isComplete: () => Boolean(this.checkoutSession.deliveryAddress)
},
{
route: 'checkout.billing.method',
isComplete: () => Boolean(this.checkoutSession.billingMethod)
},
{
route: 'checkout.delivery.method',
isComplete: () => Boolean(this.checkoutSession.deliveryMethod)
}
];
firstIncompleteBefore(targetStep) {
for (let index = 0; index < targetStep; index++) {
let step = this.steps[index];
if (!step.isComplete()) {
return step.route;
}
}
return null;
}
}
这里只检查目标步骤之前的状态。访问 billing address 时,不应因为 billing address 还没填写就触发重定向。只有访问 billing method 时,才需要检查之前要求完成的 address 步骤。
建立可复用的 route guard
可以创建一个 checkout route 基类,避免在每个 route 中重复编写验证代码:
// app/routes/checkout/guarded.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class GuardedCheckoutRoute extends Route {
@service checkoutFlow;
@service checkoutSession;
@service router;
checkoutStep = 0;
async beforeModel(transition) {
await super.beforeModel(transition);
// 从服务端或持久化存储恢复 checkout 状态。
await this.checkoutSession.restore();
let fallbackRoute =
this.checkoutFlow.firstIncompleteBefore(this.checkoutStep);
if (fallbackRoute) {
return this.router.replaceWith(fallbackRoute);
}
}
}
各步骤再继承这个基类:
// app/routes/checkout/billing/method.js
import GuardedCheckoutRoute from '../../guarded';
export default class CheckoutBillingMethodRoute extends GuardedCheckoutRoute {
checkoutStep = 2;
}
// app/routes/checkout/delivery/method.js
import GuardedCheckoutRoute from '../../guarded';
export default class CheckoutDeliveryMethodRoute extends GuardedCheckoutRoute {
checkoutStep = 3;
}
如果项目不打算使用继承,也可以把检查方法放到 service 中,再由每个受保护 route 的 beforeModel() 调用。无论采用哪种写法,都应集中定义步骤规则,route 只需声明自己对应第几步。
正确处理页面刷新
刷新深层 URL 后,内存中的 service 会重新创建。能否恢复到刷新前的步骤,取决于 checkout 数据保存在哪里。
例如:
// app/services/checkout-session.js
import Service from '@ember/service';
export default class CheckoutSessionService extends Service {
billingAddress = null;
deliveryAddress = null;
billingMethod = null;
deliveryMethod = null;
async restore() {
// 可以从服务端、sessionStorage 或 localStorage 恢复状态。
// 应保证重复调用不会产生副作用。
}
}
如果 checkout 状态只保存在内存中,刷新后所有步骤都会恢复为未完成。此时跳转到 billing address 是预期结果,并非 router 出了问题。
如果状态保存在服务端,应先恢复数据,再判断跳转位置。否则 guard 可能在数据尚未加载时,把步骤误判为未完成。
replaceWith() 与 transitionTo() 的选择
这种回退通常适合使用:
this.router.replaceWith('checkout.billing.address');
replaceWith() 会替换当前浏览器历史记录。用户按“后退”时,不会再次进入那个无权访问的深层 URL。
如果希望无效地址继续保留在历史记录中,可以使用:
this.router.transitionTo('checkout.billing.address');
对于步骤前置条件校验,通常优先使用 replaceWith()。
注意事项
- fallback route 本身必须允许进入,否则可能造成重定向循环。
restore()应能安全地重复调用,最好缓存已经完成的加载操作。- 进入某一步骤时,只检查它之前的步骤,不要把用户从较早步骤自动推进到较晚步骤。
- 如果条件依赖当前 route 的 model,应在
afterModel()或redirect()中判断。 - 较老版本的 Ember 可能使用不同的类语法或 route API,但思路不变:在 transition 完成前验证,验证失败后通过 router 正式重定向,不要直接修改 hash。
完整流程是先恢复 checkout 状态,再按照固定顺序查找最早未完成的前置步骤,最后使用 replaceWith() 导航到对应 route。这样可以让 URL、浏览器历史和应用状态保持一致。
备注:内容仅供参考。
