关于路由
路由其实是根据不同的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
- 页面可能在服务端渲染(首屏),因此需要
-