一、背景
當(dāng)我們?cè)谑褂胿ue-router的時(shí)候是否會(huì)產(chǎn)生疑惑,為什么這個(gè)東西能幫助我們建立起url與頁(yè)面組件之間的映射關(guān)系?vue-router的hash模式和history模式有什么區(qū)別?以及他們是怎樣實(shí)現(xiàn)的?一直以來(lái)我對(duì)于vue-router這方面的了解都不是很深入,僅限于知道如何使用,但是隨著技術(shù)的深入,我發(fā)現(xiàn)這遠(yuǎn)遠(yuǎn)不夠。我們不應(yīng)當(dāng)停留在使用別人的工具的表面,而要去嘗試深究其原理。下面大家就跟著我一起去探索吧,由于技術(shù)深度有限,個(gè)人見解可能會(huì)不是很到位,望指出!謝謝大家!
二、相關(guān)知識(shí)背景
我們?cè)谔剿鱲ue-router之前,我覺得很有必要去了解一下vue的響應(yīng)式原理以及其他的相關(guān)概念。
1.深入響應(yīng)式原理
當(dāng)你把一個(gè)普通的 JavaScript 對(duì)象傳入 Vue 實(shí)例作為
data選項(xiàng),Vue 將遍歷此對(duì)象所有的 property,并使用Object.defineProperty把這些 property 全部轉(zhuǎn)為 getter/setter。Object.defineProperty是 ES5 中一個(gè)無(wú)法 shim 的特性,這也就是 Vue 不支持 IE8 以及更低版本瀏覽器的原因。
這些 getter/setter 對(duì)用戶來(lái)說是不可見的,但是在內(nèi)部它們讓 Vue 能夠追蹤依賴,在 property 被訪問和修改時(shí)通知變更。這里需要注意的是不同瀏覽器在控制臺(tái)打印數(shù)據(jù)對(duì)象時(shí)對(duì) getter/setter 的格式化并不同,所以建議安裝 vue-devtools 來(lái)獲取對(duì)檢查數(shù)據(jù)更加友好的用戶界面。
每個(gè)組件實(shí)例都對(duì)應(yīng)一個(gè) watcher 實(shí)例,它會(huì)在組件渲染的過程中把“接觸”過的數(shù)據(jù) property 記錄為依賴。之后當(dāng)依賴項(xiàng)的 setter 觸發(fā)時(shí),會(huì)通知 watcher,從而使它關(guān)聯(lián)的組件重新渲染。
——來(lái)源于 vue官網(wǎng)

Vue支持我們通過data參數(shù)傳遞一個(gè)JavaScript對(duì)象做為組件數(shù)據(jù),然后Vue將遍歷此對(duì)象屬性,使用Object.defineProperty方法設(shè)置描述對(duì)象,通過存取器函數(shù)可以追蹤該屬性的變更,Vue創(chuàng)建了一層Watcher層,在組件渲染的過程中把屬性記錄為依賴,之后當(dāng)依賴項(xiàng)的setter被調(diào)用時(shí),會(huì)通知Watcher重新計(jì)算,從而使它關(guān)聯(lián)的組件得以更新,如下圖:

作者:kangaroo_v
鏈接:http://www.itdecent.cn/p/7508d2a114d3
2.Vue渲染流程

