- 原文地址:Vue Router — The Missing Manual
- 原文作者:Harshal Patil
- 译文出自:掘金翻译计划
- 本文永久链接:github.com/xitu/gold-m…
- 译者:Sam
- 校对者:Ranjay, shixi-li
除了 DOM 操作,事件处理,表单和组件之外,每个单页应用程序(SPA)框架如果要用于大型应用程序都需要两个核心部分:
- 客户端路由
- 显式状态管理(通常是单向的)
幸运的是,Vue 为路由和状态管理提供了官方解决方案。这篇文章里,我们将要探寻 vue-router,以了解路由在诸多场景中的行为表现,并探索一些编写优雅代码的模式。这里假设你已经对 vue,vue-router 和 SPA 有所深入了解。
我们将使用下面开启了 HTML5 路由模式的应用程序作为示例。
路由:
- 项目中所有用户的列表
/projects/:projectId/users
- 单个用户的详细信息视图
/projects/:projectId/users/:userId
- 单个用户的简要信息视图
/projects/:projectId/users/:userId/profile
- 创建一个新用户
/projects/:projectId/users/new
组件树结构:
从应用程序路由派生出的组件层次结构
1. 当前的路由对象是共享且不可变的
Vue-router 在每一个组件里注入当前路由对象。每个组件里可以通过 this.$route
访问它。但关于这个对象有两点需要注意的事项。
路由对象是不可改变的。
如果你使用 $router.push()
,$router.replace()
或者链接导航到任何路由上,则会创建 $route
对象的新副本。已有的(路由)对象是不会被修改的。由于它(路由对象)是不可变的,所以你不需要设置 deep 属性监听这个 $route
对象:
Vue.component('app-component', {
watch: {
$route: {
handler() {},
deep: true // <-- 并不需要
}
}
});
复制代码
路由对象是共享的。
不可变性带来了进一步的优势。路由在所有组件内部共享同一个 $route
对象实例。所以下面这些内容都将生效:
// 父组件
Vue.component('app-component', {
mounted() { window.obj1 = this.$route; }
});
// 子组件
Vue.component('user-list', {
mounted() { window.obj2 = this.$route; }
});
// 一旦 App 实例化
window.obj1 === window.obj2; // <-- 返回 true
复制代码
2. Vue-router 不是状态路由
理论上来说,路由是分解大型网络应用程序的第一级抽象。状态管理更晚一些。
有两种关于分解网络应用程序的思考方式。一种是把应用程序分解成一系列的页面(例如,每个页面都根据 URL 边界进行拆分),另一种是把应用程序理解成已经定义好的一组状态(可选择让每个状态都有一个 URL)。
state-router 会把应用程序拆解成一组状态。url-router 会把应用程序拆解成一组页面。
Vue-router 是 url-router。Vue 没有官方 state-router。有 Angular 背景的人员马上会意识到它们的区别。状态路由器(state-router)相较于 URL 路由器(url-router)方式的区别:
- 状态路由器像状态机一样工作。
- 状态路由器中 URL 是非必要的。
- 状态是可以嵌套的。
- 一个应用程序被拆分成一组定义好的状态集合而不是页面。从一个状态转变为另一个状态时可选择性的改变 URL。
- 当从一个状态转变成另一个状态时可以传递任何复杂的数据。但使用 URL 路由器,在页面间传递数据一般是将它作为 URL 地址的一部分或查询参数。
- 使用状态路由器,当整体页面发生刷新的时候已传递的数据会丢失(除非你使用了 session 或者 local storage 做了存储)。使用 URL 路由器,可以重建状态,因为大部分传递的数据都存在于 URL 中。
3. 路由之间传递的隐式数据
即便不是状态路由器,在转变过程中,你仍然可以把复杂数据从一个路径传递到另一个上,而不用将数据作为 URL 的一部分。
当使用 vue-router 从一个路由导航到另一个路由时,你可以传递隐式数据或状态。
这在哪里有用呢?主要是优化的时候。考虑下面的例子:
- 我们有两个页面: 详情页 ——
/users/:userId
简介页 ——/users/:userId/profile
- 在详情页面里,我们调起一个 API 请求获取用户信息。并且,页面上有一个链接帮助用户跳转到简介页面。
- 第二个页面上,我们需要发起两个 API 请求 —— 获取用户信息和获取用户概要。
- 这里的问题是 —— 当我从详情页面导航到简介页面时做了两次一样的 API 请求。最佳的解决方案是当我们用户从详情视图页转变成简介视图页时,把已检索的用户数据传递给下一个路由。另外,这些已检索的数据不需要作为 URL 的一部分(就像状态路由器一样,传递一个隐式的状态)。
- 如果用户通过任何其他方式直接跳转到简介页面,比如整个页面刷新或者从其他视图进入,那么在
created
钩子函数里,我们可以选择检查数据的可用性。
// 用户详情组件内部
Vue.component('user-details', {
methods: {
onLinkClick() {
this.$router.push({
name: 'profile',
params: {
userId: 123,
userData // 隐式数据/状态
}
});
}
}
});
// 用户简介组件内部
Vue.component('user-profile', {
created() {
// 访问附带过来的数据
if (this.$route.params.userData) {
this.userData = this.$route.params.userData;
} else {
// 不然就发起 API 请求获取用户数据
this.getUserDetails(this.$route.params.userId)
.then(/* handle response */);
}
}
});
复制代码
注意:能够这样处理是因为 $route
对象注入在每个组件中且是共享不可变的。不然会很难办。
4. 导航保护阻塞父组件
如果你有嵌套配置,那么任何子组件上的保护都有可能阻塞父组件的渲染。例如:
const ParentComp = Vue.extend({
template: `<div>
<progress-loader></progress-loader>
<router-view>
</div>`
});
{
path: '/projects/:projectId',
name: 'project',
component: ParentComp,
children: [{
path: 'users',
name: 'list',
component: UserList,
beforeEnter (to, from, next) {
setTimeout(() => next(), 2000);
}
}]
}
复制代码
如果你直接导航到 /projects/100/users/list
,那么由于 beforeEnter
的异步保护,导航会被当作等待中(pending),并且 ParentComp
组件不会被渲染。所以,如果你希望看到进程加载器(progress-loader)
直到保护解除,它应该是不会出现。对于你可能从父组件发起的任何 API 请求也是如此。
在这种情况下,如果你希望显示父级组件而不顾子级路由的保护策略,解决方案是改变你组件的层级结构并且通过某种方式更新 进程加载器(progress-loader)
的逻辑。如果你做不到,那么你可以像这样使用双重传递 —— 先导航到父组件然后再到子组件:
goToUserList () {
this.$router.push('/projects/100',
() => this.$router.replace('users'))
}
复制代码
这个行为是有道理的。如果父级视图不等待子级的保护,那么它可能先渲染一会父级视图,然后如果保护失败则导航到其他地方去。
注意:相比之下,Angular 的路由是完全相反地。父级组件一般不会等待任何子级保护的触发。那么哪种方案是正确的?都不是。乍看上去,Angular 采取的方法感觉自然而有序,但如果开发者不仔细的话它很容易搞砸用户体验(UX)。
使用 vue-router,渲染层级似乎有点尴尬。但却少有机会破坏用户体验(UX)。Vue 隐含地预先强制执行这项决定。同时,不要忘记 vue-router 提供的作用域。你可以使用全局级别,路由级别或者组件内级别的保护。你会拥有真正细粒度的控制。
在理解了关于 vue-router 的一些概念之后,是时候讨论关于编写优雅代码的模式了。
5. Vue-router 不是基于前缀(trie-based)的路由器
Vue-router 是构建在 path-to-regexp 之上的。Express.js 路由也是如此。URL 匹配是基于正则表达式的。这意味着你可以像这样定义你的路由:
const prefix = `/projects/:projectId/users`;
const routes = [
{
path: `${prefix}/list`,
name: 'user-list',
component: UserList,
},
{
path: `${prefix}/:userId`,
name: 'user-details',
component: UserDetails
},
{
// 这里不会造成问题吗?
path: `${prefix}/new`,
name: 'user-new',
component: NewUser
}
];
复制代码
这里不那么明显的问题是路径 ${prefix}/new
永远不会被匹配,因为它定义在路由列表的最后。这是基于正则表达式路由的缺陷。不止一个路由会被匹配上(译者注:路径 ${prefix}/:userId
会覆盖匹配路径 ${prefix}/new
)。当然,这对于小型网络应用程序不是问题。或者,你可以像这样定义一棵路由树:
const routes = [{
path: '/projects/:projectId/users',
name: 'project',
component: ProjectUserView,
children: [
{
path: '',
name: 'list',
component: UserList,
},
{
path: 'new',
name: 'user-details',
component: NewUser,
},
{
path: ':userId',
name: 'user-new',
component: UserDetails,
}
]
}];
复制代码
基于树结构配置有一些优点:
- 结构清晰。易于维护。
- 授权/保护的管理变得容易。基于 CRUD (增删改查) 的权限执行变得非常简单。
- 比起扁平的路由列表有更可预见的路由。
使用基于树结构配置的细微差别在于创建中间组件,它们可能只包含一个 router-view
组件。Vue-router 没有将 RouterView
组件直接暴露给最终开发者。但是一个包装 router-view
的小技巧可以极大地帮助减少中间组件:
const RouterViewWrapper = Vue.extend({
template: `<router-view></router-view>`
});
// 现在,可以在路由配置树的任何位置
// 使用 RouterViewWrapper 组件。
复制代码
注意:Trie 是一种搜索树数据结构的类型(译者注:前缀树)。基于前缀的路由是可预见的,并且不管路由的定义顺序。在 Nodejs 生态环境里,存在很多基于前缀或者类似的路由。Hapi.js 和 Fastify.js 使用的是基于前缀的路由。
简而言之:
树结构配置优于扁平结构配置。
6. 路由器的依赖注入
当你使用导航保护的时候,你可能在这些保护函数里需要一些依赖。大多数常见的例子是 Vuex/Redux 的 store。这个解决方案过于简单。比起路由器本身,还有更多关于代码组织的工作要做。假定你有以下这些文件:
src/
|-- main.js
|-- router.js
|-- store.js
复制代码
你可以创建一个在定义导航守护时的存储(store)注入函数:
// 在你的 store.js 里,定义存储注入器
export const store = new Vuex.Store({ /* config */ });
export function storeInjector(fn) {
return (...args) => fn(...args, store);
}
// 在你的 router.js 里,使用存储注入器
const routeConfig = {
// 其他内容
beforeEnter: storeInjector((to, from, next, store) => {})
}
复制代码
或者,你也可以将路由创建器封装到可以传递任何依赖的函数中:
// main.js 文件
import { makeStore } from './store.js';
const store = makeStore();
const router = makeRouter(store);
const app = new Vue({ store, router, template: `<div></div>` });
// router.js 文件
export function makeRouter(store) {
// 使用 store 处理任何事情
return new VueRouter({
routes: []
})
}
复制代码
7. 单次监听路由对象
设想你在一个异步组件里使用路由配置。异步组件是通过懒加载方式引入的。这通常是使用像 Webpack 或 Rollup 这样的工具进行包(bundle)拆分实现的。配置看起来将会是这样的:
const routes = [{
path: '/projects/:projectId/users',
name: 'user-list',
// 异步组件(Webpack 的代码拆分)
component: import('../UserList.js'),
}];
复制代码
在根实例或者父级 AppComponent
组件里,你可能希望检索 projectId
用来做一些引导性的 API 调用。典型的代码是:
Vue.component('app-comp', {
created() {
// 问题:projectId 未定义
console.log(this.$route.params.projectId);
}
}
复制代码
这里的问题是 projectId
将是未定义的,因为子组件没有准备好,路由器还没有完成传递。
当你在路由配置里使用异步组件时,在未创建子组件之前,父组件中将不提供路径或查询参数。
这里的解决方案是在父组件里监听 $route
。另外,你必须只监听它一次,因为它只是一个引导性 API 请求并且不应该再被触发:
Vue.component('app-comp', {
created() {
const unwatch = this.$watch('$route', () => {
const projectId = this.$route.params.projectId;
// 做剩余的工作
this.getProjectInfo(projectId);
// 立即解开监听
unwatch();
});
}
}
复制代码
8. 使用扁平路由混合监听嵌套组件
const routes = [{
path: '/projects/:projectId',
name: 'project',
component: ProjectView,
beforeEnter(to, from, next) {
next();
},
children: [{
// 仔细观察
// 嵌套路由以 `/` 开头
path: '/users',
name: 'list',
component: UserList,
}]
}];
复制代码
在上面的配置中,子级路由以 /
开头因此被当作根路径。所以你可以使用 https://example.com/users
而不是 https://example.com/projects/100/users
就可以访问 UserList
组件。然而,UserList
组件将被渲染成 ProjectView
组件的子组件。这种路径被称为根相对嵌套路径。
当然,组件层级,导航保护依然在处理中。你仍然需要嵌套的 <router-view>
组件。唯一改变的事情是 URL 的结构。其他的都还保持原样。这意味着 beforeEnter
保护将在 UserList
组件之前执行。
这个技巧是纯粹的便利,因此需要谨慎的使用它。从长远来看,它往往会产生令人困惑的代码。然而 ——
根相对嵌套路径在构建 App Shell Model 的 PWA 时非常有用。
Vue 提供的官方路由解决方案是非常灵活的。除去简单的路由,它还提供了许多功能,如 meta
字段,transition
,高级 scroll-behavior
,lazy-loading
等。
此外,当我们使用导航保护,预路由数据获取时,vue-router 设计了关于用户体验(UX)的考量。你可以使用全局或者组件内保护,但需谨慎地使用它们,因此你应该牢记关注点分离并把路由职责从组件中移除。
如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。