基于react的hash路由简易实现

3,310 阅读8分钟

基于react的hash路由简易实现

背景介绍

在SPA项目中,如果展示页面繁多,根据前端工程化理论中组件化思想,我们通常会将不同的页面,按照功能,职责等进行划分。 这个时候,每个页面,就需要一个相应的路由去对应。

现在react社区中已经有react-router这个成熟的轮子,我们可以直接引入并使用。

但具体hash路由是如何实现的,我们现在就来探讨一下...

在路由设计中,我们用到了设计模式中的 单例模式 观察者模式 中介者模式

所以如果有该设计模式基础或实践的小伙伴,会更容易看懂一些...

开始

首先我们要了解一下,监听路由改变,其实是监听2个事件并操作回调,进行渲染:

  1. 页面加载事件(window.onload)
  2. 页面url中hash部分改变事件(window.onhashchange)

原生html代码编写

<!doctype html>
<html>
	<head>
		<title>hash-router</title>
	</head>
	<body>
		<ul>
			<li><a href="#/">home</a></li>
			<li><a href="#/login">
					login
					<ul>
						<li><a href="#/login/login1">login1</a></li>
						<li><a href="#/login/login2">login2</a></li>
						<li><a href="#/login/login3">login3</a></li>
					</ul>
				</a></li>
			<li><a href="#/abort">abort</a></li>
		</ul>
		<div id = "content"></div>
	</body>
</html>

在这里 我们指定了一系列路由,包括"#/","#/login"等。然后,我们就要针对页面onload事件和点击每个a标签之后hash改变事件来进行处理了。

原生js代码编写

创建一个HashRouter类

<script type = 'text/javascript'>
	"use strict"
	class HashRouter{
		constructor(){
			this.routers = {},
			this.init();
		}
	}
</script>
  1. 在这段代码中 我们创建了一个HashRouter类,并且指定了一个routers空对象来存储路由信息。
  2. 将来在这个routers对象中,我们存储路由的名称及回调,如routers = { "#/login" : [ callback1, callback2 ] }。键名为hash路由名称,键值为数组,数组内为跳转到该路由所要执行的回调方法。
  3. 现在,我们来执行init方法,当HashRouter类被创建的时候,我们需要执行的一些初始化方法

当HashRouter类被创建时,执行的初始化方法

<script type = 'text/javascript'>
	"use strict"
	class HashRouter{
		constructor(){
			this.routers = {},
			this.init();
		}
		trigger(){
			//取出当前url中的hash部分,并过滤掉参数
			let hash = window.location.hash && window.location.hash.split('?')[0];
			//在routers中,找到相应的hash,并执行已保存在其中的回调方法
			if(this.routers[hash] && this.routers[hash].length > 0){
				for(let i = 0 ; i < this.routers[hash].length ; i++){
					this.routers[hash][i]();
				}	
			}
		}
		init(){
			window.addEventListener('load', () => this.trigger(), false);
			window.addEventListener('hashchange', () => this.trigger(), false);
		}
	}
</script>

兼听了页面加载时和hash改变时事件,并针对改变事件做回调处理,即trigger方法

需要抛出api,让开发者添加监听事件回调

在上一步我们进行了初始化,监听了页面hash改变的事件并做相应的处理。但是,我们需要执行的回调方法,需要开发人员手动添加才行。

<script type = 'text/javascript'>
	"use strict"
	class HashRouter{
		constructor(){
			this.routers = {},
			this.init();
		}
		listen(path, callback){
			//如果routers中已经存在该hash,则为它pushcallback方法,否则新建一个相应数组,并push回调方法
			if(!this.routers[path]){
				this.routers[path] = [];	
			}
			this.routers[path].push(callback);
		}
		trigger(){
			//取出当前url中的hash部分,并过滤掉参数
			let hash = window.location.hash && window.location.hash.split('?')[0];
			//在routers中,找到相应的hash,并执行已保存在其中的回调方法
			if(this.routers[hash] && this.routers[hash].length > 0){
				for(let i = 0 ; i < this.routers[hash].length ; i++){
					this.routers[hash][i]();
				}	
			}
		}
		init(){
			window.addEventListener('load', () => this.trigger(), false);
			window.addEventListener('hashchange', () => this.trigger(), false);
		}
	}
