手寫 Vue Router 源碼

Vue Router 基礎回顧

使用步驟

首先使用 vue cli 創(chuàng)建一個 Vue 項目來回顧一下 vue router 的使用。

全局安裝 vue cli。

npm i -g @vue/cli

安裝完成后檢查版本是否正常。

vue --version

然后創(chuàng)建一個演示項目。

vue create vue-router-demo

首先使用自定義選擇。Manually select features。

vue cli 會詢問一些問題。只需要選擇三項 Babel, Router, Linter。

這樣 vue cli 會幫我們創(chuàng)建 vue router 的基本代碼結(jié)構(gòu)。

進入項目并啟動項目。

npm run serve

然后就可以在瀏覽器中看到路由的效果了。

在 vue 中使用 vue router 的步驟大致有如下幾步。

  1. 路由頁面

創(chuàng)建路由對應的頁面。

默認在 views 文件夾中。

  1. 注冊路由插件

使用 Vue.use(VueRouter)來注冊路由插件。Vue.use 方法是專門用來注冊插件的,如果傳入的是函數(shù),會直接調(diào)用。如果傳入的是對象,會調(diào)用對象的 install 方法。

默認在 router/index.js 中。

  1. 創(chuàng)建路由對象

首先定義一套路由規(guī)則,路由規(guī)則是一個數(shù)組,數(shù)組中包含很多對象,每一個對象都是一個規(guī)則。對象上面會有 path 和 component 等屬性,path 代表著路徑,compoent 代表著渲染的組件。當瀏覽器中 path 發(fā)生變化時,會渲染對應的 component 到頁面中。

通過 new VueRouter 的方式創(chuàng)建對象,VueRouter 的構(gòu)造函數(shù)是一個對象。要把這個對象的 routes 屬性設置為剛剛定義的路由規(guī)則。

默認在 router/index.js 中。

  1. 注冊 router 對象

在 new Vue 時,配置對象中的 router 選項設置為上面創(chuàng)建的路由對象。

  1. 創(chuàng)建路由組件占位

在 Vue 實例指定的 el 選項對應的元素中,使用 router-view 標簽創(chuàng)建路由組件的占位。路由組件每次都會渲染到這個位置。

  1. 創(chuàng)建鏈接

使用 router-link 創(chuàng)建鏈接,通過 router-link 來改變路由。

當 Vue 實例開啟 router 選項后,實例對象會多出兩個屬性,分別是route和router。

$route 是當前的路由規(guī)則對象,里面存儲了路徑、參數(shù)等信息。

$router 是路由實例對象,里面存儲了很多路由的方法,比如 push、replace、go 等。還存儲了路由的信息,比如 mode 和 currentRoute。

動態(tài)路由

假設有一個用戶詳情頁面。我們不會給每一個用戶都創(chuàng)建一個詳情頁面,因為這個頁面是通用的,變化的只是用戶的 id。

首先添加一個路由。

:id 前面的路徑是固定的,:id 本身的意思就是接收一個 id 參數(shù)。

component 返回的是一個函數(shù),這就是路由懶加載的寫法。

也就是當這個路由被觸發(fā)時,才會渲染這個組件。當這個路由沒有被觸發(fā)時,不會渲染組件??梢蕴岣咝阅堋?/p>

// ... other code
const routes = [
  // ... other code
  {
    path: "/detail/:id",
    name: "Detail",
    component: () => import("../views/Detail.vue"),
  },
];

有了路由之后,再創(chuàng)建一個用戶頁面。

<template>
  <div>當前用戶ID:{{ $route.params.id }}</div>
</template>

<script>
export default {
  name: "Detail",
};
</script>

這就是第一種獲取動態(tài)路由參數(shù)的方式,通過路由規(guī)則,獲取參數(shù)。

但是這種方式有一個缺點,就是強制依賴route。也就是說這個組件必須使用route 才能正常工作。

可以使用另一種方式來降低這種依賴。

在路由規(guī)則中開啟 props 屬性。

// ... other code
const routes = [
  // ... other code
  {
    path: "/detail/:id",
    name: "Detail",
    props: true,
    component: () => import("../views/Detail.vue"),
  },
];

props 屬性的作用是將路由中的參數(shù)以 props 的形式傳入到組件中,這樣在組件內(nèi)就可以通過 props 獲取到參數(shù)。

<template>
  <div>當前用戶ID:{{ id }}</div>
</template>

<script>
export default {
  name: "Detail",
  props: ["id"],
};
</script>

這樣 Detail 這個組件就不是必須在路由中才可以使用,只要傳遞一個 id 屬性,它就可以被應用到任何位置。

所以更加推薦使用 props 的方式傳遞路由參數(shù)。

嵌套路由

當多個路由組件具有相同的內(nèi)容,可以把多個路由組件相同的內(nèi)容提取到一個公共的組件中。

假設首頁和詳情頁具有相同的頭部和尾部??梢蕴崛∫粋€ layout 組件,把頭部和尾部抽取到 layout 組件中,并在發(fā)生變化的位置放置一個 router view。當訪問對應的路由時,會把路由組件和 layout 組件的內(nèi)容合并輸出。

假設還有一個登錄頁面,它不是需要 layout 的,所以它也不需要嵌套路由。

編寫 layout 組件。

<template>
  <div>
    <div>
      <header>header</header>
    </div>
    <div>
      <router-view />
    </div>
    <div>
      <footer>footer</footer>
    </div>
  </div>
</template>

<script>
export default {
  name: "login",
};
</script>

<style>
header {
  width: 100%;
  background: #65b687;
  color: #39495c;
}
footer {
  width: 100%;
  background: #39495c;
  color: #65b687;
}
</style>

創(chuàng)建 Login.vue 組件。

<template>
  <div>登陸頁</div>
</template>

<script>
export default {
  name: "login",
};
</script>

修改 app.vue 中 template 代碼塊中的內(nèi)容。

<template>
  <div id="app">
    <router-view />
  </div>
</template>

修改 routes 配置。

const routes = [
  {
    path: "/login",
    name: "login",
    component: Login,
  },
  {
    path: "/",
    component: Layout,
    children: [
      {
        name: "home",
        path: "",
        component: Home,
      },
      {
        name: "detail",
        path: "detail:id",
        props: true,
        component: () => import("../views/Detail.vue"),
      },
    ],
  },
];

這樣當訪問http://localhost:8080/login時,會正常進入Login組件。

訪問http://localhost:8080/時,會首先加載/對應的Layout組件,然后再加載Home組件,并把Layout組件和Home組件的內(nèi)容進行合并。

訪問http://localhost:8080/detail/id時,也會和Home加載方式一樣,先加載Layout,再加載Detail,并把id傳遞進去。最后把兩個組件的內(nèi)容合并。

編程式導航

除了使用 router-link 進行導航以外,我們還可以使用 js 代碼的方式進行導航。

這種需求非常常見,比如點擊一個按鈕,進行邏輯判斷后再進行導航。

常用的編程式導航 API 有 4 個。分別是router.push、router.replace、router.back和router.go。

改造一下上面的三個頁面,來體驗一下這 3 個 API。

登陸頁通過點擊登陸按鈕跳轉(zhuǎn)到首頁。

<template>
  <div>
    <div>登陸頁</div>
    <button @click="push">登陸</button>
  </div>
</template>

<script>
export default {
  name: "login",
  methods: {
    push() {
      this.$router.push("/");
      // this.$router.push({ name: 'home' })
    },
  },
};
</script>

<style>
button {
  background: #39495c;
  color: #65b687;
  border-radius: 8px;
  padding: 5px 10px;
  border: none;
  outline: none;
}
</style>

首頁可以跳轉(zhuǎn)到用戶詳情頁,也可以退出,退出的話跳轉(zhuǎn)到登陸頁,并且在瀏覽器的瀏覽歷史中不保存當前頁。

<template>
  <div class="home">
    <div>Home Page.</div>
    <button @click="goToDetail">查看用戶8的資料</button>
    <button @click="exit">退出</button>
  </div>
