vue router 4 源码篇:router history的原生结合
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
源码专栏感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:
《vue router 4 源码篇:路由诞生——createRouter原理探索》《vue router 4 源码篇:路由matcher的前世今生》《vue router 4 源码篇:router history的原生结合》开场哈喽大咖好,我是跑手,本次给大家继续探讨vue-router@4.x源码中有关Web History API能力的部分,也就是官方文档中历史模式。
大家多少有点了解,包括react router、vue-router在内大多数单页路由库,是基于H5 History API能力来实现的。History API其实做的事情也很简单,就是改变当前web URL而不与服务器交互,完成纯前端页面的URL变型。
撰写目的在这篇文章里,你能获得以下增益:
了解vue-router中对Web History API能力的应用。了解createWebHistory和createWebHashHistory的实现原理。事不宜迟,开讲。。。
。。
。
Web History API在H5 History API完成页面url变化有2个重要函数:pushState()和replaceState(),它们的差异无非就是
举个沉浸式例子我们随便打开一个页面,在控制台查看下原始History是这样的,其中length是一个只读属性,代表当前 session记录的页面历史数量(包括当前页)。
image.png然后再执行这段代码,有得到如下效果:浏览器url发生了变化,但页面内容没有任何改动:
history.pushState(
{myName:'test',state:{page:1,index:2}},
'divtitle',
'/divPath'
)
我们再看看History内容,如下图:
image.png会发现和之前的变化有:
length由2变3。虽然页面不跳转,但我们执行pushState时往history堆栈中插入了一条新数据,所以依旧被History对象收录,因此length加1;scrollRestoration是描述页面滚动属性,auto|manual: 分别表示自动 | 手动恢复页面滚动位置,在vue-router滚动行为中就用到这块的能力;History.state值变成了我们在pushState传的第一个参数,理论上这个参数可以是任意对象,这也是单页应用在路由跳转时可以随心所欲传值的关键。另外如果不是pushState()和replaceState()调用,state 的值将会是 null。服务器适配用pushState()和replaceState()改变URL确实也有个通病,就是刷新页面报404,因为刷新行为属于浏览器与后台服务通信的默认行为,服务器没法解析前端自定义path而导致404错误。
image.png要解决这个问题,你需要在服务器上添加一个简单的回退路由,如果 URL 不匹配任何静态资源,直接回退到 index.html。
结论说了那么多,总结下Web History API能给我们带来:
在不与服务端交互情况下改变页面url,给单页路由应用带来可玩(有)性(戏)能传值,并且能在history栈顶的state读到这些值,解决单页之间的跳转数据传输问题兼容性好,主流和不是那么主流的客户端都兼容基于此,各类的路由库应用应运而生,当然vue-router也是其中之一。
createWebHistory创建一个适配Vue的 H5 History记录,需要用到createWebHistory方法,入参是一个路径字符串,表示history的根路径,返回是一个vue的history对象,返回类型定义如下:
Typescript类型:
exportdeclarefunctioncreateWebHistory(base?:string):RouterHistory
/**
*InterfaceimplementedbyHistoryimplementationsthatcanbepassedtothe
*routeras{@linkRouter.history}
*
*@alpha
*/
exportinterfaceRouterHistory{
/**
*Basepaththatisprependedtoeveryurl.ThisallowshostinganSPAata
*sub-folderofadomainlike`example.com/sub-folder`byhavinga`base`of
*`/sub-folder`
*/
readonlybase:string
/**
*CurrentHistorylocation
*/
readonlylocation:HistoryLocation
/**
*CurrentHistorystate
*/
readonlystate:HistoryState
//readonlylocation:ValueContainerHistoryLocationNormalized
/**
*Navigatestoalocation.InthecaseofanHTML5Historyimplementation,
*thiswillcall`history.pushState`toeffectivelychangetheURL.
*
*@paramto-locationtopush
*@paramdata-optional{@linkHistoryState}tobeassociatedwiththe
*navigationentry
*/
push(to:HistoryLocation,data?:HistoryState):void
/**
*Sameas{@linkRouterHistory.push}butperformsa`history.replaceState`
*insteadof`history.pushState`
*
*@paramto-locationtoset
*@paramdata-optional{@linkHistoryState}tobeassociatedwiththe
*navigationentry
*/
replace(to:HistoryLocation,data?:HistoryState):void
/**
*Traverseshistoryinagivendirection.
*
*@example
*```js
*myHistory.go(-1)//equivalenttowindow.history.back()
*myHistory.go(1)//equivalenttowindow.history.forward()
*```
*
*@paramdelta-distancetotravel.Ifdeltais0,itwillgoback,
*ifit's0,itwillgoforwardbythatamountofentries.
*@paramtriggerListeners-whetherthisshouldtriggerlistenersattachedto
*thehistory
*/
go(delta:number,triggerListeners?:boolean):void
/**
*AttachalistenertotheHistoryimplementationthatistriggeredwhenthe
*navigationistriggeredfromoutside(liketheBrowserbackandforward
*buttons)orwhenpassing`true`to{@linkRouterHistory.back}and
*{@linkRouterHistory.forward}
*
*@paramcallback-listenertoattach
*@returnsacallbacktoremovethelistener
*/
listen(callback:NavigationCallback):()=void
/**
*Generatesthecorrespondinghreftobeusedinananchortag.
*
*@paramlocation-historylocationthatshouldcreateanhref
*/
createHref(location:HistoryLocation):string
/**
*Clearsanyeventlistenerattachedbythehistoryimplementation.
*/
destroy():void
}
在《vue router 4 源码篇:路由诞生——createRouter原理探索》中讲到,createRouter创建vue-router实例时,会添加单页跳转时的监听回调,其能力源于本方法createWebHistory创建的history对象。该对象中导出的方法(如:listen、destroy、push等等...),都是依托了原生Web History API能力,并且结合了Vue技术而封装的中间层SDK,把两者连接起来。
实现原理流程图image.pngcreateWebHistory总流程非常简单,分4步走:
创建vue router的history对象,包含4个属性:location(当前location)、state(路由页面的history state)、和push、replace2个方法;创建vue router监听器:主要支持路由跳转时的state处理和自定义的跳转逻辑回调;添加location劫持,当routerHistory.location变动时返回标准化的路径;添加state劫持,当routerHistory.state变动时返回里面的state;步骤对应的源码如下「附注释」:
/**
*CreatesanHTML5history.Mostcommonhistoryforsinglepageapplications.
*
*@parambase-
*/
exportfunctioncreateWebHistory(base?:string):RouterHistory{
base=normalizeBase(base)
//步骤1:创建`vuerouter`的history对象
consthistoryNavigation=useHistoryStateNavigation(base)
//步骤2:创建`vuerouter`监听器
consthistoryListeners=useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
)
functiongo(delta:number,triggerListeners=true){
if(!triggerListeners)historyListeners.pauseListeners()
history.go(delta)
}
//组装routerHistory对象
constrouterHistory:RouterHistory=assign(
{
//it'soverriddenrightafter
location:'',
base,
go,
createHref:createHref.bind(null,base),
},
historyNavigation,
historyListeners
)
//步骤3:添加location劫持
Object.defineProperty(routerHistory,'location',{
enumerable:true,
get:()=historyNavigation.location.value,
})
//步骤4:添加state劫持
Object.defineProperty(routerHistory,'state',{
enumerable:true,
get:()=historyNavigation.state.value,
})
//返回整个routerHistory对象
returnrouterHistory
}
最后,createWebHistory方法返回处理好后的routerHistory对象,供createRouter使用。
接下来,我们跟着源码,拆分上面四个流程,看具体是怎么实现的。
创建History第一步,创建vue router的history对象,在上面源码用useHistoryStateNavigation方法来创建这个对象,方便大家理解,笔者简化一个流程图:
流程图image.png从左到右,vue router history使用了H5 History能力。其中history.pushState和history.replaceState方法被封装到一个名为locationChange的路径变化处理函数中,而locationChange作为一个公共函数,则被push 和 replace 函数调用,这2个函数,也就是我们熟知的Router push和Router replace方法。
另外,vue router history的state对象底层也是用到了history.state,只不过再封装成符合vue router的state罢了。
最后,useHistoryStateNavigation方法把push、replace、state、location集成到一个对象中返回,完成了history的初始化。
源码解析changeLocation先看changeLocation,源码如下:
functionchangeLocation(
to:HistoryLocation,
state:StateEntry,
replace:boolean
):void{
/**
*ifabasetagisprovided,andweareonanormaldomain,wehaveto
*respecttheprovided`base`attributebecausepushState()willuseitand
*potentiallyeraseanythingbeforethe`#`likeat
*https://github.com/vuejs/router/issues/685whereabaseof
*`/folder/#`butabaseof`/`woulderasethe`/folder/`section.If
*thereisnohost,the`base`tagmakesnosenseandifthereisn'ta
*basetagwecanjustuseeverythingafterthe`#`.
*/
consthashIndex=base.indexOf('#')
consturl=
hashIndex-1
?(location.hostdocument.querySelector('base')
?base
:base.slice(hashIndex))+to
:createBaseLocation()+base+to
try{
//BROWSERQUIRK
//NOTE:SafarithrowsaSecurityErrorwhencallingthisfunction100timesin30seconds
history[replace?'replaceState':'pushState'](state,'',url)
historyState.value=state
}catch(err){
if(__DEV__){
warn('Errorwithpush/replaceState',err)
}else{
console.error(err)
}
//Forcethenavigation,thisalsoresetsthecallcount
location[replace?'replace':'assign'](url)
}
}
首先是结合base根路径计算最终的跳转url,然后根据replace标记决定使用history.pushState或history.replaceState进行跳转。
buildStatereplace和push里都使用到一个公共函数buildState,这函数作用是在原来state中添加页面滚动位置记录,方便页面回退时滚动到原来位置。
/**
*Createsastateobject
*/
functionbuildState(
back:HistoryLocation|null,
current:HistoryLocation,
forward:HistoryLocation|null,
replaced:boolean=false,
computeScroll:boolean=false
):StateEntry{
return{
back,
current,
forward,
replaced,
position:window.history.length,
scroll:computeScroll?computeScrollPosition():null,
}
}
//computeScrollPosition方法定义
exportconstcomputeScrollPosition=()=
({
left:window.pageXOffset,
top:window.pageYOffset,
}as_ScrollPositionNormalized)
replacereplace方法实现也比较简单:先把state和传进来的data整合得到一个最终state,再调用changeLocation进行跳转,最后更新下当前Location变量。
functionreplace(to:HistoryLocation,data?:HistoryState){
conststate:StateEntry=assign(
{},
history.state,
buildState(
historyState.value.back,
//keepbackandforwardentriesbutoverridecurrentposition
to,
historyState.value.forward,
true
),
data,
{position:historyState.value.position}
)
changeLocation(to,state,true)
currentLocation.value=to
}
pushfunctionpush(to:HistoryLocation,data?:HistoryState){
//Addtocurrententrytheinformationofwherewearegoing
//aswellassavingthecurrentposition
constcurrentState=assign(
{},
//usecurrenthistorystatetogracefullyhandleawrongcallto
//history.replaceState
//https://github.com/vuejs/router/issues/366
historyState.value,
history.stateasPartialStateEntry|null,
{
forward:to,
scroll:computeScrollPosition(),
}
)
if(__DEV__!history.state){
warn(
`history.stateseemstohavebeenmanuallyreplacedwithoutpreservingthenecessaryvalues.Makesuretopreserveexistinghistorystateifyouaremanuallycallinghistory.replaceState:\n\n`+
`history.replaceState(history.state,'',url)\n\n`+
`Youcanfindmoreinformationathttps://next.router.vuejs.org/guide/migration/#usage-of-history-state.`
)
}
changeLocation(currentState.current,currentState,true)
conststate:StateEntry=assign(
{},
buildState(currentLocation.value,to,null),
{position:currentState.position+1},
data
)
changeLocation(to,state,false)
currentLocation.value=to
}
和replace差不多,都是调用changeLocation完成跳转,但是push方法会跳转2次:第一次是给router history添加forward和scroll的中间跳转,其作用是保存当前页面的滚动位置。
为什么要2次跳转才能保存页面位置?大家试想下,当你浏览一个页面,滚动到某个位置,你利用history.pushState跳转到另一个页面时,history堆栈会压入一条记录,但同时vue router会帮助你记录跳转前页面位置,以便在回退时恢复滚动位置。要实现这个效果,就必须在push方法中,在调用changeLocation前把当前页面位置记录到router state中。
要实现这个功能方法有多种,最简单方法就是在跳转前把位置信息记录好放进state里面,然后通过changeLocation(to, state, false)实现跳转。
但官方用了另一种优雅方法解决这个问题,就是在最终跳转前先来一次replace模式的中间跳转,这样在不破坏原页面信息基础上更新了router state,省去更多与页面位置相关的连带处理。这就有了push方法中2次调用changeLocation。
至此,vue router history的创建流程全部执行完成,但仅仅依靠history的改变是不够的,下面我们再看看监听器的实现过程。
创建路由监听器流程图image.png众所周知,history.go、history.forward、history.back都会触发popstate事件,然后再将popStateHandler方法绑定到popstate事件即可实现路由跳转监听。
而页面关闭或离开时会触发beforeunload事件,同理将beforeUnloadListener方法绑定到该事件上实现对此类场景的监控。
最后为了能自定义监控逻辑,监听器抛出了3个钩子函数:pauseListeners「停止监听」、listen「注册监听回调,符合订阅发布模式」、destroy「卸载监听器」。
源码解析popStateHandlerconstpopStateHandler:PopStateListener=({
state,
}:{
state:StateEntry|null
})={
//新跳转地址
constto=createCurrentLocation(base,location)
//当前路由地址
constfrom:HistoryLocation=currentLocation.value
//当前state
constfromState:StateEntry=historyState.value
//计步器
letdelta=0
if(state){
//目标路由state不为空时,更新currentLocation和historyState缓存
currentLocation.value=to
historyState.value=state
//暂停监控时,中断跳转并重置pauseState
if(pauseStatepauseState===from){
pauseState=null
return
}
//计算距离
delta=fromState?state.position-fromState.position:0
}else{
//否则执行replace回调
replace(to)
}
//console.log({deltaFromCurrent})
//Herewecouldalsorevertthenavigationbycallinghistory.go(-delta)
//thislistenerwillhavetobeadaptedtonottriggeragainandtowaitfortheurl
//tobeupdatedbeforetriggeringthelisteners.Somekindofvalidationfunctionwouldalso
//needtobepassedtothelistenerssothenavigationcanbeaccepted
//callalllisteners
//发布跳转事件,将Location、跳转类型、跳转距离等信息返回给所有注册的订阅者,并执行注册回调
listeners.forEach(listener={
listener(currentLocation.value,from,{
delta,
type:NavigationType.pop,
direction:delta
?delta0
?NavigationDirection.forward
:NavigationDirection.back
:NavigationDirection.unknown,
})
})
}
纵观而视,popStateHandler在路由跳转时,做了这些事情:
更新history的location和state等信息,使得缓存信息同步;暂停监控时,中断跳转并重置pauseState;将必要信息告知所有注册的订阅者,并执行注册回调;beforeUnloadListenerfunctionbeforeUnloadListener(){
const{history}=window
if(!history.state)return
history.replaceState(
assign({},history.state,{scroll:computeScrollPosition()}),
''
)
}
关闭页面前会执行这个方法,主要作用是记录下当前页面滚动。
3个listener hooks//暂停监听
functionpauseListeners(){
pauseState=currentLocation.value
}
//注册监听逻辑
functionlisten(callback:NavigationCallback){
//setupthelistenerandprepareteardowncallbacks
listeners.push(callback)
constteardown=()={
constindex=listeners.indexOf(callback)
if(index-1)listeners.splice(index,1)
}
teardowns.push(teardown)
returnteardown
}
//监听器销毁
functiondestroy(){
for(constteardownofteardowns)teardown()
teardowns=[]
window.removeEventListener('popstate',popStateHandler)
window.removeEventListener('beforeunload',beforeUnloadListener)
}
添加location和state劫持Object.defineProperty(routerHistory,'location',{
enumerable:true,
get:()=historyNavigation.location.value,
})
Object.defineProperty(routerHistory,'state',{
enumerable:true,
get:()=historyNavigation.state.value,
})
这里没啥好说的,就是读取routerHistory.location或routerHistory.state时能获取到historyNavigation方法中的内容。
到这里就是createWebHistory如何结合vue创建出一个router history的整个过程了。
createWebHashHistorycreateMemoryHistory主要创建一个基于内存的历史记录,这个历史记录的主要目的是处理 SSR。
其逻辑和createWebHistory大同小异,都是通过history和监听器实现,只不过在服务器场景中,没有window对象,也没法用到H5 History API能力,所以history用了一个queue(队列)代替,而监听器也是消费队列完成路由切换。以下是关键源码:
/**
*Createsanin-memorybasedhistory.ThemainpurposeofthishistoryistohandleSSR.Itstartsinaspeciallocationthatisnowhere.
*It'suptotheusertoreplacethatlocationwiththestarterlocationbyeithercalling`router.push`or`router.replace`.
*
*@parambase-Baseappliedtoallurls,defaultsto'/'
*@returnsahistoryobjectthatcanbepassedtotherouterconstructor
*/
exportfunctioncreateMemoryHistory(base:string=''):RouterHistory{
letlisteners:NavigationCallback[]=[]
letqueue:HistoryLocation[]=[START]
letposition:number=0
base=normalizeBase(base)
//通过position(计步器)改变queue达到路由跳转效果
functionsetLocation(location:HistoryLocation){
position++
if(position===queue.length){
//weareattheend,wecansimplyappendanewentry
queue.push(location)
}else{
//weareinthemiddle,weremoveeverythingfromhereinthequeue
queue.splice(position)
queue.push(location)
}
}
//监听器触发
functiontriggerListeners(
to:HistoryLocation,
from:HistoryLocation,
{direction,delta}:PickNavigationInformation,'direction'|'delta'
):void{
constinfo:NavigationInformation={
direction,
delta,
type:NavigationType.pop,
}
for(constcallbackoflisteners){
callback(to,from,info)
}
}
//构建routerhistory
constrouterHistory:RouterHistory={
//rewrittenbyObject.defineProperty
location:START,
//TODO:shouldbekeptinqueue
state:{},
base,
createHref:createHref.bind(null,base),
//replace方法
replace(to){
//removecurrententryanddecrementposition
queue.splice(position--,1)
setLocation(to)
},
//push方法
//这2种方法都是调用setLocation来改变queue
push(to,data?:HistoryState){
setLocation(to)
},
//添加监听回调
listen(callback){
listeners.push(callback)
return()={
constindex=listeners.indexOf(callback)
if(index-1)listeners.splice(index,1)
}
},
destroy(){
listeners=[]
queue=[START]
position=0
},
go(delta,shouldTrigger=true){
constfrom=this.location
constdirection:NavigationDirection=
//weareconsideringdelta===0goingforward,butinabstractmode
//using0forthedeltadoesn'tmakesenselikeitdoesinhtml5where
//itreloadsthepage
delta0?NavigationDirection.back:NavigationDirection.forward
position=Math.max(0,Math.min(position+delta,queue.length-1))
if(shouldTrigger){
triggerListeners(this.location,from,{
direction,
delta,
})
}
},
}
//增加获取数据劫持
Object.defineProperty(routerHistory,'location',{
enumerable:true,
get:()=queue[position],
})
//针对单测时处理
if(__TEST__){
//...
}
returnrouterHistory
}
落幕好了好了,这节先到这里,最后感谢大家阅览并欢迎纠错。
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线