前言
Hello~大家好。我是秋天的一阵风
🎉 欢迎来到 Vue3探秘系列专栏!在这里,我们将深入探索 Vue3 的各种奥秘,从源码到实践,一步步揭开它的神秘面纱。📚
🚀 以下是本系列文章的导航目录,方便你快速找到感兴趣的内容:
- 虚拟结点 vnode 的页面挂载之旅(一)
不止响应式:Vue3探秘系列--- 虚拟结点vnode的页面挂载之旅(一)
🌟 探索虚拟 DOM 如何变成页面上的真实内容,开启 Vue3 的渲染之旅!- 组件更新会发生什么(二)
不止响应式:Vue3探秘系列--- 组件更新会发生什么(二)
🔃 深入组件更新的内部机制,看看 Vue3 是如何高效更新界面的。- diff 算法的完整过程(三)
不止响应式:Vue3探秘系列--- diff算法的完整过程(三)
🧩 揭秘 Vue3 的 diff 算法,理解它是如何高效比较和更新 DOM 的。- 组件的初始化过程(四)
不止响应式:Vue3探秘系列--- 组件的初始化过程(四)
🌱 从零开始,了解 Vue3 组件是如何初始化的,掌握组件生命周期的关键步骤。- 响应式设计(五)
终于轮到你了:Vue3探秘系列--- 响应式设计(五)
🔗 深入 Vue3 的响应式系统,探索Proxy
如何实现高效的数据响应机制。这只是系列的一部分,更多精彩内容还在持续更新中!🔍
开始本篇的内容之前,我们先引出一个问题, 什么是SPA?
SPA
全称是Single Page Application
,也就是单页面应用 。单页面应用是一种现代Web应用的设计模式,它的主要特点是可以通过动态更新局部页面内容的方式,实现了无需重新加载整个页面即可完成页面间的跳转和内容更新,极大地提升了应用性能和用户体验。
为了更好地支持 SPA
中的导航和路由功能,Vue.js
提供了一个官方的路由管理库 ------ Vue Router 。Vue Router
是一个功能强大的路由管理器,它不仅能够帮助我们轻松地管理应用中的多个视图状态,还提供了丰富的API
和机制,以应对复杂的应用场景。
我们先来用vue-cli
搭建一个简单的案例环境代码:
1. public/index.html
javascript
<div id="app">
<h1>Hello App!</h1>
<p>
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
</p>
<router-view></router-view>
</div>
其中,RouterLink
和 RouterView
是 Vue Router
内置的组件。
-
RouterLink
表示路由的导航组件,我们可以配置to
属性来指定它跳转的链接,它最终会在页面上渲染生成 a 标签。 -
RouterView
表示路由的视图组件,它会渲染路径对应的Vue
组件,也支持嵌套。
2. main.js
javascript
import { createApp } from "vue";
import { createRouter, createWebHashHistory } from "vue-router";
// 1. 定义路由组件
const Home = { template: "<div>Home</div>" };
const About = { template: "<div>About</div>" };
// 2. 定义路由配置,每个路径映射一个路由视图组件
const routes = [
{ path: "/", component: Home },
{ path: "/about", component: About },
];
// 3. 创建路由实例,可以指定路由模式,传入路由配置对象
const router = createRouter({
history: createWebHistory(),
routes,
});
// 4. 创建 app 实例
const app = createApp({});
// 5. 在挂载页面 之前先安装路由
app.use(router);
// 6. 挂载页面
app.mount("#app");
非常简单哈,首先是定义了两个组件Home
和About
,定义一个路由配置数组routes
,设置路径与组件的映射关系。
接着就是通过createRouter
方法指定路由模式为history
以及传入路由配置数组。
最后使用 app.use
方法以插件的方式来安装路由,use方法
执行时,会去找router
对象的install
方法,所以每一个插件都必须实现一个install
方法
Tips:
插件可以是一个带
install()
方法的对象,亦或直接是一个将被用作install()
方法的函数。插件选项 (app.use()
的第二个参数) 将会传递给插件的install()
方法。若
app.use()
对同一个插件多次调用,该插件只会被安装一次。
一、 路由对象的创建 :createRouter
接下来,我们从创建路由对象开始探究:Vue Router
提供了一个 createRouter API
,你可以通过它来创建一个路由对象,我们来看它的实现:
javascript
function createRouter(options) {
// 定义一些辅助方法和变量
// ...
// 创建 router 对象
const router = {
// 当前路径
currentRoute,
addRoute,
removeRoute,
hasRoute,
getRoutes,
resolve,
options,
push,
replace,
go,
back: () = >go(-1),
forward: () = >go(1),
beforeEach: beforeGuards.add,
beforeResolve: beforeResolveGuards.add,
afterEach: afterGuards.add,
onError: errorHandlers.add,
isReady,
install(app) {
// 安装路由函数
}
}
return router
}
createRouter
会定义非常多的辅助方法和变量,我们现在不需要关注。只要知道它会返回一个router
对象,里面有currentRoute
等数据,这代表当前路径信息。除此之外,还有其他辅助参数和辅助方法。
其中install
方法是安装路由的核心。
创建完路由对象后,我们现在来安装它。
二、安装路由:install
我们在开头提过,以插件的方式进行安装的时候,会执行插件对象的install
方法,并且会将app
作为参数传入。
所以我们接着关注install
方法:
javascript
const router = {
install(app) {
const router = this;
// 注册路由组件
app.component("RouterLink", RouterLink);
app.component("RouterView", RouterView);
// 全局配置定义 $router 和 $route
app.config.globalProperties.$router = router;
Object.defineProperty(app.config.globalProperties, "$route", {
get: () => unref(currentRoute),
});
// 在浏览器端初始化导航
if (
isBrowser &&
!started &&
currentRoute.value === START_LOCATION_NORMALIZED
) {
// see above
started = true;
push(routerHistory.location).catch((err) => {
warn("Unexpected error when starting the router:", err);
});
}
// 路径变成响应式
const reactiveRoute = {};
for (let key in START_LOCATION_NORMALIZED) {
reactiveRoute[key] = computed(() => currentRoute.value[key]);
}
// 全局注入 router 和 reactiveRoute
app.provide(routerKey, router);
app.provide(routeLocationKey, reactive(reactiveRoute));
let unmountApp = app.unmount;
installedApps.add(app);
// 应用卸载的时候,需要做一些路由清理工作
app.unmount = function () {
installedApps.delete(app);
if (installedApps.size < 1) {
removeHistoryListener();
currentRoute.value = START_LOCATION_NORMALIZED;
started = false;
ready = false;
}
unmountApp.call(this, arguments);
};
},
};
路由的安装的过程我们需要记住以下两件事情。
-
首先需要全局注册
RouterView
和RouterLink
组件,这也是为什么你安装后无需自己再手动注册就能使用这两个组件的原因。 -
我们之前探究过
provide
的实现原理,使用provide
注册的对象可以在其子孙组件中使用。 -
这里通过
provide
方式全局注入router
对象和reactiveRoute
对象,其中router
表示用户通过createRouter
创建的路由对象,我们可以通过它去动态操作路由,reactiveRoute
表示响应式的路径对象,它维护着路径的相关信息。
三、 路由切换
所谓路由切换,本质上可以视为依据不同的路径选择,将用户导向相应的页面。
1.初始值对象:START_LOCATION_NORMALIZED
首先我们先介绍一个当前路径对象currentRoute
的初始值: START_LOCATION_NORMALIZED
javascript
const START_LOCATION_NORMALIZED = {
path: "/",
name: undefined,
params: {},
query: {},
hash: "",
fullPath: "/",
matched: [],
meta: {},
redirectedFrom: undefined,
};
可以看到对象上有path
、name
等等属性。
vue-router
给我们提供了非常多的切换路径Api
,比如router.push
,router.replace
等等,它们的底层其实都是通过pushWithRedirect
方法来实现的。
javascript
function push(to: RouteLocationRaw | RouteLocation) {
return pushWithRedirect(to)
}
function replace(to: RouteLocationRaw | RouteLocationNormalized) {
return push(assign(locationAsObject(to), { replace: true }))
}
2. 切换路径:pushWithRedirect
javascript
function pushWithRedirect(to, redirectedFrom) {
const targetLocation = (pendingLocation = resolve(to));
const from = currentRoute.value;
const data = to.state;
const force = to.force;
const replace = to.replace === true;
const toLocation = targetLocation;
toLocation.redirectedFrom = redirectedFrom;
let failure;
if (!force && isSameRouteLocation(stringifyQuery$1, from, targetLocation)) {
failure = createRouterError(16 /* NAVIGATION_DUPLICATED */, {
to: toLocation,
from,
});
handleScroll(from, from, true, false);
}
return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
.catch((error) => {
if (
isNavigationFailure(
error,
4 /* NAVIGATION_ABORTED */ |
8 /* NAVIGATION_CANCELLED */ |
2 /* NAVIGATION_GUARD_REDIRECT */
)
) {
return error;
}
return triggerError(error);
})
.then((failure) => {
if (failure) {
// 处理错误
} else {
failure = finalizeNavigation(toLocation, from, true, replace, data);
}
triggerAfterEach(toLocation, from, failure);
return failure;
});
}
- 参数
to
有多种形式,可以是字符串路径,可以是带有路径的对象或者查询参数的对象等等。比如下面的例子:
javascript
// 字符串路径
router.push('/users/eduardo')
// 带有路径的对象
router.push({ path: '/users/eduardo' })
// 命名的路由,并加上参数,让路由建立 url
router.push({ name: 'user', params: { username: 'eduardo' } })
// 带查询参数,结果是 /register?plan=private
router.push({ path: '/register', query: { plan: 'private' } })
// 带 hash,结果是 /about#team
router.push({ path: '/about', hash: '#team' })
所以pushWithRedirect
第一步就是先通过reslove
方法返回一个新的路径对象,这个新的路径对象会多一个matchedRoute
属性,现在暂时不用管。
- 拿到新的路径对象后,就会执行
navigate
方法,这是一个处理导航守卫的函数,现在可以先忽略。
3. navigate
执行后,就会执行 finalizeNavigation
完成导航,这个finalizeNavigation
函数才是真正切换路径的核心
3. 核心:finalizeNavigation
javascript
function finalizeNavigation(toLocation, from, isPush, replace, data) {
const error = checkCanceledNavigation(toLocation, from);
if (error) return error;
const isFirstNavigation = from === START_LOCATION_NORMALIZED;
const state = !isBrowser ? {} : history.state;
if (isPush) {
if (replace || isFirstNavigation)
routerHistory.replace(
toLocation.fullPath,
assign(
{
scroll: isFirstNavigation && state && state.scroll,
},
data
)
);
else routerHistory.push(toLocation.fullPath, data);
}
currentRoute.value = toLocation;
handleScroll(toLocation, from, isPush, isFirstNavigation);
markAsReady();
}
-
finalizeNavigation
主要做两件事,第一个就是更新当前的路径currentRoute
的值 -
第二个就是执行
routerHistory.push
或者是routerHistory.replace
方法更新浏览器的URL
的记录。
同学们在这里可能会疑惑,这个routerHistory
对象是什么东西,从哪里来的?
我们回顾下路由创建时代码:
javascript
// main.js
const router = createRouter({
history: createWebHistory(),
routes,
});
const app = createApp({});
app.use(router);
createWebHistory
方法会创建一个routerHistory
对象并且赋值给history属性。
history
属性会和routes
路径映射数组一起当作参数options
传入createRouter
方法里面。
在createRouter
里面会将它赋值给routerHistory
变量
javascript
let routerHistory = options.history
4. history对象 :createWebHistory
在我们创建 router
对象的时候,会创建一个 history
对象,前面提到 Vue Router
支持三种模式,这里我们重点分析 HTML5
的 history
的模式:
javascript
function createWebHistory(base) {
base = normalizeBase(base);
const historyNavigation = useHistoryStateNavigation(base);
const historyListeners = useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
);
function go(delta, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners();
history.go(delta);
}
const routerHistory = assign(
{
// it's overridden right after
location: "",
base,
go,
createHref: createHref.bind(null, base),
},
historyNavigation,
historyListeners
);
Object.defineProperty(routerHistory, "location", {
get: () => historyNavigation.location.value,
});
Object.defineProperty(routerHistory, "state", {
get: () => historyNavigation.state.value,
});
return routerHistory;
}
history
对象有两个功能,第一个是通过historyNavigation
来实现切换路径,第二个是通过historyListeners
实现路由变化的监听。 一个切换,一个监听。这两个功能就是能实现页面路径变化无需刷新的关键。
(1)historyNavigation
historyNavigation
是 useHistoryStateNavigation
函数的返回值,我们来看它的实现:
javascript
function useHistoryStateNavigation(base) {
const { history, location } = window;
let currentLocation = {
value: createCurrentLocation(base, location),
};
let historyState = { value: history.state };
if (!historyState.value) {
changeLocation(
currentLocation.value,
{
back: null,
current: currentLocation.value,
forward: null,
position: history.length - 1,
replaced: true,
scroll: null,
},
true
);
}
function changeLocation(to, state, replace) {
const url =
createBaseLocation() +
// preserve any existing query when base has a hash
(base.indexOf("#") > -1 && location.search
? location.pathname + location.search + "#"
: base) +
to;
try {
history[replace ? "replaceState" : "pushState"](state, "", url);
historyState.value = state;
} catch (err) {
warn("Error with push/replace State", err);
location[replace ? "replace" : "assign"](url);
}
}
function replace(to, data) {
const state = assign(
{},
history.state,
buildState(
historyState.value.back,
// keep back and forward entries but override current position
to,
historyState.value.forward,
true
),
data,
{ position: historyState.value.position }
);
changeLocation(to, state, true);
currentLocation.value = to;
}
function push(to, data) {
const currentState = assign({}, historyState.value, history.state, {
forward: to,
scroll: computeScrollPosition(),
});
if (!history.state) {
warn(
`history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` +
`history.replaceState(history.state, '', url)\n\n` +
`You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`
);
}
changeLocation(currentState.current, currentState, true);
const state = assign(
{},
buildState(currentLocation.value, to, null),
{ position: currentState.position + 1 },
data
);
changeLocation(to, state, false);
currentLocation.value = to;
}
return {
location: currentLocation,
state: historyState,
push,
replace,
};
}
我们直接看返回的对象里面含有push
和replace
函数,这两个函数会添加给routerHistory
对象上,因此当我们调用 routerHistory.push
或者是 routerHistory.replace
方法的时候实际上就是在执行这两个函数。
push
和 replace
方法内部都是执行了changeLocation
方法,该函数内部执行了浏览器底层的 history.pushState
或者history.replaceState
方法,会向当前浏览器会话的历史堆栈中添加一个状态,这样就在不刷新页面的情况下修改了页面的 URL
。
我们使用这种方法修改了路径,这个时候假设我们点击浏览器的回退按钮回到上一个 URL,这需要恢复到上一个路径以及更新路由视图,因此我们还需要监听这种history
变化的行为,做一些相应的处理。
historyListeners
History
变化的监听主要是通过 historyListeners
来完成的,它是 useHistoryListeners
函数的返回值,我们来看它的实现
javascript
function useHistoryListeners(base, historyState, currentLocation, replace) {
let listeners = [];
let teardowns = [];
let pauseState = null;
const popStateHandler = ({ state }) => {
const to = createCurrentLocation(base, location);
const from = currentLocation.value;
const fromState = historyState.value;
let delta = 0;
if (state) {
currentLocation.value = to;
historyState.value = state;
if (pauseState && pauseState === from) {
pauseState = null;
return;
}
delta = fromState ? state.position - fromState.position : 0;
} else {
replace(to);
}
listeners.forEach((listener) => {
listener(currentLocation.value, from, {
delta,
type: NavigationType.pop,
direction: delta
? delta > 0
? NavigationDirection.forward
: NavigationDirection.back
: NavigationDirection.unknown,
});
});
};
function pauseListeners() {
pauseState = currentLocation.value;
}
function listen(callback) {
listeners.push(callback);
const teardown = () => {
const index = listeners.indexOf(callback);
if (index > -1) listeners.splice(index, 1);
};
teardowns.push(teardown);
return teardown;
}
function beforeUnloadListener() {
const { history } = window;
if (!history.state) return;
history.replaceState(
assign({}, history.state, { scroll: computeScrollPosition() }),
""
);
}
function destroy() {
for (const teardown of teardowns) teardown();
teardowns = [];
window.removeEventListener("popstate", popStateHandler);
window.removeEventListener("beforeunload", beforeUnloadListener);
}
window.addEventListener("popstate", popStateHandler);
window.addEventListener("beforeunload", beforeUnloadListener);
return {
pauseListeners,
listen,
destroy,
};
}
-
还是一样,我们直接看返回的对象里的
listen
方法,允许你添加一些侦听器,侦听history
的变化,同时这个方法也被挂载到了routerHistory
对象上,这样外部就可以访问到了。 -
该函数内部还监听了浏览器底层
Window
的popstate
事件,当我们点击浏览器的回退按钮或者是执行了history.back
方法的时候,会触发事件的回调函数popStateHandler
,进而遍历侦听器listeners
,执行每一个侦听器函数。
那么,问题来了,Vue Router
是如何添加这些侦听器的呢?
答案就是在安装路由执行install
方法时,会先执行一次初始化导航,里面调用了push
方法,进而执行了 finalizeNavigation
方法。
javascript
// this initial navigation is only necessary on client, on server it doesn't
// make sense because it will create an extra unnecessary navigation and could
// lead to problems
if (
isBrowser &&
// used for the initial navigation client side to avoid pushing
// multiple times when the router is used in multiple apps
!started &&
currentRoute.value === START_LOCATION_NORMALIZED
) {
// see above
started = true
push(routerHistory.location).catch(err => {
if (__DEV__) warn('Unexpected error when starting the router:', err)
})
}
在 finalizeNavigation
的最后,会执行 markAsReady
方法,我们来看它的实现:
javascript
function markAsReady(err) {
if (ready) return;
ready = true;
setupListeners();
readyHandlers
.list()
.forEach(([resolve, reject]) => (err ? reject(err) : resolve()));
readyHandlers.reset();
}
markAsReady
内部会执行 setupListeners
函数初始化侦听器,且保证只初始化一次。我们再接着来看 setupListeners
的实现
javascript
function setupListeners() {
removeHistoryListener = routerHistory.listen((to, _from, info) => {
const toLocation = resolve(to);
pendingLocation = toLocation;
const from = currentRoute.value;
if (isBrowser) {
saveScrollPosition(
getScrollKey(from.fullPath, info.delta),
computeScrollPosition()
);
}
navigate(toLocation, from)
.catch((error) => {
if (
isNavigationFailure(
error,
4 /* NAVIGATION_ABORTED */ | 8 /* NAVIGATION_CANCELLED */
)
) {
return error;
}
if (isNavigationFailure(error, 2 /* NAVIGATION_GUARD_REDIRECT */)) {
if (info.delta) routerHistory.go(-info.delta, false);
pushWithRedirect(error.to, toLocation).catch(noop);
// avoid the then branch
return Promise.reject();
}
if (info.delta) routerHistory.go(-info.delta, false);
return triggerError(error);
})
.then((failure) => {
failure = failure || finalizeNavigation(toLocation, from, false);
if (failure && info.delta) routerHistory.go(-info.delta, false);
triggerAfterEach(toLocation, from, failure);
})
.catch(noop);
});
}
setupListeners
函数也是执行 navigate
方法,执行路由切换过程中的一系列导航守卫函数,在 navigate
成功后执行 finalizeNavigation
完成导航,完成真正的路径切换。这样就保证了在用户点击浏览器回退按钮后,可以恢复到上一个路径以及更新路由视图。
总结
通过上述机制,Vue Router
实现了在单页面应用中无需重新加载页面即可完成路径切换的功能。它通过维护 currentRoute
对象来管理当前路径状态,利用浏览器的 history
API(history.pushState
和 history.replaceState
) 动态更新 URL,并通过监听 popstate
事件处理用户点击回退按钮的情况。这种设计不仅提升了用户体验,还使得路径管理更加灵活和高效。