</script>

原生代码实操

<!doctype html>
<html>
	<head>
		<title>hash-router</title>
	</head>
	<body>
		<ul>
			<li><a href="#/">home</a></li>
			<li><a href="#/login">
					login
					<ul>
						<li><a href="#/login/login1">login1</a></li>
						<li><a href="#/login/login2">login2</a></li>
						<li><a href="#/login/login3">login3</a></li>
					</ul>
				</a></li>
			<li><a href="#/abort">abort</a></li>
		</ul>
		<div id = "content"></div>
	</body>
	<script type = 'text/javascript'>
		"use strict"
		let getById = (id) => document.getElementById(id);
	</script>
	<script type = 'text/javascript'>
		"use strict"
		class HashRouter{
			constructor(){
				this.routers = {},
				this.init();
			}
			listen(path, callback){
				//如果routers中已经存在该hash,则为它push回调方法,否则新建一个相应数组,并push回调方法
				if(!this.routers[path]){
					this.routers[path] = [];	
				}
				this.routers[path].push(callback);
			}
			trigger(){
				//取出当前url中的hash部分,并过滤掉参数
				let hash = window.location.hash && window.location.hash.split('?')[0];
				//在routers中,找到相应的hash,并执行已保存在其中的回调方法
				if(this.routers[hash] && this.routers[hash].length > 0){
					for(let i = 0 ; i < this.routers[hash].length ; i++){
						this.routers[hash][i]();
					}	
				}
			}
			init(){
				window.addEventListener('load', () => this.trigger(), false);
				window.addEventListener('hashchange', () => this.trigger(), false);
			}
		}
	</script>
	<script type = 'text/javascript'>
		"use strict"
		let router = new HashRouter();
		router.listen('#/',() => { getById('content').innerHTML = 'home' });
		router.listen('#/login',() => { console.info('login-') });
		router.listen('#/login',() => { console.info('login+') });
		router.listen('#/login',() => { getById('content').innerHTML = 'login' });
		router.listen('#/login/login1',() => { getById('content').innerHTML = 'login1' });
		router.listen('#/login/login2',() => { getById('content').innerHTML = 'login2' });
		router.listen('#/login/login3',() => { getById('content').innerHTML = 'login3' });
		router.listen('#/abort',() => { getById('content').innerHTML = 'abort' });
	</script>
</html>

可以看到,在页面点击时,url改变,并执行了我们根据执行路径所注册的回调方法。此时,一个简易原生的hash-router我们便实现了

对原生代码进行react封装

由于es6模块化横空出世,我们可以封装存储变量,抛出方法,操作内部变量,不会变量污染。 如果放在以前,我们需要通过闭包来实现类似功能

实际开发人员需要编写的业务代码部分(文件引用路径根据个人项目不同可以修改)

import React from 'react';
import ReactDOM from 'react-dom';
import { Router , Route } from './component/router/router';
import MainLayout from './page/main-layout/MainLayout';
import Menu from './page/menu/Menu';
import Login from './page/login/Login';
import Abort from './page/abort/Abort';

const Routers = [
	{ path : '#/login' , menu : 'login' , component : () => Login({ bread : '#/abort' }) },
	{ path : '#/abort' , menu : 'abort' , component : <Abort bread = { '#/login' }/> },
]

export default function RouterPage(){
	return(
		<Router>
			<Menu routers = { Routers }/>
			<MainLayout>
				{ Routers && Routers.map((item, index) => (<Route path = { item.path } key = { index } component = { item.component }/>)) }
			</MainLayout>
		</Router>
	)
}

ReactDOM.render(<RouterPage/>, document.getElementById('easy-router'));

