关于路由
路由其实是根据不同的URL
地址展示不同的内容或页面;
广义上来说,访问路由会映射到相应的函数里,然后由相应的函数来决定返回给这个URL
的内容。路由就是一个匹配过程;
后端路由
在Web前端开发早期,一直是后端路由占据主导地位,不管是PHP
,还是JSP、ASP
,用户通过URL
访问页面时,大多是通过后端路由匹配之后再返回给浏览器。经典面试题[说说浏览器地址栏输入www.baidu.com到网页展示的过程]
其实也是在讲这个道理。
不管是什么语言的Web后端框架,都会有一个专门的路由模块或路由区域,用于匹配用户给出的URL
地址,以及一些表单提交、AJAX
请求的地址。通常遇到无法匹配的路由,后端会返回一个404
状态码,这也是404 NOT FOUND
的由来。
服务端渲染
在后端为主导的年代,网页HTML
一般是后端通过模板引擎渲染好之后响应给前端,这就是服务端渲染。浏览器在地址栏中切换不同的URL
时,每次都会向后端发出请求,服务器响应请求。
服务端渲染的好处有很多,比如对SEO
友好,一些对安全性要求高的页面采用服务端渲染更保险。
Node.js
诞生以后,前端拥有自己的后端模板引擎成为了现实,常见的有ejs、nunjucks
。这些模板引擎搭配Express、Koa
等Node
框架也风靡一时。
不过,随着Web
应用的开发越来越复杂,单纯的服务端渲染问题开始慢慢暴露了出来:耦合性太强!耦合性问题虽然能通过良好的代码结构、规范来解决,但jQuery
时代的页面不好维护也是有目共睹的,全局变量满天飞,代码入侵性太高;后续维护通常也是在给前面的代码打补丁;页面切换白屏问题虽然可以通过AJAX
或iframe
等方案解决,但实际上却进一步增加了可维护性的难度。
前端路由
前端路由:页面跳转的URL
规则匹配由前端来控制;应用最广泛的例子就是当今的SPA
的Web项目。
前端渲染:以Vue
项目为例,浏览器从服务器拿到的HTML
里只有一个<div id="app"></div>
,并搭配一系列js
文件。所以,我们看到的页面其实是通过这些js
渲染出来的。
前端渲染把渲染的任务交给了浏览器,通过客户端的算力来解决页面的构建,在很大程度上缓解了服务端的压力。而且配合前端路由,无缝的页面切换体验,自然对用户是友好的。不过带来的坏处就是对
SEO
不友好,毕竟搜索引擎的爬虫只能爬到上面那个空荡荡的HTML
,而且对浏览器的版本也会有相应的要求。
注意:只要在浏览器地址栏输入URL
再回车,是一定会去后端服务器请求一次的。而如果是在页面里通过点击按钮等操作,利用router
库的api
来进行的URL
更新,则不会去后端服务器请求。
前端路由主要有两种方式:
-
hash
模式,锚点操作,利用hash
值的变化感知路由变化,优点是兼容性高,缺点是URL
带有#
号不好看,而且有些场景如微信分享 会破坏掉#
后面的内容; -
HTML5
的history
模式,优点是URL
不带#
号,缺点是需要浏览器和后端同时支持。
hash模式
hash
是浏览器URL
中 #
后面的内容,包含#
。hash
是URL
中的锚点,代表网页中的一个位置,单单改变 #
后的部分,浏览器只会加载相应位置的内容,不会重新加载页面。
-
#
是用来指导浏览器动作的,对服务器完全无用,HTTP
请求中并不包含#
; - 每一次改变
#
后的部分,都会在浏览器的访问历史中增加一个记录,点击后退按钮,就可以回到上一个位置。
所以说Hash
模式通过锚点值的改变,根据不同的值,渲染指定DOM
位置的不同数据。
https://www.abc.com/xv/Home/index#plan
https://www.abc.com/xv/Home/index/#/add/index
hash
值的变化不会让浏览器重新发起请求,但会触发 window.onhashChange
事件;
触发hashChange
事件的情况:
- 直接更改浏览器地址,在最后面增加或改变
#hash
; - 改变
location.href
或location.hash
的值; - 通过触发点击带锚点的链接;
- 浏览器前进后退可能导致
hash
的变化,前提是两个地址中的文档相同、但hash
值不同。
如果我们在 hashChange
事件中获取当前的hash
值,根据hash
值来修改页面内容,就能达到前端路由的目的。
另外, hashChange
事件回调的对象参数中,有两个比较重要的属性newURL、oldURL
,分别表示当前变化前、后的URL
简略版
#html:
<ul>
<li><a href="#index">首页</a></li>
<li><a href="#news">资讯</a></li>
<li><a href="#user">个人中心</a></li>
</ul>
<div id="app"></div>
#script: 应该封装成Router
const app = document.getElementById('app')
function hashChange(e){
// 当前跳转的新URL 上次的旧URL
console.log(e.newURL, e.oldURL)
// 根据 hash 值决定显示什么内容
switch (location.hash) {
case '#index':
app.innerHTML = '<h1>这是首页内容</h1>'
break
case '#news':
app.innerHTML = '<h1>这是新闻内容</h1>'
break
case '#user':
app.innerHTML = '<h1>这是个人中心内容</h1>'
break
default:
app.innerHTML = '<h1>404</h1>'
}
}
window.onhashchange = hashChange
hashChange()
除此之外,还需要记录当前URL
,监听刷新事件(onload
),在onhashchange
中实现回退和前进等功能。。。
history模式
history
其实浏览器历史栈(历史记录)的一个接口,基于window.history
对象的方法
https://www.plysummer.com/#/plan/index // hash模式路由
https://www.plysummer.com/plan/index // history模式路由
- 在
HTML4
中,已经支持window.history
对象来控制页面历史记录跳转,常用的方法包括:-
history.forward()
在历史栈中前进一步; -
history.back()
在历史栈中后退一步; -
history.go(n)
在历史栈中跳转n
步骤,n=0
为刷新本页,n=-1
为后退一页。
-
- 在
HTML5
中,window.history
对象得到了扩展,新增的API包括:-
history.pushState(data [,title] [,url])
向历史栈中追加一条记录,data
表示需要保存的数据,在触发popstate
事件时,可以在event.state
里获取;# 当前url:https://www.xxx.com/a/ # 1. 对新URL使用绝对路径 history.pushState(null, null, '/qq/') // https://www.xxx.com/qq/ # 2. 对新URL使用相对路径 history.pushState(null, null, './qq/') // https://www.xxx.com/a/qq/ # 3. 对新URL使用完整的同源路径 history.pushState(null, null, 'https://www.xxx.com/kk/qq') // https://www.xxx.com/kk/qq
-
history.replaceState(data [,title] [,url])
替换当前页在历史栈中的记录,其他特性与pushState
一致; -
history.state
是一个属性,可以得到当前页的state
信息; -
history.length
当前历史栈中的记录数; -
window.onpopstate
是一个事件,只有在点击浏览器前进、后退按钮,js
调用forward()、back()、go()
时触发。
-
-
注意:
-
IE9
及其以下版本浏览器是不支持的,IE10
开始支持。vue-router
会检测浏览器版本,当无法启用history
模式时会自动降级为hash
模式; -
pushState()/replaceState()
虽然可以改变历史栈,让浏览器地址栏中的URL
发生变化,但并不会向后端发起请求! -
pushState()/replaceState()
对URL
的修改受同源策略限制,防止恶意脚本模仿其他网站的URL
欺骗用户,所以当违背同源策略时将会报错; - 火狐目前会忽略
title
参数。
-
- 简略版
# html
<ul id="menu">
<li><a href="/index">首页</a></li>
<li><a href="/news">资讯</a></li>
<li><a href="/user">个人中心</a></li>
</ul>
<div id="app"></div>
# script: 应该封装为Router
document.querySelector('#menu').addEventListener('click', e => {
if(e.target.nodeName === 'A') {
e.preventDefault() // 阻止 <a> 的默认事件,默认的跳转会刷新页面
//获取超链接的href,改为 pushState 跳转,不刷新页面
const path = e.target.getAttribute('href')
// 修改浏览器中显示的 url
window.history.pushState(null, null, path)
// 根据path,更改页面内容
render(path)
}
})
const app = document.getElementById('app')
function render(path) {
switch (path) {
case '/index':
app.innerHTML = '<h1>这是首页内容</h1>'
break
case '/news':
app.innerHTML = '<h1>这是新闻内容</h1>'
break
case '/user':
app.innerHTML = '<h1>这是个人中心内容</h1>'
break
default:
app.innerHTML = '<h1>404</h1>'
}
}
//监听浏览器前进后退事件,并根据当前路径渲染页面
window.onpopstate = e => {
render(location.pathname)
}
//第一次进入页面显示首页
render('/index')
我们还可以通过自定义事件,实现对history.pushState
和history.replaceState
的监听。
var _rewrite = function(type) {
var fn = window.history[type] // 保存原函数的引用
var evt = new Event(type) // 自定义事件
return function() { // 闭包
// 调用原函数
var res = fn.apply(this, arguments)
evt.arguments = arguments // 把参数塞进去
window.dispatchEvent(evt) // 分发事件
return res
}
}
// 重写方法
window.history.pushState = _rewrite('pushState')
window.history.replaceState = _rewrite('replaceState')
// 监听自定义事件
window.addEventListener('replaceState', e => {
console.log('replaceState: ', e.arguments)
})
window.addEventListener('pushState', e => {
console.log('pushState: ', e.arguments)
})
404问题
在前端做页面跳转时,通常是利用
history API
完成的,router
库调用history.pushState()
跟后端没有任何关系。但是一旦从浏览器地址栏里输入一个URL(不管是否有效)
并回车或者手动刷新页面,那就会向后端发起一个GET
请求。而后端路由表中又没有配置相应的路由,那么自然就会返回404 NOT FOUND
!这也就是为什么很多人在生产模式下遇到404
页面的原因。
-
hash
模式,发送的HTTP
请求是不变的,不包含锚点部分,本质上始终请求的是打包后的index.html
; -
history
模式,发送的HTTP
请求是完整的浏览器地址,对后端来说,这样的路由是不存在,所以会出现404
。
这也就是history
模式为什么需要后端同时支持。
vue-router
文档上给出了一个配置例子:在所有后端路由规则的最后,加上一个默认匹配规则 --
如果URL
匹配不到任何静态资源,则响应同一个 index.html
给前端。
这样就解决了后端路由抛出的404
问题,前端拿到的也始终是打包后的index.html
了。再通过路由库的处理,获取地址栏的URL
信息,告知前端库(Vue、React
)渲染对应的页面。到了这一步就跟hash
模式类似了。
但 这样在后端配置之后,404
页面的处理权又交回了前端。以Nginx
和vue-router
为例,同时解决手动刷新浏览器和手动输入URL
并回车的 404
问题:
# 后端:nginx.conf
server {
listen 8080;
server_name xx.xxx.xxx.xx;
root html; # vue项目的打包后的dist
location / {
# 指向下面的@router,解决刷新出现404问题
try_files $uri $uri/ @router;
index index.html index.htm;
}
location @router {
# 重写到index.html中,然后交给前端路由去处理请求资源
rewrite ^.*$ /index.html last;
}
}
# 前端:vue-router
{
path: "/404",
name: "404",
component: () => import('@/views/404.vue')
},
// 当输入不存在的URL时,在前端重定向到404页面
{ path: "*", redirect: "/404" }
问题延伸:
在IE
浏览器下刷新仍然还是404
,是因为IE
自作聪明,对于页面大小 < 1024b
会被认为十分不友好,所以ie就将改页面给替换成自己的错误提示页面了,而SPA
打包后的 index.html
可能会小于临界值。
资源路径问题
在history
模式下,访问路由和嵌套路由页面,显示正常,但是刷新页面的时候,嵌套路由页面就出异常了!查看网络请求发现,请求加载的静态资源(图片、CSS、JS...)都是404
!查看请求路径发现,根路径发生了变化。
而资源的引入方式:
<link ref="stylesheet" href="./static/css/base.css" /> <script type="text/javascript" src="./static/js/app.js" /> <img src="./static/img/bg.png" />
这种引入方式在hash
模式下是可行的,因为hash
模式监听的是hash
值的变化,./
的相对路径不变,始终是根路径;
https://www.plysummer.com/#/login
https://www.plysummer.com/#/plan/index
但在history
模式下,./
的相对路径是变化的
https://www.plysummer.com/login
https://www.plysummer.com/plan/index
/login
映射的页面中的资源路径,与/plan/index
映射的页面资源路径,各不相同!所以,在嵌套路由中出现资源加载失败问题。
解决方式也很简单:相对于根目录就可以了!
./
表示相对于当前目录,/
则是一个绝对目录,www.plysummer.com
映射的根路径,对于上面的Nginx
配置(root html;
),根路径就是nginx/html
目录!
在Vue
项目的Webpack
配置中,history
模式的 publicPath
应该配置为/
,而并非./
等相对路径;
在引入资源时,如img:src
,也应该使用/static/xxx
,而不是./static/xxx
。
其实hash
模式下的./
本身就是相对于根路径,所以 /
的设置在两种模式下是通用的!
两种模式的比较
-
history
模式是H5
新特性,URL
更优雅,但history
模式需要服务器配合,而hash
不需要; -
pushState
设置的新URL
必须是与当前URL
同源的任意URL
,而hash
只是修改#
后面的部分,所以只能设置与当前同文档的URL
; -
pushState
设置的URL即使与当前URL
一样,也会被添加进历史栈中;而hash
设置的值必须与当前的不一样才会被添加进历史栈; -
pushState
可以通过第一个参数stateObject
向记录中添加任意类型的数据,而hash
只能添加短字符串; -
pushState
可额外设置title
属性供后续使用; -
hash
兼容IE8
以上,history
兼容IE10
以上。
扩展
另外,vue-router
还提供了第三种模式:abstract
,使用一个不依赖于浏览器的浏览历史虚拟管理后端。
根据平台差异可以看出,在 Weex
环境中只支持使用 abstract
模式。
不过,vue-router
自身会对环境做校验,如果发现没有浏览器的API
,vue-router
会自动强制进入abstract
模式,所以在使用 vue-router
时,只要不声明 mode
,默认会在浏览器环境中使用hash
模式,在移动端原生环境中使用 abstract
模式。
SSR
虽然前端渲染有诸多好处,但SEO
的问题还是比较突出的。所以React、Vue
等框架也在服务端渲染上做了一些努力,也就是SSR
,但又和传统的服务端渲染有所不同。
前端框架的服务端渲染(SSR
)大多依然采用前端路由,并且由于引用了状态统一、VNode
等等概念,导致SSR
对服务器的性能要求比传统的模板引擎渲染对服务器的性能要求高得多!所以不仅前端框架本身在不断改进算法、优化,服务端的性能也必须有所提升。ps:
当初掘金换成SSR
时也遇到了对应的性能问题,就是这个原因。
当然,在二者之间,也许出现了预渲染的概念。即现在服务端构建出一部分静态的HTML
文件,剩下的页面再通过常规的前端渲染来实现。通常可以把首页采用预渲染的方式。好处也比较明显,兼顾了SEO
和服务器的性能要求。不过,它无法做到全站SEO
,生产构建阶段耗时也会有所提高。
关于预渲染,可以考虑使用webpack
插件 prerender-spa-plugin
前后端分离
得益于前端路由和现代前端框架完整的前后端渲染能力,跟页面渲染、组织、组件相关的工作,后端终于可以不用再参与了。
前后端分离的开发模式也逐渐开始普及。前端开始更加注重页面开发的工程化、自动化,而后端则更专注于api
的提供和数据库的保障。代码层面上耦合度也进一步降低,分工也更加明确。
总结
-
后端路由
- 优点
- 缺点
每次更新页面都需要发起新的请求,服务器压力会很大,如果网络状况不好,还会造成极差的用户体验。
-
前端路由
- 优点
- 用户体验好,和后台网速没有关系,不需要每次都从服务器全部获取,界面展现快;
- 可以在浏览器中输入指定想要访问的
URL
路径地址; - 实现了前后端的分离,方便开发。
- 缺点
- 等待js加载完毕,且执行完毕,才能渲染出首屏页面;
- 对SEO不友好;页面中只有一个元素
<div id="app"></div>
,爬虫/搜索引擎认为页面是空的 - 在浏览器前进和后退时会重新发送请求(因为组件重新挂载),没有合理缓存数据;
- 优点
-
SSR
是传统服务端渲染与SPA
之间的一个折中方案,后端渲染出完整的首屏DOM
结构,返回给前端,后续的页面操作再利用单页的路由跳转和渲染。-
vue SSR - Nuxt
的方法nuxtServerInit
仅在服务端初始化渲染时 执行一次!在刷新浏览器时填充vuex,即持久化数据 -
nuxt
隐藏了很多细节,如 开发过程中,页面都是.vue
文件,需要用vue-loader
构建,所以SSR
环境需要webpack
打包。- 页面可能在服务端渲染(首屏),因此需要
Server entry
执行首屏渲染逻辑,将来打包输出Server Bundle
- 页面也可能在客户端渲染(浏览器端操作),因此需要
Client entry
执行相关控制逻辑,将来打包输出Client Bundle
- 页面可能在服务端渲染(首屏),因此需要
-