手写 Vue Router 源码
Vue Router 基础回顾
使用步骤
首先使用 vue cli 创建一个 Vue 项目来回顾一下 vue router 的使用。
全局安装 vue cli。
npm i -g @vue/cli
安装完成后检查版本是否正常。
vue --version
然后创建一个演示项目。
vue create vue-router-demo
首先使用自定义选择。Manually select features。
vue cli 会询问一些问题。只需要选择三项 Babel, Router, Linter。
这样 vue cli 会帮我们创建 vue router 的基本代码结构。
进入项目并启动项目。
npm run serve
然后就可以在浏览器中看到路由的效果了。
在 vue 中使用 vue router 的步骤大致有如下几步。
路由页面
创建路由对应的页面。
默认在 views 文件夹中。
注册路由插件
使用 Vue.use(VueRouter)来注册路由插件。Vue.use 方法是专门用来注册插件的,如果传入的是函数,会直接调用。如果传入的是对象,会调用对象的 install 方法。
默认在 router/index.js 中。
创建路由对象
首先定义一套路由规则,路由规则是一个数组,数组中包含很多对象,每一个对象都是一个规则。对象上面会有 path 和 component 等属性,path 代表着路径,compoent 代表着渲染的组件。当浏览器中 path 发生变化时,会渲染对应的 component 到页面中。
通过 new VueRouter 的方式创建对象,VueRouter 的构造函数是一个对象。要把这个对象的 routes 属性设置为刚刚定义的路由规则。
默认在 router/index.js 中。
注册 router 对象
在 new Vue 时,配置对象中的 router 选项设置为上面创建的路由对象。
创建路由组件占位
在 Vue 实例指定的 el 选项对应的元素中,使用 router-view 标签创建路由组件的占位。路由组件每次都会渲染到这个位置。
创建链接
使用 router-link 创建链接,通过 router-link 来改变路由。
当 Vue 实例开启 router 选项后,实例对象会多出两个属性,分别是router。
$route 是当前的路由规则对象,里面存储了路径、参数等信息。
$router 是路由实例对象,里面存储了很多路由的方法,比如 push、replace、go 等。还存储了路由的信息,比如 mode 和 currentRoute。
动态路由
假设有一个用户详情页面。我们不会给每一个用户都创建一个详情页面,因为这个页面是通用的,变化的只是用户的 id。
首先添加一个路由。
:id 前面的路径是固定的,:id 本身的意思就是接收一个 id 参数。
component 返回的是一个函数,这就是路由懒加载的写法。
也就是当这个路由被触发时,才会渲染这个组件。当这个路由没有被触发时,不会渲染组件。可以提高性能。
// ... other code
const routes = [
// ... other code
{
path: "/detail/:id",
name: "Detail",
component: () => import("../views/Detail.vue"),
},
];
有了路由之后,再创建一个用户页面。
header
创建 Login.vue 组件。
修改 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组件的内容进行合并。
访问http://localhost:8080/detail/id时,也会和Home加载方式一样,先加载Layout,再加载Detail,并把id传递进去。最后把两个组件的内容合并。
编程式导航
除了使用 router-link 进行导航以外,我们还可以使用 js 代码的方式进行导航。
这种需求非常常见,比如点击一个按钮,进行逻辑判断后再进行导航。
常用的编程式导航 API 有 4 个。分别是router.replace、router.go。
改造一下上面的三个页面,来体验一下这 3 个 API。
登陆页通过点击登陆按钮跳转到首页。
首页可以跳转到用户详情页,也可以退出,退出的话跳转到登陆页,并且在浏览器的浏览历史中不保存当前页。
用户详情页中可以回退到上一页,也可以回退两页。
其中 push 方法和 replace 方法的用法基本上是一致的,都可以通过传递一个字符串或者传递一个对象来实现页面导航。如果传递字符串的话,就表示页面的路径。传递对象的话,会根据对象的 name 属性去寻找对应的页面组件。如果需要传递参数,可以拼接字符串,也可以在对象中设置 params 属性。两者不同之处在于 replace 方法不会在浏览器中记录当前页面的浏览历史,而 push 方法会记录。
back 方法是回到上一页,它的用法最简单,不需要传递参数。
go 方法可以传递一个 number 类型的参数,表示是前进还是后退。负数表示后退,正数表示前进,0 的话刷新当前页面。
Hash 模式和 History 模式
Vue Router 中的路由模式有两种,分别是 hash 模式和 history 模式,hash 模式会在导航栏地址中具有一个井号(#),history 模式则没有。
两种模式都是由客户端来处理的,使用 JavaScript 来监听路由的变化,根据不同的 URL 渲染不同的内容。如果需要服务端内容的话,使用 Ajax 来获取。
表现形式的区别
从美观上来看,history 模式更加美观。
hash 模式的 URL。会附带一个井号(#),如果传递参数的话,还需要问号(?)。
http://localhost:8080/#/user?id=15753140
history 模式的链接。
http://localhost:8080/user/15753140
但是 history 不可以直接使用,需要服务端配置支持。
原理的区别
Hash 模式是基于锚点以及 onhashchange 事件。
History 模式是基于 HTML5 中的 History API。history 对象具有 pushState 和 replaceState 两个方法。但是需要注意 pushState 方法需要 IE10 以后才可以支持。在 IE10 之前的浏览器,只能使用 Hash 模式。
history 对象还有一个 push 方法,可以改变导航栏的地址,并向服务器发送请求。pushState 方法可以只改变导航栏地址,而不向服务器发送请求。
History 模式
History 需要服务器的支持。
原因是单页面应用中,只有一个 index.html。而在单页面应用正常通过点击进入 http://localhost:8080/login 不会有问题。但是当刷新浏览器时,就会请求服务器,而服务器上不存在这个 URL 对应的资源,就会返回 404。
所以在服务器上应该配置除了静态资源以外的所有请求都返回 index.html。
下面演示一下页面匹配不到的效果。
在 views 目录下创建 404.vue。
在 routes 中添加 404 的路由。
const routes = [
// other code
{
path: "*",
name: "404",
component: () => import("../views/404.vue"),
},
];
在 Home.vue 中添加一个不存在的链接。
video
然后启动服务器,进入首页,点击 video 链接,就会跳转到 404 页面。
这是一个我们预期想要的效果。在 vue cli 默认的服务器中,已经帮我们配置好了。但是在我们实际部署的时候,仍然需要自己去配置服务器。
node.js 服务器配置
首先使用 nodejs 开发一个服务器。
创建一个 server 文件夹,并初始化项目。
npm init -y
安装项目的依赖,这里使用 express 和 connect-history-api-fallback。
express 是一个 nodejs 著名的 web 开发服务器框架。
connect-history-api-fallback 是一个处理 history 模式的模块。
npm i express connect-history-api-fallback
创建并编写 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());
// 注册处理静态资源的中间件
app.use(express.static(path.join(__dirname, "./web")));
app.listen(4000, () => {
console.log(`
App running at:
- Local: http://localhost:4000/
`);
});
这里把 server 项目下根目录的 web 文件夹设置为网站的根路径。
当启动 server.js 后,请求http://localhost:4000/的URL都会去web文件夹下找到相应的资源。
现在打包原来的 vue 项目。
回到 vue 项目中,运行打包命令。
npm run build
可以得到 dist 文件夹。
将 dist 目录中的所有内容复制到 server 项目的 web 目录中,就完成了项目的部署。
接下来运行 server.js。
node server.js
打开浏览器,进入 detail 页面(http://localhost:4000/detail/8)。刷新浏览器,一切正常。
如果不处理 history,就会出现问题。
尝试把 app.use(history()) 注释掉,重新启动服务器。
同样进入 detail 页面,刷新浏览器,就会进入 express 默认的 404 页面。原因就是刷新浏览器,会请求服务器。服务器在 web 目录下找不到 detail/8 资源。如果开启了 history 处理,服务器找不到 detail/8,就会返回 index.html,客户端会根据当前路径渲染组件。
nginx 服务器配置
首先安装 nginx。
可以在 nginx 官网下载 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 文件夹中的内容拷贝到 nginx 文件夹中的 html 文件夹中。html 文件夹就是 nginx 的默认文件夹。
部署成功后,在浏览器中访问项目,发现会存在同样的刷新 404 问题。
这时就需要在 nginx 的配置文件中添加对应的配置。
nginx 的默认配置在 conf/nginx.conf 中。
在 nginx.conf 中找到监听 80 的那个 server 模块,在从中找到 location /的位置。
添加 try_files 配置。
location / {
root html;
index index.html index.htm;
# $uri 是 nginx 的变量,就是当前这次请求的路径
# try files 会尝试在这个路径下寻找资源,如果找不到,会继续朝下一个寻找
# $uri/ 的意思是在路径目录下寻找 index.html 或 index.htm
# 最后都找不到的话,返回 index.html
try_files $uri $uri/ /index.html;
}
修改完配置文件后,nginx 需要重启。
nginx -s reload
重启后在浏览器中操作,一切正常。
模拟实现 Vue Router
由于 history 和 hash 模式的实现很像,这里直接使用 history 模式进行模拟。
实现原理回顾
现在再次回顾一下 vue router 的工作原理。
vue router 是前端路由,当路径切换时,在浏览器端判断当前路径并加载当前路径对应的组件。
hash 模式:
URL 中#后面的内容作为路径地址
监听 hashchange 事件
根据当前路由地址找到对应组件重新渲染
history 模式:
通过 history.pushState() 方法改变地址栏
监听 popstate 事件
根据当前路由地址找到对应组件重新渲染
分析
通过观察 vue router 的使用,可以快速推断出 vue router 是如何实现的。
下面是一个简单的使用流程。
// 注册插件
Vue.use(VueRouter);
// 创建路由对象
const router = new VueRouter({
routes: [{ name: "home", path: "/", component: homeComponent }],
});
// 创建 Vue 实例,注册 router 对象
new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
首先是执行 Vue.use 注册 VueRouter。
Vue.use 方法是用于注册插件的,Vue 的强大,得益于它的插件机制。像 VueRouter、Vuex 和一些组件,都是使用插件机制实现的。
Vue.use 方法可以接受 1 个函数或者 1 个对象,如果是函数,则直接调用该函数,如果是对象,则调用对象上的 install 方法。这里的 VueRouter 是一个对象。
接下来创建了一个 router 实例,那么 VueRouter 应该是一个构造函数或者是一个类。
结合上面的分析,可以得知,VueRouter 是一个具有 install 方法的类。
VueRouter 的构造函数是一个对象,构造参数对象会有一个 routes 属性,记录了路由的配置信息。
最后在创建 Vue 实例的构造参数对象中传入了 router 对象。
可以通过 UML 类图来描述 VueRouter 这个类。
VueRouterUML
UML 类图包含 3 个部分。
最上面是类的名字,第二部分是类的实例属性,第三部分是类的方法,其中加号(+)表示原型方法、下划线(_)表示静态方法。
属性
options:对象,用于存储构造函数中传入的对象。
data:对象,具有 current 属性,用于记录当前路由的地址。这个对象是响应式的。
routeMap:对象,用于记录路由地址和组件的对应关系。
方法
constructor:构造方法。
Install:Vue 插件机制约定的静态方法。
init:初始化函数,用于组合 initRouteMap、initComponents 和 initEvent。
initRouteMap:解析 options 中的 routes,并将规则设置到 routeMap 上面。
initComponents:创建 router-link 和 router-view 组件。
initEvent:监听 data.current 变化,切换视图。
install
使用 vue cli 创建一个新的项目,配置选项中选择 babel、vue router、eslint,以便用于我们测试。
当使用 Vue.use()时,会首先调用 install,所以先实现 install。
首先要分析,install 中要实现哪几件事情。
Vue 是否已经安装了该插件,如果已安装,那么就不需要再次重复安装。
把 Vue 的构造函数存储到全局变量中。因为在后面 VueRouter 的实例方法中会用到 Vue 构造函数中的方法,比如创建 router-link、router-view 等组件时,需要调用 Vue.components。
将创建 Vue 实例时传入的 VueRouter 实例对象注入到所有的 Vue 实例上。this.$router 就是在这个地方被注入到 Vue 实例上的。
在 src 目录下创建 vue-router 目录,并在其中创建 index.js 文件。
let _Vue = null;
export default class VueRouter {
static install(Vue) {
// 1. 判断当前插件是否已安装
if (VueRouter.install.installed) {
return;
}
VueRouter.install.installed = true;
// 2. 把 Vue 构造函数存储到全局变量中
_Vue = Vue;
// 3. 把创建 Vue 实例时传入的 router 对象注入到所有 Vue 实例上
// 混入
_Vue.mixin({
beforeCreate() {
if (this.$options.router) {
_Vue.prototype.$router = this.$options.router;
}
},
});
}
}
第一步比较简单,记录一个是否被插件,相比于全局变量,更好的方式就是在插件本身的 install 方法上添加一个 installed 属性。如果已安装,直接返回。未安装,把 installed 设置为 true,继续执行逻辑。
第二步非常简单,只需要给全局的_Vue 赋值就可以了。
第三步比较难,因为在这里我们并不知道什么时候会调用 new Vue,所以也获取不到构造参数中的 router 对象。这时可以借助混入来解决这个问题。在 mixin 方法中传入的对象具有 beforeCreate 方法,这个是 new Vue 时的钩子函数,该函数中的 this 指向的就是 Vue 实例,所以在这里可以将 VueRouter 实例注入到所有的 Vue 实例上。由于每个组件也是一个 Vue 实例,所以还需要区分是 Vue 实例还是组件,不然原型扩展的逻辑会被执行很多次。具体通过 this.$options 是否具备 router 属性来判断,因为只有 Vue 实例才会具有 router 属性,组件是没有的。
构造函数
接下来实现构造函数,构造函数的逻辑比较简单。
创建了三个实例属性。options 用来存储构造参数;routerMap 就是一个键值对对象,属性名就是路由地址,属性值就是组件;data 是一个响应式对象,具有一个 current 属性,用于记录当前的路由地址。可以通过_Vue.observable 来创建响应式对象。
export default class VueRouter {
// other code
constructor(options) {
this.options = options;
this.routeMap = {};
this.data = _Vue.observable({
current: "/",
});
}
}
initRouteMap
该函数的作用是将构造函数参数 options 中的 routes 属性转换为键值对的形式存储到 routeMap 上。
export default class VueRouter {
// other code
initRouteMap() {
this.options.routes.forEach((route) => {
this.routeMap[route.path] = route.component;
});
}
}
router-link
接下来实现 initComponents,这个方法主要是注册 router-link 和 router-view 这两个组件。
initComponents 方法接收 1 个 Vue 构造方法作为参数,传入参数的目的是为了减少方法和外部的依赖。
router-link 组件会接收一个字符串类型的参数 to,就是一个链接。router-link 本身会转换成 a 标签,而 router-link 的内容也会被渲染到 a 标签内。
export default class VueRouter {
// other code
initComponents(Vue) {
Vue.component("router-link", {
props: {
to: String,
},
template: ` `,
});
}
}
创建 init 函数,这个函数将 initRouteMap 和 initComponents 包装起来,方便使用。
然后在创建 Vue 实例时调用 init 方法,创建 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;
// 在这里调用 init
this.$options.router.init();
}
},
});
}
init() {
initRouteMap();
initComponents();
}
}
现在就可以去测试了。
将 src/router/index.js 的 vue-router 替换为我们自己写的 vue router。
// import VueRouter from 'vue-router'
import VueRouter from "../../vue-router/index";
启动项目。
npm run serve
打开浏览器,会发现页面上一片空白,但是控制台会得到两个错误。
第一个错误是:
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,模板编译器不可用。可以使用预编译将模板编译成渲染函数,或者使用编译版本的 Vue。
Vue 的构建版本:
运行时版:不支持 template 模板,需要打包的时候提前编译。
完整版:包含运行时和编译器,体积比运行时版本大 10k 左右,程序运行的时候把模板转换成 render 函数。
第二个错误是 router-view 组件未定义,因为现在还没有处理 router-view,可以忽略。
在 VueCLI 中使用完整版 Vue
Vue Cli 创建的项目,默认使用运行时版本的 Vue,因为它的效率更高。
如果要修改 Vue Cli 项目的配置,需要在项目根目录下创建 vue.config.js 文件,这个文件使用 CommonJS 规范导出一个模块。
将 runtimeCompiler 设置为 true 就可以使用完整版 Vue,默认情况下这个选项是 false。
module.exports = {
runtimeCompiler: true,
};
然后重新启动项目,之前碰到的第一个问题就得到了解决。
但是完整版本的 Vue 体积会大 10k,而且是运行时编译,消耗性能,不建议使用。
运行时版本 Vue render 方法
运行时版本的 Vue 不包含编译器,所以也不支持 template 选项。而编译器的作用就是将 template 选项转换为 render 函数。
我们在编写.vue 文件时,在不开启 runtimeCompiler 时也不会编写 render 函数。这时因为 Vue Cli 中配置的 webpack 会在代码编译打包阶段将 vue 文件中的 template 转换为 render 函数,也就是预编译。而我们编写的 js 文件,是没有进行这种预编译的。所以要在运行时版本的 Vue 中需要使用 render 函数。
首先删除掉 vue.config.js。
修改 initComponents 函数。
export default class VueRouter {
// other code
initComponents(Vue) {
Vue.component("router-link", {
props: {
to: String,
},
// template: ` `
render(h) {
return h(
"a",
{
attrs: {
href: this.to,
},
},
[this.$slots.default]
);
},
});
}
}
render 函数接收一个 h 函数,h 函数的作用是创建虚拟 DOM,最终 render 将返回虚拟 DOM。
h 函数的用法有很多种,具体可参考官方文档:https://cn.vuejs.org/v2/guide/render-function.html
重新启动项目,符合预期。
router-view
router-view 组件类似于 slot 组件,提供一个占位符的作用。根据不同的路由地址,获取到不同的路由组件,并渲染到 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 组件。
但是现在去尝试点击超链接,发现并不能正常跳转。原因是因为 a 标签会默认请求服务器,导致页面刷新。
所以需要阻止 a 标签默认请求服务器的行为,并使用 histor.pushState 方法改变导航栏的 URL,改变的 URL 要保存到 this.data.current 中。因为 this.data 是响应式数据。
修改 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 标签,就可以正常刷新页面内容了。
initEvent
虽然上面已经实现了所有的功能,但是还存在一个小问题。
点击浏览器左上角的前进、后退按钮时,只是修改了地址栏的 URL,页面并没有随之发生改变。
解决这个问题也很简单。
实现思路是监听 popstate 方法,并在其中将 this.data.current 的值设置为当前导航栏的 URL。由于 this.data 是响应式的数据,所以当 this.data 发生变化时,所有用到 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 简单实现已经完成。
附全部源码:
let _Vue = null;
export default class VueRouter {
static install(Vue) {
// 1. 判断当前插件是否已经被安装
if (VueRouter.install.installed) {
return;
}
VueRouter.install.installed = true;
// 2. 把 Vue 构造函数记录到全局变量
_Vue = Vue;
// 3. 把创建的 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() {
// 遍历所有的路由规则,把路由规则解析成键值对的形式 存储到 routerMap 中
this.options.routes.forEach((route) => {
this.routeMap[route.path] = route.component;
});
}
initComponents(Vue) {
Vue.component("router-link", {
props: {
to: String,
},
// template: `
//
//
//
// `,
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;
});
}
}
当前用户ID:{{ $route.params.id }}
这就是第一种获取动态路由参数的方式,通过路由规则,获取参数。
但是这种方式有一个缺点,就是强制依赖route 才能正常工作。
可以使用另一种方式来降低这种依赖。
在路由规则中开启 props 属性。
// ... other code
const routes = [
// ... other code
{
path: "/detail/:id",
name: "Detail",
props: true,
component: () => import("../views/Detail.vue"),
},
];
props 属性的作用是将路由中的参数以 props 的形式传入到组件中,这样在组件内就可以通过 props 获取到参数。
当前用户ID:{{ id }}
这样 Detail 这个组件就不是必须在路由中才可以使用,只要传递一个 id 属性,它就可以被应用到任何位置。
所以更加推荐使用 props 的方式传递路由参数。
嵌套路由
当多个路由组件具有相同的内容,可以把多个路由组件相同的内容提取到一个公共的组件中。
假设首页和详情页具有相同的头部和尾部。可以提取一个 layout 组件,把头部和尾部抽取到 layout 组件中,并在发生变化的位置放置一个 router view。当访问对应的路由时,会把路由组件和 layout 组件的内容合并输出。
假设还有一个登录页面,它不是需要 layout 的,所以它也不需要嵌套路由。
编写 layout 组件。
登陆页
修改 app.vue 中 template 代码块中的内容。
登陆页
Home Page.
当前用户ID:{{ id }}
发表评论 (审核通过后显示评论):