下面我们来逐个分析{ Router , Route , dispatchRouter , listenAll , listenPath }中的每一个作用

Router

其实是个很简单的方法,相当于包装了一层div(未做容错处理,意思一下)

export function Router(props){
	let { className , style , children } = props;
	return(
		<div className = { className } style = { style }>
			{ children }
		</div>
	)
}
Router.defaultProps = {
	className : '',
	style : {},
	children : []
}

Route

这个算是路由组件中的核心组件了,我们需要在Route这个方法中对监听进行封装

这里是业务代码:

//路由组件定义
const Routers = [
	{ path : '#/login' , menu : 'login' , component : () => Login({ bread : '#/abort' }) },
	{ path : '#/abort' , menu : 'abort' , component : <Abort bread = { '#/login' }/> },
]

//return方法中Route的应用
<MainLayout>
	{ Routers && Routers.map((item, index) => (<Route path = { item.path } component = { item.component }/>)) }
</MainLayout>

注意,在我们写Route标签并传参的同时,其实path和component已经注册到变量中保存下来了,当路径条件成立时,就会渲染已经相应存在的component(这里的component可以是ReactDOM或者function,具体参见Routers数组中定义)。话不多说,show me code:

export class Route extends React.Component{
	constructor(props){
		super(props);
		this.state = {
			renderItem : [],            //需要渲染的内容
		}		
	}
	componentDidMount(){
		this.initRouter();
		window.addEventListener('load', () => this.changeReturn());
		window.addEventListener('hashchange', () => this.changeReturn());	
	}
	
	initRouter(){
		let { path , component } = this.props;
		//保证相同的路由只有一个组件 不能重复
		if(routers[path]){
			throw new Error(`router error:${path} has existed`);
		}else{
			routers[path] = component;	
		}
	}
	
	changeReturn(){
		//防止url中有参数干扰监听
		let hash = window.location.hash.split('?')[0];
		let { path } = this.props;
		//当前路由是选中路由时加载当前组件
		if(hash === path && routers[hash]){
			let renderItem;
			//如果组件参数的方法 则执行并push
			//如果组件参数是ReactDOM 则直接渲染
			if(typeof routers[hash] === 'function'){
				renderItem = (routers[hash]())	
			}else{
				renderItem = (routers[hash])		
			}
			//当前路由是选中路由 渲染组件并执行回调
			this.setState({ renderItem }, () => callListen(hash));
		}else{
			//当前路由非选中路由 清空当前组件
			this.setState({ renderItem : [] });
		}		
	}
	
	render(){
		let { renderItem } = this.state;
		return(
		 	<React.Fragment>
				{ renderItem }
			</React.Fragment>
		)
	}
}

listenPath和listenAll

这两个方法就是开发者需要添加的监听方法。我们先来介绍如何使用,通过使用方法,再进行实现。

listenPath('#/login', () => {
	console.info('listenPath login1')
})

listenAll((pathname) => {
	if(pathname === '#/login'){
		console.info('listenAll login')
	}
})
  1. listenPath方法,有两个参数,第一个是指定路由,第二个是该路由执行回调
  2. listenAll方法,只有一个方法参数,但是这个方法会将当前hash路径pathname返回,我们可以根据pathname来进行处理
  3. 这两种方法只是在表现上做了区别,本质还是一样的
  4. 注意this.setState({ renderItem }, () => callListen(hash)),页面路由为该组件指定path的时候,需要执行回调

具体实现:

/**
 * 路由监听事件对象,分为2部分
 * all array 存listenAll监听方法中注册的数组
 * path array 存listenPath监听方法中注册的hash路径名称和相应的回调方法
 * { all : [callback1, callback2] , path : [{ path: path1, callback : callback1 }, { path : path2, callback : callback2 }] }
 */
let listenEvents = {};	