從上圖中,不難發(fā)現(xiàn)一個(gè)Vue的應(yīng)用程序是如何運(yùn)行起來(lái)的,模板通過編譯生成AST,再由AST生成Vue的render函數(shù)(渲染函數(shù)),渲染函數(shù)結(jié)合數(shù)據(jù)生成Virtual DOM樹,Diff和Patch后生成新的UI。從這張圖中,可以接觸到Vue的一些主要概念:
- 模板:Vue的模板基于純HTML,基于Vue的模板語(yǔ)法,我們可以比較方便地聲明數(shù)據(jù)和UI的關(guān)系。
- AST:AST是Abstract Syntax Tree的簡(jiǎn)稱,Vue使用HTML的Parser將HTML模板解析為AST,并且對(duì)AST進(jìn)行一些優(yōu)化的標(biāo)記處理,提取最大的靜態(tài)樹,方便Virtual DOM時(shí)直接跳過Diff。
- 渲染函數(shù):渲染函數(shù)是用來(lái)生成Virtual DOM的。Vue推薦使用模板來(lái)構(gòu)建我們的應(yīng)用界面,在底層實(shí)現(xiàn)中Vue會(huì)將模板編譯成渲染函數(shù),當(dāng)然我們也可以不寫模板,直接寫渲染函數(shù),以獲得更好的控制 (這部分是我們今天主要要了解和學(xué)習(xí)的部分)。
- Virtual DOM:虛擬DOM樹,Vue的Virtual DOM Patching算法是基于Snabbdom的實(shí)現(xiàn),并在些基礎(chǔ)上作了很多的調(diào)整和改進(jìn)。
-
Watcher:每個(gè)Vue組件都有一個(gè)對(duì)應(yīng)的
watcher,這個(gè)watcher將會(huì)在組件render的時(shí)候收集組件所依賴的數(shù)據(jù),并在依賴有更新的時(shí)候,觸發(fā)組件重新渲染。你根本不需要寫shouldComponentUpdate,Vue會(huì)自動(dòng)優(yōu)化并更新要更新的UI。
上圖中,render函數(shù)可以作為一道分割線,render函數(shù)的左邊可以稱之為編譯期,將Vue的模板轉(zhuǎn)換為渲染函數(shù)。render函數(shù)的右邊是Vue的運(yùn)行時(shí),主要是基于渲染函數(shù)生成Virtual DOM樹,Diff和Patch。
相關(guān)render函數(shù)的知識(shí)也可以去看下面這位同學(xué)的博客,講得很詳細(xì)。
作者:kangaroo_v
鏈接:http://www.itdecent.cn/p/7508d2a114d3
3.路由hash和histroy
使用 URL 的 hash 來(lái)模擬一個(gè)完整的 URL,于是當(dāng) URL 改變時(shí),頁(yè)面不會(huì)重新加載。 hash(#)是URL 的錨點(diǎn),代表的是網(wǎng)頁(yè)中的一個(gè)位置,單單改變#后的部分,瀏覽器只會(huì)滾動(dòng)到相應(yīng)位置(綁定了相應(yīng)的id的dom位置),不會(huì)重新加載網(wǎng)頁(yè),也就是說hash 出現(xiàn)在 URL 中,但不會(huì)被包含在 http 請(qǐng)求中,對(duì)后端完全沒有影響,因此改變 hash 不會(huì)重新加載頁(yè)面;同時(shí)每一次改變#后的部分,都會(huì)在瀏覽器的訪問歷史中增加一個(gè)記錄,使用”后退”按鈕,就可以回到上一個(gè)位置;所以說Hash模式通過錨點(diǎn)值的改變,根據(jù)不同的值,渲染指定DOM位置的不同數(shù)據(jù)。
與hash相關(guān)的屬性和方法是,location.hash,與hashchange事件,這是實(shí)現(xiàn)路由跳轉(zhuǎn)的關(guān)鍵。
history模式充分利用了html5 history interface 中新增的 pushState() 和 replaceState() 方法。這兩個(gè)方法應(yīng)用于瀏覽器記錄棧,在當(dāng)前已有的 back、forward、go 基礎(chǔ)之上,它們提供了對(duì)歷史記錄修改的功能。只是當(dāng)它們執(zhí)行修改時(shí),雖然改變了當(dāng)前的 URL ,但瀏覽器不會(huì)立即向后端發(fā)送請(qǐng)求。不過這種模式要玩好,還需要后臺(tái)配置支持。因?yàn)槲覀兊膽?yīng)用是個(gè)單頁(yè)客戶端應(yīng)用,如果后臺(tái)沒有正確的配置,當(dāng)用戶在瀏覽器直接訪問 outsite.com/user/id 就會(huì)返回 404,這就不好看了。所以呢,你要在服務(wù)端增加一個(gè)覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態(tài)資源,則應(yīng)該返回同一個(gè) index.html 頁(yè)面,這個(gè)頁(yè)面就是你 app 依賴的頁(yè)面。
作者:前端開膛手
鏈接:https://juejin.im/post/5caf0cddf265da03474def8a
三、手工實(shí)現(xiàn)
有了以上知識(shí)的了解我們就可以來(lái)敲代碼了!
效果圖如下:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>vue-router 實(shí)現(xiàn)原理模擬</title>
<style type="text/css">
.nav {
text-align: center;
margin-top: 20px;
height: 60px;
line-height: 60px;
}
a {
margin: 0 16px;
color: #aaa;
}
a:hover {
font-size: 120%;
color: #00e;
}
</style>
</head>
<body>
<div class="nav">
<a href="#/index">index</a>
<a href="#/home">home</a>
<a href="#/otherPage">otherPage</a>
<div id="app"></div>
</div>
<script type="text/javascript" src="./index.js"></script>
</body>
</html>
JS
class Router {
constructor(config) {
this.routes = config ? config.routes : []; // 獲取路由注冊(cè)信息
this.mode = config ? config.mode : 'hash'; // 獲取路由模式 hash/history
this.currentUrl = ''; // 當(dāng)前的路徑
this.refresh = this.refresh.bind(this); // 為了事件監(jiān)聽不丟失this
window.addEventListener('load', this.refresh, false); // 頁(yè)面初始化
window.addEventListener('hashchange', this.refresh, false); // 監(jiān)聽路由變化
}
refresh() {
this.currentUrl = location.hash.slice(1) || '/'; // 獲取瀏覽器當(dāng)前路徑
const page = this.routes.find((route) => this.currentUrl.match(new RegExp('^' + route.path + '$'))) // 相應(yīng)的組件進(jìn)行掛載
page && page.component();
}
push(url) {
location.hash = url;
}
}
(function init() {
location.hash = '/index';
}())
const Index = '<p>index page</p>';
const Home = '<p>home page</p>';
const Other = '<p>other page</p>';
const Error = '<p>404 not found</p>'
const APP = document.getElementById("app");
function deleteChild() {
for(let child = APP.firstElementChild; child; child = APP.firstElementChild) {
child.remove();
}
}
const routes = [
{
path: '/index',
name: 'Index',
component: () => {
deleteChild();
const div = document.createElement('div');
div.innerHTML = Index;
APP.appendChild(div);
}
},
{
path: '/home',
name: 'Home',
component: () => {
deleteChild();
const div = document.createElement('div');
div.innerHTML = Home;
APP.appendChild(div);
}
},
{
path: '/otherPage',
name: 'OtherPage',
component: () => {
deleteChild();
const div = document.createElement('div');
div.innerHTML = Other;
APP.appendChild(div);
}
},
{
path: '.*',
name: 'default',
component: () => {
deleteChild();
const div = document.createElement('div');
div.innerHTML = Error;
APP.appendChild(div);
}
}
]
var route = new Router({ routes });
// route.push('/otherPage');
四、驗(yàn)證
為了證明vue-router的路由懶加載是通過匹配路由hash變化然后去執(zhí)行component函數(shù),我改寫了在配置vue-router中的路由的方式

