前言
Vue 是一個漸進式的框架,這意味著你可以只使用 Vue 的核心庫來開發,但是當你在開發一個完整的業務項目時,路由是一個必不可少的部分
在曾經的前端領域中,一直都使用的是服務端渲染的模式,即用戶輸入 url 后,瀏覽器向服務器請求這個 url 對應的HTML,服務器返回 HTML給前端,前端再展示,然后當需要瀏覽別的頁面時,需要點擊 a 標簽再向服務器發送一個請求,服務器就會再發給你目標頁面的 HTML
這樣會暴露一些缺點:
每次跳轉都向服務器請求,會增加服務器的壓力
每次跳轉都會刷新頁面導致跳轉過程中會有一瞬間的白屏,用戶體驗不是非常好
由于是服務端渲染,受到 XSS 的攻擊可能性也較高
在 MVVM 框架興起的同時,越來越多的開發者傾向于使用前端渲染的模式,服務端返回固定 JS 文件給前端,瀏覽器執行 JS 文件再渲染出整個頁面,而在路由方面,前端會維護一個路由的層級樹,當輸入 url 后,不再向后端請求 HTML,而是去這個層級樹中找到對應頁面的 JS 文件并執行,從而渲染出新的頁面,整個過程是純前端控制的,所以也被稱為前端路由
而 vue-router 作為 Vue 的路由庫,它是怎么實現路由地址和組件之間的轉換的呢,這篇文章中,我將會帶大家深入 vue- router 的源碼,解密 vue-router API 背后的原理
文中的源碼截圖只保留核心邏輯 完整源碼地址
需要了解一些 Vue 的公共函數(mixins,install,defineReactive)
vue-router 版本:3.0.2
vue-router的使用方法
我們從 vue-router 的使用方法說起,當使用 vue-router 時,一般會分為3步
引入 vue-router,調用 Vue.use(Router)
實例化 router 對象,傳入一個路由層級表 routes
在 main.js 中給根實例傳入 router 對象
注冊 vue-router 插件
當我們調用 Vue.use(Router)時會執行插件的注冊流程
圖1:
(刪除了部分和入口無關的邏輯)
所有的 Vue 插件都會暴露一個 install
方法,當執行 Vue.use 時,實質上 Vue 會執行插件的 install
方法
混入全局鉤子
了解過 Vue 響應式原理的朋友可以發現,vue-router 會通過 Vue.mixin 的方法全局混入 beforeCreate,destroyed 2個鉤子,因為是全局混入的,所以之后所有的根實例和組件實例都會有這2個生命周期鉤子
當根實例被實例化時,混入的 beforeCreate 第一次被執行,因為我們在 new Vue 時傳入了 router 對象,它會被 Vue 作為 $options 的屬性,所以會執行到 true 的邏輯,這里的核心在于 init
方法,它會初始化整個 vue-router 我們之后詳解,另外將傳入的 router 對象變成一個響應式對象,這個我們也之后討論
除開根實例,其余所有的組件實例都會執行 false 的邏輯,它會給組件實例定義一個 _routerRoot 屬性,因為 Vue 生成組件時是從上到下的,所以所有組件實例的 _routerRoot 屬性都指向根實例
之后執行 registerInstance
這個也放到后面討論
定義 $router,$route 屬性
隨后 Vue 在原型上定義了 $router,$route 2個對象,攔截 get 方法指向 _routerRoot.router,從上面一章可以發現,實質上指向的就是根實例的 router 對象,即日常開發中調用的 this.$router 最終都會指向根實例上的 router 對象
定義全局組件
最后通過 Vue.component 方法注冊了2個全局組件,這樣我們可以在任何地方直接使用<router-view>和<router-link>組件
實例化 vue-router
通常使用 vue-router 時,會在 router.js 中通過 new Router 的形式生成一個 router 的實例,并傳入一個路由的層級表 routes 數組
圖2:
隨后我們找到源碼中的 vue-router 類
圖3:
整個 vue-router 實例化的過程核心就做了2件事
創建路由的映射表
根據傳入的 mode 屬性實例化不同的 history 路由實例
創建路由的映射表
圖中第四行會執行到 createMatcher
方法,返回一個對象,包含 match
和 addRoutes
這2個方法,這2個方法是 vue-router 中比較重要的函數,之后我們會分析它們的作用,在這之前先看一下 createMatcher
函數中的 createRouteMap
函數
圖4:
而 createRouteMap
這個函數就是用來創建路由的映射表的,它是一個記錄所有信息(路由記錄)的對象,將傳入的 routes 數組進行一系列處理,生成 pathList,pathMap,nameMap 3張路由映射表
圖5:
createRouteMap
內部會遍歷 routes 數組,執行 addRouteRecord
方法來為**每一個數組的每個元素(route 對象)創建記錄,并儲存在這3個路由映射表中
圖6:
addRouteRecord
會將每個 route 對象轉換為一個路由記錄并保存在之前聲明的3個路由映射表中,通過源代碼發現,路由記錄(record 對象)非常詳細的記錄了 route 對象的很多屬性
path:路由的完整路徑
regex:匹配到當前 route 對象的正則
components:route 對象的組件(因為 vue-router 中有命名視圖,所以會默認放在 default 屬性下,instances 同理)
instances: route 對象對應的 vm 實例
name:route 對象的名字
parent:route 對象的父級路由記錄
matchAs:路由別名
redirect:路由重定向
beforeEnter:組件級別的路由鉤子
meta:路由元信息
props:路由跳轉時的傳參
在創建路由記錄前,會使用 normalizedPath
規范化 route 對象的路徑,如果傳入的 route 對象含有父級 route 對象,會將父級 route 對象的 path 拼上當前的 path
圖7:
例如圖2中的 comp1Child 這個 route 對象,它的 path 最終會變成
"/comp1" + "comp1Child" => "/comp1/com1Child"
而最終會生成的路由記錄是這樣的
圖8:
隨后因為 route 可能含有 children 屬性,即含有子的 route 對象組成的數組,所以需要進行遞歸的遍歷,然后將 record 對象放入這3個路由映射表中,而這3個路由映射表的區別在于
pathList:數組,保存了 route 對象的路徑
pathMap:對象,保存了所有 route 對象對應的 record 對象
nameMap:對象,保存了所有含有name屬性的 route 對象對應的 record 對象
圖2中的路由對應的3張路由映射表如下:
pathList:
pathMap:
nameMap:
可以看到 pathMap 和 nameMap 是一樣的,因為圖2中的路由都有 name 屬性,如果某個路由沒有 name 屬性,則只會在 pathMap 中存在
對比保存了所有 route 對象的 routes 數組和這3個路由映射表,我們可以發現:routes 對象是一個遞歸的樹形結構,而路由映射表是一個扁平的一維結構,通過路由映射表里的 parent 屬性來維護父子關系
動態添加路由的 addRoutes 函數
在創建完路由映射表后,會向外暴露一個動態添加路由的 API addRoutes
圖10:
它的原理其實很簡單,就是接受一個 route 對象,并且把它轉換成 record 對象,然后合并到之前生成的路由映射表中,所以我們可以在外部調用 router.addRoutes 動態注冊路由
返回 $route 對象的 match 函數
createMatcher
返回的第二個函數是 match
,match
函數會返回一個 route 對象
圖11:
之前說的 route 是針對 new Router 時傳入的 routes 數組的每個元素,而 $route 是最終返回作為 Vue.prototype.$route 使用的對象,在 flow 語言中,route 的類型是 RouteConfig,而 $route 的類型是 Route,具體接口的定義可以查看源代碼,雖然在源碼中兩者變量名都是 route,但我下文會使用 $route 來區分通過 this.$route 返回 route 對象
圖12:
routes :
$route :
前者表示的是路由的一些基礎配置項,而后者是真正經過 vue-router 處理后表示當前路由的對象
每次路由跳轉的時候都會執行這個 match
函數生成一個 $route 對象,具體什么時候會觸發 match
放到下篇中講,這章先分析 match
函數是如何最終生成一個真正的 $route 對象的
生成 loaction 對象
match
函數首先會執行 normalizeLocation
函數,它是一個輔助函數,會將調用 router.push / router.replace 時跳轉的路由地址轉為一個 location 對象
那什么是 location 對象? MDN 上是這么解釋的
Location
接口表示其鏈接到的對象的位置(URL)。所做的修改反映在與之相關的對象上。Document
和Window
接口都有這樣一個鏈接的Location,分別通過Document.location
和Window.location
訪問。
通俗的來說就是用一個對象來描述當前 url 的一些信息。當我們在地址欄中輸入 www.baidu.com
,按 F12 打開控制臺,輸入 loaction 就能展示出當前地址的一些信息
圖13:
vue-router 在 location 接口的基礎上做了一些增強,添加了 name,path,hash 等 vue-router 特有的屬性
舉個例子,當調用 router.push({name:"comp1"})
使用 name 的形式進行路由跳轉時,返回的 loaction 對象就會有一個 name 屬性,當 name 存在時,會走到圖11中的 true 邏輯,從之前 createMatcher
生成的 nameMap 路由映射表中找到對應 name 的路由記錄 record 對象,最終會執行 _createRoute
這個方法
而調用 router.push("/comp1")
使用路徑的形式進行路由跳轉,同樣也會返回一個 location 對象,但不會有 name 屬性,走圖11的 false 邏輯,從另外2個路由映射表 pathMap,pathList 中找到對應的路由記錄,最終也會執行 _createRoute
這個方法
可見無論使用 name 跳轉還是使用 path 跳轉,最終都會執行 _createRoute
,帶下劃線的 _createRoute
是一個私有方法,它最終會調用 createRoute
生成 $route 對象
生成 $route 對象
圖14:經過對一些 query 參數的處理,最終返回 $route 對象,其中有一個 matched 屬性值得注意,它通過 formatMatch
函數生成,查看過 this.$route 返回值的朋友應該知道,matched 是一個數組,每個元素都是一個路由記錄(record)
圖15:
還記得之前在生成路由記錄的時定義的 parent 屬性嗎?它的其中一個用途就是通過不斷的向上查找父級的路由記錄,放入 matched 數組中,最終返回一個保存了當前路由記錄和所有父級數組,順序是 父 => 子
圖16:
而這個 matched 數組最終會決定觸發哪些路由組件的哪些路由守衛鉤子,關于路由鉤子部分我們放到下篇來說
生成 history 路由實例
再次回到圖3,vue-router 根據傳入參數的 mode 屬性來實例化不同的路由類(HTML5,hash,abstract),這也是官方提供給開發者的3種不同的選擇來生成路由
HTML5 路由是相對比較美觀的一種路由,和正常的 url 顯示沒有什么區別,核心依靠
pushState
和replaceState
來實現不向后端發送請求的路由跳轉,但是當用戶點擊刷新按鈕時會存在找不到頁面的情況,需要配合 nginx 來做一層轉發hash 路由是默認使用的路由,在 url 中會存在一個 # 號,核心依靠這個 # 號也就是曾經作為路由的錨點來實現不向后端發送請求的路由跳轉
abstract 路由是一種抽象路由,一般用在非瀏覽器端,維護一種抽象的路由結構,使得能夠嫁接在客戶端或者服務端等沒有 history 路由的地方
總結
當調用 Vue.use(Router) 時,會給全局的 beforeCreate,destroyed 混入2個鉤子,使得在組件初始化時能夠通過 this.$router / this.$route 訪問到根實例的 router / route 對象,同時還定義了全局組件 router-view / router-link
在實例化 vue-router 時,通過
createRouteMap
創建3個路由映射表,保存了所有路由的記錄,另外創建了match
函數用來創建 $route 對象,addRoutes
函數用來動態生成路由,這2個函數都是需要依賴路由映射表生成的vue-router 還給開發者提供了3種不同的路由模式,每個模式下的跳轉邏輯都有所差異
vue-router 定義了 match 方法用來生成 $route 對象,而什么時候會調用 match 方法還沒有分析過,另外文章開頭的 registerInstance
又是做什么的,在下篇中我會分析 vue-router 中的跳轉邏輯,包括路由守衛,vue-router 的全局組件,以及組件相關的視圖更新