Vue Router 的原理是什么?怎样手写一个 mini router?
路由器做三件事:监听地址变化、把路径匹配成路由记录、把当前路由放进响应式数据让 RouterView 重新渲染。手写版用一个 history 对象封装 hash 或 pushState,用 shallowRef 保存当前路由,再实现 RouterView、RouterLink 和 install。
简答
点一下导航里的“用户详情”,地址栏变成
/user/1,页面中间换成用户组件,整个页面却没有重新加载。Vue Router 做的就是这件事。它由三部分组成。history 层负责改地址、听地址变化,hash 模式靠
hashchange,HTML5 模式靠pushState加popstate。匹配层把路径和路由表比对,找出对应的组件,顺便解析出:id这样的参数。渲染层把当前路由存在响应式数据里,RouterView读它,所以路由一变,RouterView就渲染新的组件。手写时,
router.push调 history 改地址,再匹配出新路由赋给响应式变量;RouterLink渲染一个<a>,点击时阻止默认跳转改调push;install里注册这两个组件,再用provide把 router 交给组件使用。要注意,
pushState不会触发popstate,所以push之后要自己更新当前路由;HTML5 模式下刷新页面会真的请求这个路径,服务器要回退到index.html。
先拆成三层
官方文档列出了 app.use(router) 做的事:全局注册 RouterView 和 RouterLink,添加 $router 和 $route,启用 useRouter()、useRoute(),并触发初始路由的解析。手写版只要实现其中的主干就够了。
hash 和 history 两种模式怎样监听地址、服务器怎样配置回退,已经在《hash 路由和 history 路由的原理是什么?服务器要怎样配置回退?》里讲过,这里把它们看成一个统一的 history 对象:能读当前路径,能跳转,能在用户前进后退时通知路由器。导航守卫的执行顺序见《Vue Router 有哪些导航守卫?完整的导航流程是怎样的?》,手写版只保留 beforeEach。
匹配层:把路径变成路由
匹配层和浏览器无关,可以单独用 Node 运行。下面把 /user/:id 这样的路径编译成正则,冒号开头的段落变成捕获组,匹配成功时再按顺序取出参数。
function createMatcher(routes) {
const records = routes.map((route) => {
const keys = [];
const pattern = route.path.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
});
return { route, keys, regex: new RegExp('^' + pattern + '$') };
});
return function match(path) {
for (const { route, keys, regex } of records) {
const result = regex.exec(path);
if (result) {
const params = {};
keys.forEach((key, index) => {
params[key] = decodeURIComponent(result[index + 1]);
});
return { ...route, path, params };
}
}
return { path, name: 'not-found', params: {} };
};
}
const match = createMatcher([
{ path: '/', name: 'home' },
{ path: '/user/:id', name: 'user' },
{ path: '/user/:id/post/:postId', name: 'post' },
]);
console.log(match('/').name); // home
console.log(match('/user/42').params.id); // 42
console.log(match('/user/42/post/7').params.postId); // 7
console.log(match('/about').name); // not-found
正则两头加了 ^ 和 $,所以 /user/42 不会被 / 匹配上,/user/42/post/7 也不会被 /user/:id 截走。([^/]+) 只匹配到下一个斜杠为止,一个参数不会吃掉后面的路径。
手写版按书写顺序找第一个匹配的路由,真正的 Vue Router 匹配语法更丰富,还支持自定义参数正则、可选参数和重复参数。找不到时返回 not-found,对应官方建议配置的 /:pathMatch(.*) 兜底路由。
路由器:push 之后自己更新当前路由
路由器把 history 和匹配器连起来。当前路由放在 shallowRef 里,因为每次导航都整体换成一个新对象,不需要深层响应。
import { shallowRef } from 'vue';
function createRouter({ history, routes }) {
const match = createMatcher(routes);
const currentRoute = shallowRef(match(history.location()));
const guards = [];
async function navigate(path, mode) {
const to = match(path);
for (const guard of guards) {
if ((await guard(to, currentRoute.value)) === false) return;
}
if (mode === 'push') history.push(path);
if (mode === 'replace') history.replace(path);
currentRoute.value = to;
}
// 前进后退时地址已经变了,只需要更新当前路由
history.listen((path) => navigate(path, 'pop'));
return {
currentRoute,
push: (path) => navigate(path, 'push'),
replace: (path) => navigate(path, 'replace'),
beforeEach: (guard) => guards.push(guard),
install(app) {
app.provide('router', this);
app.config.globalProperties.$router = this;
app.component('RouterView', RouterView);
app.component('RouterLink', RouterLink);
},
};
}
为什么 push 里改完地址还要手动给 currentRoute 赋值?MDN 说明,调用 pushState 不会触发 popstate,popstate 只在用户前进、后退等切换历史记录时触发。所以主动跳转时路由器得自己更新,被动的前进后退才交给监听器。
match 返回的对象展开了整条路由记录,路由表里写的 component 也会一起带出来,RouterView 就靠它渲染。
HTML5 模式的 history 对象大致这样写:
function createWebHistory() {
return {
location: () => window.location.pathname,
push: (path) => window.history.pushState({}, '', path),
replace: (path) => window.history.replaceState({}, '', path),
listen: (callback) => {
window.addEventListener('popstate', () => callback(window.location.pathname));
},
};
}
hash 模式只要把读路径换成 location.hash.slice(1) || '/',跳转改成设置 location.hash,监听改成 hashchange。设置 location.hash 本身会触发 hashchange,所以 hash 版的 push 改完地址后可以不手动更新,交给监听器统一处理,免得一次跳转更新两遍。
RouterView 和 RouterLink
RouterView 读当前路由,渲染对应的组件。它在渲染函数里读了 currentRoute.value,所以路由一换,它就重新渲染。
import { h, inject } from 'vue';
const RouterView = {
setup() {
const router = inject('router');
return () => {
const component = router.currentRoute.value.component;
return component ? h(component) : null;
};
},
};
const RouterLink = {
props: { to: { type: String, required: true } },
setup(props, { slots }) {
const router = inject('router');
function onClick(event) {
event.preventDefault();
router.push(props.to);
}
return () => h('a', { href: props.to, onClick }, slots.default?.());
},
};
RouterLink 仍然渲染真正的 <a href>,这样能看到链接地址、能用新标签页打开。点击时用 preventDefault 拦住浏览器的整页跳转,改调 router.push。官方文档也说,用 RouterLink 代替普通 <a>,是为了让 Vue Router 不重新加载页面就能改地址。
这两个组件用到的 h() 写法见《Vue 的渲染函数、JSX 和 h() 怎样使用?》。
使用方式和官方一样,use 要写在 mount 前面:
const router = createRouter({ history: createWebHistory(), routes });
createApp(App).use(router).mount('#app');
常见追问
手写版和真正的 Vue Router 差在哪里?
手写版只保留了监听地址、匹配、响应式渲染这条主线。官方版本还支持嵌套路由、命名路由、完整的导航守卫流程、路由懒加载和滚动行为控制,这些在 Vue Router 文档里各有专门章节。
memory 模式是做什么的?
createMemoryHistory()不依赖浏览器环境,不读也不改地址栏,适合 Node 和 SSR。它也不会自动触发初始导航,要在app.use(router)之后自己push初始路由。在浏览器里用它,前进后退就没有效果。