這個(gè)時(shí)候我們通過在vue項(xiàng)目中的瀏覽器路由中輸入相應(yīng)的'/404'路徑是無(wú)法獲取到頁(yè)面的,而且路徑信息也不會(huì)變化,一片空白,但是我們能看到控制臺(tái)打印了如下內(nèi)容:

現(xiàn)在證實(shí)了vue-router在匹配到相應(yīng)的路徑后會(huì)去調(diào)用component這個(gè)函數(shù),而路徑信息沒有變化可能是因?yàn)闆]有相應(yīng)的組件返回,如果有相應(yīng)的組件返回,那么應(yīng)該會(huì)有一個(gè)回調(diào)函數(shù)來(lái)改變當(dāng)前匹配后的路徑。我在vue-router源碼里找了很久也沒找到這個(gè)component的調(diào)用地方,下次再找吧。這個(gè)component必須為一個(gè)組件對(duì)象或者返回一個(gè)組件對(duì)象的函數(shù),他在加載相應(yīng)的頁(yè)面時(shí)候會(huì)調(diào)用這個(gè)函數(shù),大致是這么個(gè)過程。

五、總結(jié)
history模式我還沒去模擬實(shí)現(xiàn),用空試試,這個(gè)hash模式也只是簡(jiǎn)單地弄了一下,梳理了一下,url和頁(yè)面之間的映射關(guān)系,僅僅是我的理解,可能原理大相徑庭,如有錯(cuò)誤,我再去查資料。