简介
文章的背景,在一个月黑风高夜,项目经理把前端程序员叫到了一起,用愤青的表情说道,这个树的操作为什么这么卡,项目经理三下五除二的将流程演示一遍,每次操作在IE11下大概延迟1s左右,之前从未有过类似的情况,我们优先排出数量的问题,发现这棵树的数据大小为5M,数据量大约在1万条,按照正常理解,不应该在同步的情况下做如此大的数据量处理,但是项目紧急,后端童鞋不可能在这个时间内支持我们改异步结构,那么只能前端去调整结构了,既然用的是Iview的树,那么我们就得去分析一下Iview的源码了
初步分析
在分析iview的树之后发现每次节点的check或者select,都会引起一次大的Diff计算,当时以为是iview这块做的有问题,其实是对Vue原理的认知浅薄,于是和element-ui做了一次对比,element-ui的tree基本上抽象了一个节点类,每个节点转换过来都是一个节点实例,于是决定对element-ui和iview的tree做一个对比
对比
先把线上的5M大小的数据拿过来,分别对element-ui和iview的树做数据转化赋值,在IE11下进行测试,发现两者在IE11上表现基本一致,不会有1s左右的延迟卡顿,iview首次点击会有1s延迟卡顿,其余操作都比较流畅,那么我们的问题在哪呢,问题焦点回到了我们项目中对iview做二次封装的tree上
自己的坑自己填
既然证明iview的树没问题,那么我们开始梳理自己的树,发现我们在扩展iview的树的同时,在每一个节点上面做了一个数据转换,如下所示
<Tree :data-data="JSON.stringify(node)" />
这个有点厉害了,每一个节点的子节点量是相当大的,如果每次改变岂不是要大量遍历转换字符串,于是果断去掉,采用数据传值方式,优化此方式,到此我们的树基本上海加尔iview的同步了,但是我们回到上文,第一次点击iview树的时候会卡顿,那么问题是为什么呢,我们接下来进入本文的正题
观察者
关于响应式原理,网上一抓一大把,接下来我会按照我的理解来讲解这个响应式原理,如有不对,欢迎大家指正,我们知道在Vue中,整个响应式过程主要依赖于Observer、Dep、Watch这三个类来做关联,面向对象中叫做建模,这里面用到了经典的观察者模式,我们先来看一下Dep和Watch是如何做关联的,如下所示
//Dep
interface Watcher {...}
class Dep {
static target: ?Watcher
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
//触发观察者
}}
// Watcher
interface Dep {}
class Watcher {
constructor () {
...
}
addDep (dep: Dep) { const id = dep.id if (!this.newDepIds.has(id)) { this.newDepIds.add(id) this.newDeps.push(dep) if (!this.depIds.has(id)) {
// 还是Dep在收集Watcher dep.addSub(this) } } } depend () { let i = this.deps.length while (i--) { this.deps[i].depend() } }
...}
这里观察两个重点,一个是Dep中的addSub方法,参数为Watcher的实例,而Watcher中的addDep参数为Dep实例, 不过始终是Dep在自己的依赖集合中收集Watcher,这样依赖模式,熟悉的朋友一定很happy。这不就是经典的观察者模式吗,那么什么是观察者,观察者和发布订阅者模式有什么区别呢,那么先让我们来揭秘这个
发布订阅者VS观察者
这里得祭出 Addy Osmani的《JavaScript设计模式》,我们先来看一下观察者的流程图,如下所示
Observer模式要求希望接收到主体通知的观察者,必须订阅内容改变的事件,使用Observer模式背后的动机是我们需要在那里维护相关对象的一致性,无需使类紧密耦合。例如当一个对象能够统治其他对象,而不需要在这些方面做假设。主题对象和观察者是一对一注册和监听,在错误发生时能够快速对依赖进行追溯
对比上面提到的Dep和Watcher,是不是Dep和Subject对应,Watcher和Observer对应,在特定时间,Watcher.addDep将自己加入到Dep的收集中,然后在特定时间,Dep通过notify通知Watcher做出改变,如上图中的Fire Event
观察者模式我感觉更多是耦合性较高,两个类已经完全耦合到一起,接下来我们去看一下发布订阅者,如图
我想大家常用的模式应该这是这种吧,从上图中可以看到发布订阅者使用了一个事件通道Channel,这个通道介于希望接受到命名空间和回调函数,我们来看一下简版的实现
let EventChannel = {}
let Publish = {
$emit (name, args) {
if (EventChannel[name]) {
EventChannel[name](args)
return
}
console.warn(`${name} is not exist`)
},
$once (name, args) {...},
$on (name, cb) {
EventChannel[name] || (EventChannel[name] = []).push(cb)
}}
简版实现基本上是基于一个消息通道进行互相通信,Vue在事件触发时候用到了这个模式,$on和$emit
这个模式的缺点在于订阅者和发布者经常是无视对方的,它们之间是没有任何关系的,很难追踪依赖更新
响应式原理Observer
看到这个Observer不要懵,这个Observer是Vue源码中的一个双向绑定的重要事件,也是推动Watcher和Dep相互关联的重要媒介,我们先看看Observer都有哪些功能
class Observer {
constructor (value: any) { this.value = value this.dep = new Dep() this.vmCount = 0 def(value, '__ob__', this) if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods) } else { copyAugment(value, arrayMethods, arrayKeys) } this.observeArray(value) } else { this.walk(value) } }
walk () {}
observerArray () {}
}
Observer类中包含的方法,其实不多,注意这里的__ob__,一会要用到,更多核心实现在它依赖的工具函数,例如set、del、defineReactive等,我们看一下defineReactive函数实现,这个是Vue中双绑的核心Core
function defineReactive (obj, key, val, customSetter, shallow) {
const dep = new Dep()
let childOb = observer(val)
Object.defaineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(val)) {
dependArray(val)
}
}
}
},
get () {
dep.notify()
}
})
}
一个简版的defineReactive实现,基本上可以看到都是围绕在Dep和Watcher,很多童鞋可能有点郁闷,哪里有Watcher,注意代码中有一个Dep.target这个引用,再回到上面Dep的定义,我们可以看到Dep上有一个target的静态属性,值为Watcher的实例,所以Dep.target为Watcher实例,到这一步,我们可以看到基本上所有定义到data中的属性都会走到这一步,为它们所有的属性都创建一个Dep实例用来收集自己的Watcher
到这我们基本分析了,通过Observer的实现,我们已经打通了Dep和Watcher,那么Watcher到底是什么,什么时候会调用Watcher呢,这个具体讲解不是我今天的重点,我简单描述一下,Watcher分为渲染Watcher(render)、用户Watcher($watch)、异步Watcher(computed)这三种,所以我们可以看到为什么一个值的改变会触发这些的改变,都是因为它们的dep收集了这些Watcher的实例
我们分析了对象,基本上已经能解决很大一部分双向绑定的问题吗,但是我们回到这次问题的根源是Tree这棵树,它的数据结构是数组,我们貌似忽略了刚才在Vue中,数组是如何做到响应式收集的,在讲述这个问题之前,我先提一个问题,看代码
<template>
<div
:key="item.id"
v-for="item in list">
{{item.title}}
</div></template>
上面这段代码中,item这个对象是否会收集当前的Render Watcher,如果你答对了,并且可以很流畅的说出原理,那么恭喜你,你已经对这块了解的很透彻了,可以洗洗睡了,如果没有答对,或者对这块抱有疑问,那么我们继续往下看
有过前端经验的童鞋都知道,Object.defineProperty的劫持相对于数组是无法拦截的,所以Vue内部使用了更改原型链的方式,对数组的常用方法进行了拦截,例如push、splice、shift等,那么数组的成员如何被监听起来,它们的依赖关系怎么建立起来的,还记得上面提到的监听属性会在Observer类中添加一个__ob__的属性,这个属性指向自己,也就是一个Observer实例,这块该他们登场了。
当一个元素是数组时,会继续递归遍历它们的子属性进行监听,监听完成后,我们会注意到在defineReactive中的get方法最后收集watcher时候会调用dependArray方法,这个方法实现如下
function dependArray (value: Array<any>) { for (let e, i = 0, l = value.length; i < l; i++) { e = value[i] e && e.__ob__ && e.__ob__.dep.depend() if (Array.isArray(e)) { dependArray(e) } }}
看到每一个数组元素的__ob__去收集了当前的Watcher,它们自身是不会收集当前Watcher的,所以回到上面那个问题,我们知道item对象是不会收集当前Watcher的,那么新问题来了,__ob__啥时候用,为什么要收集,我们来看一个场景,如果一个监听元素要新增属性,如何引起页面重新刷新,有经验的童鞋肯定会高呼$set可以,那我们看一下$set究竟是什么
function set (target: Array<any> | Object, key: any, val: any): any {
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
return val
}
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
我们可以看到__ob__登场了,之前我们知道__ob__收集的就是元素渲染当前的Watcher,这时候可以顺利的引用到,并且触发notify刷新,至此整个过程大概完成了,我们回到最后一个终极问题,为什么iview的树第一次点击会卡,奥妙就在刚才将的原理中
来看一下iview的树的监听做了那些处理
watch: {
data: {
deep: true,
handler () {
this.stateTree = this.data;
this.flatState = this.compileFlatState();
this.rebuildTree();
}
}
},
根组件监听了data数据的改变,然后做了一次深度优先遍历,如果是本次数据的10000条,就会遍历10000次,我们在第一次点击checked时候,会触发$set,进而触发根组件的watcher,而在随后的点击中,checked属性已经监听到了自己的Watcher,所以不会再出发根组件的Watcher。
总结
Vue源码在去年已经看过一次了,很多知识点有点生疏了,借此重温一下,如有问题,请指正。