您的位置:首页 > Web前端 > Vue.js

Vue3.0 放弃 Object.defineProperty 你了解多少?

2020-07-14 06:35 323 查看

想必大家都知道Vue3.0 把数据对象侦测的API 从Object.defineProperty 换成 proxy,原因是使用了Proxy 初始性能更好。

因为proxy是真正的在对象层面做了proxy不会去改变对象的结构,Object.defineProperty需要转化数据对象属性为getter、setter 这是比较昂贵的操作。

如果你想从源码去了解 Object.defineProperty 有多么糟糕那我们开始…

数据侦测:

function observe(value, asRootData) {
if (!isObject(value) || value instanceof VNode) {
return
}
var ob;
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value);
}
if (asRootData && ob) {
ob.vmCount++;
}
return ob
}

observe工厂函数是整个数据响应式系统的入口,它会做几个事情:

  • value 如果不是对象 或者是VNode的实例直接终止函数的执行。
  • value如有"ob"属性,或者 value.ob 的值是 Observer实例直接把 value.__ob__的值作为observe返回值。(注:当一个数据对象被观测之后将会在该对象上定义__ob__属性)
  • 检测value的合法性。(不能是Vue实例、必须是数组对象或者纯对象、必须为可配置…)
  • 创建Observer实例,将value作为参数传递。

Observer构造函数

var Observer = function Observer(value) {
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);
}
};

将数据对象转换成响应式数据是Observer构造函数任务之一,一会我们会重点讲this.walk这个方法。现在先来认识下 Dep 。

this.dep = new Dep();

很多人会把 Dep 理解为订阅者构造函数,但订阅者本身就是一个很抽象的概念,理解上难免会增加心智负担。 我更愿意把Dep 理解成一个"容器" 这个"容器"中存储的就是观察者。 什么是观察者一会我们来讲。先说下这里的this.dep 就是创建了一个"容器" 这个"容器"中存储的就是某个对象或者数组依赖的观察者。

现在进入到walk中看看:

Observer.prototype.walk = function walk(obj) {
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
defineReactive$$1(obj, keys[i]);
}
};

walk 方法很简单,使用 Object.keys(obj) 获取对象所有可枚举的属性,然后通过 for 循环遍历这些属性,同时为每个属性调用了 defineReactive$$1 函数。

function defineReactive$$1(
obj,
key,
val,
customSetter,
shallow
) {
var dep = new Dep();

var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return
}

// cater for pre-defined getter/setters
var getter = property && property.get;
var setter = property && property.set;
if ((!getter || setter) && arguments.length === 2) {
val = obj[key];
}

var childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
var 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) {
var value = getter ? getter.call(obj) : val;
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (customSetter) {
customSetter();
}
// #7981: for accessor properties without setter
if (getter && !setter) {
return
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});
}

defineReactive1函数核心就是将数据对象的数据属性转换为访问器属性,但其中做了很多处理边界条件的工作这里我们将不会做过多的阐述。defineReactive接收五个参数,但是在walk方法中调用defineReactive1 函数核心就是将数据对象的数据属性转换为访问器属性,但其中做了很多处理边界条件的工作这里我们将不会做过多的阐述。defineReactive 接收五个参数,但是在 walk 方法中调用 defineReactive 1函数核心就是将数据对象的数据属性转换为访问器属性,但其中做了很多处理边界条件的工作这里我们将不会做过多的阐述。defineReactive接收五个参数,但是在walk方法中调用defineReactive1函数时只传递了前两个参数,数据对象和属性的键名。

重要代码:

var dep = new Dep(); //1
var childOb = !shallow && observe(val); //2
Object.defineProperty(obj, key, { //3
enumerable: true,
configurable: true,
get: function reactiveGetter() {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
...
}
return value
},
set: function reactiveSetter(newVal) {
var value = getter ? getter.call(obj) : val;
...
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});

需要注意的是:每次调用 defineReactive$$1 都会创建一个 Dep 实例即之前我们讲过的"容器",这里通过闭包的方法让数据对象中每个属性都会对应有一个 “容器” , 这个"容器"会在依赖收集的过程中存储对应的Watcher对象。

