2025年|浏览器前端路由实现
问题关键点
路由,hash路由,History路由,无刷新,SPA
路由是什么
- 路由(Routing) 是指根据不同的 URL 地址(路径)来显示不同的内容或页面的机制。
- 服务端路由(Server Routing):URL 发送到服务器,由服务器决定返回哪个页面或数据。
- 前端路由(Client-side Routing):在浏览器端,根据 URL 的变化动态切换页面内容,而无需重新加载整个页面。
路由(routing)就是通过互联的网络把信息从源地址传输到目的地址的活动。对于 Web 开发来说,路由的实质是 URL 到对应的处理程序的映射。Web路由既可以由服务端,也可以由前端实现。其中前端路由根据实现方式的不同,可以分为Hash路由和History路由。
前端路由对于服务端路由来说,最显著的特点是页面可以在无刷新的情况下进行页面的切换。基于前端路由的这一特点,诞生了一种无刷新的单页应用开发模式SPA。SPA通过前端路由避免了页面的切换打断用户体验,让Web应用的体验更接近一个桌面应用程序。
前端路由的实现方式和实现原理
- 前端路由主要通过 监听 URL 的变化 来控制页面内容的渲染,常见实现方式有两种:
方法一:Hash 模式 - URL 中带 #(例如:https://music.163.com/?from=infinity#/song?id=5308028)
- 浏览器不会向服务器发送 # 后面的内容
- 通过监听 window.onhashchange 来捕捉 URL 变化fragment 本质是用来标识次级资源,fragment 有以下特点:
1
2
3
4
5
6
7foo://example.com:8042/over/there?name=ferret#nose
\_/ \______________/\_________/ \_________/ \__/
| | | | |
scheme authority path query fragment
| _____________________|__
/ \ / \
urn:example:animal:ferret:nose - 修改 fragment 的内容不会触发网页重载。
- 修改 fragment 的内容会改变浏览器的历史记录。
- 修改 fragment 的内容会触发浏览器的 onhashchange 事件。
- 基于 fragment 的以上特点,可实现基于 Hash 的前端路由。
原理: - 用户点击链接 → 改变 hash
- hashchange 事件触发
- 根据 hash 渲染对应的页面内容
- 优点:兼容性好,不会刷新页面, 无需服务端配置
- 缺点:URL不够美观,无法完全利用浏览器历史API,服务端无法获取
hash部分内容,可能和锚点功能冲突,SEO不友好。
M1M21
2
3
4window.addEventListener('hashchange', () => {
const route = location.hash.slice(1); // 去掉 #
render(route);
});方法二:History 模式(HTML5 History API)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54/**
* 解析 hash
* @param hash
* @returns
*/
function parseHash(hash) {
// 去除 # 号
hash = hash.replace(/^#/, "");
// 简单解析示例
const parsed = hash.split("?");
//如果 hash 中没有 ?,search 会是 undefined。
// 返回 hash 的 path 和 query
return {
pathname: parsed[0],
search: parsed[1],
};
//如果URL hash是#page?foo=1&bar=2
//const parsedHashRes = parseHash("#page?foo=1&bar=2");
//console.log(parsedHashRes);
// { pathname: "page", search: "foo=1&bar=2" }
//const params = new URLSearchParams(parsedHashRes.search);
//console.log(params.get("foo")) // "1"
//console.log(params.get("bar")) // "2"
/**
for (const [key, value] of params) {
console.log(key, value);
}
**/
//foo 1
//bar 2
}
/**
* 监听 hash 变化
* @returns
*/
function onHashChange() {
// 解析 hash
const { pathname, search } = parseHash(location.hash);
// 切换页面内容
switch (pathname) {
case "/home":
document.body.innerHTML = `Hello ${search}`;
return;
default:
return;
}
}
window.addEventListener("hashchange", onHashChange); - URL 看起来像正常路径(例如:http://example.com/home)
- 利用 history.pushState() 和 history.replaceState() 修改浏览器历史记录
- history.pushState:将给定的 Data 添加到当前标签页的历史记录栈中。
- history.replaceState:将给定的 Data 更新到历史记录栈中最新的一条记录中。
- 监听 popstate 事件来检测浏览器前进/后退
原理: - 用户点击链接 → 调用 pushState 改变 URL
- 渲染对应页面内容
- 用户按浏览器回退 → 触发 popstate 事件 → 渲染对应页面
- 优点:前端监控友好,SEO相对Hash路由友好,服务端可获取完整的链接和参数。
- 缺点:兼容性稍弱。,刷新页面会请求服务器,服务器需要配置重定向到入口页(各 path 均指向同一个 HTML如 index.html)
M1M21
2
3
4
5
6
7
8window.addEventListener('popstate', () => {
render(location.pathname);
});
function navigate(path) {
history.pushState({}, '', path);
render(path);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26function onHistoryChange() {
const {pathname, search} = location;
switch(){
case "/home":
document.body.innnerHTML = `Hello ${search.replace(/^\?/,"")}`
return;
default:
document.body.innerHTML = "hello world";
return;
}
}
function pushState(target) {
history.pushState(null, "", target);
onHistoryChange()
}
setTimeout(() => {
pushState("/home?name=HZSDD")
},3000)
setTimeout(() => {
history.back()
},6000)
window.addEventListener("popstate", onHistoryChange)前端路由和服务端路由的区别
特性 前端路由 服务端路由 页面刷新 页面不刷新,只渲染局部 每次请求 URL 都刷新页面 URL 变化 浏览器地址栏变化,但请求不发送到服务器 URL 变化时,浏览器向服务器发请求 响应速度 快,局部渲染 慢,需要重新加载整个页面 SEO 不太友好(除非使用 SSR) 好,搜索引擎可直接抓取 技术实现 JS + hash/history API 服务器端路由框架(Express、Django、Rails 等)
前端路由的优势
- 体验更流畅:页面不刷新,用户操作即时响应
- 减少服务器压力:不需要每次请求都重新渲染整个页面
- 灵活控制页面渲染:可以按需加载组件或模块,提高性能
- 便于构建单页应用(SPA):结合 Vue、React、Angular 等框架使用
前端路由的不足
- 首次加载慢:单页应用需要一次性加载 JS 包
- SEO不友好:搜索引擎抓取困难,需要 SSR 或 prerender 解决
- 浏览器刷新问题:History 模式下刷新会向服务器发请求,服务器需配置重定向
- JS 依赖:如果用户禁用 JS,页面可能无法正常访问
参考资料:
- 剑指前端 Offer-前端路由实现
- chatgpt 4
- Hash嵌套路由解析 -> *Prac.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88function parseHash(hash) {
hash = hash.replace(/^#/, "");
const [pathname, search] = hash.split("?");
const query = {};
if (search) {
const params = new URLSearchParams(search);
for (const [key, value] of params) {
const keys = key.split(/[\[\]]+/).filter(Boolean);
let parsedValue;
// 改进的类型转换逻辑
if (value === "") {
parsedValue = "";
} else if (!isNaN(value) && value.trim() !== "" && value.trim() !== "NaN") {
// 避免将 "NaN" 转换为数字
const num = Number(value);
parsedValue = String(num) === value.trim() ? num : value;
} else if (value.toLowerCase() === "true") {
parsedValue = true;
} else if (value.toLowerCase() === "false") {
parsedValue = false;
} else if (value === "null") {
parsedValue = null;
} else if (value === "undefined") {
parsedValue = undefined;
} else {
parsedValue = value;
}
let current = query;
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
// 处理数组语法:tags[] 或 tags[0]
const isArrayKey = k === "" || (!isNaN(k) && k !== "");
if (i === keys.length - 1) {
// 最终赋值
if (isArrayKey) {
// 如果是数组语法,确保父级是数组
const parentKey = keys[i - 1];
if (i > 0 && current[parentKey] && !Array.isArray(current[parentKey])) {
current[parentKey] = [current[parentKey]];
}
if (Array.isArray(current)) {
current.push(parsedValue);
} else {
current[k] = parsedValue;
}
} else if (current[k] !== undefined) {
// 处理重复键名
if (!Array.isArray(current[k])) {
current[k] = [current[k]];
}
current[k].push(parsedValue);
} else {
current[k] = parsedValue;
}
} else {
// 遍历嵌套对象
if (!current[k] || typeof current[k] !== "object") {
current[k] = isArrayKey ? [] : {};
}
current = current[k];
}
}
}
}
return { pathname, query };
}
// 增强的测试用例
const testCases = [
"#page?foo=1&bar=2&bar=3&tags[]=a&tags[]=b&empty&flag=true&count=0&user[name]=Alice&user[age]=20",
"#home?ids[0]=1&ids[1]=2&filter[name]=John&filter[active]=true",
"#test?single=hello&number=42&bool=false&nullval=null",
"#empty",
"#path?arr=1&arr=2&obj[a]=1&obj[b]=2"
];
testCases.forEach(test => {
console.log(test);
console.log(parseHash(test));
console.log("---");
});