- 原文地址: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、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。