shallow 这个属性未传值,它的作用是当未传值或者传递false 那是需要进行深度观测。接下来会在递归调用observe 检测 val 的数据类型是不是引用类型,如果是在把 val 对象中的字段加入到响应式系统当中,用重新调用walk 、defineReactive$$1函数。

看到这里大家就知道为什么说Object.defineProperty很糟糕了吧? 当项目复杂度上升、数据对象结构过于复杂、初始性能将会变得越来越差,而Proxy 能完美的规避掉这些东西。如果你只是看标题进来这里应该能解决你的疑惑了。

但是现在我想跟大家讲讲关于Dep "容器"的事情。

get: function reactiveGetter() {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
...
}
return value
}

这是我们给数据对象属性设置的getter, 首先判断Dep.target是否存在,那么Dep.target 是什么呢? 直言不讳的讲,Dep.target中保存的值就是要被收集的依赖(观察者)。所以如果 Dep.target 存在的话说明有依赖需要被收集,这个时候才需要执行 if 语句块内的代码,如果 Dep.target 不存在就意味着没有需要被收集的依赖,所以当然就不需要执行 if 语句块内的代码了。

在 if 语句块内第一句执行的代码就是:dep.depend(),它会将依赖收集到 dep 这个"容器"中,这里的 dep 对象就是属性的 getter/setter 通过闭包关联它自身的那个"容器"。

Dep构造函数代码很简单大家自行去阅读,我们重点放在Dep.target 上。

Dep.target = null;

Dep.target初始值为null, 那什么时候赋值呢?那我们需要从模板编译组件挂载说起,模板编译专栏系列文章,接下来重点放在组件挂载。

function mountComponent(vm, el, hydrating) {
vm.$el = el;
...
callHook(vm, 'beforeMount');

var updateComponent = function() {
vm._update(vm._render(), hydrating);
}
...
new Watcher(vm, updateComponent, noop, {
before: function before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */ );
...
}

mountComponent就是组件挂载的核心函数了,其内部定义了 updateComponent 函数,该函数的作用是以 vm._render() 函数的返回值作为第一个参数调用 vm._update() 函数。没有看过专栏的朋友可能还不了解 vm._render 函数和 vm._update 函数的作用,但可以先简单地认为:

  • vm._render 函数的作用是调用 vm.$options.render 函数并返回生成的虚拟节点(VNode)
  • vm._update 函数的作用是把 vm._render 函数生成的虚拟节点渲染成真正的 DOM

再往下,我们将遇到创建观察者(Watcher)实例的代码:

new Watcher(vm, updateComponent, noop, {
before: function before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */ );

简单说下Watcher的作用,他在数据响应式系统中扮演的是对表达式的求值的角色,触发数据属性的 get 拦截器函数(做到这一步不困难想想我们之前讲到的 render 函数),从而收集到了依赖,当数据变化时能够触发响应。

在上面的代码中 Watcher 观察者实例将对 updateComponent 函数求值,updateComponent 函数执行会间接触发渲染函数(vm.$options.render)的执行,而渲染函数的执行则会触发数据属性的 get 拦截器函数,从而将依赖(观察者)收集,当数据变化时将重新执行 updateComponent 函数,这就完成了重新渲染。同时我们把上面代码中实例化的观察者对象称为 渲染函数的观察者。

Watcher构造函数

var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) {
...
if (options) {
this.deep = !!options.deep;
this.user = !!options.user;
this.lazy = !!options.lazy;
this.sync = !!options.sync;
this.before = options.before;
} else {
this.deep = this.user = this.lazy = this.sync = false;
}
this.cb = cb;
this.id = ++uid$1; // uid for batching
this.active = true;
this.dirty = this.lazy; // for lazy watchers
this.deps = [];
this.newDeps = [];
this.depIds = new _Set();
this.newDepIds = new _Set();
this.expression = expOrFn.toString();
...
this.value = this.lazy ? undefined : this.get();
};

我们目光只放在主线代码上,创建 Watcher 实例时可以传递五个参数,分别是:组件实例对象vm、要观察的表达式 expOrFn、当被观察的表达式的值变化时的回调函数 cb、一些传递给当前观察者对象的选项 options 以及一个布尔值 isRenderWatcher 用来标识该观察者实例是否是渲染函数的观察者。

这里特别注意的是expOrFn 参数, 我们在创建实例的时候对应传递给它的是updateComponent 函数,刚刚我们讲到 Watcher 的原理是通过对"被观测目标"的求值,触发数据属性的get 拦截器函数从而收集依赖, 那当数据变化的时候呢? 数据一但是发生变化会执行cb回调,还会重新对"被观察目标"求值,也就是说 updateComponent 也会被调用,在此过程中生成新的VNode。 说到这里大家或许又产生了一个疑问:“再次执行updateComponent函数难道不会导致再次触发数据属性的get拦截器函数导致重复收集依赖吗?” 不用担心,因为 Vue 已经实现了避免收集重复依赖的处理,稍后会讲到的。

if (options) {
this.deep = !!options.deep;
this.user = !!options.user;
this.lazy = !!options.lazy;
this.sync = !!options.sync;
this.before = options.before;
} else {
this.deep = this.user = this.lazy = this.sync = false;
}

重点讲讲一会用到的lazy,options.lazy 用来标识当前观察者实例对象是否是计算属性的观察者。 在低版本代码中它还有另外一个时髦的名字 “options.computed” 。计算属性的观察者并不是指一个观察某个计算属性变化的观察者,而是指 Vue 内部在实现计算属性这个功能时为计算属性创建的观察者。后面用到了在详细解释。

this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.computed // for computed watchers
  • this.cb属性,它的值为cb回调函数。
  • this.id属性,它是观察者实例对象的唯一标识。
  • this.active属性,它标识着该观察者实例对象是否是激活状态,默认值为true代表激活。
  • this.dirty属性,该属性的值与this.lazy属性的值相同,也就是说只有计算属性的观察者实例对象的this.dirty属性的值才会为真,因为计算属性是惰性求值。
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()

重点关注: this.newDeps 与 this.newDepIds 它们两就是用来避免收集重复依赖,且移除无用依赖。

this.value = this.lazy ? undefined : this.get();

最后一句代码意思是除计算属性的观察者之外的所有观察者实例对象都将执行 this.get() 方法。

依赖收集的过程

this.get()它的作用就是求值。求值的目的有两个,第一个是能够触发访问器属性的get拦截器函数,第二个是能够获得被观察目标的值。而且能够触发访问器属性的get拦截器函数是依赖被收集的关键,下面我们具体查看一下this.get()方法的内容:

Watcher.prototype.get = function get() {
pushTarget(this);
var value;
var vm = this.vm;
try {
value = this.getter.call(vm, vm);
} catch (e) {
if (this.user) {
handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value);
}
popTarget();
this.cleanupDeps();
}
return value
};

this.get()方法调用了pushTarget(this) 函数,并将当前观察者实例对象作为参数传递。

Dep.target = null;
var targetStack = [];

function pushTarget(target) {
targetStack.push(target);
Dep.target = target;
}

function popTarget() {
targetStack.pop();
Dep.target = targetStack[targetStack.length - 1];
}

到目前为止已经解决了之前的疑惑。Dep.target 是什么呢? 直言不讳的讲,Dep.target中保存的值就是要被收集的依赖(观察者)。

总结下:

Dep.target属性初始值为null,pushTarget函数的作用就是用来为Dep.target属性赋值的,pushTarget函数会将接收到的参数赋值给Dep.target属性,传递给pushTarget函数的参数就是调用该函数的观察者对象,所以Dep.target保存着一个观察者对象,其实这个观察者对象就是即将要收集的目标。

接下来在回到get方法中:

Watcher.prototype.get = function get() {
pushTarget(this);
var value;
var vm = this.vm;
try {
value = this.getter.call(vm, vm);
} catch (e) {
if (this.user) {
handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value);
}
popTarget();
this.cleanupDeps();
}
return value
};

在调用pushTarget函数之后,定义了value变量,该变量的值为this.getter函数的返回值,你先简单认定this.getter的值就是我们刚刚传过来的updateComponent 函数,这个函数的执行就意味着对被观察目标的求值,将得到的值赋值给value变量。而且我们可以看到this.get方法的最后将value返回。

this.value = this.lazy ? undefined : this.get();

在Watcher构造函数中我们看到被观察目标的值,最终都会存储在实例的value属性上。this.get()方法除了对被观察目标求值之外,大家别忘了正是因为对被观察目标的求值才得以触发数据属性的get拦截器函数,还是以渲染函数的观察者为例,假设我们有如下模板:

<div id="app">
{{message}}
</div>

这段模板被编译将生成如下渲染函数:

function anonymous () {
with (this) {
return _c('div',
{ attrs:{ "id": "app" } },
[_v(_s(message))]
)
}
}

这个过程在专栏的编译器中都讲过不在重述,可以发现渲染函数的执行会读取数据属性message 的值,这将会触发message 属性的 get 拦截器函数。

执行如下代码defineReactive$$1部分源码:

Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
var value = getter ? getter.call(obj) : val;
if (Dep.target) {
dep.depend();
...
}
return value
},
set: function reactiveSetter(newVal) {
var value = getter ? getter.call(obj) : val;
...
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});

由于渲染函数读取了 message 属性的值,所以 message 属性的 get 拦截器函数将被执行,执行过程中首先判断了 Dep.target 是否存在,如果存在则调用dep.depend方法收集依赖。那么 Dep.target 是否存在呢?答案是存在,这就是为什么 pushTarget 函数要在调用this.getter 函数之前被调用的原因。既然 dep.depend 方法被执行,那么我们就找到dep.depend方法。

Dep.prototype.depend = function depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
};

顺藤摸瓜去找下addDep的源码:

Watcher.prototype.addDep = function addDep(dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
};

可以看到addDep方法接收一个参数,这个参数是一个Dep对象,在 addDep 方法内部首先定义了常量id,它的值是Dep实例对象的唯一 id 值。接着是一段 if 语句块,该 if 语句块的代码很关键,因为它的作用就是用来避免收集重复依赖的,既然是用来避免收集重复的依赖,那么就不得不用到我们前面提到过的两组属性,即newDepIds、newDeps以及depIds、deps。

什么叫收集重复的依赖?举个栗子有模板如下:

<div id="app">
{{message}}{{message}}
</div>

这段模板被编译将生成如下渲染函数:

function anonymous () {
with (this) {
return _c('div',
{ attrs:{ "id": "app" } },
[_v(_s(message)+_s(message))]
)
}
}

渲染函数的执行将读取两次数据对象 message 属性的值,这必然会触发两次 message 属性的 get 拦截器函数,同样的道理,dep.depend 也将被触发两次,最后导致dep.addSub 方法被执行了两次,且参数一模一样,这样就产生了同一个观察者被收集多次的问题。

if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
...
}

在 addDep 内部并不是直接调用 dep.addSub 收集观察者,而是先根据 dep.id属性检测该Dep实例对象是否已经存在于 newDepIds 中,如果存在那么说明已经收集过依赖了,什么都不会做。如果不存在才会继续执行if语句块的代码,同时将 dep.id 属性和 Dep 实例对象本身分别添加到 newDepIds 和 newDeps 属性中,这样无论一个数据属性被读取了多少次,对于同一个观察者它只会收集一次。

呼出一口长气,文章到这结束了…现在你是否有点明白用Object.defineProperty 构建数据响应式系统有多糟糕了。

推荐:

申请即送:

  • BAT大厂面试题、独家面试工具包,

  • 资料免费领取,包括 各类面试题以及答案整理,各大厂面试真题分享!

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: