在SPA应用这么流行的当下,我们看到每个MV**框架都会有自己的路由插件用于实现单页应用的路由设置与监控,并且提供了一系列的生命周期来方便用户,那么到底它们都是怎么做到的呢,今天我会放上自己写的一个小轮子的思路,因为是轮子所以大家就别吐槽这么多啦~更多的是记录这个思路
当我第一次自学vue并用于实际工作中时,还不知道element-ui、mint-ui等组件库,于是选择了直接用于移动端的weui作为组件库,当我看到微信提供的文档时,我发现他们的demo页面是单页的,这就激起了我的兴趣了,于是看了下他们的路由源码,是基于zepto封装的一个基于location.hash的一个前端路由监控,所以在这里我的轮子也是这个原理,废话不多说,回到主题,最近又去完整的看了下,并自己写了一个轮子,weui中它实现了基于hashchange事件的监控及完整的配置构造及改变,但是只做了一级路由,于是经过我的改造,我简单实现了:
- 路由信息的灵活自主配置与入口的绑定
- 支持嵌套路由
支持组件化思想
下面我们按照思路一步步给出代码:
1、首先,我们知道轮子的原理是基于 location对象 的 hashchange 事件来实现的,所以我们要围绕着它来写,其次我们需要一个路由对应一个组件来实现页面的重绘(没组件轮子就没意义啦对吧),所以我们先随意给一个路由配置并写出对应组件中的html。
// 路由配置及各组件片段
var routeConfig = [
{
url: '',
templateId: 'home'
},
{
url: 'home',
templateId: 'home',
children:[
{
url: 'page1',
templateId: 'page4',
children: [
{
url: 'page2',
templateId: 'page2'
}
]
},
{
url: 'page2',
templateId: 'page5'
},
{
url: 'page3',
templateId: 'page6'
}
]
},
{
url: 'page1',
templateId: 'page1'
},
{
url: 'page2',
templateId: 'page2'
},
{
url: 'page3',
templateId: 'page3'
}
];
// html 片段
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<style media="screen">
.home a{
text-decoration: none;
color: blue;
}
</style>
<body>
<h1>H5前端路由hash变化demo</h1>
<div class="container" id=container>
</div>
<script type="text/html" id="home">
<div class="home">
<a href="#/page1">go page1</a>
<a href="#/page2">go page2</a>
<a href="#/page3">go page3</a>
<a href="#/home/page1">go child-page1</a>
<a href="#/home/page2">go child-page2</a>
<a href="#/home/page3">go child-page3</a>
<a href="#/home/page1/page2">go child-page1-page2</a>
</div>
</script>
<script type="text/html" id="page1">
<div>
<h2>页面1</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page2">
<div>
<h2>页面2</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page3">
<div>
<h2>页面3</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page4">
<div>
<h2>嵌套页面1</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page5">
<div>
<h2>嵌套页面2</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page6">
<div>
<h2>嵌套页面3</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
</body>
</html>
由上述配置中可看出,我们这个demo是有二重与三重嵌套路由的,并且每个路由我们都给它一个指定组件去对应展示的html
2、配置信息大家看起来一定很熟悉,没错,就跟vue-route的配置一样简单明了,由此配置我们看出我们只要实现由对应 url 的 templateId 去匹配对应组件然后构造dom到页面上就可以完成了,所以下面我们都围绕这个来写,首先写轮子的构造函数
// 构建路由函数
function Route(selector, routeConfig) {
// 过滤 location.hash
String.prototype.filterUrl = function () {
return this.replace(/#\//, '');
}
// 读取路由配置文件
this.config = routeConfig;
// 读取需要绑定的根选择器
this.root = document.querySelector(selector);
// 初始化最初的路由以及监控路由的 hash 值变化
this.init = function () {
var that = this;
that._init()
window.addEventListener('hashchange', function () {
that._init()
});
}
}
为了代码的清晰明了,我们将公用方法写到构造函数内,将实现过程需要的函数写入原型内,令其实现完整的面向对象及函数式编程的思路,我们定义2个参数用于配置,一是根选择器,二就是需要读取的路由配置,由于是利用 location.hash 来实现,所以我们还需在其中写一个过滤#的方法,再监控 hashchange 事件即可,构造函数中的是主函数的运行,具体实现过程我们将其放到了原型中,因为这些方法只是用于内部实现,所以我们将其与主函数分离。
3、具体实现过程及思路
Route.prototype = {
// 内部的初始化,用于重构各个组件的dom结构,最终利用文档树绘制到绑定的选择器中
_init: function () {
var that = this,
root = this.root,
urlArr = location.hash.filterUrl().split('/'), // 获取路由并抽象为数组,数组长度为后续节点树的总深度,对应下标为深度层数
fragTree = document.createDocumentFragment();
// 利用闭包保存每次遍历的深度为 1 的树,并每次遍历找到结果后,将下一次的深度为 1 的树保存
function appendTreeNode (configArr, rootNode) {
var tree = configArr;
var currTreeNode = rootNode;
return function (url) {
for (var i = 0, len = tree.length; i < len; i++) {
if (tree[i].url === url) {
// 添加节点到文档碎片中
var node = that._parseDom(tree[i].templateId)
currTreeNode.appendChild(node);
// 将下一次需要寻找的深度为1的树重新赋值
tree = tree[i].children || null ;
currTreeNode = node;
break;
}
}
}
}
// 初始化页面元素结构,再重新构建文档碎片
root.innerHTML = '';
var multiWayTree = appendTreeNode(that.config, fragTree);
for (var i = 0, len = urlArr.length; i < len; i++) {
multiWayTree(urlArr[i]);
}
root.appendChild(fragTree);
},
// 用于寻找对应组件模块
_find: function (id) {
return document.querySelector('script[type="text/html"][id=' + id + ']')
},
// 将模板的 innerHTML 抽象为 dom
_parseDom: function (templateId) {
var node = document.createElement('div');
node.innerHTML = this._find(templateId).innerHTML;
if (node.childElementCount !== 1) {
throw new Error('template component must have a node and only one node as the root node.')
}
return node.firstElementChild;
}
}
虽然我已经注释的比较详细,但是有几点需要说明
_init
方法用于初始化页面需要重绘的组件,它会利用hashchange事件读取当前页面的url,并将其抽象为数组的结构,那么这个数组的长度即为路由树(或者说后续的DOM树)的深度,下标对应着相应的深度层数,eg:#/home/page1/page2
=>['home', 'page1', 'page2']
home 即为根组件,page1为home的嵌套组件,page2为page1的嵌套组件;- 这里由于是小轮子,所以我没做模块的缓存处理机制,只是简单的每次url的hash变化后将页面innerHTML置空及重绘,大家如有兴趣可以自己去做;
- 我们看到路由配置及dom树实际上为一个多叉树,那么难点显而易见,就是要将抽象的url深度数组精确遍历并找到对应的组件匹配,最后组成文档碎片丢到根选择器下,按照正常的遍历思路这里是行不通的,为什么呢?因为每次遍历的路由配置数组是不一样的,你很难确定这棵多叉树下的位移轨迹究竟是哪条,所以我这里通过闭包保存每次寻找到的树的轨迹,并保存下一次需要遍历的深度为1的多叉树,这样我就不用去考虑要多少次嵌套循环了,而是每次只用遍历深度为1的多叉树,后面我会放简图来解释;
- text/html 这种方式是拿不到其中html的伪标签的,如果大家需要拿到dom元素可以换成template标签实现,所以我们这里会创建一个div将其添加至其下再返回第一个子节点,这样我们就完整的保证每个组件绘制的纯净度,我们不会为你添加任何其他标签,每个组件需要一个根标签来包裹的原因也很简单,方便你自己控制,最重要的是为嵌套路由的便利铺路,否则如果你没有根标签,那么你在深度为3的dom树中,你这个组件的自我深度为第二层,那么第三层的组件我就没办法帮你嵌套了,因为我不知道嵌套到哪,这是最重要的原因,我想这也是为什么vue、react都会这么要求的原因。
- parseDom 这个简单方法实现组件的纯净度,要感谢 @liuqing_1 同学的想法,一开始我自己都陷进了自己的思路,还去用template标签拿到伪元素去appendChild了,后面换成了它这种更简单的思路。
下面放上多叉树解释图
上图红线为url为 home/page1/page2 的情况,由这个多叉树我们就可看出,将整个过程精确渲染需要至少经过3次遍历寻址,并要动态改变渲染的节点,由此可看出这里复杂点就是,寻址的次数与遍历次数有关,但是我们无法得知会有多复杂的路由,甚至5层嵌套都不是不可能?所以我们不能进行简单的嵌套循环与递归解决,简单来说就是原本每次深度像下一层,都要多一次嵌套循环,那么我们假设深度为 x,那么时间复杂度即为 O(n^x)
,但由于我这里引进了闭包来保存每层遍历的结果,所以就将算法的时间复杂度变为了 O(n*x)
,利用闭包的效率和理由在此就显而易见了,就是将算法的时间复杂度由 O(n^x) => O(n*x)
。
轮子非常简单,所以很多功能并没有去考虑,主要是提供一个思路给大家,其中写的不到位的地方,还请大家谅解,欢迎探讨其他前端路由的解决方案,最后放上完成轮子及demo实现↓
// 完整示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<style media="screen">
.home a{
text-decoration: none;
color: blue;
}
</style>
<body>
<h1>H5前端路由hash变化demo</h1>
<div class="container" id=container>
</div>
<script type="text/html" id="home">
<div class="home">
<a href="#/page1">go page1</a>
<a href="#/page2">go page2</a>
<a href="#/page3">go page3</a>
<a href="#/home/page1">go child-page1</a>
<a href="#/home/page2">go child-page2</a>
<a href="#/home/page3">go child-page3</a>
<a href="#/home/page1/page2">go child-page1-page2</a>
</div>
</script>
<script type="text/html" id="page1">
<div>
<h2>页面1</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page2">
<div>
<h2>页面2</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page3">
<div>
<h2>页面3</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page4">
<div>
<h2>嵌套页面1</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page5">
<div>
<h2>嵌套页面2</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/html" id="page6">
<div>
<h2>嵌套页面3</h2>
<a href="#/home">Go Home!</a>
</div>
</script>
<script type="text/javascript">
// 构建路由函数
function Route(selector, routeConfig) {
// 过滤 location.hash
String.prototype.filterUrl = function () {
return this.replace(/#\//, '');
}
// 读取路由配置文件
this.config = routeConfig;
// 读取需要绑定的根选择器
this.root = document.querySelector(selector);
// 初始化最初的路由以及监控路由的 hash 值变化
this.init = function () {
var that = this;
that._init()
window.addEventListener('hashchange', function () {
that._init()
});
}
}
Route.prototype = {
// 内部的初始化,用于重构各个组件的dom结构,最终利用文档树绘制到绑定的选择器中
_init: function () {
var that = this,
root = this.root,
urlArr = location.hash.filterUrl().split('/'), // 获取路由并抽象为数组,数组长度为后续节点树的总深度,对应下标为深度层数
fragTree = document.createDocumentFragment();
// 利用闭包保存每次遍历的深度为 1 的树,并每次遍历找到结果后,将下一次的深度为 1 的树保存
function appendTreeNode (configArr, rootNode) {
var tree = configArr;
var currTreeNode = rootNode;
return function (url) {
for (var i = 0, len = tree.length; i < len; i++) {
if (tree[i].url === url) {
// 添加节点到文档碎片中
var node = that._parseDom(tree[i].templateId)
currTreeNode.appendChild(node);
// 将下一次需要寻找的深度为1的树重新赋值
tree = tree[i].children || null ;
currTreeNode = node;
break;
}
}
}
}
// 初始化页面元素结构,再重新构建文档碎片
root.innerHTML = '';
var multiWayTree = appendTreeNode(that.config, fragTree);
for (var i = 0, len = urlArr.length; i < len; i++) {
multiWayTree(urlArr[i]);
}
root.appendChild(fragTree);
},
// 用于寻找对应组件模块
_find: function (id) {
return document.querySelector('script[type="text/html"][id=' + id + ']')
},
// 将模板的 innerHTML 抽象为 dom
_parseDom: function (templateId) {
var node = document.createElement('div');
node.innerHTML = this._find(templateId).innerHTML;
if (node.childElementCount !== 1) {
throw new Error('template component must have a node and only one node as the root node.')
}
return node.firstElementChild;
}
}
// 程序主体
window.onload = function () {
var routeConfig = [
{
url: '',
templateId: 'home'
},
{
url: 'home',
templateId: 'home',
children:[
{
url: 'page1',
templateId: 'page4',
children: [
{
url: 'page2',
templateId: 'page2'
}
]
},
{
url: 'page2',
templateId: 'page5'
},
{
url: 'page3',
templateId: 'page6'
}
]
},
{
url: 'page1',
templateId: 'page1'
},
{
url: 'page2',
templateId: 'page2'
},
{
url: 'page3',
templateId: 'page3'
}
];
var router = new Route('#container', routeConfig); // arg1: 绑定的元素选择器, arg2: 路由配置
router.init();
}
</script>
</body>
</html>