我从Vue源码中学到了这些算法

844 阅读7分钟

面试中我们经常会被问到很多复杂的算法,而工作中大多数时候又用不到,"面试造火箭,工作拧螺丝"成了大家的面后感🤣。确实,我们在工作中写代码的首要目标是完成功能,同时保证良好的可读性,在时间充裕或存在性能问题的前提下,才会去思考此处是否应该使用某些算法来降低时间或空间复杂度。

Vue,作为三大知名框架之一,为了提高框架运行性能,减少代码执行时长,在他的源码实现中也应用了很多算法:

栈的应用

与浏览器解析html类似,Vue使用了栈来记录当前正在解析的标签。主要通过parseHTML这个方法来解析template模版。这个方法使用了大量的正则表达式去匹配template模版中的标签、属性、文本、注释等,解析流程如下:

HTML解析器在解析HTML时,是从前向后解析。

  • 每当遇到开始标签,就触发钩子函数start。
  • 每当遇到结束标签,就会触发钩子函数end。

基于HTML解析器的逻辑,我们可以在每次触发钩子函数start时,把当前构建的节点推入栈中;每当触发钩子函数end时,就从栈中弹出一个节点。 这样就可以保证每当触发钩子函数start时,栈的最后一个节点就是当前正在构建的节点的父节点

img6.png

// 省略了很多代码
export function parseHTML (html, options) {
  const stack = []
  while (html) {
    // End tag:
    const endTagMatch = html.match(endTag)
    if (endTagMatch) {
      // parseEndTag(endTagMatch[1], curIndex, index)
      for (pos = stack.length - 1; pos >= 0; pos--) {
        // 检查当前栈顶的标签是否一致
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
      // 出栈,删除最后一个元素
      stack.length = pos
      continue
    }

    // Start tag:
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      // 入栈
      stack.push({
        tag: tagName,
        lowerCasedTag: tagName.toLowerCase(),
        attrs: attrs,
        start: match.start,
        end: match.end
      })
      continue
    }
  }
}

路径解析

在Vue的侦听属性中,我们可以使用以下语法:

watch: {
  'a.b.c': function(newVal, oldVal) {
    console.log(newVal, oldVal)
  }
}

侦听器可以接受路径字符串,表示访问当前组件data函数返回的对象中嵌套的某个属性。如果这个属性发生变化就会执行回调函数。为了解析这个路径,touch到这条路径上的所有属性,触发他的getter函数进行依赖收集,Vue使用了parsePath这个函数来实现:(通过split函数进行分词,循环获取子对象的属性)

/**
 * Parse simple path.
 */
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

dfs+剪枝

在Vue源码中使用了大量的dfs(深度优先遍历),这些方法比较耗时,所以Vue通过在遍历的同时进行剪枝来节省时间,如traverseobserve等方法。

在使用watch监听对象属性时,我们可以设置deep属性,表示深度监听这个对象的子对象的变化,也就是说无论是他自身进行了变化还是他的内部属性发生了变化,都需要执行回调函数。因此,当deep属性存在的时候,就会使用traverse函数去递归touch这个对象的所有属性。

这个方法使用了seenObjects这个Set结构的对象来记录每个属性上挂载的Observer实例(__ob__)的depid,表示这个dep已经访问过了,从而做到提前返回,提高程序运行效率。

const seenObjects = new Set()

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  // ...
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  // ...
  keys = Object.keys(val)
  i = keys.length
  while (i--) _traverse(val[keys[i]], seen)
}

队列调度

队列调度一般会使用缓存 + 锁的形式来处理同步代码中存在的多次相同的请求,将其合并为一次,从而减少请求次数,一般格式为:

let pending = false
const callbacks = []
const p = Promise.resolve()

// 清空缓冲队列
function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 处理请求
timerFunc = () => {
  p.then(flushCallbacks)
}

function nextTick(cb) {
  callbacks.push(() => {
    cb?.()
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
}

以上代码为vue nextTick的实现原理,咱们这里的timerFunc可以换成对应的bridge调用网络请求等。

除此之外,Vue的一整套侦听系统中数据的变化是极为复杂的,当我们修改了某个响应式属性后,如:

this.xxx = xxx

与之关联的render watchercomputed watcherwatch watcher等都会执行update方法。但是在同步代码执行的时候,类似的赋值语句实际上是不止一条的,这就会涉及到多次触发,例如:

computed: {
  compA() {
    return this.a + this.b
  }
},
methods: {
  update() {
      this.a = 'a'
      this.b = 'b'
  }
},
mounted() {
  this.update()
}

当我们同时修改了a或者b,都会触发compAwatcherupdate方法。显然同步的去更新dom或者重复执行同一个watcher都是低效的。因此:

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

Vue通过queueWatcher来实现watcher回调的异步执行(包括视图更新):

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

LRU缓存

相信大家或多或少都接触过<keep-alive></keep-alive>这个标签,他是个抽象组件,主要用来缓存它所包含的第一个子组件,从而做到路由切换的时候可以保存已有组件的状态。

  1. 初始化缓存store
created () {
  this.cache = Object.create(null)
  this.keys = []
}
  1. 获取子组件的key
const { cache, keys } = this
const key: ?string = vnode.key == null
  // same constructor may get registered as different local components
  // so cid alone is not enough (#3269)
  ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
  : vnode.key
  1. 如果缓存中存在,那么就把当前缓存的key移动到this.keys的末尾(表示最近使用)
if (cache[key]) {
  vnode.componentInstance = cache[key].componentInstance
  // make current key freshest
  remove(keys, key)
  keys.push(key)
}
  1. 如果缓存中不存在,那么创建对应的缓存,并把keypush到this.keys的末尾(表示最近使用),如果当前缓存的实例数量大于了最大限制数量,那么就把第一个key删除,并销毁与之对应的组件。
else {
  cache[key] = vnode
  keys.push(key)
  // prune oldest entry
  if (this.max && keys.length > parseInt(this.max)) {
    pruneCacheEntry(cache, keys[0], keys, this._vnode)
  }
}

需要注意的是,实际上这个LRU缓存getput操作的平均时间复杂度O(n),因为使用了remove方法:

/**
 * Remove an item from an array.
 */
export function remove (arr: Array<any>, item: any): Array<any> | void {
  if (arr.length) {
    const index = arr.indexOf(item)
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}

如果要实现O(1)的平均复杂度可以采用哈希表 + 双向链表实现。

diff算法

所谓的diff算法实际上就是vue更新新旧vnodepatch算法,如果要更细粒度的话,实际上是指updateChildren,因为只有当两个vnode都有children的时候(如列表更新),此时的比对更新是最复杂的,也就是diff算法的核心。

这块儿的代码比较多,就不贴代码了,感兴趣的朋友可以自行阅读github上vue的相关源码。主要流程为:

1.快捷查找,共有4种查找方式,分别是:

  • 新前与旧前
  • 新后与旧后
  • 新后与旧前
  • 新前与旧后

image.png

  1. 如果快捷查找无法找到对应的节点,那么通过循环的方式去oldChildren中详细找一圈,看看能否找到
  2. 如果新旧节点中的某个节点的头尾指针产生了交叉(头指针超过尾指针):
    • 新节点头尾指针交叉(先遍历完):删除oldChildren中未被遍历的节点
    • 旧节点头尾指针交叉(先遍历玩完):将newChildren中未被遍历的节点插入到dom中

参考

(文中有些图片来源于网络,如有需要请联系我删除)