</template>

<script>
export default {
  name: "Home",
  methods: {
    goToDetail() {
      this.$router.push("/detail/8");
      // this.$router.push({ name: 'detail', params: { id: 8 } })
    },
    exit() {
      // this.$router.replace('/login')
      this.$router.replace({ name: "login" });
    },
  },
};
</script>

用戶詳情頁中可以回退到上一頁,也可以回退兩頁。

<template>
  <div>
    <div>當前用戶ID:{{ id }}</div>
    <button @click="back">返回</button>
    <button @click="backTwo">回退兩頁</button>
  </div>
</template>

<script>
export default {
  name: "Detail",
  props: ["id"],
  methods: {
    back() {
      this.$router.back();
    },
    backTwo() {
      this.$router.go(-2);
    },
  },
};
</script>

其中 push 方法和 replace 方法的用法基本上是一致的,都可以通過傳遞一個字符串或者傳遞一個對象來實現(xiàn)頁面導航。如果傳遞字符串的話,就表示頁面的路徑。傳遞對象的話,會根據(jù)對象的 name 屬性去尋找對應的頁面組件。如果需要傳遞參數(shù),可以拼接字符串,也可以在對象中設置 params 屬性。兩者不同之處在于 replace 方法不會在瀏覽器中記錄當前頁面的瀏覽歷史,而 push 方法會記錄。

back 方法是回到上一頁,它的用法最簡單,不需要傳遞參數(shù)。

go 方法可以傳遞一個 number 類型的參數(shù),表示是前進還是后退。負數(shù)表示后退,正數(shù)表示前進,0 的話刷新當前頁面。

Hash 模式和 History 模式

