一、背景
当我们在使用vue-router的时候是否会产生疑惑,为什么这个东西能帮助我们建立起url与页面组件之间的映射关系?vue-router的hash模式和history模式有什么区别?以及他们是怎样实现的?一直以来我对于vue-router这方面的了解都不是很深入,仅限于知道如何使用,但是随着技术的深入,我发现这远远不够。我们不应当停留在使用别人的工具的表面,而要去尝试深究其原理。下面大家就跟着我一起去探索吧,由于技术深度有限,个人见解可能会不是很到位,望指出!谢谢大家!
二、相关知识背景
我们在探索vue-router之前,我觉得很有必要去了解一下vue的响应式原理以及其他的相关概念。
1.深入响应式原理
当你把一个普通的 JavaScript 对象传入 Vue 实例作为
data
选项,Vue 将遍历此对象所有的 property,并使用Object.defineProperty
把这些 property 全部转为 getter/setter。Object.defineProperty
是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
——来源于 vue官网
Vue支持我们通过data
参数传递一个JavaScript对象做为组件数据,然后Vue将遍历此对象属性,使用Object.defineProperty
方法设置描述对象,通过存取器函数可以追踪该属性的变更,Vue创建了一层Watcher
层,在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter
被调用时,会通知Watcher
重新计算,从而使它关联的组件得以更新,如下图:
作者:kangaroo_v
链接:https://www.jianshu.com/p/7508d2a114d3
2.Vue渲染流程
从上图中,不难发现一个Vue的应用程序是如何运行起来的,模板通过编译生成AST,再由AST生成Vue的render
函数(渲染函数),渲染函数结合数据生成Virtual DOM树,Diff和Patch后生成新的UI。从这张图中,可以接触到Vue的一些主要概念:
- 模板:Vue的模板基于纯HTML,基于Vue的模板语法,我们可以比较方便地声明数据和UI的关系。
- AST:AST是Abstract Syntax Tree的简称,Vue使用HTML的Parser将HTML模板解析为AST,并且对AST进行一些优化的标记处理,提取最大的静态树,方便Virtual DOM时直接跳过Diff。
- 渲染函数:渲染函数是用来生成Virtual DOM的。Vue推荐使用模板来构建我们的应用界面,在底层实现中Vue会将模板编译成渲染函数,当然我们也可以不写模板,直接写渲染函数,以获得更好的控制 (这部分是我们今天主要要了解和学习的部分)。
- Virtual DOM:虚拟DOM树,Vue的Virtual DOM Patching算法是基于Snabbdom的实现,并在些基础上作了很多的调整和改进。
-
Watcher:每个Vue组件都有一个对应的
watcher
,这个watcher
将会在组件render
的时候收集组件所依赖的数据,并在依赖有更新的时候,触发组件重新渲染。你根本不需要写shouldComponentUpdate
,Vue会自动优化并更新要更新的UI。
上图中,render
函数可以作为一道分割线,render
函数的左边可以称之为编译期,将Vue的模板转换为渲染函数。render
函数的右边是Vue的运行时,主要是基于渲染函数生成Virtual DOM树,Diff和Patch。
相关render函数的知识也可以去看下面这位同学的博客,讲得很详细。
作者:kangaroo_v
链接:https://www.jianshu.com/p/7508d2a114d3
3.路由hash和histroy
使用 URL 的 hash 来模拟一个完整的 URL,于是当 URL 改变时,页面不会重新加载。 hash(#)是URL 的锚点,代表的是网页中的一个位置,单单改变#后的部分,浏览器只会滚动到相应位置(绑定了相应的id的dom位置),不会重新加载网页,也就是说hash 出现在 URL 中,但不会被包含在 http 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面;同时每一次改变#后的部分,都会在浏览器的访问历史中增加一个记录,使用”后退”按钮,就可以回到上一个位置;所以说Hash模式通过锚点值的改变,根据不同的值,渲染指定DOM位置的不同数据。
与hash相关的属性和方法是,location.hash,与hashchange事件,这是实现路由跳转的关键。
history模式充分利用了html5 history interface 中新增的 pushState() 和 replaceState() 方法。这两个方法应用于浏览器记录栈,在当前已有的 back、forward、go 基础之上,它们提供了对历史记录修改的功能。只是当它们执行修改时,虽然改变了当前的 URL ,但浏览器不会立即向后端发送请求。不过这种模式要玩好,还需要后台配置支持。因为我们的应用是个单页客户端应用,如果后台没有正确的配置,当用户在浏览器直接访问 outsite.com/user/id 就会返回 404,这就不好看了。所以呢,你要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。
作者:前端开膛手
链接:https://juejin.im/post/5caf0cddf265da03474def8a
三、手工实现
有了以上知识的了解我们就可以来敲代码了!
效果图如下:
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 实现原理模拟</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 : []; // 获取路由注册信息
this.mode = config ? config.mode : 'hash'; // 获取路由模式 hash/history
this.currentUrl = ''; // 当前的路径
this.refresh = this.refresh.bind(this); // 为了事件监听不丢失this
window.addEventListener('load', this.refresh, false); // 页面初始化
window.addEventListener('hashchange', this.refresh, false); // 监听路由变化
}
refresh() {
this.currentUrl = location.hash.slice(1) || '/'; // 获取浏览器当前路径
const page = this.routes.find((route) => this.currentUrl.match(new RegExp('^' + route.path + '$'))) // 相应的组件进行挂载
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');
四、验证
为了证明vue-router的路由懒加载是通过匹配路由hash变化然后去执行component函数,我改写了在配置vue-router中的路由的方式
这个时候我们通过在vue项目中的浏览器路由中输入相应的'/404'路径是无法获取到页面的,而且路径信息也不会变化,一片空白,但是我们能看到控制台打印了如下内容:
现在证实了vue-router在匹配到相应的路径后会去调用component这个函数,而路径信息没有变化可能是因为没有相应的组件返回,如果有相应的组件返回,那么应该会有一个回调函数来改变当前匹配后的路径。我在vue-router源码里找了很久也没找到这个component的调用地方,下次再找吧。这个component必须为一个组件对象或者返回一个组件对象的函数,他在加载相应的页面时候会调用这个函数,大致是这么个过程。
五、总结
history模式我还没去模拟实现,用空试试,这个hash模式也只是简单地弄了一下,梳理了一下,url和页面之间的映射关系,仅仅是我的理解,可能原理大相径庭,如有错误,我再去查资料。