/**
 * 执行回调
 * 将listenEvents中的all数组中的方法全部执行
 * 遍历listenEvents中的path,找出与当前hash对应的path并执行callback(可能存在多个path相同的情况,因为开发人员可以多次注册)
 */
function callListen(path){
	if(listenEvents && listenEvents.all && listenEvents.all.length > 0){
		let listenArr = listenEvents.all;
		for(let i = 0 ; i < listenArr.length ; i++){
			listenArr[i](path);
		}	
	}
	if(listenEvents && listenEvents.path && listenEvents.path.length > 0){
		let listenArr = listenEvents.path;
		for(let i = 0 ; i < listenArr.length ; i++){
			if(path === listenArr[i].path){
				listenArr[i].callback();	
			}
		}
	}
}

/**
 * 监听路由并触发回调事件
 * @params
 * path string 需要监听的路由
 * callback function 需要执行的回调
 */
export function listenPath(path, callback){
	if(!listenEvents.path){
		listenEvents.path = [];		
	}		
	listenEvents.path.push({ path, callback });
}

/**
 * 监听路由改变并触发所有回调事件(会将当前路由传出)
 * @params
 * callback function 需要执行的回调
 */
export function listenAll(callback){
	if(!listenEvents.all){
		listenEvents.all = [];	
	}
	listenEvents.all.push(callback);
}

dispatchRouter

简单的一个路由跳转方法,没怎么深思,网上应该有更大佬的方法,这里就是意思一下

//路由跳转
export function dispatchRouter({ path = '' , query = {} }){
	let queryStr = [];
	for(let i in query){
		queryStr.push(`${i}=${query[i]}`);
	}
	window.location.href = `${path}?${queryStr.join('&')}`;
}

成品展示

Router组件

import React from 'react';

//当前路由组件存储对象{ path : [ component1, component2 ] }
let routers = {};

/**
 * 路由监听事件对象,分为2部分
 * all array 存listenAll监听方法中注册的数组
 * path array 存listenPath监听方法中注册的hash路径名称和相应的回调方法
 * { all : [callback1, callback2] , path : [{ path: path1, callback : callback1 }, { path : path2, callback : callback2 }] }
 */
let listenEvents = {};	

export function Router(props){
	let { className , style , children } = props;
	return(
		<div className = { className } style = { style }>
			{ children }
		</div>
	)
}

Router.defaultProps = {
	className : '',
	style : {},
	children : []
}

/*
 * 执行所有的路由事件
 * @parmas
 * path string 当前的hash路径
 */
function callListen(path){
	if(listenEvents && listenEvents.all && listenEvents.all.length > 0){
		let listenArr = listenEvents.all;
		for(let i = 0 ; i < listenArr.length ; i++){
			listenArr[i](path);
		}	
	}
	if(listenEvents && listenEvents.path && listenEvents.path.length > 0){
		let listenArr = listenEvents.path;
		for(let i = 0 ; i < listenArr.length ; i++){
			if(path === listenArr[i].path){
				listenArr[i].callback();	
			}
		}
	}
}

//路由监听路由并加载相应组件
export class Route extends React.Component{
	constructor(props){
		super(props);
		this.state = {
			renderItem : [],				//需要渲染的内容
		}		
	}
	componentDidMount(){
		this.initRouter();
		window.addEventListener('load', () => this.changeReturn());
		window.addEventListener('hashchange', () => this.changeReturn());	
	}
	
	initRouter(){
		let { path , component } = this.props;
		//保证相同的路由只有一个组件 不能重复
		if(routers[path]){
			throw new Error(`router error:${path} has existed`);
		}else{
			routers[path] = component;	
		}
	}
	
	changeReturn(){
		//防止url中有参数干扰监听
		let hash = window.location.hash.split('?')[0];
		let { path } = this.props;
		//当前路由是选中路由时加载当前组件
		if(hash === path && routers[hash]){
			let renderItem;
			//如果组件参数的方法 则执行并push
			//如果组件参数是ReactDOM 则直接渲染
			if(typeof routers[hash] === 'function'){
				renderItem = (routers[hash]())	
			}else{
				renderItem = (routers[hash])		
			}
			//当前路由是选中路由 渲染组件并执行回调
			this.setState({ renderItem }, () => callListen(hash));
		}else{
			//当前路由非选中路由 清空当前组件
			this.setState({ renderItem : [] });
		}		
	}
	
	render(){
		let { renderItem } = this.state;
		return(
		 	<React.Fragment>
				{ renderItem }
			</React.Fragment>
		)
	}
}

//路由跳转
export function dispatchRouter({ path = '' , query = {} }){
	let queryStr = [];
	for(let i in query){
		queryStr.push(`${i}=${query[i]}`);
	}
	window.location.href = `${path}?${queryStr.join('&')}`;
}

/**
 * 监听路由并触发回调事件
 * @params
 * path string 需要监听的路由
 * callback function 需要执行的回调
 */
export function listenPath(path, callback){
	if(!listenEvents.path){
		listenEvents.path = [];		
	}		
	listenEvents.path.push({ path, callback });
}

/**
 * 监听路由改变并触发所有回调事件(会将当前路由传出)
 * @params
 * callback function 需要执行的回调
 */
export function listenAll(callback){
	if(!listenEvents.all){
		listenEvents.all = [];	
	}
	listenEvents.all.push(callback);
}

业务代码

代码结构

这里简单写了个结构

index.html

<!doctype html>
<html>
	<head>
		<title>简易路由</title>	
	</head>
	<body>
		<div id = 'easy-router'></div>
	</body>
</html>

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Router , Route } from './component/router/router';
import MainLayout from './page/main-layout/MainLayout';
import Menu from './page/menu/Menu';
import Login from './page/login/Login';
import Abort from './page/abort/Abort';

const Routers = [
	{ path : '#/login' , menu : 'login' , component : () => Login({ bread : '#/abort' }) },
	{ path : '#/abort' , menu : 'abort' , component : <Abort bread = { '#/login' }/> },
]

export default function RouterPage(){
    return(
		<Router>
			<Menu routers = { Routers }/>
			<MainLayout>
				{ Routers && Routers.map((item, index) => (<Route path = { item.path } component = { item.component }/>)) }
			</MainLayout>
		</Router>
	)
}

ReactDOM.render(<RouterPage/>, document.getElementById('easy-router'));

MainLayout.js

import React from 'react';

export default function MainLayout({ children }){
	return(
		<div>
			{ children }
		</div>
	)
}

Menu.js

import React from 'react';

export default function Menu({ routers }){
	return(
		<div>
			{ routers && routers.map((item, index) => {
				let { path , menu , component } = item;
				return(
					<div key = { path } onClick = {() => { window.location.href = path }}><a>{ menu }</a></div>
				)
			}) }
		</div>
	)
}

Login.js

import React from 'react';
import { listenAll , listenPath } from '../../component/router/router';

listenPath('#/login', () => {
	console.info('listenPath login1')
})

listenAll((pathname) => {
	if(pathname === '#/login'){
		console.info('listenAll login')
	}
})

export default function Login(props = {}){
	let { bread } = props;
	return (
		<div style = {{ width : '100%' , height : '100%' , border : '1px solid #5d9cec' }}>
			<div>login</div>
			{ bread && <a href = { bread }>bread{ bread }</a> }
		</div>
	)
}

Abort.js

import React from 'react';
import { listenAll , listenPath } from '../../component/router/router';

listenPath('#/abort', () => {
	console.info('listenPath abort')
})

listenAll((pathname) => {
	if(pathname === '#/abort'){
		console.info('listenAll abort')
	}
})

export default function Abort(props = {}){
	let { bread } = props;
	return (
		<div style = {{ width : '100%' , height : '100%' , border : '1px solid #5d9cec' }}>
			<div>abort</div>
			{ bread && <a href = { bread }>bread{ bread }</a> }
		</div>
	)
}

大功告成!