Vue Router 中的路由模式有兩種,分別是 hash 模式和 history 模式,hash 模式會在導航欄地址中具有一個井號(#),history 模式則沒有。

兩種模式都是由客戶端來處理的,使用 JavaScript 來監(jiān)聽路由的變化,根據(jù)不同的 URL 渲染不同的內(nèi)容。如果需要服務端內(nèi)容的話,使用 Ajax 來獲取。

表現(xiàn)形式的區(qū)別

從美觀上來看,history 模式更加美觀。

hash 模式的 URL。會附帶一個井號(#),如果傳遞參數(shù)的話,還需要問號(?)。

http://localhost:8080/#/user?id=15753140

history 模式的鏈接。

http://localhost:8080/user/15753140

但是 history 不可以直接使用,需要服務端配置支持。

原理的區(qū)別

Hash 模式是基于錨點以及 onhashchange 事件。

History 模式是基于 HTML5 中的 History API。history 對象具有 pushState 和 replaceState 兩個方法。但是需要注意 pushState 方法需要 IE10 以后才可以支持。在 IE10 之前的瀏覽器,只能使用 Hash 模式。

history 對象還有一個 push 方法,可以改變導航欄的地址,并向服務器發(fā)送請求。pushState 方法可以只改變導航欄地址,而不向服務器發(fā)送請求。

History 模式

History 需要服務器的支持。

原因是單頁面應用中,只有一個 index.html。而在單頁面應用正常通過點擊進入 http://localhost:8080/login 不會有問題。但是當刷新瀏覽器時,就會請求服務器,而服務器上不存在這個 URL 對應的資源,就會返回 404。

所以在服務器上應該配置除了靜態(tài)資源以外的所有請求都返回 index.html。

下面演示一下頁面匹配不到的效果。

在 views 目錄下創(chuàng)建 404.vue。

<template>
  <div class="about">
    <h1>404</h1>
  </div>
</template>

在 routes 中添加 404 的路由。

const routes = [
  // other code
  {
    path: "*",
    name: "404",
    component: () => import("../views/404.vue"),
  },
];

在 Home.vue 中添加一個不存在的鏈接。

<router-link to="/video">video</router-link>

然后啟動服務器,進入首頁,點擊 video 鏈接,就會跳轉(zhuǎn)到 404 頁面。

這是一個我們預期想要的效果。在 vue cli 默認的服務器中,已經(jīng)幫我們配置好了。但是在我們實際部署的時候,仍然需要自己去配置服務器。

node.js 服務器配置

首先使用 nodejs 開發(fā)一個服務器。

創(chuàng)建一個 server 文件夾,并初始化項目。

npm init -y

安裝項目的依賴,這里使用 express 和 connect-history-api-fallback。

express 是一個 nodejs 著名的 web 開發(fā)服務器框架。

connect-history-api-fallback 是一個處理 history 模式的模塊。

npm i express connect-history-api-fallback

創(chuàng)建并編寫 server.js 文件。

const path = require("path");
// 處理 history 模式的模塊
const history = require("connect-history-api-fallback");
const express = require("express");

const app = express();
// 注冊處理 history 模式的中間件
app.use(history());
// 注冊處理靜態(tài)資源的中間件
app.use(express.static(path.join(__dirname, "./web")));

app.listen(4000, () => {
  console.log(`
  App running at:
  - Local:   http://localhost:4000/ 
  `);
});

這里把 server 項目下根目錄的 web 文件夾設置為網(wǎng)站的根路徑。

當啟動 server.js 后,請求http://localhost:4000/的URL都會去web文件夾下找到相應的資源。

現(xiàn)在打包原來的 vue 項目。

回到 vue 項目中,運行打包命令。

npm run build

可以得到 dist 文件夾。

將 dist 目錄中的所有內(nèi)容復制到 server 項目的 web 目錄中,就完成了項目的部署。

接下來運行 server.js。

node server.js

打開瀏覽器,進入 detail 頁面(http://localhost:4000/detail/8)。刷新瀏覽器,一切正常。

如果不處理 history,就會出現(xiàn)問題。

嘗試把 app.use(history()) 注釋掉,重新啟動服務器。

同樣進入 detail 頁面,刷新瀏覽器,就會進入 express 默認的 404 頁面。原因就是刷新瀏覽器,會請求服務器。服務器在 web 目錄下找不到 detail/8 資源。如果開啟了 history 處理,服務器找不到 detail/8,就會返回 index.html,客戶端會根據(jù)當前路徑渲染組件。

nginx 服務器配置

首先安裝 nginx。

可以在 nginx 官網(wǎng)下載 nginx 的壓縮包。

http://nginx.org/en/download.html

把壓縮包解壓到不附帶中文的目錄下。

或者借助某些工具安裝,比如 brew。

brew install nginx

nginx 的命令比較簡單,常用的命令如下。

啟動

nginx

重啟

nginx -s reload

停止

nginx -s stop

壓縮包的方式安裝,nginx 的默認端口是 80,如果 80 未被占用,會正常啟動。啟動后在瀏覽器訪問http://localhost即可訪問。

brew 方式安裝的 nginx 默認端口是 8080。

把 vue 項目 dist 文件夾中的內(nèi)容拷貝到 nginx 文件夾中的 html 文件夾中。html 文件夾就是 nginx 的默認文件夾。

部署成功后,在瀏覽器中訪問項目,發(fā)現(xiàn)會存在同樣的刷新 404 問題。

這時就需要在 nginx 的配置文件中添加對應的配置。

nginx 的默認配置在 conf/nginx.conf 中。

在 nginx.conf 中找到監(jiān)聽 80 的那個 server 模塊,在從中找到 location /的位置。

添加 try_files 配置。

location / {
  root   html;
  index  index.html index.htm;
  # $uri 是 nginx 的變量,就是當前這次請求的路徑
  # try files 會嘗試在這個路徑下尋找資源,如果找不到,會繼續(xù)朝下一個尋找
  # $uri/ 的意思是在路徑目錄下尋找 index.html 或 index.htm
  # 最后都找不到的話,返回 index.html
  try_files $uri $uri/ /index.html;
}

修改完配置文件后,nginx 需要重啟。

nginx -s reload

重啟后在瀏覽器中操作,一切正常。

模擬實現(xiàn) Vue Router

由于 history 和 hash 模式的實現(xiàn)很像,這里直接使用 history 模式進行模擬。

實現(xiàn)原理回顧

現(xiàn)在再次回顧一下 vue router 的工作原理。

vue router 是前端路由,當路徑切換時,在瀏覽器端判斷當前路徑并加載當前路徑對應的組件。

hash 模式:

  • URL 中#后面的內(nèi)容作為路徑地址
  • 監(jiān)聽 hashchange 事件
  • 根據(jù)當前路由地址找到對應組件重新渲染

history 模式:

  • 通過 history.pushState() 方法改變地址欄
  • 監(jiān)聽 popstate 事件
  • 根據(jù)當前路由地址找到對應組件重新渲染

分析

通過觀察 vue router 的使用,可以快速推斷出 vue router 是如何實現(xiàn)的。

下面是一個簡單的使用流程。

// 注冊插件
Vue.use(VueRouter);
// 創(chuàng)建路由對象
const router = new VueRouter({
  routes: [{ name: "home", path: "/", component: homeComponent }],
});
// 創(chuàng)建 Vue 實例,注冊 router 對象
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");

首先是執(zhí)行 Vue.use 注冊 VueRouter。

Vue.use 方法是用于注冊插件的,Vue 的強大,得益于它的插件機制。像 VueRouter、Vuex 和一些組件,都是使用插件機制實現(xiàn)的。

Vue.use 方法可以接受 1 個函數(shù)或者 1 個對象,如果是函數(shù),則直接調(diào)用該函數(shù),如果是對象,則調(diào)用對象上的 install 方法。這里的 VueRouter 是一個對象。

接下來創(chuàng)建了一個 router 實例,那么 VueRouter 應該是一個構(gòu)造函數(shù)或者是一個類。

結(jié)合上面的分析,可以得知,VueRouter 是一個具有 install 方法的類。

VueRouter 的構(gòu)造函數(shù)是一個對象,構(gòu)造參數(shù)對象會有一個 routes 屬性,記錄了路由的配置信息。

最后在創(chuàng)建 Vue 實例的構(gòu)造參數(shù)對象中傳入了 router 對象。

可以通過 UML 類圖來描述 VueRouter 這個類。

VueRouterUML

UML 類圖包含 3 個部分。

最上面是類的名字,第二部分是類的實例屬性,第三部分是類的方法,其中加號(+)表示原型方法、下劃線(_)表示靜態(tài)方法。

屬性
  • options:對象,用于存儲構(gòu)造函數(shù)中傳入的對象。
  • data:對象,具有 current 屬性,用于記錄當前路由的地址。這個對象是響應式的。
  • routeMap:對象,用于記錄路由地址和組件的對應關(guān)系。
方法
  • constructor:構(gòu)造方法。
  • Install:Vue 插件機制約定的靜態(tài)方法。
  • init:初始化函數(shù),用于組合 initRouteMap、initComponents 和 initEvent。
  • initRouteMap:解析 options 中的 routes,并將規(guī)則設置到 routeMap 上面。
  • initComponents:創(chuàng)建 router-link 和 router-view 組件。
  • initEvent:監(jiān)聽 data.current 變化,切換視圖。

install

使用 vue cli 創(chuàng)建一個新的項目,配置選項中選擇 babel、vue router、eslint,以便用于我們測試。

當使用 Vue.use()時,會首先調(diào)用 install,所以先實現(xiàn) install。

首先要分析,install 中要實現(xiàn)哪幾件事情。

  1. Vue 是否已經(jīng)安裝了該插件,如果已安裝,那么就不需要再次重復安裝。
  2. 把 Vue 的構(gòu)造函數(shù)存儲到全局變量中。因為在后面 VueRouter 的實例方法中會用到 Vue 構(gòu)造函數(shù)中的方法,比如創(chuàng)建 router-link、router-view 等組件時,需要調(diào)用 Vue.components。
  3. 將創(chuàng)建 Vue 實例時傳入的 VueRouter 實例對象注入到所有的 Vue 實例上。this.$router 就是在這個地方被注入到 Vue 實例上的。

在 src 目錄下創(chuàng)建 vue-router 目錄,并在其中創(chuàng)建 index.js 文件。

let _Vue = null;

export default class VueRouter {
  static install(Vue) {
    // 1. 判斷當前插件是否已安裝
    if (VueRouter.install.installed) {
      return;
    }
    VueRouter.install.installed = true;
    // 2. 把 Vue 構(gòu)造函數(shù)存儲到全局變量中
    _Vue = Vue;
    // 3. 把創(chuàng)建 Vue 實例時傳入的 router 對象注入到所有 Vue 實例上
    // 混入
    _Vue.mixin({
      beforeCreate() {
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router;
        }
      },
    });
  }
}

第一步比較簡單,記錄一個是否被插件,相比于全局變量,更好的方式就是在插件本身的 install 方法上添加一個 installed 屬性。如果已安裝,直接返回。未安裝,把 installed 設置為 true,繼續(xù)執(zhí)行邏輯。

第二步非常簡單,只需要給全局的_Vue 賦值就可以了。

第三步比較難,因為在這里我們并不知道什么時候會調(diào)用 new Vue,所以也獲取不到構(gòu)造參數(shù)中的 router 對象。這時可以借助混入來解決這個問題。在 mixin 方法中傳入的對象具有 beforeCreate 方法,這個是 new Vue 時的鉤子函數(shù),該函數(shù)中的 this 指向的就是 Vue 實例,所以在這里可以將 VueRouter 實例注入到所有的 Vue 實例上。由于每個組件也是一個 Vue 實例,所以還需要區(qū)分是 Vue 實例還是組件,不然原型擴展的邏輯會被執(zhí)行很多次。具體通過 this.$options 是否具備 router 屬性來判斷,因為只有 Vue 實例才會具有 router 屬性,組件是沒有的。

構(gòu)造函數(shù)

接下來實現(xiàn)構(gòu)造函數(shù),構(gòu)造函數(shù)的邏輯比較簡單。

創(chuàng)建了三個實例屬性。options 用來存儲構(gòu)造參數(shù);routerMap 就是一個鍵值對對象,屬性名就是路由地址,屬性值就是組件;data 是一個響應式對象,具有一個 current 屬性,用于記錄當前的路由地址??梢酝ㄟ^_Vue.observable 來創(chuàng)建響應式對象。

export default class VueRouter {
  // other code
  constructor(options) {
    this.options = options;
    this.routeMap = {};
    this.data = _Vue.observable({
      current: "/",
    });
  }
}

initRouteMap

該函數(shù)的作用是將構(gòu)造函數(shù)參數(shù) options 中的 routes 屬性轉(zhuǎn)換為鍵值對的形式存儲到 routeMap 上。

export default class VueRouter {
  // other code
  initRouteMap() {
    this.options.routes.forEach((route) => {
      this.routeMap[route.path] = route.component;
    });
  }
}

router-link

接下來實現(xiàn) initComponents,這個方法主要是注冊 router-link 和 router-view 這兩個組件。

initComponents 方法接收 1 個 Vue 構(gòu)造方法作為參數(shù),傳入?yún)?shù)的目的是為了減少方法和外部的依賴。

router-link 組件會接收一個字符串類型的參數(shù) to,就是一個鏈接。router-link 本身會轉(zhuǎn)換成 a 標簽,而 router-link 的內(nèi)容也會被渲染到 a 標簽內(nèi)。

export default class VueRouter {
  // other code
  initComponents(Vue) {
    Vue.component("router-link", {
      props: {
        to: String,
      },
      template: `<a :href="to"><slot></slot></a>`,
    });
  }
}

創(chuàng)建 init 函數(shù),這個函數(shù)將 initRouteMap 和 initComponents 包裝起來,方便使用。

然后在創(chuàng)建 Vue 實例時調(diào)用 init 方法,創(chuàng)建 router-link 組件。

export default class VueRouter {
  // other code
  static install(Vue) {
    if (VueRouter.install.installed) {
      return;
    }
    VueRouter.install.installed = true;
    _Vue = Vue;
    _Vue.mixin({
      beforeCreate() {
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router;
          // 在這里調(diào)用 init
          this.$options.router.init();
        }
      },
    });
  }

  init() {
    initRouteMap();
    initComponents();
  }
}

現(xiàn)在就可以去測試了。

將 src/router/index.js 的 vue-router 替換為我們自己寫的 vue router。

// import VueRouter from 'vue-router'
import VueRouter from "../../vue-router/index";

