1. 传统路由和前端路由
1.1 什么是传统路由:
传统路由也可以叫做后端路由,简单来理解,在传统的网站设计中,每个HTML文件都是一个完整的页面,涵盖了全部的HTML结构。当我们访问某一个具体的网址的时候,实际上访问的是与这个网址相对应的一个HTML文件,该文件又会加载自己依赖的资源,然后组成一个新的页面。如图所示:
时序图如下:
这样的方式更像是页面流,所有的页面相当于一个一个文档组成,前端只负责处理展示,不存在数据和管理状态的概念;然后后端直接根据数据库的一些数据按照模板进行html的拼接。数据的更新就是文档的更新,这样的模式叫做Java Server Pages(JSP)。存在的问题:
- 页面切换都需要刷新页面,会产生白屏的现象;
- 模块之间很难相互共享状态;
- 前后端不容易分离;
随着前端框架的成熟和稳定以及Ajax技术的不断发展、前后端的分离已经是当前架构开发的主流模式,所以JSP的开发模式已经辉煌不再了。
1.2 什么是SPA:
之前的JSP,的P只的是page,一个页面相当于一个文档;SPA的全称叫做single page web application,单页网页应用,越来越趋向于把页面做成一个应用。而在一个应用中,不会有路由的概念。而我们这个应用是内置在浏览器中,浏览器的地址栏的存在为一个前端单页应用提供了一个访问途径,也是各个应用的入口。
单页网页应用的出现大大提高了 WEB 应用的交互体验。在与用户的交互过程中,不再需要重新刷新页面,获取数据也是通过 Ajax 异步获取,页面显示变的更加流畅。
1.3 什么是前端路由
在保证只有一个HTML页面的前提下,为应用内的每个视图都匹配一个特殊的URL,在页面刷新、前进、后退都能够通过这个特殊的URL来实现。他的实质是,通过js动态渲染页面的内容。
时序图如下:
2. 如何实现前端路由
要实现上述的这个目标,我们需要做到两点:
- 改变浏览器中的url不会让浏览器向服务器发送资源请求;
- 可以监听到url的变化
目前有两种主流的方式:
- hash模式
- history模式
2.1 Hash模式
hash指的是url后面的#号后面的字符,如www.baidu.com/#/hello,后面的/hello就是我们设定的hash值。
利用hash模式天然可以实现前端路由:
- hash值的变化不会导致浏览器向服务器发送请求
- hash的改变会触发hashchange事件,可以支持我们去监听
- 浏览器的前进后退也能对其进行控制
核心实现逻辑:
- 通过创建一个路由对象routers,记录每个hash值对应的页面渲染方法;
- 监听hashchange事件,然后通过routers[newHash]去执行页面的渲染方法;
- 添加使用者手动注册视图的方法;
核心API:
- window.location.hash = 'new hash' // 用于设置hash值
- let hash = window.location.hash // 获取当前hash值
- window.addEventListener('hashchange', function(event) {
let newURL = event.newURL;
let oldURL = event.oldURL
}, false)
实现代码如下:
// JS
class HashRouter {
constructor() {
//用于存储不同hash值对应的回调函数
this.routers = {};
window.addEventListener('hashchange', this.load, false)
}
//用于注册每个视图
register(hash, callback = function () { }) {
this.routers[hash] = callback;
}
//用于调用不同视图的回调函数
load() {
let hash = window.location.hash.slice(1),
handler;
if (hash) {
handler = this.routers[hash];
//执行注册的回调函数
handler();
}
}
}
//HTML
<body>
<div id="nav">
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
</div>
<div id="container"></div>
</body>
<script>
let router = new HashRouter();
let container = document.getElementById('container');
//注册首页回调函数
router.registerIndex(()=> container.innerHTML = '我是首页');
//注册其他视图回到函数
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');
//加载视图
router.load();
</script>
2.2 History模式
在HTML5之前,浏览器已经有了history对象,但在早期的history中只能用于多页面的跳转:
- history.go(-1); // 后退一页
- history.forward(); // 前进一页
- history.back(); // 后退一页
HTML5版本出现之后,针对history新增了一下几个API:
- history.pushState(); // 添加新的状态到历史状态栈
- history.replaceState(); // 用新的状态代替当前状态
- history.state; // 返回当前状态对象
因为pushState和replaceState都能在改变url的同时不会刷新页面,所以在HTML5中的history具备了实现前端路由的能力。
pushState和replaceState的异同:
- 相同点:
- 两者都可以接受三个参数
- state: 一个合法的js对象,可以用在popstate事件中
- title:设置当前页面的标题,但是被大多数浏览器忽略,可用null代替
- url:任意有效的URL,用于更新浏览器的地址栏
- 两者都可以改变地址栏的url,并且不会向服务端发起请求
- 两者都可以接受三个参数
- 不同点
- pushState在保留现有历史记录的同时,将url追加到历史记录中,历史记录长度+1
- repalceState会将历史记录中的当前页面替换为传入的url,历史记录长度不变
popstate事件是当同一个文档的浏览历史,即history对象出现变化时,会触发popstate事件;pushState或者replaceState并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用go、back、forward方法时才会触发。
相较于hash路由,在hash变化时,可以通过hashchange事件来监听到页面的变化,但是在history模式中,history模式的路由变化并不会触发任何事件,所以我们很难直接监听history的路由改变。
所以我们可以换一个思路:
枚举history对象改变的情况,然后根据这些引发改变的方式进行一一手动更新:
- 点击浏览器的前进或者后退 => history对象改变 => popstate
- 点击a标签 => 阻止默认行为,使用history.pushState
- 在JS代码中触发history.pushState、replaceState => 主动更新
class HistoryRouter {
constructor() {
this.routers = {};
// 在初始化时监听popstate事件
this.bindPopState();
this.listenLink();
}
register(path, callback = function () { }) {
this.routers[path] = callback;
}
push(path) {
window.history.pushState({ path: path }, '', path);
if (this.routers[path]) {
this.routers[path]()
}
}
replace(path) {
window.history.replaceState({ path: path }, '', path);
if (this.routers[path]) {
this.routers[path]()
}
}
bindPopState() {
window.addEventListener('popstate', (e) => {
const path = e.state && e.state.path;
this.routers[path] && this.routers[path].call(this);
}, false);
}
//全局监听A链接
listenLink() {
window.addEventListener('click', (e) => {
let dom = e.target;
if (dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')) {
e.preventDefault()
console.log(11111);
this.push(dom.getAttribute('href'));
}
}, false)
}
}
2.3 两者更新对比
| 标题 | 应用主动跳转地址 | 浏览器操作(前进、后退等) | 组件更新的手段 |
|---|---|---|---|
| history模式 | history.pushState(); history.replaceState() | popstate事件 | 1. 首先注册对应路由的回调函数;在浏览器和操作和页面主动触发的时候,都会去主动调用回调函数 |
| hash模式 | window.location.hash = '/newHash'; window.location.replace('www.baidu.com/#/world') | hashchange事件 | 1. 注册对应路由函数; 2. 当hashchange事件触发时调用回调函数 |
3. 探究History库
react-router路由离不开history库,在history专注于记录路由的history状态,以及path变更之后,我们需要如何处理,在history模式下用popstate监听路由变化,在hash模式下利用hashchange监听路由变化。
3.1 react-router-dom和react-router和history库三者之间的关系
- history是react-router的核心,也是整个路由原理的核心,里面集成了popState、history.pushState等底层路由实现的原理
- react-router可以理解是react-router-dom的核心,里面封装了Router,Route, Switch等核心组件,实现了从路由实现了从路由的改变到组件的更新的核心功能,在我们的项目里面只需要一次性引入react-router-dom就可以了
- react-router-dom在react-router的基础之上,添加了用于跳转的Link组件,和history模式下的BroserRouter和Hash模式下的HashRouter组件,但是这两个组件都是用了history库中的createBrowserHistory和createHashHistory方法。
3.2 History库的实现
3.2.1 createBrowserHistory
History模式的路由运行,是从createBrowserHistory函数开始,虽然版本可能有迭代但是整体的逻辑的还是相同的,我们重点关注setState, push, handlePopState, listen方法
首先我们先从两个最常见的使用场景入手,探寻在内部的一些奥秘:
场景一:用户主动调用push方法的流程
当我们在调用一个history.push方法的时候,在内部做了哪些事情:
- 我们能够调用history.push方法,说明该方法返回了一个对象,里面含有push方法;
- 看到上述的流程图,在调用之后,会记录action为PUSH,并且生成一个新的location对象,即下一个即将跳转过去的地址;
- 将新的跳转对象传入一个跳转管理器,通过跳转管理器判断最新的地址对象是否能够跳转;
- 如果不能够跳转则直接返回;如果能够进行过跳转,则调用window.history.push修改浏览器地址,并且再次调用一次setState方法
- 在setState方法内部接受一个location和action,location即为刚才的新的地址对象,action也是之前创建的action: 'PUSH',最终将传入的对象和动作类型和方法中的history对象合并
- 最后,在合并完成之后,调用跳转管理器中的通知方法,通知各个监听器页面发生变化。
在第一个场景中,我们能够预见,在createBrowserHistory中会有以下几点:
- 该方法会返回一个history对象,内部会有一个方法属性;
- 该方法会调用一个跳转管理器;
- 会有一个push方法,里面去调用window.history.push并且调用setState方法;
- 有一个setState方法
- ...
场景二:用户通过浏览器行为刷新、前进、后退页面
第二个场景在于,用户通过浏览器的一些方法比如前进后退来修改地址栏的改变。此时我们可以看一下在createBrowserHistory中会有哪些措施。
- 当浏览器地址改变时,如果添加了时间监听事件即 history.listen(),则会在路由地址改变时触发popstate方法,否则不会触发;
- 触发popstate方法则会创建一个action为pop的一个新的location对象
- 将该对象传入到setState中去
所以在当前场景下会有一下几个方法:
- 监听浏览器地址栏的变化的方法:history.listen()
- 地址变更触发的方法popstate
所以从上面两个场景来入手我们能够看到createBrowserHistory方法中的一下关键代码:
主要逻辑:
- 通过调用createBrowserHistory创建一个history对象,来替代window.history对象;里面包含push、listen以及window.history等常用的方法
const PopStateEvent = 'popstate'
const HashChangeEvent = 'hashchange'
/* 这里简化了createBrowserHistory,列出了几个核心api及其作用 */
function createBrowserHistory(){
/* 全局history */
const globalHistory = window.history
/* 处理路由转换,记录了listens信息。 */
const transitionManager = createTransitionManager()
/* 改变location对象,通知组件更新 */
const setState = () => { /* ... */ }
/* 处理当path改变后,处理popstate变化的回调函数 */
const handlePopState = () => { /* ... */ }
/* history.push方法,改变路由,通过全局对象history.pushState改变url, 通知router触发更新,替换组件 */
const push = () => { /*...*/ }
/* 底层应用事件监听器,监听popstate事件 */
const listen=()=>{ /*...*/ }
return {
push,
listen,
action: 'POP', // POP => 浏览器 PUSH => pushState REPLACE => replaceState
location
/* .... */
}
}
核心方法:
1. 跳转管理器实例
生成过度管理器实例,支持以下几种方法:
- confirmTransitionTo 判断能否进行跳转,该版本默认支持
- notifyListener 通知监听器,已经发生跳转
- appendListener 添加监听器
2. setState:
- 合并history信息
- 通知每一个监听事件 路由已经发生变化
const setState = (nextState) => {
// 合并信息
Object.assign(history, nextState)
history.length = globalHistory.length;
transitjionManger.notifyListener(
history.location,
history.action
)
}
3. listen
- 添加自定义监听事件
- 添加成功之后,返回一个方法,可供用户自行销毁
const listen = (listener) => {
/* 添加listen */
const unlisten = transitionManager.appendListener(listener)
checkDOMListeners(1)
return () => {
checkDOMListeners(-1)
unlisten()
}
}
4. checkDOMListeners
- 允许用户通过传参来绑定或者解绑popstate事件,当路由发生改变时,调用处理函数hanslePopState方法
const checkDOMListeners = (delta) => {
listenerCount += delta
if (listenerCount === 1) {
addEventListener(window, PopStateEvent, handlePopState)
if (needsHashChangeListener)
addEventListener(window, HashChangeEvent, handleHashChange)
} else if (listenerCount === 0) {
removeEventListener(window, PopStateEvent, handlePopState)
if (needsHashChangeListener)
removeEventListener(window, HashChangeEvent, handleHashChange)
}
}
5. push
- 生成一个最新的location对象
- 通过window.history.pushState来改变路由器当前路由
- 通过setState方法通知路由变更,并传递当前的location对象
- 这次的url是由history.pushState产生的,不会触发popState方法,需要手动setState,触发组件更新
const push = (path, state) => {
const action = 'PUSH'
/* 1 创建location对象 */
const location = createLocation(path, state, createKey(), history.location)
/* 确定是否能进行路由转换,还在确认的时候又开始了另一个转变 ,可能会造成异常 */
transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
// 当前版本一定走ok的逻辑
if (!ok)
return
const href = createHref(location)
const { key, state } = location
if (canUseHistory) {
setState({ action, location })
} else {
window.location.href = href
}
})
}
- handlePopState
-
监听popstate函数,当path改变时的处理
- 判断一下action的类型,然后setState,重新加载组件
/* 我们简化一下handlePopState */
const handlePopState = (event)=>{
const location = getDOMLocation(event.state)
const action = 'POP'
setState({ action, location })
}
3.2.2 createHashHistory
基本原理同上,此处不再叙述。
4. React-Router源码探查
4.1 Router - 用来接受location变化,派发更新
Router组件的作用是,在初始化时绑定history的listen方法,当路由变更之后,通过setState来触发组件的变更。路由状态通过Context来维护在react组件上下文中。
/* Router 作用是把 history location 等路由信息 传递下去 */
class Router extends React.Component {
static computeRootMatch(pathname) {
return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
}
constructor(props) {
super(props);
this.state = {
location: props.history.location
};
}
componentDidMount() {
/* 此时的history,是history创建的history对象 */
/* 这里判断 componentDidMount 和 history.listen 执行顺序 然后把 location复制 ,防止组件重新渲染 */
this.unlisten = props.history.listen(location => {
/* 创建监听者 */
this.setState({ location });
});
}
componentDidUnmount() {
if (this.unlisten) this.unlisten();
}
render() {
return (
/* 这里可以理解 react.createContext 创建一个 context上下文 ,保存router基本信息。children */
<RouterContext.Provider
// ...
children={this.props.children || null}
value={{ location: this.state.location }}
match: Router.computeRootMatch(this.state.location.pathname),
/>
);
}
}
4.2 Switch - 匹配正确的唯一的路由
Switch组件的作用是找到与当前path,匹配的组件进行渲染。 通过pathname和组件的path进行匹配。找到符合path的router组件。
/* switch组件 */
class Switch extends React.Component {
render() {
return (
<RouterContext.Consumer>
{/* 含有 history location 对象的 context */}
{context => {
const location = this.props.location || context.location;
let element, match;
React.Children.forEach(this.props.children, child => {
if (match == null && React.isValidElement(child)) {
element = child;
// 子组件 也就是 获取 Route中的 path 或者 rediect 的 from
const path = child.props.path || child.props.from;
match = path
? matchPath(location.pathname, { ...child.props, path })
: context.match;
}
});
return match
? React.cloneElement(element, { location, computedMatch: match })
: null;
}}
</RouterContext.Consumer>
);
}
}
4.3 Route - 组件页面承载容器
这个地方的简单理解,可以将Route组件作为组件的容器,然后在Switch组件匹配之后,可以渲染其匹配的Route组件,当然Route组件也可以脱离Switch组件直接当做Router组件的子组件,其内部也有匹配path的操作。
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
return (
<RouterContext.Provider value={props}>
{ /** 暂时这么理解 */}
{ children }
</RouterContext.Provider>
);
}}
</RouterContext.Consumer>
);
}
}
总的来说,history提供了核心的一些API,如监听路由变更,保存路由状态等;然后react-router提供路由渲染的容器、路由匹配等组件功能。
5. umi-router
在umi的history中,基本上都是从history和react-router-dom中集成过来,没有针对这两个库做更多的修改。
export {
createBrowserHistory,
createHashHistory,
createMemoryHistory,
} from 'history-with-query';
export {
Link,
// ...
} from 'react-router-dom';
...