面试中我们经常会被问到很多复杂的算法,而工作中大多数时候又用不到,"面试造火箭,工作拧螺丝"成了大家的面后感🤣。确实,我们在工作中写代码的首要目标是完成功能,同时保证良好的可读性,在时间充裕或存在性能问题的前提下,才会去思考此处是否应该使用某些算法来降低时间或空间复杂度。
Vue,作为三大知名框架之一,为了提高框架运行性能,减少代码执行时长,在他的源码实现中也应用了很多算法:
栈的应用
与浏览器解析html类似,Vue使用了栈来记录当前正在解析的标签。主要通过parseHTML这个方法来解析template模版。这个方法使用了大量的正则表达式去匹配template模版中的标签、属性、文本、注释等,解析流程如下:
HTML解析器在解析HTML时,是从前向后解析。
- 每当遇到开始标签,就触发钩子函数start。
- 每当遇到结束标签,就会触发钩子函数end。
基于HTML解析器的逻辑,我们可以在每次触发钩子函数start时,把当前构建的节点推入栈中;每当触发钩子函数end时,就从栈中弹出一个节点。 这样就可以保证每当触发钩子函数start时,栈的最后一个节点就是当前正在构建的节点的父节点
// 省略了很多代码
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通过在遍历的同时进行剪枝来节省时间,如traverse、observe等方法。
在使用watch监听对象属性时,我们可以设置deep属性,表示深度监听这个对象的子对象的变化,也就是说无论是他自身进行了变化还是他的内部属性发生了变化,都需要执行回调函数。因此,当deep属性存在的时候,就会使用traverse函数去递归touch这个对象的所有属性。
这个方法使用了seenObjects这个Set结构的对象来记录每个属性上挂载的Observer实例(__ob__)的dep的id,表示这个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 watcher、computed watcher、watch watcher等都会执行update方法。但是在同步代码执行的时候,类似的赋值语句实际上是不止一条的,这就会涉及到多次触发,例如:
computed: {
compA() {
return this.a + this.b
}
},
methods: {
update() {
this.a = 'a'
this.b = 'b'
}
},
mounted() {
this.update()
}
当我们同时修改了a或者b,都会触发compA的watcher的update方法。显然同步的去更新dom或者重复执行同一个watcher都是低效的。因此:
Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的
Promise.then、MutationObserver和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>这个标签,他是个抽象组件,主要用来缓存它所包含的第一个子组件,从而做到路由切换的时候可以保存已有组件的状态。
- 初始化缓存store
created () {
this.cache = Object.create(null)
this.keys = []
}
- 获取子组件的
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
- 如果缓存中存在,那么就把当前缓存的
key移动到this.keys的末尾(表示最近使用)
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance
// make current key freshest
remove(keys, key)
keys.push(key)
}
- 如果缓存中不存在,那么创建对应的缓存,并把
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缓存get和put操作的平均时间复杂度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更新新旧vnode的patch算法,如果要更细粒度的话,实际上是指updateChildren,因为只有当两个vnode都有children的时候(如列表更新),此时的比对更新是最复杂的,也就是diff算法的核心。
这块儿的代码比较多,就不贴代码了,感兴趣的朋友可以自行阅读github上vue的相关源码。主要流程为:
1.快捷查找,共有4种查找方式,分别是:
- 新前与旧前
- 新后与旧后
- 新后与旧前
- 新前与旧后
- 如果快捷查找无法找到对应的节点,那么通过循环的方式去oldChildren中详细找一圈,看看能否找到
- 如果新旧节点中的某个节点的头尾指针产生了交叉(头指针超过尾指针):
- 新节点头尾指针交叉(先遍历完):删除oldChildren中未被遍历的节点
- 旧节点头尾指针交叉(先遍历玩完):将newChildren中未被遍历的节点插入到dom中
参考
(文中有些图片来源于网络,如有需要请联系我删除)