前端路由
1.什么是前端路由
对于用户来说,路由就是浏览器地址栏中的 url 与所见网页的对应关系。而对于 web 开发人员来说,路由更像是 url 与处理函数的对应关系。
传统的服务端路由,根据客户端请求的不同网址,返回不同的网页内容,这种情况一是会造成服务器压力增加,二是每次都重新请求,响应较慢、用户体验下降。于是,单页应用(spa,single page application)应运而生。在 url 地址改变的过程中,通过 js 来实现不同 UI 之间的切换(js 对 DOM 的操作),而不再向服务器重新请求页面,只通过 ajax 向服务端请求数据,对用户来说这种无刷新的、即时响应是更好的体验。其中,根据 url 地址栏的变化而展示不同的 UI,就是通过前端路由来实现的。目前主流的支持单页应用的前端框架,基本都有配套的或第三方的路由系统。
2.前端路由的实现方式
在 HTML5 的 history API 出现之前,前端路由主要是通过 hash 来实现的,hash 能兼容低版本的浏览器。下面分别来介绍这 2 种方式。
我们知道 location.hash 的值是 url 中#
后面的内容,如http://www.163.com#netease
此网址中,location.hash=’#netease’。
hash 满足以下几个特性,才使得其可以实现前端路由:
- url 中 hash 值的变化并不会重新加载页面,因为 hash 是用来指导浏览器行为的,对服务端是无用的,所以不会包括在 http 请求中。
- hash 值的改变,都会在浏览器的访问历史中增加一个记录,也就是能通过浏览器的回退、前进按钮控制 hash 的切换
- 我们可以通过 hashchange 事件,监听到 hash 值的变化,从而响应不同路径的逻辑处理。
1 | window.addEventListener("hashchange", funcRef, false); |
如此一来,我们就可以在 hashchange 事件里,根据 hash 值来更新对应的视图,但不会去重新请求页面,同时呢,也在 history 里增加了一条访问记录,用户也仍然可以通过前进后退键实现 UI 的切换。
触发 hash 值的变化有 2 种方法:
一种是通过 a 标签,设置 href 属性,当标签点击之后,地址栏会改变,同时会触发 hashchange 事件
1 | <a href="#kaola">to KAOLA</a> |
另一种是通过 js 直接赋值给 location.hash,也会改变 url,触发 hashchange 事件。
1 | location.hash = "#kaola"; |
下面展示一个通过 hash 实现的简易 Router:
1 | function Router() { |
【route vs router】
route 是一条路由,是将一个 URL 路径和一个处理函数相关联,是一条 url 和函数的映射规则,如上面代码中通过原型上的 route 可以设置一条路由规则,将一个 path 和其 callback 关联起来。
而 router 则更像是一个容器,或者说一种机制,它管理了一组 route。简单来说,route 只是进行了 URL 和函数的映射,而在当接收到一个 URL 之后,去路由映射表中查找相应的函数,这个过程是由 router 来处理的,如上面代码,Router 管理传入的 route,并且在 hash 改变的时候,根据当前的 url 响应其对应的函数。
HTML5 中 history 对象上新的 API,同样能实现前端的路由。通过 pushState()方法或 replaceState()方法可以修改 url 的地址,并在 popstate 事件中能监听地址的改变,不同的是,手动的进行 pushState()并不会触发 popstate 事件。
两个新增的 API:history.pushState 和 history.replaceState
这两个 API 都接收三个参数:
1 | window.history.pushState(null, null, "http://www.163.com"); |
- 状态对象(state object),一个 JavaScript 对象,与用 pushState()方法创建的新历史记录条目关联。无论何时用户导航到新创建的状态,会触发 popstate 事件,并能在事件中使用该对象。
- 标题(title) :传入一个短标题给当前 state。现在大多数浏览器不支持或者会忽略此参数,最好传入 null 代替;
- 地址(URL):新的历史记录条目的地址。浏览器不会在调用 pushState()方法后加载该地址,但之后,可能会试图加载,例如用户重启浏览器。新的 URL 不一定是绝对路径;如果是相对路径,它将以当前 URL 为基准;传入的 URL 与当前 URL 应该是同源的,否则,pushState()会抛出异常。该参数是可选的;不指定的话则为文档当前 URL。
这两个 API 的相同之处是都会操作浏览器的历史记录,而不会引起页面的刷新。不同之处在于,pushState 会增加一条新的历史记录,而 replaceState 则会替换当前的历史记录。这两个 api,加上 state 改变触发的 popstate 事件,提供了单页应该的另一种路由方式。
下面展示一个使用这种方式的简单 demo:
1 | <p id="menu"> |
当我们在历史记录中切换时就会触发 popstate 事件,可以在事件中还原当前 state 对应的 UI。对于触发 popstate 事件的方式,各浏览器实现也有差异,我们可以根据不同浏览器做兼容处理。
两种方式对比,基于 Hash 的路由,兼容性更好;基于 History API 的路由,则更正式,可以设置与当前 URL 同源的任意 URL,路径更直观。另外,基于 Hash 的路由不需要对服务器做改动,基于 History API 的路由需要对服务器做一些改造,配置不同的路由都返回相同的页面。
- 当直接访问http://example.com/的时候,两者的行为是一致的,都是返回了index.html文件。当从http://example.com/跳转到http://example.com/#/foobar或者http://example.com/foobar的时候,也都是正常的,因为此时已经加载了页面以及脚本文件,所以路由跳转正常。
- 当直接访问http://example.com/#/foobar的时候,实际上向服务器发起的请求是http://example.com/,因此会首先加载页面及脚本文件,接下来脚本执行路由跳转,一切正常。
- 当直接访问http://example.com/foobar的时候,实际上向服务器发起的请求也是http://example.com/foobar,然而服务器端只能匹配/而无法匹配/foobar,因此会出现404错误。
因此如果使用了基于 History API 的路由,需要改造服务器端,使得访问/foobar 的时候也能返回 index.html 文件,这样当浏览器加载了页面及脚本之后,就能进行路由跳转了。
Vue Router
vue-router 基本的路由配置如下:
1 | import Router from 'vue-router' |
可以通过 vue-router 实例来配置路由规则列表,指定路径 path 与组件 component 的对应关系。
1.模式参数
在 vue-router 中是通过 mode 这一参数控制路由的实现模式的:
1 | const router = new VueRouter({ |
创建 VueRouter 的实例对象时,mode 以构造函数参数的形式传入。从 VueRouter 类的定义入手。一般插件对外暴露的类都是定义在源码 src 根目录下的 index.js 文件中,打开该文件,可以看到 VueRouter 类的定义,摘录与 mode 参数有关的部分如下:
1 | export default class VueRouter { |
可以看出:
作为参数传入的字符串属性 mode 只是一个标记,用来指示实际起作用的对象属性 history 的实现类,两者对应关系如下:
modehistory:
- ‘history’——HTML5History
- ‘hash’——HashHistory
- ‘abstract’——AbstractHistory
在初始化对应的 history 之前,会对 mode 做一些校验:若浏览器不支持 HTML5History 方式(通过 supportsPushState 变量判断),则 mode 强制设为’hash’;若不是在浏览器环境下运行,则 mode 强制设为’abstract’
VueRouter 类中的 onReady(), push()等方法只是一个代理,实际是调用的具体 history 对象的对应方法,在 init()方法中初始化时,也是根据 history 对象具体的类别执行不同操作
在浏览器环境下的两种方式,分别就是在 HTML5History,HashHistory 两个类中实现的。他们都定义在 src/history 文件夹下,继承自同目录下 base.js 文件中定义的 History 类。History 中定义的是公用和基础的方法,直接看会一头雾水,我们先从 HTML5History,HashHistory 两个类中看着亲切的 push(), replace()方法的说起。
2.HashHistory
看源码前先回顾一下原理:
hash(“#”)符号的本来作用是加在 URL 中指示网页中的位置:
#符号本身以及它后面的字符称之为 hash,可通过 window.location.hash 属性读取。它具有如下特点:
hash 虽然出现在 URL 中,但不会被包括在 HTTP 请求中。它是用来指导浏览器动作的,对服务器端完全无用,因此,改变 hash 不会重新加载页面
可以为 hash 的改变添加监听事件:
1
window.addEventListener("hashchange", funcRef, false);
每一次改变 hash(window.location.hash),都会在浏览器的访问历史中增加一个记录
利用 hash 的以上特点,就可以来实现前端路由“更新视图但不重新请求页面”的功能了。
HashHistory.push()
我们来看 HashHistory 中的 push()方法:
1 | push (location: RawLocation, onComplete?: Function, onAbort?: Function) { |
transitionTo()方法是父类中定义的是用来处理路由变化中的基础逻辑的,push()方法最主要的是对 window 的 hash 进行了直接赋值:
1 | window.location.hash = route.fullPath; |
hash 的改变会自动添加到浏览器的访问历史记录中。
那么视图的更新是怎么实现的呢,我们来看父类 History 中 transitionTo()方法的这么一段:
1 | transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) { |
可以看到,当路由变化时,调用了 History 中的 this.cb 方法,而 this.cb 方法是通过 History.listen(cb)进行设置的。回到 VueRouter 类定义中,找到了在 init()方法中对其进行了设置:
1 | init (app: any /* Vue component instance */) { |
根据注释,app 为 Vue 组件实例,但我们知道 Vue 作为渐进式的前端框架,本身的组件定义中应该是没有有关路由内置属性_route,如果组件中要有这个属性,应该是在插件加载的地方,即 VueRouter 的 install()方法中混合入 Vue 对象的,查看 install.js 源码,有如下一段:
1 | export function install(Vue) { |
通过 Vue.mixin()方法,全局注册一个混合,影响注册之后所有创建的每个 Vue 实例,该混合在 beforeCreate 钩子中通过 Vue.util.defineReactive()定义了响应式的_route 属性。所谓响应式属性,即当_route 值改变时,会自动调用 Vue 实例的 render()方法,更新视图。
总结一下,从设置路由改变到视图更新的流程如下:
1 | $router.push() --> HashHistory.push() --> History.transitionTo() --> History.updateRoute() --> {app._route = route} --> vm.render() |
HashHistory.replace()
replace()方法与 push()方法不同之处在于,它并不是将新路由添加到浏览器访问历史的栈顶,而是替换掉当前的路由:
1 | replace (location: RawLocation, onComplete?: Function, onAbort?: Function) { |
可以看出,它与 push()的实现结构上基本相似,不同点在于它不是直接对 window.location.hash 进行赋值,而是调用 window.location.replace 方法将路由进行替换。
监听地址栏
以上讨论的 VueRouter.push()和 VueRouter.replace()是可以在 vue 组件的逻辑代码中直接调用的,除此之外在浏览器中,用户还可以直接在浏览器地址栏中输入改变路由,因此 VueRouter 还需要能监听浏览器地址栏中路由的变化,并具有与通过代码调用相同的响应行为。在 HashHistory 中这一功能通过 setupListeners 实现:
1 | setupListeners () { |
该方法设置监听了浏览器事件 hashchange,调用的函数为 replaceHash,即在浏览器地址栏中直接输入路由相当于代码调用了 replace()方法
3.HTML5History
History interface 是浏览器历史记录栈提供的接口,通过 back(), forward(), go()等方法,我们可以读取浏览器历史记录栈的信息,进行各种跳转操作。
从 HTML5 开始,History interface 提供了两个新的方法:pushState(), replaceState()使得我们可以对浏览器历史记录栈进行修改:
1 | window.history.pushState(stateObject, title, URL); |
- stateObject: 当浏览器跳转到新的状态时,将触发 popState 事件,该事件将携带这个 stateObject 参数的副本
- title: 所添加记录的标题
- URL: 所添加记录的 URL
这两个方法有个共同的特点:当调用他们修改浏览器历史记录栈后,虽然当前 URL 改变了,但浏览器不会立即发送请求该 URL(the browser won’t attempt to load this URL after a call to pushState()),这就为单页应用前端路由“更新视图但不重新请求页面”提供了基础。
我们来看 vue-router 中的源码:
1 | push (location: RawLocation, onComplete?: Function, onAbort?: Function) { |
代码结构以及更新视图的逻辑与 hash 模式基本类似,只不过将对 window.location.hash 直接进行赋值 window.location.replace()改为了调用 history.pushState()和 history.replaceState()方法。
在 HTML5History 中添加对修改浏览器地址栏 URL 的监听是直接在构造函数中执行的:
1 | constructor (router: Router, base: ?string) { |
当然了 HTML5History 用到了 HTML5 的新特特性,是需要特定浏览器版本的支持的,前文已经知道,浏览器是否支持是通过变量 supportsPushState 来检查的:
1 | // src/util/push-state.js |
这两种模式都是通过浏览器接口实现的,除此之外 vue-router 还为非浏览器环境准备了一个 abstract 模式,其原理为用一个数组 stack 模拟出浏览器历史记录栈的功能。
4.vue 中两种模式比较
在一般的需求场景中,hash 模式与 history 模式是差不多的,但几乎所有的文章都推荐使用 history 模式,理由竟然是:”#” 符号太丑…0_0 “
如果不想要很丑的 hash,我们可以用路由的 history 模式 ——官方文档
当然,严谨的我们肯定不应该用颜值评价技术的好坏。根据 MDN 的介绍,调用 history.pushState()相比于直接修改 hash 主要有以下优势:
- pushState 设置的新 URL 可以是与当前 URL 同源的任意 URL;而 hash 只可修改#后面的部分,故只可设置与当前同文档的 URL
- pushState 设置的新 URL 可以与当前 URL 一模一样,这样也会把记录添加到栈中;而 hash 设置的新值必须与原来不一样才会触发记录添加到栈中
- pushState 通过 stateObject 可以添加任意类型的数据到记录中;而 hash 只可添加短字符串
- pushState 可额外设置 title 属性供后续使用
a 标签 hash 跳转,会改变页面路径的 hash 值,但不会重载
history 模式的一个问题
我们知道对于单页应用来讲,理想的使用场景是仅在进入应用时加载 index.html,后续在的网络操作通过 Ajax 完成,不会根据 URL 重新请求页面,但是难免遇到特殊情况,比如用户直接在地址栏中输入并回车,浏览器重启重新加载应用等。
hash 模式仅改变 hash 部分的内容,而 hash 部分是不会包含在 HTTP 请求中的:
1 | http://oursite.com/#/user/id // 如重新请求只会发送http://oursite.com/ |
故在 hash 模式下遇到根据 URL 请求页面的情况不会有问题。
而 history 模式则会将 URL 修改得就和正常请求后端的 URL 一样
1 | http://oursite.com/user/id |
在此情况下重新向后端发送请求,如后端没有配置对应/user/id 的路由处理,则会返回 404 错误。官方推荐的解决办法是在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。同时这么做以后,服务器就不再返回 404 错误页面,因为对于所有路径都会返回 index.html 文件。为了避免这种情况,在 Vue 应用里面覆盖所有的路由情况,然后在给出一个 404 页面。或者,如果是用 Node.js 作后台,可以使用服务端的路由来匹配 URL,当没有匹配到路由的时候返回 404,从而实现 fallback。
5.AbstractHistory
‘abstract’模式,不涉及和浏览器地址的相关记录,流程跟’HashHistory’是一样的,其原理是通过数组模拟浏览器历史记录栈的功能
1 | // 对于 go 的模拟 |
6.直接加载应用文件
Tip: built files are meant to be served over an HTTP server.
Opening index.html over file:// won’t work.
Vue 项目通过 vue-cli 的 webpack 打包完成后,命令行会有这么一段提示。通常情况,无论是开发还是线上,前端项目都是通过服务器访问,不存在 “Opening index.html over file://“ ,但程序员都知道,需求和场景永远是千奇百怪的,只有你想不到的,没有产品经理想不到的。
本文写作的初衷就是遇到了这样一个问题:需要快速开发一个移动端的展示项目,决定采用 WebView 加载 Vue 单页应用的形式,但没有后端服务器提供,所以所有资源需从本地文件系统加载:
1 | // AndroidAppWrapper |
此情此景看来是必须 “Opening index.html over file://“ 了,为此,我首先要进行了一些设置
- 在项目 config.js 文件中将 assetsPublicPath 字段的值改为相对路径 ‘./‘
- 调整生成的 static 文件夹中图片等静态资源的位置与代码中的引用地址一致
这是比较明显的需要改动之处,但改完后依旧无法顺利加载,经过反复排查发现,项目在开发时,router 设置为了 history 模式(为了美观…0_0”),当改为 hash 模式后就可正常加载了。
为什么会出现这种情况呢?我分析原因可能如下:
当从文件系统中直接加载 index.html 时,URL 为:
1 | file:///android_asset/index.html |
而首页视图需匹配的路径为 path: ‘/‘ :
1 | export default new Router({ |
我们先来看 history 模式,在 HTML5History 中:
1 | ensureURL (push?: boolean) { |
逻辑只会确保存在 URL,path 是通过剪切的方式直接从 window.location.pathname 获取到的,它的结尾是 index.html,因此匹配不到 ‘/‘ ,故 “Opening index.html over file:// won’t work” 。
再看 hash 模式,在 HashHistory 中:
1 | export class HashHistory extends History { |
我们看到在代码逻辑中,多次出现一个函数 ensureSlash(),当#符号后紧跟着的是’/‘,则返回 true,否则强行插入这个’/‘,故我们可以看到,即使是从文件系统打开 index.html,URL 依旧会变为以下形式:
1 | file:///C:/Users/dist/index.html#/ |
getHash()方法返回的 path 为 ‘/‘ ,可与首页视图的路由匹配。
故要想从文件系统直接加载 Vue 单页应用而不借助后端服务器,除了打包后的一些路径设置外,还需确保 vue-router 使用的是 hash 模式。
React Router
react-router 是基于 history 模块提供的 api 进行开发的。其路由配置是通过 react component 方式进行的,路由更改触发的回调函数是我们自己写的操作 DOM 的函数;在 react 中我们不直接操作 DOM,而是管理抽象出来的 VDOM 或者说 JSX,对 react 的来说路由需要管理组件的生命周期,对不同的路由渲染不同的组件。
如下:
1 | render(( |
以上声明了一份含有 path to component 的各个映射的路由表。Router 在 react 组件生命周期的钩子 componentWillMount (组件被挂载前)中,使用 this.history.listen 去注册了 url 更新的回调函数。回调函数将在 url 更新时触发,回调中的 setState 会去 render 新的 component。
1.history
history 库,是 rr4 依赖的一个对 window.history
加强版的 history 库。
match
源自 history 库,表示当前的 URL 与 path 的匹配的结果
1 | match: { |
location
还是源自 history 库,是 history 库基于 window.location 的一个衍生。
1 | hash: "" // hash |
我们带着问题去分析源码,先逐个分析每个组件的作用,在最后会有回答,在这里先举一个 rr4 的小 DEMO
- 页面初始化时的渲染过程?
- 点击一个 Link 跳转及渲染更新的过程?
2.packages
rr4 将路由拆成了几个包:
- react-router 负责通用的路由逻辑
- react-router-dom 负责浏览器的路由管理
- react-router-native 负责 react-native 的路由管理
通用的部分直接从 react-router 中导入,用户只需引入 react-router-dom 或 react-router-native 即可,react-router 作为依赖存在不再需要单独引入。
Router
1 | import React from 'react' |
这是我们调用 Router 的方式,这里拿 BrowserRouter 来举例。
BrowserRouter 的源码在 react-router-dom 中,它是一个高阶组件,在内部创建一个全局的 history 对象(可以监听整个路由的变化),并将 history 作为 props 传递给 react-router 的 Router 组件(Router 组件再会将这个 history 的属性作为 context 传递给子组件)
1 | render() { |
整个 Router 的核心是在 react-router 的 Router 组件中,如下,借助 context 向 Route 传递组件,这也解释了为什么 Router 要在所有 Route 的外面。
1 | getChildContext() { |
这是 Router 传递给子组件的 context,事实上 Route 也会将 router 作为 context 向下传递,如果我们在 Route 渲染的组件中加入
1 | static contextTypes = { |
来通过 context 访问 router,不过 rr4 一般通过 props 传递,将 history, location, match 作为三个独立的 props 传递给要渲染的组件,这样访问起来方便一点(实际上已经完全将 router 对象的属性完全传递了)。
在 Router 的 componentWillMount 中, 添加了
1 | componentWillMount() { |
history.listen
能够监听路由的变化并执行回调事件。
在这里每次路由的变化执行的回调事件为
1 | this.setState({ |
相比于在 setState 里做的操作,setState 本身的意义更大 —— 每次路由变化 -> 触发顶层 Router 的回调事件 -> Router 进行 setState -> 向下传递 nextContext(context 中含有最新的 location)-> 下面的 Route 获取新的 nextContext 判断是否进行渲染。
之所以把这个 subscribe 的函数写在 componentWillMount 里,就像源码中给出的注释:是为了 SSR 的时候,能够使用 Redirect。
Route
Route 的作用是匹配路由,并传递给要渲染的组件 props。
在 Route 的 componentWillReceiveProps 中
1 | componentWillReceiveProps(nextProps, nextContext) { |
Route 接受上层的 Router 传入的 context,Router 中的 history 监听着整个页面的路由变化,当页面发生跳转时,history 触发监听事件,Router 向下传递 nextContext,就会更新 Route 的 props 和 context 来判断当前 Route 的 path 是否匹配 location,如果匹配则渲染,否则不渲染。
是否匹配的依据就是 computeMatch 这个函数,在下文会有分析,这里只需要知道匹配失败则 match 为 null
,如果匹配成功则将 match 的结果作为 props 的一部分,在 render 中传递给传进来的要渲染的组件。
接下来看一下 Route 的 render 部分。
1 | render() { |
rr4 提供了三种渲染组件的方法:component props,render props 和 children props,渲染的优先级也是依次按照顺序,如果前面的已经渲染后了,将会直接 return。
- component (props) —— 由于使用 React.createElement 创建,所以可以传入一个 class component。
- render (props) —— 直接调用 render() 展开子元素,所以需要传入 stateless function component。
- children (props) —— 其实和 render 差不多,区别是不判断 match,总是会被渲染。
- children(子元素)—— 如果以上都没有,那么会默认渲染子元素,但是只能有一个子元素。
这里解释一下官网的 tips,component 是使用 React.createElement 来创建新的元素,所以如果传入一个内联函数,比如
1 | <Route path='/' component={()=>(<div>hello world</div>)} |
的话,由于每次的 props.component 都是新创建的,所以 React 在 diff 的时候会认为进来了一个全新的组件,所以会将旧的组件 unmount,再 re-mount。这时候就要使用 render,少了一层包裹的 component 元素,render 展开后的元素类型每次都是一样的,就不会发生 re-mount 了(children 也不会发生 re-mount)。
Switch
我们紧接着 Route 来看 Switch,Switch 是用来嵌套在 Route 的外面,当 Switch 中的第一个 Route 匹配之后就不会再渲染其他的 Route 了。
1 | render() { |
Switch 也是通过 matchPath 这个函数来判断是否匹配成功,一直按照 Switch 中 children 的顺序依次遍历子元素,如果匹配失败则 match 为 null,如果匹配成功则标记这个子元素和它对应的 location、computedMatch。在最后的时候使用 React.cloneElement 渲染,如果没有匹配到的子元素则返回 null
。
接下来我们看下 matchPath 是如何判断 location 是否符合 path 的。
matchPath
matchPath 返回的是一个如下结构的对象
1 | { |
这些信息将作为匹配的参数传递给 Route 和 Switch(Switch 只是一个代理,它的作用还是渲染 Route,Switch 计算得到的 computedMatch 会传递给要渲染的 Route,此时 Route 将直接使用这个 computedMatch 而不需要再自己来计算)。
在 matchPath 内部 compilePath 时,有个
1 | const patternCache = {}; |
作为 pathToRegexp 的缓存,因为 ES6 的 import 模块导出的是值的引用,所以将 patternCache 可以理解为一个全局变量缓存,缓存以 {option:{pattern: }}
的形式存储,之后如果需要匹配相同 pattern 和 option 的 path,则可以直接从缓存中获得正则表达式和 keys。
加缓存的原因是路由页面大部分情况下都是相似的,比如要访问 /user/123
或 /users/234
,都会使用 /user/:id
这个 path 去匹配,没有必要每次都生成一个新的正则表达式。SPA 在页面整个访问的过程中都维护着这份缓存。
Link
实际上我们可能写的最多的就是 Link 这个标签了,我们从它的 render 函数开始看
1 | render() { |
可以看到Link 最终还是创建一个 a 标签来包裹住要跳转的元素,但是如果只是一个普通的带 href 的 a 标签,那么就会直接跳转到一个新的页面而不是 SPA 了,所以在这个 a 标签的 handleClick 中会 preventDefault 禁止默认的跳转,所以这里的 href 并没有实际的作用,但仍然可以标示出要跳转到的页面的 URL 并且有更好的 html 语义。
在 handleClick 中,对没有被 “preventDefault 的 && 鼠标左键点击的 && 非 _blank
跳转 的&& 没有按住其他功能键的“ 单击进行 preventDefault,然后 push 进 history 中,这也是前面讲过的 —— 路由的变化 与 页面的跳转 是不互相关联的,rr4 在 Link 中通过 history 库的 push 调用了 HTML5 history 的 pushState
,但是这仅仅会让路由变化,其他什么都没有改变。还记不记得 Router 中的 listen,它会监听路由的变化,然后通过 context 更新 props 和 nextContext 让下层的 Route 去重新匹配,完成需要渲染部分的更新。
1 | handleClick = event => { |
withRouter
1 | const withRouter = Component => { |
withRouter 的作用是让我们在普通的非直接嵌套在 Route 中的组件也能获得路由的信息,这时候我们就要 WithRouter(wrappedComponent)
来创建一个 HOC 传递 props,WithRouter 的其实就是用 Route 包裹了 SomeComponent 的一个 HOC。
创建 Route 有三种方法,这里直接采用了传递 children
props 的方法,因为这个 HOC 要原封不动的渲染 wrappedComponent(children
props 比较少用得到,某种程度上是一个内部方法)。
在最后返回 HOC 时,使用了 hoistStatics 这个方法,这个方法的作用是保留 SomeComponent 类的静态方法,因为 HOC 是在 wrappedComponent 的外层又包了一层 Route,所以要将 wrappedComponent 类的静态方法转移给新的 Route,具体参见 Static Methods Must Be Copied Over。
3.理解
现在回到一开始的问题,重新理解一下点击一个 Link 跳转的过程。
有两件事需要完成:
- 路由的改变
- 页面的渲染部分的改变
过程如下:
- 在最一开始 mount Router 的时候,Router 在 componentWillMount 中 listen 了一个回调函数,由 history 库管理,路由每次改变的时候触发这个回调函数。这个回调函数会触发 setState。
- 当点击 Link 标签的时候,实际上点击的是页面上渲染出来的 a 标签,然后通过 preventDefault 阻止 a 标签的页面跳转。
- Link 中也能拿到 Router -> Route 中通过 context 传递的 history,执行
hitsory.push(to)
,这个函数实际上就是包装了一下window.history.pushState()
,是 HTML5 history 的 API,但是 pushState 之后除了地址栏有变化其他没有任何影响,到这一步已经完成了目标 1:路由的改变。 - 第 1 步中,路由改变是会触发 Router 的 setState 的,在 Router 那章有写道:每次路由变化 -> 触发顶层 Router 的监听事件 -> Router 触发 setState -> 向下传递新的 nextContext(nextContext 中含有最新的 location)
- 下层的 Route 拿到新的 nextContext 通过 matchPath 函数来判断 path 是否与 location 匹配,如果匹配则渲染,不匹配则不渲染,完成目标 2:页面的渲染部分的改变。