SPA Router 导航器的三种实现方式
前阵子有个读者私信问我:“老哥,我看网上讲前端路由的文章,要么只贴概念,要么直接甩源码,能不能给几段真正能跑的代码,让我一眼看懂三种实现到底差在哪?”
我说行,今天就写一篇,把我这些年写 SPA 时踩过的坑和用过的方案摊开聊。
Hash 实现:# 号后面的世界
核心就两件事:监听 hashchange,切组件。
// 路由表
const routes = {
'/home': () => render('Home'),
'/about': () => render('About'),
'/user/:id': (params) => render('User', params)
};
// 监听 hash 变化
window.addEventListener('hashchange', () => {
const path = location.hash.slice(1) || '/home';
matchRoute(path);
});
// 初始化
matchRoute(location.hash.slice(1) || '/home');URL 变化过程
| 操作 | 浏览器地址栏 | 页面刷新? | 服务器收到请求 |
|---|---|---|---|
| 初始访问 | https://example.com/ | 是 | GET / |
| 点击链接 | https://example.com/#/about | 否 | 不请求 |
| 刷新页面 | https://example.com/#/about | 是 | GET /(# 后不发) |
| 直接输入带 # 的 URL | https://example.com/#/user/123 | 是 | GET / |
关键点:# 号及后面的内容不会发给服务器。所以你随便刷新,服务器永远只收到对 / 的请求,根本不会 404。这也是 Hash 模式"零配置"的原因。
History 实现:把 # 号干掉
用 history.pushState 改路径,监听 popstate 做回退。
// 导航
function navigate(path) {
history.pushState({ path }, '', path);
matchRoute(path);
}
// 浏览器前进/后退
window.addEventListener('popstate', (e) => {
matchRoute(location.pathname);
});
// 拦截 <a> 标签点击(防止默认跳转)
document.addEventListener('click', (e) => {
if (e.target.tagName === 'A') {
e.preventDefault();
navigate(e.target.getAttribute('href'));
}
});URL 变化过程
| 操作 | 浏览器地址栏 | 页面刷新? | 服务器收到请求 |
|---|---|---|---|
| 初始访问 | https://example.com/ | 是 | GET / |
| JS 导航 | https://example.com/about | 否 | 不请求 |
| 刷新页面 | https://example.com/about | 是 | GET /about ⚠️ |
| 直接输入 URL | https://example.com/user/123 | 是 | GET /user/123 ⚠️ |
坑来了:一旦刷新或直接访问子路径,浏览器会真的向服务器请求 /about 或 /user/123。如果服务器上没有对应文件,直接 404。
我第一次部署 History 模式的 SPA 时,刷新页面全白,控制台一片红,还以为是前端代码 bug,查了半天才发现是服务器没配 fallback。
Vue Router:一行代码切换两种模式
Vue Router 本质上就是上面两种方案的封装,但做得更完善。
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router';
// History 模式(需服务端支持)
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/user/:id', component: User }
]
});
// Hash 模式(零配置,把上面 createWebHistory 换成下面这个)
// history: createWebHashHistory(),URL 变化对比
| 模式 | 地址栏示例 | 刷新行为 | 服务端要求 |
|---|---|---|---|
| Hash | /#/about | 安全 | 无 |
| History | /about | 需 fallback | 必须配置 |
Vue Router 还顺手解决了你手写时容易忽略的问题:比如 <router-link> 会自动拦截点击事件,不用你手动 e.preventDefault();比如路由守卫、滚动恢复、懒加载,都是开箱即用。
History 模式的服务器配置大全
这是本文重点。以下配置都是把任意路径回退到 index.html,让前端路由接管。
Nginx
server {
listen 80;
server_name example.com;
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}try_files 的意思是:先找文件,再找目录,都找不到就回退到 index.html。我现在的项目基本都用 Nginx,这段配置复制粘贴就能用。
Apache
.htaccess 文件(放在项目根目录):
<<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>或者直接在 httpd.conf 里配:
<<Directory "/var/www/html">
Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.html [QSA,L]
</Directory>记得确保 mod_rewrite 已启用(a2enmod rewrite),这是我当年用 Apache 时漏掉的一步,配了半天没生效。
Caddy
Caddy 配置最简洁,这也是我后来喜欢它的原因:
example.com {
root * /var/www/html
file_server
try_files {path} {path}/ /index.html
}或者更现代的 Caddyfile v2 写法:
example.com {
root * /var/www/html
file_server
handle {
rewrite {path} {path}/
file_server {
try_files {path} /index.html
}
}
}Lighttpd
server.modules += ( "mod_rewrite" )
url.rewrite-if-not-file = (
"^/(.*)$" => "/index.html"
)或者更稳妥的版本(排除真实文件和目录):
url.rewrite-once = (
"^/(.*\.(js|css|png|jpg|ico|svg|woff|woff2|ttf))$" => "$0",
"^/(.*)$" => "/index.html"
)Node.js / Express
如果你用 Node 做静态服务器,别忘了兜底:
const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(path.join(__dirname, 'dist')));
// 所有非 API 请求回退到 index.html
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'dist', 'index.html'));
});
app.listen(3000);注意:这段 app.get('*') 要放在所有其他路由最后面,否则会把你的 API 接口也吞掉。我排这个 bug 排了半小时。
汇总表
| 服务器 | 核心思路 | 配置难度 |
|---|---|---|
| Nginx | try_files 回退 | ⭐ |
| Apache | mod_rewrite 规则 | ⭐⭐ |
| Caddy | try_files 或 rewrite | ⭐ |
| Lighttpd | url.rewrite | ⭐⭐ |
| Express | 末尾通配路由 | ⭐ |
我的建议
Hash 模式:内部工具、快速原型、懒得配服务器的时候用。URL 丑是丑点,但省心。
History 模式:正式项目、对外产品、有 SEO 需求时用。但务必把上面的服务器配置加上,否则用户刷新一次就流失了。
Vue Router:Vue 项目无脑用。需要 Hash 就 createWebHashHistory(),需要 History 就 createWebHistory(),切换成本为零。
AD: