Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端路由简介以及vue-router实现原理 #22

Open
muwoo opened this issue May 28, 2018 · 2 comments
Open

前端路由简介以及vue-router实现原理 #22

muwoo opened this issue May 28, 2018 · 2 comments

Comments

@muwoo
Copy link
Owner

muwoo commented May 28, 2018

后端路由简介

路由这个概念最先是后端出现的。在以前用模板引擎开发页面时,经常会看到这样

http://www.xxx.com/login

大致流程可以看成这样:

  1. 浏览器发出请求

  2. 服务器监听到80端口(或443)有请求过来,并解析url路径

  3. 根据服务器的路由配置,返回相应信息(可以是 html 字串,也可以是 json 数据,图片等)

  4. 浏览器根据数据包的 Content-Type 来决定如何解析数据

简单来说路由就是用来跟后端服务器进行交互的一种方式,通过不同的路径,来请求不同的资源,请求不同的页面是路由的其中一种功能。

前端路由

1. hash 模式

随着 ajax 的流行,异步数据请求交互运行在不刷新浏览器的情况下进行。而异步交互体验的更高级版本就是 SPA —— 单页应用。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的,为了实现单页应用,所以就有了前端路由。
类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 url 路径,进行解析,然后动态的渲染出区域 html 内容。但是这样存在一个问题,就是 url 每次变化的时候,都会造成页面的刷新。那解决问题的思路便是在改变 url 的情况下,保证页面的不刷新。在 2014 年之前,大家是通过 hash 来实现路由,url hash 就是类似于:

http://www.xxx.com/#/login

这种 #。后面 hash 值的变化,并不会导致浏览器向服务器发出请求,浏览器不发出请求,也就不会刷新页面。另外每次 hash 值的变化,还会触发hashchange 这个事件,通过这个事件我们就可以知道 hash 值发生了哪些变化。然后我们便可以监听hashchange来实现更新页面部分内容的操作:

function matchAndUpdate () {
   // todo 匹配 hash 做 dom 更新操作
}

window.addEventListener('hashchange', matchAndUpdate)

2. history 模式

14年后,因为HTML5标准发布。多了两个 API,pushStatereplaceState,通过这两个 API 可以改变 url 地址且不会发送请求。同时还有 popstate 事件.调用history.pushState()或者history.replaceState()不会触发popstate事件. popstate事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在JavaScript中调用history.back()、history.forward()、history.go()方法).。通过这些就能用另一种方式来实现前端路由了,但原理都是跟 hash 实现相同的。用了 HTML5 的实现,单页路由的 url 就不会多出一个#,变得更加美观。但因为没有 # 号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求。为了避免出现这种情况,所以这个实现需要服务器的支持,需要把所有路由都重定向到根页面。

function matchAndUpdate () {
   // todo 匹配路径 做 dom 更新操作
}

window.addEventListener('popstate', matchAndUpdate)

Vue router 实现

我们来看一下vue-router是如何定义的:

import VueRouter from 'vue-router'
Vue.use(VueRouter)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
})

new Vue({
  router
  ...
})

可以看出来vue-router是通过 Vue.use的方法被注入进 Vue 实例中,在使用的时候我们需要全局用到 vue-routerrouter-viewrouter-link组件,以及this.$router/$route这样的实例对象。那么是如何实现这些操作的呢?下面我会分几个章节详细的带你进入vue-router的世界。

vue-router 实现 -- install

vue-router 实现 -- new VueRouter(options)

vue-router 实现 -- HashHistory

vue-router 实现 -- HTML5History

vue-router 实现 -- 路由变更监听

造轮子 -- 动手实现一个数据驱动的 router

经过上面的阐述,相信您已经对前端路由以及vue-router有了一些大致的了解。那么这里我们为了贯彻无解肥,我们来手把手撸一个下面这样的数据驱动的 router

new Router({
  id: 'router-view', // 容器视图
  mode: 'hash', // 模式
  routes: [
    {
      path: '/',
      name: 'home',
      component: '<div>Home</div>',
      beforeEnter: (next) => {
        console.log('before enter home')
        next()
      },
      afterEnter: (next) => {
        console.log('enter home')
        next()
      },
      beforeLeave: (next) => {
        console.log('start leave home')
        next()
      }
    },
    {
      path: '/bar',
      name: 'bar',
      component: '<div>Bar</div>',
      beforeEnter: (next) => {
        console.log('before enter bar')
        next()
      },
      afterEnter: (next) => {
        console.log('enter bar')
        next()
      },
      beforeLeave: (next) => {
        console.log('start leave bar')
        next()
      }
    },
    {
      path: '/foo',
      name: 'foo',
      component: '<div>Foo</div>'
    }
  ]
})

思路整理

首先是数据驱动,所以我们可以通过一个route对象来表述当前路由状态,比如:

current = {
    path: '/', // 路径
    query: {}, // query
    params: {}, // params
    name: '', // 路由名
    fullPath: '/', // 完整路径
    route: {} // 记录当前路由属性
}

current.route内存放当前路由的配置信息,所以我们只需要监听current.route的变化来动态render页面便可。

接着我么需要监听不同的路由变化,做相应的处理。以及实现hashhistory模式。

数据驱动

这里我们延用vue数据驱动模型,实现一个简单的数据劫持,并更新视图。首先定义我们的observer

class Observer {
  constructor (value) {
    this.walk(value)
  }

  walk (obj) {
    Object.keys(obj).forEach((key) => {
      // 如果是对象,则递归调用walk,保证每个属性都可以被defineReactive
      if (typeof obj[key] === 'object') {
        this.walk(obj[key])
      }
      defineReactive(obj, key, obj[key])
    })
  }
}

