1. 前言
上面谈到的浏览器历史管理,是浏览器层面的原生技术,现实不仅如此:
站在业务开发者角度,vue-router 用起来更舒服一些,因为 vue-router 提供的导航守卫、命名路由、路由传参等特性,基本上不需要再去二次封装,拿来就能用,实用性比较高。react-router 则更自由灵活一些,很多场景、模式,需要根据官方文档的建议,再结合实际业务场景,进行二次封装,才能应用到生产项目中,复杂度高一些。
这篇文章分析一下浏览器原生的历史管理、react-router 中的历史管理,以及vue-router 中的历史管理。给大家直观展示一下两大主流框架(React、Vue)在路由管理方面的异同。
2. HTML5 History API(浏览器原生 history)
The DOM Window object provides access to the browser's session history (not to be confused for WebExtensions history) through the history object. It exposes useful methods and properties that let you navigate back and forth through the user's history, and manipulate the contents of the history stack.
HTML5 introduced the pushState() and replaceState() methods for add and modifying history entries, respectively. These methods work in conjunction with the onpopstate event.
2.1. Adding and modifying history entries
2.1.1. pushState()
Suppose https://mozilla.org/foo.html executes the following JavaScript:
let stateObj = {
foo: "bar",
}
history.pushState(stateObj, "page 2", "bar.html")
This will cause the URL bar to display https://mozilla.org/bar.html, but won't cause the browser to load bar.html or even check that bar.html exists.
Suppose now that the user navigates to https://google.com, then clicks the Back button. At this point, the URL bar will display https://mozilla.org/bar.html and history.state will contain the stateObj. The popstate event won't be fired because the page has been reloaded. The page itself will look like bar.html.
If the user clicks Back once again, the URL will change to https://mozilla.org/foo.html, and the document will get a popstate event, this time with a null state object. Here too, going back doesn't change the document's contents from what they were in the previous step, although the document might update its contents manually upon receiving the popstate event.
语法:
注意:pushState 与 window.location
In a sense, calling pushState() is similar to setting window.location = "#foo"(备注:因为只改地址,不刷新页面), in that both will also create and activate another history entry associated with the current document. But pushState() has a few advantages:
Note that pushState() never causes a hashchange event to be fired, even if the new URL differs from the old URL only in its hash.
示例:index.html
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<h1>Hello World</h1>
<script type="text/javascript">
setTimeout(()=>{
let stateObj = { foo: "bar" }
history.pushState(stateObj, "page 2", "b.html")
}, 3000)
</script>
</body>
</html>
2.1.2. replaceState()
history.replaceState() operates exactly like history.pushState(), except that replaceState() modifies the current history entry instead of creating a new one.
replaceState() is particularly useful when you want to update the state object or URL of the current history entry in response to some user action.
示例:
2.2. Reading the current state
When your page loads, it might have a non-null state object. This can happen, for example, if the page sets a state object (using pushState() or replaceState()) and then the user restarts their browser. When the page reloads, the page will receive an onload event, but no popstate event. However, if you read the history.state property, you'll get back the state object you would have gotten if a popstate had fired.
let currentState = history.state
2.3. The popstate event
A popstate event is dispatched to the window each time the active history entry changes between two history entries for the same document. If the activated history entry was created by a call to history.pushState(), or was affected by a call to history.replaceState(), the popstate event's state property contains a copy of the history entry's state object.
Note: Calling history.pushState() or history.replaceState() won't trigger a popstate event.The popstate event is only triggered by performing a browser action, such as clicking on the back button (or calling history.back() in JavaScript), when navigating between two history entries for the same document.
示例:
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<h1>Hello World</h1>
<script type="text/javascript">
window.onpopstate = function(event) {
console.log("location: " + document.location + ", state: " + JSON.stringify(event.state));
};
function delay(ms){
return new Promise(resolve => setTimeout(resolve, ms));
}
async function init(){
await delay(1000);
history.pushState({page: 1}, "title 1", "?page=1");
await delay(1000);
history.pushState({page: 2}, "title 2", "?page=2");
await delay(1000);
history.replaceState({page: 3}, "title 3", "?page=3");
await delay(1000);
history.back();
await delay(1000);
history.back();
await delay(1000);
history.go(2);
}
init();
</script>
</body>
</html>
3. ReactRouter's history
特别注意
"react-router" 目前最新版本是 "5.2.0"
"react-router": "^5.2.0" 中依赖的是 "history": "^4.9.0"
小心:history 5.x 版本与 4.x 版本的实现差异很大
本文分析的是 "4.10.1" 版本源码
3.1. 定位
The history library lets you easily manage session history anywhere JavaScript runs. A history object abstracts away the differences in various environments and provides a minimal API that lets you manage the history stack, navigate, and persist state between sessions.
3.2. 核心构成
ReactRouter 所使用的 history 库(后面称作 react-router's history),主要由以下几部分构成:
3.3. 分析源码前的基础知识(path 与 location)
path 与 location 的基本概念
path 与 location 的相互转换
在源码中大量出现,有必要先了解一下
3.3.1. path 与 location
3.3.2. path -> location
位置:LocationUtils.js:createLocation
定义:path -> location,根据 path 创建 location
解析:
深度:resolve-pathname
import resolvePathname from 'resolve-pathname';
// Simply pass the pathname you'd like to resolve. Second
// argument is the path we're coming from, or the current
// pathname. It defaults to "/".
resolvePathname('about', '/company/jobs'); // /company/about
resolvePathname('../jobs', '/company/team/ceo'); // /company/jobs
resolvePathname('about'); // /about
resolvePathname('/about'); // /about
// Index paths (with a trailing slash) are also supported and
// work the same way as browsers.
resolvePathname('about', '/company/info/'); // /company/info/about
// In browsers, it's easy to resolve a URL pathname relative to
// the current page. Just use window.location! e.g. if
// window.location.pathname == '/company/team/ceo' then
resolvePathname('cto', window.location.pathname); // /company/team/cto
resolvePathname('../jobs', window.location.pathname); // /company/jobs
深度:PathUtils.js:parsePath(string-> Location)
3.3.3. location -> path
定义:location -> path,根据 location 创建 path
鉴于 createHashHistory、createBrowserHistory 、createMemoryHistory 三者架构极其类似,
所以下面只以 createHashHistory 为例进行源码分析,
另外两个,感兴趣的同学,可以自己研究一下
3.4. 源码分析 —— createHashHistory
3.4.1. HashHistory 的特性
<!DOCTYPE html>
<html>
<body>
<script>
window.addEventListener("hashchange", function () {
console.log("hashchange", window.location.href);
});
</script>
</body>
</html>
3.4.2. createHashHistory 的边界特性
先看看 createHashHistory 的入参:
createHashHistory({
basename: '', // The base URL of the app (see below)
hashType: 'slash', // The hash type to use (see below)
// A function to use to confirm navigation with the user (see below)
getUserConfirmation: (message, callback) => callback(window.confirm(message))
});
再看看 createHashHistory 的返回值:
这个 history 就是我们平常在 react-router 中使用 HashRouter 时,通过 useHistory() 得到的那个对象。
在分析 createHashHistory 内核前,先关注一下它的过渡控制对象 createTransitionManager,它与 createHashHistory 中的参数 getUserConfirmation 有内在联系,提前分析一下
3.4.3. createTransitionManager 又是啥?
示例:
const createTransitionManager = require("./createTransitionManager")
// callback
function callback(ok){
console.log(`callback triggered with '${ok}'`);
}
console.log("\n----Test1----\n");
// Test 1
// 不设置 prompt 时,直接触发 callback(true)
const o = new createTransitionManager();
o.confirmTransitionTo(null, null, null, callback); // true
console.log("\n----Test2----\n");
// Test 2
// 设置 string 类型 prompt 时,需要配合 userConfirmation 使用,
// 并且 callback 的触发控制权交给 userConfirmation 处理
const o1 = new createTransitionManager();
o1.setPrompt("This is a promot!");
o1.confirmTransitionTo(null, null, function userConfirmation(prompt, callback){
console.log(`userConfirmation triggered width '${prompt}'`);
callback('result from userConfirmation'); //
}, callback); // true
console.log("\n----Test3----\n");
// Test 3
// 设置 function 类型 prompt 时,会获取 prompt(location, action) 的返回值 result
// a. 若 result 是 string 类型,又会交给 userConfirmation 处理,走 Test 2 模式
// b. 否则以 callback(result !== false) 触发 callback
const o2 = new createTransitionManager();
o2.setPrompt(function(location, action){
console.log(`function prompt triggered with location:'${location}', action:'${action}'`);
return "result from function prompt."
});
o2.confirmTransitionTo("some-location", "some-action", function userConfirmation(prompt, callback){
console.log(`userConfirmation triggered width '${prompt}'`);
callback('result from userConfirmation'); //
}, callback); // true
console.log("\n----Test3----\n");
示例:
const createTransitionManager = require("./createTransitionManager");
const o = createTransitionManager();
const unlisten1 = o.appendListener(function(...args){
console.log('listener1 receives: ', ...args);
});
const unlisten2 = o.appendListener(function(...args){
console.log('listener2 receives: ', ...args);
});
// 消息发布
o.notifyListeners("v1", "v2", "v3");
// listener1 取消监听
unlisten1();
// 消息发布
o.notifyListeners("v4", "v5", "v6"); // 消息发布
此时可以思考一下 createHashHistory 中的参数 getUserConfirmation 的用途
3.4.4. history.listen、history.block
3.4.5. history.push
3.4.6. history.replace
套路跟 history.push 几乎一样
上面的 history.push、history.replace 的事件发起方为 API 调用,
即 "API调用" -> "浏览器历史变化"
即 "API调用" -> "浏览器 Hash 变化" -> "浏览器 history 变化"
下面将从"浏览器历史变化"(例如:点击"前进"、"后退")为出发点,
观察 createHashHistory 做了什么
3.4.7. handleHashChange
3.4.8. history.go、goBack、goForward
3.4.9. 总结一下
4. VueRouter‘s history
4.1. 怎么又一个 history?
VueRouter 同 ReactRouter 一样,也实现了自己的一套 history 管理。
4.2. 导航守卫是啥?
正如其名,vue-router 提供的导航守卫主要用来通过跳转或取消的方式守卫导航。有多种机会植入路由导航过程中:全局的, 单个路由独享的, 或者组件级的。 https://router.vuejs.org/zh/guide/advanced/navigation-guards.html
导航守卫在实现路由鉴权、自定义路由跳转方面,功能强大,给开发人员带来了很大的便捷。
4.3. VueRouter 也支持三种模式
同 ReactRouter 类似,VueRouter 也支持三种模式,分别为 history、hash、abstract。这三种模式分别由 html5.js、hash.js、abstract.js 实现。
其中,base.js 是 hash.js、html5.js、abstract.js 的基类
下面的内容,与分析 ReactRouter 的套路相同
还是只以 HashHistory 为例对 VueRouter 进行分析
4.4. VueRouter's history 的重要结构定义?
4.4.1. RouteConfig:路由配置
结构:
示例:
4.4.2. RouteRecord:路由记录
路由记录 是 路由配置的运行时描述。它主要用于 VueRouter 内部描述路由的运行时状态。
结构:
示例:
4.4.3. Route(路由对象)
4.4.4. Location:描述一个路由位置的对象
Vue-Router 中定义的 Location 数据结构和浏览器提供的 window.location 部分结构有点类似,它们都是对 url 的结构化描述。举个例子:/abc?foo=bar&baz=qux#hello,它的 path 是 /abc,query 是 {foo:'bar',baz:'qux'}。
注意:一个路由目标可以是一个字符串,也可以是一个 Location 对象,RawLocation 是对他们的一个统一描述。
4.5. RouteConfig 与 RouteRecord 之间的转换
先看看 Matcher 对象的定义:
export type Matcher = {
match: (raw: RawLocation, current?: Route, redirectedFrom?: Location) => Route;
addRoutes: (routes: Array<RouteConfig>) => void;
addRoute: (parentNameOrRoute: string | RouteConfig, route?: RouteConfig) => void;
getRoutes: () => Array<RouteRecord>;
};
再看看 Matcher 如何利用 createRouteMap 对外提供接口的:
createMatcher 接收 2 个参数,一个是 router,它是我们 new VueRouter 返回的实例,一个是 routes,它是用户定义的路由配置。
VueRouter 支持动态路由, pathList, pathMap, nameMap 可以在初始化后由createRouteMap 方法进行增量修改(例如:addRoutes、addRoute)。
createRouteMap 本质是通过 addRouteRecord,完成转换:
createRouteMap 函数的目标是把用户的路由配置转换成一张路由映射表,它包含 3 个部分,pathList 存储所有的 path,pathMap 表示一个 path 到 RouteRecord 的映射关系,而 nameMap 表示 name 到 RouteRecord 的映射关系。
addRouteRecord 通过深度优先遍历,完成转换:
4.6. RouteRecord 到 Route 的转换(匹配过程)
Matcher 对象中,不仅包含 addRoute、addRoutes、getRoutes,还包含一个 match 方法。
export type Matcher = {
match: (raw: RawLocation, current?: Route, redirectedFrom?: Location) => Route;
addRoutes: (routes: Array<RouteConfig>) => void;
addRoute: (parentNameOrRoute: string | RouteConfig, route?: RouteConfig) => void;
getRoutes: () => Array<RouteRecord>;
};
match 方法接收 3 个参数,其中 raw 是 RawLocation 类型,它可以是一个 url 字符串,也可以是一个 Location 对象;currentRoute 是 Route 类型,它表示当前的路径;redirectedFrom 和重定向相关,这里先忽略。match 方法返回的是一个路径(Route),它的作用是根据传入的 raw 和当前的路径 currentRoute 计算出一个新的路径(Route)并返回。
首先执行了 normalizeLocation,它的定义在 src/util/location.js 中:
normalizeLocation 方法的作用是根据 raw(跳转目标),current(当前路由) 计算出结果 location(标准化的 location)。它主要处理了以下几种情况:
最后我们来看一下 _createRoute 的实现:
其中redirect,alias最终都会调用createRoute方法。而 createRoute 函数最终会返回一个冻结的Route对象。
createRoute 可以根据 record 和 location 创建出来,最终返回的是一条 Route 路径,我们之前也介绍过它的数据结构。在 Vue-Router 中,所有的 Route 最终都会通过 createRoute 函数创建,并且它最后是不可以被外部修改的。Route 对象中有一个非常重要属性是 matched,它通过 formatMatch(record) 计算而来:
可以看它是通过 record 循环向上找 parent,直到找到最外层,并把所有的 record 都 push 到一个数组中,最终返回的就是 record 的数组,它记录了一条线路上的所有 record,而且顺序为从外向里(树的外层到内层)。
matched 属性非常有用,它为之后渲染组件提供了依据。
4.7. VueRouter、HashHistory 的主体结构
前面分析了很多细节,我们换个角度,看看 VueRouter 的整体结构:
再看看基于 Hash 的 HashHistory 的整体结构:
4.8. 核心分析:History.transitionTo、confirmTransition、updateRoute
history.transitionTo 是 Vue-Router 中非常重要的方法,当我们切换路由线路的时候,就会执行到该方法,前一节我们分析了 matcher 的相关实现,知道它是如何找到匹配的新线路,那么匹配到新线路后又做了哪些事情,接下来我们来完整分析一下 transitionTo 的实现,它的定义在 src/history/base.js 中:
transitionTo 首先根据目标 location 和当前路径 this.current 执行 this.router.match 方法去匹配到目标的路径。
这里 this.current 是 history 维护的当前路径,它的初始值是在 history 的构造函数中初始化的(注:这样就创建了一个初始的 Route,而 transitionTo 实际上也就是在切换 this.current)
transitionTo 的 location 参数是我们的目标路径, 可以是string或者RawLocation对象。我们通过router.match方法(我们在matcher介绍过)返回我们的目标路由对象。紧接着我们会调用confirmTransition函数做真正的切换,由于这个过程可能有一些异步的操作(如异步组件),所以整个 confirmTransition API 设计成带有成功回调函数和失败回调函数。
confirmTransition 函数中会使用,isSameRoute会检测是否导航到相同的路由,如果导航到相同的路由会停止?导航,并执行终止导航的回调。
接着我们调用resolveQueue方法,resolveQueue接受当前的路由和目标的路由的matched属性作为参数,resolveQueue的工作方式可以如下图所示。我们会逐一比较两个数组的路由,寻找出需要销毁的,需要更新的,需要激活的路由,并返回它们(因为我们需要执行它们不同的路由守卫)
然后,我们会把所有要执行的路由守卫,组合成队列 queue,由 runQueue 执行。
按照顺序如下:
接下来我们来分别介绍这 5 步的实现。
4.8.1. 第一步,extractLeaveGuards(deactivated)
extractLeaveGuards函数能提取出deactivated中所有需要销毁的组件内的beforeRouteLeave 守卫。extractLeaveGuards函数中会调用extractGuards函数,extractGuards函数会调用flatMapComponents函数,flatMapComponents函数会遍历records(resolveQueue返回deactivated), 在遍历过程中我们将组件,组件的实例,route对象,传入了fn(extractGuards中传入flatMapComponents的回调), 在fn中我们会获取组件中beforeRouteLeave守卫。
4.8.2. 第二步:this.router.beforeHooks
第二步是 this.router.beforeHooks,在 VueRouter 类中定义了 beforeEach 方法:
当用户使用 router.beforeEach 注册了一个全局守卫,就会往 router.beforeHooks 添加一个钩子函数,这样 this.router.beforeHooks 获取的就是用户注册的全局 beforeEach 守卫。
4.8.3. 第三步:extractUpdateHooks(updated)
extractUpdateHooks(updated)和 extractLeaveGuards(deactivated) 很类似,它获取到的就是所有重用的组件中定义的 beforeRouteUpdate 钩子函数。
4.8.4. 第四步:activated.map(m => m.beforeEnter)
第四步获取的是在激活的路由配置中定义的 beforeEnter 函数。
4.8.5. 第五步:resolveAsyncComponents(activated)
第五步是执行 resolveAsyncComponents(activated) 解析异步组件
4.8.6. runQueue —— 执行守卫队列
在获取所有的路由守卫后我们定义了一个迭代器iterator。接着我们使用runQueue遍历queue队列。将queue队列中每一个元素传入fn(迭代器iterator)中,在迭代器中会执行路由守卫,并且路由守卫中必须明确的调用next方法才会进入下一个管道,进入下一次迭代。迭代完成后,会执行runQueue的callback。
这是一个非常经典的异步函数队列化执行的模式, queue 是一个 NavigationGuard 类型的数组,我们定义了 step 函数,每次根据 index 从 queue 中取一个 guard,然后执行 fn 函数,并且把 guard 作为参数传入,第二个参数是一个函数,当这个函数执行的时候再递归执行 step 函数,前进到下一个,注意这里的 fn 就是我们刚才的 iterator 函数。
4.8.7. 小结一下
那么至此我们把所有导航守卫的执行分析完毕了,我们知道路由切换除了执行这些钩子函数,从表象上有 2 个地方会发生变化,一个是 url 发生变化,一个是组件发生变化。
4.9. vue-router's history 的几个重要辅助函数?
4.9.1. util/path.js
4.9.2. hsitory/base.js -> normalizeBase
示例:
<!DOCTYPE html>
<html>
<head>
<base href="http://10.1.60.10:8810/xyz/">
</head>
<body>
<script>
const inBrowser = typeof window !== 'undefined'
function normalizeBase(base) {
if (!base) {
if (inBrowser) {
// respect <base> tag
const baseEl = document.querySelector('base')
base = (baseEl && baseEl.getAttribute('href')) || '/'
// strip full URL origin
base = base.replace(/^https?:\/\/[^\/]+/, '')
} else {
base = '/'
}
}
// make sure there's the starting slash
if (base.charAt(0) !== '/') {
base = '/' + base
}
// remove trailing slash
return base.replace(/\/$/, '')
}
console.log(normalizeBase());
console.log(normalizeBase("/xyz/abc/"));
</script>
</body>
</html>
4.9.3. util/async.js
这是一个异步队列执行器,主要用于排队执行路由守卫函数。
示例:
4.10. 路由是怎么渲染的?
路由最终的渲染离不开组件,Vue-Router 内置了 <router-view> 组件。<router-view> 是支持嵌套的,回到 render 函数,其中定义了 depth 的概念,它表示 <router-view> 嵌套的深度。
parent._routerRoot 表示的是根 Vue 实例,那么这个循环就是从当前的 <router-view> 的父节点向上找,一直找到根 Vue 实例,在这个过程,如果碰到了父节点也是 <router-view> 的时候,说明 <router-view> 有嵌套的情况,depth++。遍历完成后,根据当前线路匹配的路径和 depth 找到对应的 RouteRecord,进而找到该渲染的组件。
4.11. 总结一下
那么至此我们把 VueRouter 的主体过程分析完毕了,路径变化是路由中最重要的功能,我们要记住以下内容:路由始终会维护当前的线路,路由切换的时候会把当前线路切换到目标线路,切换过程中会执行一系列的导航守卫钩子函数,会更改 url,同样也会渲染对应的组件,切换完毕后会把目标线路更新替换当前线路,这样就会作为下一次的路径切换的依据。
参考:
History API: https://developer.mozilla.org/zh-CN/docs/Web/API/History_API https://developer.mozilla.org/en-US/docs/Web/API/History_API/Working_with_the_History_API https://developer.mozilla.org/en-US/docs/Web/API/History/pushState https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState https://developer.mozilla.org/en-US/docs/Web/API/History/state https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate history——ReactRouter https://github.com/ReactTraining/history https://gitee.com/mirrors/history/tree/v4 https://gitee.com/mirrors/history/tree/v4/docs https://github.com/mjackson/value-equal#readme https://github.com/mjackson/resolve-pathname#readme history——VueRouter https://github.com/vuejs/vue-router https://gitee.com/mirrors/vue-router VueRouter: https://router.vuejs.org/zh/