啟動項目。

npm run serve

打開瀏覽器,會發(fā)現(xiàn)頁面上一片空白,但是控制臺會得到兩個錯誤。

第一個錯誤是:

vue.runtime.esm.js?2b0e:619 [Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

這個錯誤的意思是目前使用的是運行時版本的 Vue,模板編譯器不可用。可以使用預編譯將模板編譯成渲染函數(shù),或者使用編譯版本的 Vue。

Vue 的構(gòu)建版本:

  • 運行時版:不支持 template 模板,需要打包的時候提前編譯。

  • 完整版:包含運行時和編譯器,體積比運行時版本大 10k 左右,程序運行的時候把模板轉(zhuǎn)換成 render 函數(shù)。

第二個錯誤是 router-view 組件未定義,因為現(xiàn)在還沒有處理 router-view,可以忽略。

在 VueCLI 中使用完整版 Vue

Vue Cli 創(chuàng)建的項目,默認使用運行時版本的 Vue,因為它的效率更高。

如果要修改 Vue Cli 項目的配置,需要在項目根目錄下創(chuàng)建 vue.config.js 文件,這個文件使用 CommonJS 規(guī)范導出一個模塊。

將 runtimeCompiler 設置為 true 就可以使用完整版 Vue,默認情況下這個選項是 false。

module.exports = {
  runtimeCompiler: true,
};

然后重新啟動項目,之前碰到的第一個問題就得到了解決。

但是完整版本的 Vue 體積會大 10k,而且是運行時編譯,消耗性能,不建議使用。

運行時版本 Vue render 方法

運行時版本的 Vue 不包含編譯器,所以也不支持 template 選項。而編譯器的作用就是將 template 選項轉(zhuǎn)換為 render 函數(shù)。

我們在編寫.vue 文件時,在不開啟 runtimeCompiler 時也不會編寫 render 函數(shù)。這時因為 Vue Cli 中配置的 webpack 會在代碼編譯打包階段將 vue 文件中的 template 轉(zhuǎn)換為 render 函數(shù),也就是預編譯。而我們編寫的 js 文件,是沒有進行這種預編譯的。所以要在運行時版本的 Vue 中需要使用 render 函數(shù)。

首先刪除掉 vue.config.js。

修改 initComponents 函數(shù)。

export default class VueRouter {
  // other code
  initComponents(Vue) {
    Vue.component("router-link", {
      props: {
        to: String,
      },
      // template: `<a :href="to"><slot></slot></a>`
      render(h) {
        return h(
          "a",
          {
            attrs: {
              href: this.to,
            },
          },
          [this.$slots.default]
        );
      },
    });
  }
}

render 函數(shù)接收一個 h 函數(shù),h 函數(shù)的作用是創(chuàng)建虛擬 DOM,最終 render 將返回虛擬 DOM。

h 函數(shù)的用法有很多種,具體可參考官方文檔:https://cn.vuejs.org/v2/guide/render-function.html

重新啟動項目,符合預期。

router-view

router-view 組件類似于 slot 組件,提供一個占位符的作用。根據(jù)不同的路由地址,獲取到不同的路由組件,并渲染到 router-view 的位置。

export default class VueRouter {
  // other code
  initComponents(Vue) {
    // other code
    const self = this;
    Vue.component("router-view", {
      render(h) {
        const component = self.routeMap[self.data.current];
        return h(component);
      },
    });
  }
}

這樣就完成了 router-view 組件。

但是現(xiàn)在去嘗試點擊超鏈接,發(fā)現(xiàn)并不能正常跳轉(zhuǎn)。原因是因為 a 標簽會默認請求服務器,導致頁面刷新。

所以需要阻止 a 標簽默認請求服務器的行為,并使用 histor.pushState 方法改變導航欄的 URL,改變的 URL 要保存到 this.data.current 中。因為 this.data 是響應式數(shù)據(jù)。

修改 router-link 組件的邏輯。

export default class VueRouter {
  // other code
  initComponents(Vue) {
    // other code
    Vue.component("router-link", {
      props: {
        to: String,
      },
      render(h) {
        return h(
          "a",
          {
            attrs: {
              href: this.to,
            },
            on: {
              click: this.clickHandler,
            },
          },
          [this.$slots.default]
        );
      },
      methods: {
        clickHandler(e) {
          history.pushState({}, "", this.to);
          this.$router.data.current = this.to;
          e.preventDefault();
        },
      },
    });
  }
}

再次回到項目中,運行項目。點擊 a 標簽,就可以正常刷新頁面內(nèi)容了。

initEvent

雖然上面已經(jīng)實現(xiàn)了所有的功能,但是還存在一個小問題。

點擊瀏覽器左上角的前進、后退按鈕時,只是修改了地址欄的 URL,頁面并沒有隨之發(fā)生改變。

解決這個問題也很簡單。

實現(xiàn)思路是監(jiān)聽 popstate 方法,并在其中將 this.data.current 的值設置為當前導航欄的 URL。由于 this.data 是響應式的數(shù)據(jù),所以當 this.data 發(fā)生變化時,所有用到 this.data 的組件都會被重新渲染。

export default class VueRouter {
  // other code
  init() {
    // other code
    this.initEvent();
  }
  initEvent(Vue) {
    window.addEventListener("popstate", () => {
      this.data.current = window.location.pathname;
    });
  }
}

這樣就解決了導航欄前進后退不刷新組件的小問題。

源碼

至此,history 模式的 vue router 簡單實現(xiàn)已經(jīng)完成。

附全部源碼:

let _Vue = null;

export default class VueRouter {
  static install(Vue) {
    // 1. 判斷當前插件是否已經(jīng)被安裝
    if (VueRouter.install.installed) {
      return;
    }
    VueRouter.install.installed = true;
    // 2. 把 Vue 構(gòu)造函數(shù)記錄到全局變量
    _Vue = Vue;
    // 3. 把創(chuàng)建的 Vue 實例時所傳入的 router 對象注入到 Vue 實例上
    // 混入
    _Vue.mixin({
      beforeCreate() {
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router;
          this.$options.router.init();
        }
      },
    });
  }

  constructor(options) {
    this.options = options;
    this.routeMap = {};
    this.data = _Vue.observable({
      current: "/",
    });
  }

  init() {
    this.initRouterMap();
    this.initComponents(_Vue);
    this.initEvent();
  }

  initRouterMap() {
    // 遍歷所有的路由規(guī)則,把路由規(guī)則解析成鍵值對的形式 存儲到 routerMap 中
    this.options.routes.forEach((route) => {
      this.routeMap[route.path] = route.component;
    });
  }

  initComponents(Vue) {
    Vue.component("router-link", {
      props: {
        to: String,
      },
      // template: `
      //   <a :href="to">
      //     <slot></slot>
      //   </a>
      // `,
      render(h) {
        return h(
          "a",
          {
            attrs: {
              href: this.to,
            },
            on: {
              click: this.clickHandler,
            },
          },
          [this.$slots.default]
        );
      },
      methods: {
        clickHandler(e) {
          history.pushState({}, "", this.to);
          this.$router.data.current = this.to;
          e.preventDefault();
        },
      },
    });

    const self = this;
    Vue.component("router-view", {
      render(h) {
        console.log(self);
        const component = self.routeMap[self.data.current];
        return h(component);
      },
    });
  }

  initEvent() {
    window.addEventListener("popstate", () => {
      this.data.current = window.location.pathname;
    });
  }
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容