function defineReactive(obj, key, value) {
  let dep = new Dep()
  Object.defineProperty(obj, key, {
    get: () => {
      if (Dep.target) {
        // 依赖收集
        dep.add()
      }
      return value
    },
    set: (newValue) => {
      value = newValue
      // 通知更新,对应的更新视图
      dep.notify()
    }
  })
}

export function observer(value) {
  return new Observer(value)
}

再接着,我们需要定义DepWatcher:

export class Dep {
  constructor () {
    this.deppend = []
  }
  add () {
    // 收集watcher
    this.deppend.push(Dep.target)
  }
  notify () {
    this.deppend.forEach((target) => {
      // 调用watcher的更新函数
      target.update()
    })
  }
}

Dep.target = null

export function setTarget (target) {
  Dep.target = target
}

export function cleanTarget() {
  Dep.target = null
}

// Watcher
export class Watcher {
  constructor (vm, expression, callback) {
    this.vm = vm
    this.callbacks = []
    this.expression = expression
    this.callbacks.push(callback)
    this.value = this.getVal()

  }
  getVal () {
    setTarget(this)
    // 触发 get 方法,完成对 watcher 的收集
    let val = this.vm
    this.expression.split('.').forEach((key) => {
      val = val[key]
    })
    cleanTarget()
    return val
  }

  // 更新动作
  update () {
    this.callbacks.forEach((cb) => {
      cb()
    })
  }
}

到这里我们实现了一个简单的订阅-发布器,所以我们需要对current.route做数据劫持。一旦current.route更新,我们可以及时的更新当前页面:

  // 响应式数据劫持
  observer(this.current)

  // 对 current.route 对象进行依赖收集,变化时通过 render 来更新
  new Watcher(this.current, 'route', this.render.bind(this))

恩....到这里,我们似乎已经完成了一个简单的响应式数据更新。其实render也就是动态的为页面指定区域渲染对应内容,这里只做一个简化版的render:

  render() {
    let i
    if ((i = this.history.current) && (i = i.route) && (i = i.component)) {
      document.getElementById(this.container).innerHTML = i
    }
  }

hash 和 history

接下来是hashhistory模式的实现,这里我们可以沿用vue-router的思想,建立不同的处理模型便可。来看一下我实现的核心代码:

this.history = this.mode === 'history' ? new HTML5History(this) : new HashHistory(this)

当页面变化时,我们只需要监听hashchangepopstate事件,做路由转换transitionTo:

  /**
   * 路由转换
   * @param target 目标路径
   * @param cb 成功后的回调
   */
  transitionTo(target, cb) {
    // 通过对比传入的 routes 获取匹配到的 targetRoute 对象
    const targetRoute = match(target, this.router.routes)
    this.confirmTransition(targetRoute, () => {
      // 这里会触发视图更新
      this.current.route = targetRoute
      this.current.name = targetRoute.name
      this.current.path = targetRoute.path
      this.current.query = targetRoute.query || getQuery()
      this.current.fullPath = getFullPath(this.current)
      cb && cb()
    })
  }

  /**
   * 确认跳转
   * @param route
   * @param cb
   */
  confirmTransition (route, cb) {
    // 钩子函数执行队列
    let queue = [].concat(
      this.router.beforeEach,
      this.current.route.beforeLeave,
      route.beforeEnter,
      route.afterEnter
    )
    
    // 通过 step 调度执行
    let i = -1
    const step = () => {
      i ++
      if (i > queue.length) {
        cb()
      } else if (queue[i]) {
        queue[i](step)
      } else {
        step()
      }

    }
    step(i)
  }
}

这样我们一方面通过this.current.route = targetRoute达到了对之前劫持数据的更新,来达到视图更新。另一方面我们又通过任务队列的调度,实现了基本的钩子函数beforeEachbeforeLeavebeforeEnterafterEnter
到这里其实也就差不多了,接下来我们顺带着实现几个API吧:

  /**
   * 跳转,添加历史记录
   * @param location 
   * @example this.push({name: 'home'})
   * @example this.push('/')
   */
  push (location) {
    const targetRoute = match(location, this.router.routes)

    this.transitionTo(targetRoute, () => {
      changeUrl(this.router.base, this.current.fullPath)
    })
  }

  /**
   * 跳转,添加历史记录
   * @param location
   * @example this.replaceState({name: 'home'})
   * @example this.replaceState('/')
   */
  replaceState(location) {
    const targetRoute = match(location, this.router.routes)

    this.transitionTo(targetRoute, () => {
      changeUrl(this.router.base, this.current.fullPath, true)
    })
  }

  go (n) {
    window.history.go(n)
  }

  function changeUrl(path, replace) {
    const href = window.location.href
    const i = href.indexOf('#')
    const base = i >= 0 ? href.slice(0, i) : href
    if (replace) {
      window.history.replaceState({}, '', `${base}#/${path}`)
    } else {
      window.history.pushState({}, '', `${base}#/${path}`)
    }
  }

到这里也就基本上结束了。源码地址:

动手撸一个 router

有兴趣可以自己玩玩。实现的比较粗陋,如有疑问,欢迎指点。

@jonnzer
Copy link

jonnzer commented Aug 20, 2019

超棒思路清晰!目前我只看了前缀介绍,已是拨开些许迷雾感谢你

@Lihr1999
Copy link

你好, 请问new Watcher(this.current, 'route', this.render.bind(this)) 这个在vue源码中也是这样定义的嘛?我现在想不明白对_route进行数据劫持之后,改变路由地址,是如何重新调用render函数渲染页面的。
希望得到解答,谢谢!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants