[譯] Vue Router 實戰手冊

SamChord發表於2019-02-12

[譯] Vue Router 實戰手冊

除了 DOM 操作,事件處理,表單和元件之外,每個單頁應用程式(SPA)框架如果要用於大型應用程式都需要兩個核心部分:

  1. 客戶端路由
  2. 顯式狀態管理(通常是單向的)

幸運的是,Vue 為路由和狀態管理提供了官方解決方案。這篇文章裡,我們將要探尋 vue-router,以瞭解路由在諸多場景中的行為表現,並探索一些編寫優雅程式碼的模式。這裡假設你已經對 vue,vue-router 和 SPA 有所深入瞭解。

我們將使用下面開啟了 HTML5 路由模式的應用程式作為示例。

路由:

  1. 專案中所有使用者的列表 /projects/:projectId/users
  2. 單個使用者的詳細資訊檢視 /projects/:projectId/users/:userId
  3. 單個使用者的簡要資訊檢視 /projects/:projectId/users/:userId/profile
  4. 建立一個新使用者 /projects/:projectId/users/new

元件樹結構:

[譯] Vue Router 實戰手冊

從應用程式路由派生出的元件層次結構


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 從一個路由導航到另一個路由時,你可以傳遞隱式資料或狀態。

這在哪裡有用呢?主要是優化的時候。考慮下面的例子:

  1. 我們有兩個頁面: 詳情頁 —— /users/:userId 簡介頁 —— /users/:userId/profile
  2. 在詳情頁面裡,我們調起一個 API 請求獲取使用者資訊。並且,頁面上有一個連結幫助使用者跳轉到簡介頁面。
  3. 第二個頁面上,我們需要發起兩個 API 請求 —— 獲取使用者資訊和獲取使用者概要。
  4. 這裡的問題是 —— 當我從詳情頁面導航到簡介頁面時做了兩次一樣的 API 請求。最佳的解決方案是當我們使用者從詳情檢視頁轉變成簡介檢視頁時,把已檢索的使用者資料傳遞給下一個路由。另外,這些已檢索的資料不需要作為 URL 的一部分(就像狀態路由器一樣,傳遞一個隱式的狀態)。
  5. 如果使用者通過任何其他方式直接跳轉到簡介頁面,比如整個頁面重新整理或者從其他檢視進入,那麼在 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,
        }
    ]
}];
複製程式碼

基於樹結構配置有一些優點:

  1. 結構清晰。易於維護。
  2. 授權/保護的管理變得容易。基於 CRUD (增刪改查) 的許可權執行變得非常簡單。
  3. 比起扁平的路由列表有更可預見的路由。

使用基於樹結構配置的細微差別在於建立中間元件,它們可能只包含一個 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-behaviorlazy-loading 等。

此外,當我們使用導航保護,預路由資料獲取時,vue-router 設計了關於使用者體驗(UX)的考量。你可以使用全域性或者元件內保護,但需謹慎地使用它們,因此你應該牢記關注點分離並把路由職責從元件中移除。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章