本文共 12629 字,大约阅读时间需要 42 分钟。
源码这个东西对于实际的工作其实没有立竿见影的效果,不会像那些针对性极强的文章一样看了之后就立马可以运用到实际项目中,产生什么样的效果,源码的作用是一个潜移默化的过程,它的理念、设计模式、代码结构等看了之后可能不会立即知识变现(或者说变现很少),而是在日后的工作过程中悄无声息地发挥出来,你甚至都感觉不到这个过程
另外,优秀的源码案例,例如 vue
、react
这种,内容量比较庞大,根本不是三篇五篇十篇八篇文章就能说完的,而且写起来也很难写得清楚,也挺浪费时间的,而如果只是分析其中一个点,例如 vue
的响应式,类似的文章也已经够多了,没必要再 repeat
所以我之前没专门写过源码分析的文章,只是自己看看,不过最近闲来无事看了 vue-router
的源码,发现这种插件级别的东西,相比 vue
这种框架级别的东西,逻辑简单清晰,没有那么多道道,代码量也不多,但是其中包含的理念等东西却很精炼,值得一写,当然,文如其名,只是概览,不会一行行代码分析过去,细节的东西还是要自己看看的
vue
插件必须通过 vue.use
进行注册,vue.use
的代码位于 vue
源码的 src/core/global-api/use.js
文件中,此方法的主要作用有两个:
if (installedPlugins.indexOf(plugin) > -1) { return this}复制代码
install
方法或者直接运行插件,以实现插件的 install
if (typeof plugin.install === 'function') { plugin.install.apply(plugin, args)} else if (typeof plugin === 'function') { plugin.apply(null, args)}复制代码
vue-router
的 install
方法位于 vue-router
源码的src/install.js
中 主要是通过 vue.minxin
混入 beforeCreate
和 destroyed
钩子函数,并全局注册 router-view
和 router-link
组件
// src/install.jsVue.mixin({ beforeCreate () { if (isDef(this.$options.router)) { this._routerRoot = this this._router = this.$options.router this._router.init(this) Vue.util.defineReactive(this, '_route', this._router.history.current) } else { this._routerRoot = (this.$parent && this.$parent._routerRoot) || this } registerInstance(this, this) }, destroyed () { registerInstance(this) }})...// 全局注册 `router-view` 和 `router-link`组件Vue.component('RouterView', View)Vue.component('RouterLink', Link)复制代码
vue-router
支持三种路由模式(mode
):hash
、history
、abstract
,其中 abstract
是在非浏览器环境下使用的路由模式,例如weex
路由内部会对外部指定传入的路由模式进行判断,例如当前环境是非浏览器环境,则无论传入何种mode
,最后都会被强制指定为 abstract
,如果判断当前环境不支持 HTML5 History
,则最终会被降级为 hash
模式
// src/index.jslet mode = options.mode || 'hash'this.fallback = mode === 'history' && !supportsPushState && options.fallback !== falseif (this.fallback) { mode = 'hash'}if (!inBrowser) { mode = 'abstract'}复制代码
最后会对符合要求的 mode
进行对应的初始化操作
// src/index.jsswitch (mode) { case 'history': this.history = new HTML5History(this, options.base) break case 'hash': this.history = new HashHistory(this, options.base, this.fallback) break case 'abstract': this.history = new AbstractHistory(this, options.base) break default: if (process.env.NODE_ENV !== 'production') { assert(false, `invalid mode: ${mode}`) }}复制代码
通过递归的方式来解析嵌套路由
// src/create-route-map.jsfunction addRouteRecord ( pathList: Array, pathMap: Dictionary , nameMap: Dictionary , route: RouteConfig, parent?: RouteRecord, matchAs?: string) { ... route.children.forEach(child => { const childMatchAs = matchAs ? cleanPath(`${matchAs}/${child.path}`) : undefined addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs) }) ...}复制代码
解析完毕之后,会通过 key-value
对的形式对解析好的路由进行记录,所以如果声明多个相同路径(path
)的路由映射,只有第一个会起作用,后面的会被忽略
// src/create-route-map.jsif (!pathMap[record.path]) { pathList.push(record.path) pathMap[record.path] = record}复制代码
例如如下路由配置,路由 /bar
只会匹配 Bar1
,Bar2
这一条配置会被忽略
const routes = [ { path: '/foo', component: Foo }, { path: '/bar', component: Bar1 }, { path: '/bar', component: Bar2 },];复制代码
当访问一个 url
的时候,vue-router
会根据路径进行匹配,创建出一个 route
对象,可通过 this.$route
进行访问
// src/util/route.jsconst route: Route = { name: location.name || (record && record.name), meta: (record && record.meta) || {}, path: location.path || '/', hash: location.hash || '', query, params: location.params || {}, fullPath: getFullPath(location, stringifyQuery), matched: record ? formatMatch(record) : []}复制代码
src/history/base.js
源码文件中的 transitionTo()
是路由切换的核心方法
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) { const route = this.router.match(location, this.current) this.confirmTransition(route, () => { ...}复制代码
路由实例的push
和 replace
等路由切换方法,都是基于此方法实现路由切换的,例如 hash
模式的 push
方法:
// src/history/hash.jspush (location: RawLocation, onComplete?: Function, onAbort?: Function) { const { current: fromRoute } = this // 利用了 transitionTo 方法 this.transitionTo(location, route => { pushHash(route.fullPath) handleScroll(this.router, route, fromRoute, false) onComplete && onComplete(route) }, onAbort)}复制代码
transitionTo
方法内部通过一种异步函数队列化执⾏的模式来更新切换路由,通过 next
函数执行异步回调,并在异步回调方法中执行相应的钩子函数(即 导航守卫) beforeEach
、beforeRouteUpdate
、beforeRouteEnter
、beforeRouteLeave
通过 queue
这个数组保存相应的路由参数:
// src/history/base.jsconst queue: Array = [].concat( // in-component leave guards extractLeaveGuards(deactivated), // global before hooks this.router.beforeHooks, // in-component update hooks extractUpdateHooks(updated), // in-config enter guards activated.map(m => m.beforeEnter), // async components resolveAsyncComponents(activated))复制代码
通过 runQueue
以一种递归回调的方式来启动异步函数队列化的执⾏:
// src/history/base.js// 异步回调函数runQueue(queue, iterator, () => { const postEnterCbs = [] const isValid = () => this.current === route // wait until async components are resolved before // extracting in-component enter guards const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid) const queue = enterGuards.concat(this.router.resolveHooks) // 递归执行 runQueue(queue, iterator, () => { if (this.pending !== route) { return abort() } this.pending = null onComplete(route) if (this.router.app) { this.router.app.$nextTick(() => { postEnterCbs.forEach(cb => { cb() }) }) } })})复制代码
通过 next
进行导航守卫的回调迭代,所以如果在代码中显式声明了导航钩子函数,那么就必须在最后调用 next()
,否则回调不执行,导航将无法继续
// src/history/base.jsconst iterator = (hook: NavigationGuard, next) => { ... hook(route, current, (to: any) => { ... } else { // confirm transition and pass on the value next(to) } })...}复制代码
在路由切换的时候,vue-router
会调用 push
、go
等方法实现视图与地址url
的同步
url
与视图的同步当进行点击页面上按钮等操作进行路由切换时,vue-router
会通过改变 window.location.href
来保持视图与 url
的同步,例如 hash
模式的路由切换:
// src/history/hash.jsfunction pushHash (path) { if (supportsPushState) { pushState(getUrl(path)) } else { window.location.hash = path }}复制代码
上述代码,先检测当前浏览器是否支持 html5
的 History API
,如果支持则调用此 API
进行 href
的修改,否则直接对window.location.hash
进行赋值 history
的原理与此相同,也是利用了 History API
url
的同步当点击浏览器的前进后退按钮时,同样可以实现视图的同步,这是因为在路由初始化的时候,设置了对浏览器前进后退的事件监听器
下述是 hash
模式的事件监听:
// src/history/hash.jssetupListeners () { ... window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => { const current = this.current if (!ensureSlash()) { return } this.transitionTo(getHash(), route => { if (supportsScroll) { handleScroll(this.router, route, current, true) } if (!supportsPushState) { replaceHash(route.fullPath) } }) })}复制代码
history
模式与此类似:
// src/history/html5.jswindow.addEventListener('popstate', e => { const current = this.current // Avoiding first `popstate` event dispatched in some browsers but first // history route not updated since async guard at the same time. const location = getLocation(this.base) if (this.current === START && location === initLocation) { return } this.transitionTo(location, route => { if (supportsScroll) { handleScroll(router, route, current, true) } })})复制代码
无论是 hash
还是 history
,都是通过监听事件最后来调用 transitionTo
这个方法,从而实现路由与视图的统一
另外,当第一次访问页面,路由进行初始化的时候,如果是 hash
模式,则会对url
进行检查,如果发现访问的 url
没有带 #
字符,则会自动追加,例如初次访问 http://localhost:8080
这个 url
,vue-router
会自动置换为 http://localhost:8080/#/
,方便之后的路由管理:
// src/history/hash.jsfunction ensureSlash (): boolean { const path = getHash() if (path.charAt(0) === '/') { return true } replaceHash('/' + path) return false}复制代码
当从一个路由 /a
跳转到另外的路由 /b
后,如果在路由 /a
的页面中进行了滚动条的滚动行为,那么页面跳转到/b
时,会发现浏览器的滚动条位置和 /a
的一样(如果 /b
也能滚动的话),或者刷新当前页面,浏览器的滚动条位置依旧不变,不会直接返回到顶部的 而如果是通过点击浏览器的前进、后退按钮来控制路由切换时,则部分浏览器(例如微信)滚动条在路由切换时都会自动返回到顶部,即scrollTop=0
的位置 这些都是浏览器默认的行为,如果想要定制页面切换时的滚动条位置,则可以借助 scrollBehavior
这个 vue-router
的 options
当路由初始化时,vue-router
会对路由的切换事件进行监听,监听逻辑的一部分就是用于控制浏览器滚动条的位置:
// src/history/hash.jssetupListeners () { ... if (supportsScroll) { // 进行浏览器滚动条的事件控制 setupScroll() } ...}复制代码
这个 set
方法定义在 src/util/scroll.js
,这个文件就是专门用于控制滚动条位置的,通过监听路由切换事件从而进行滚动条位置控制:
// src/util/scroll.jswindow.addEventListener('popstate', e => { saveScrollPosition() if (e.state && e.state.key) { setStateKey(e.state.key) }})复制代码
通过 scrollBehavior
可以定制路由切换的滚动条位置,vue-router
的 上的源码中,有相关的 example
,源码位置在 vue-router/examples/scroll-behavior/app.js
router-view
和 router-link
这两个 vue-router
的内置组件,源码位于 src/components
下
router-view
是无状态(没有响应式数据)、无实例(没有 this
上下文)的函数式组件,其通过路由匹配获取到对应的组件实例,通过 h
函数动态生成组件,如果当前路由没有匹配到任何组件,则渲染一个注释节点
// vue-router/src/components/view.js...const matched = route.matched[depth]// render empty node if no matched routeif (!matched) { cache[name] = null return h()}const component = cache[name] = matched.components[name]...return h(component, data, children)复制代码
每次路由切换都会触发 router-view
重新 render
从而渲染出新的视图,这个触发的动作是在 vue-router
初始化 init
的时候就声明了的:
// src/install.jsVue.mixin({ beforeCreate () { if (isDef(this.$options.router)) { this._routerRoot = this this._router = this.$options.router this._router.init(this) // 触发 router-view重渲染 Vue.util.defineReactive(this, '_route', this._router.history.current) ...})复制代码
将 this._route
通过 defineReactive
变成一个响应式的数据,这个defineReactive
就是 vue
中定义的,用于将数据变成响应式的一个方法,源码在 vue/src/core/observer/index.js
中,其核心就是通过 Object.defineProperty
方法修改数据的 getter
和 setter
:
Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { const value = getter ? getter.call(obj) : val if (Dep.target) { // 进行依赖收集 dep.depend() if (childOb) { childOb.dep.depend() if (Array.isArray(value)) { dependArray(value) } } } return value }, set: function reactiveSetter (newVal) { ... // 通知订阅当前数据 watcher的观察者进行响应 dep.notify() }复制代码
当路由发生变化时,将会调用 router-view
的 render
函数,此函数中访问了 this._route
这个数据,也就相当于是调用了 this._route
的 getter
方法,触发依赖收集,建立一个 Watcher
,执行 _update
方法,从而让页面重新渲染
// vue-router/src/components/view.jsrender (_, { props, children, parent, data }) { // used by devtools to display a router-view badge data.routerView = true // directly use parent context's createElement() function // so that components rendered by router-view can resolve named slots const h = parent.$createElement const name = props.name // 触发依赖收集,建立 render watcher const route = parent.$route ...}复制代码
这个 render watcher
的派发更新,也就是 setter
的调用,位于 src/index.js
:
history.listen(route => { this.apps.forEach((app) => { // 触发 setter app._route = route })})复制代码
router-link
在执行 render
函数的时候,会根据当前的路由状态,给渲染出来的active
元素添加 class
,所以你可以借助此给active
路由元素设置样式等:
// src/components/link.jsrender (h: Function) { ... const globalActiveClass = router.options.linkActiveClass const globalExactActiveClass = router.options.linkExactActiveClass // Support global empty active class const activeClassFallback = globalActiveClass == null ? 'router-link-active' : globalActiveClass const exactActiveClassFallback = globalExactActiveClass == null ? 'router-link-exact-active' : globalExactActiveClass ...}复制代码
router-link
默认渲染出来的元素是 <a>
标签,其会给这个 <a>
添加 href
属性值,以及一些用于监听能够触发路由切换的事件,默认是 click
事件:
// src/components/link.jsdata.on = ondata.attrs = { href }复制代码
另外,你可以可以通过传入 tag
这个 props
来定制 router-link
渲染出来的元素标签:
Go to foo 复制代码
如果 tag
值不为 a
,则会递归遍历 router-link
的子元素,直到找到一个 a
标签,则将事件和路由赋值到这个 <a>
上,如果没找到a
标签,则将事件和路由放到 router-link
渲染出的本身元素上:
if (this.tag === 'a') { data.on = on data.attrs = { href } } else { // find the first child and apply listener and href // findAnchor即为递归遍历子元素的方法 const a = findAnchor(this.$slots.default) ... }}复制代码
当触发这些路由切换事件时,会调用相应的方法来切换路由刷新视图:
// src/components/link.jsconst handler = e => { if (guardEvent(e)) { if (this.replace) { // replace路由 router.replace(location) } else { // push 路由 router.push(location) } }}复制代码
可以看到,vue-router
的源码是很简单的,比较适合新手进行阅读分析
源码这种东西,我的理解是没必要非要专门腾出时间来看,只要你熟读文档,能正确而熟练地运用 API
实现各种需求那就行了,轮子的出现本就是为实际开发所服务而不是用来折腾开发者的,注意,我不是说不要去看,有时间还是要看看的,就算弄不明白其中的道道,但看了一遍总会有收获的,比如我在看 vue
源码的时候,经常看到类似于这种的赋值写法:
// vue/src/core/vdom/create-functional-component.js(clone.data || (clone.data = {})).slot = data.slot复制代码
如果是之前,对于这段逻辑我通常会这么写:
if (clone.data) { clone.data.slot = data.slot} else { clone.data = { slot: data.slot }}复制代码
也不是说第一种写法有什么难度或者看不明白,只是习惯了第二种写法,平时写代码的过程中自然而然不假思索地就写出来了,习惯成自然了,但是当看到第一种写法的时候才会一拍脑袋想着原来这么写也可以,以前白敲了那么多次键盘,所以没事要多看看别人优秀的源码,避免沉迷于自己的世界闭门造车,这样才能查漏补缺,这同样也是我认为代码 review
比较重要的原因,自己很难发现的问题,别人可能一眼就看出来了,此之谓当局者迷旁观者清也