从JavaScript属性描述器剖析Vue.js响应式视图

学习每一门语言,一般都是从其数据结构开始,JavaScript也是一样,而JavaScript的数据结构中对象(Object)是最基础也是使用最频繁的概念和语法,坊间有言,JavaScript中,一切皆对象,基本可以描述对象在JavaScript中的地位,而且JavaScript中对象的强大也使其地位名副其实,本篇介绍JavaScript对象属性描述器接口及其在数据视图绑定方向的实践,然后对Vue.js的响应式原理进行剖析。

可以先看一个应用实例,点击此处

前言

JavaScript的对象,是一组键值对的集合,可以拥有任意数量的唯一键,键可以是字符串(String)类型或标记(Symbol,ES6新增的基本数据类型)类型,每个键对应一个值,值可以是任意类型的任意值。对于对象内的属性,JavaScript提供了一个属性描述器接口PropertyDescriptor,大部分开发者并不需要直接使用它,但是很多框架和类库内部实现使用了它,如avalon.js,Vue.js,本篇介绍属性描述器及相关应用。

定义对象属性

在介绍对象属性描述之前,先介绍一下如何定义对象属性。最常用的方式就是使用如下方式:


var a = { name: 'jh' }; // or var b = {}; b.name = 'jh'; // or var c = {}; var key = 'name'; c[key] = 'jh';

本文使用字面量方式创建对象,但是JavaScript还提供其他方式,如,new Object(),Object.create(),了解更多请查看对象初始化

Object.defineProperty()

上面通常使用的方式不能实现对属性描述器的操作,我们需要使用defineProperty()方法,该方法为一个对象定义新属性或修改一个已定义属性,接受三个参数Object.defineProperty(obj, prop, descriptor),返回值为操作后的对象:

  • obj, 待操作对象
  • 属性名
  • 操作属性的属性描述对象

var x = {}; Object.defineProperty(x, 'count', {}); console.log(x); // Object {count: undefined}

由于传入一个空的属性描述对象,所以输出对象属性值为undefined,当使用defineProperty()方法操作属性时,描述对象默认值为:

  • value: undefined
  • set: undefined
  • get: undefined
  • writable: false
  • enumerable: false,
  • configurable: false

不使用该方法定义属性,则属性默认描述为:

  • value: undefined
  • set: undefined
  • get: undefined
  • writable: true
  • enumerable: true,
  • configurable: true

默认值均可被明确参数值设置覆盖。

当然还支持批量定义对象属性及描述对象,使用“Object.defineProperties()`方法,如:


var x = {}; Object.defineProperties(x, { count: { value: 0 }, name: { value: 'jh' } }); console.log(x); // Object {count: 0, name: 'jh'}

读取属性描述对象

JavaScript支持我们读取某对象属性的描述对象,使用Object.getOwnPropertyDescriptor(obj, prop)方法:


var x = { name: 'jh' }; Object.defineProperty(x, 'count', {}); Object.getOwnPropertyDescriptor(x, 'count'); Object.getOwnPropertyDescriptor(x, 'name'); // Object {value: undefined, writable: false, enumerable: false, configurable: false} // Object {value: "jh", writable: true, enumerable: true, configurable: true}

该实例也印证了上面介绍的以不同方式定义属性时,其默认属性描述对象是不同的。

属性描述对象

PropertyDescriptor API提供了六大实例属性以描述对象属性,包括:configurable, enumerable, get, set, value, writable.

value

指定对象属性值:


var x = {}; Object.defineProperty(x, 'count', { value: 0 }); console.log(x); // Object {count: 0}

writable

指定对象属性是否可变:


var x = {}; Object.defineProperty(x, 'count', { value: 0 }); console.log(x); // Object {count: 0} x.count = 1; // 静默失败,不会报错 console.log(x); // Object {count: 0}

使用defineProperty()方法时,默认有writable: false, 需要显示设置writable: true

存取器函数(getter/setter)

对象属性可以设置存取器函数,使用get声明存取器getter函数,set声明存取器setter函数;若存在存取器函数,则在访问或设置该属性时,将调用对应的存取器函数:

get

读取该属性值时调用该函数并将该函数返回值赋值给属性值;


var x = {}; Object.defineProperty(x, 'count', { get: function() { console.log('读取count属性 +1'); return 0; } }); console.log(x); // Object {count: 0} x.count = 1; // '读取count属性 +1' console.log(x.count); // 0

set

当设置函数值时调用该函数,该函数接收设置的属性值作参数:


var x = {}; Object.defineProperty(x, 'count', { set: function(val) { this.count = val; } }); console.log(x); x.count = 1;

执行上诉代码,会发现报错,执行栈溢出:

栈溢出

上述代码在设置count属性时,会调用set方法,而在该方法内为count属性赋值会再次触发set方法,所以这样是行不通的,JavaScript使用另一种方式,通常存取器函数得同时声明,代码如下:


var x = {}; Object.defineProperty(x, 'count', { get: function() { return this._count; }, set: function(val) { console.log('设置count属性 +1'); this._count = val; } }); console.log(x); // Object {count: undefined} x.count = 1; // '设置count属性 +1' console.log(x.count); 1

事实上,在使用defineProperty()方法设置属性时,通常需要在对象内部维护一个新内部变量(以下划线_开头,表示不希望被外部访问),作为存取器函数的中介。

注:当设置了存取器描述时,不能设置valuewritable描述。

我们发现,设置属性存取器函数后,我们可以实现对该属性的实时监控,这在实践中很有用武之地,后文会印证这一点。

enumerable

指定对象内某属性是否可枚举,即使用for in操作是否可遍历:


var x = { name: 'jh' }; Object.defineProperty(x, 'count', { value: 0 }); for (var key in x) { console.log(key + ' is ' + x[key]); } // name is jh

上面无法遍历count属性,因为使用defineProperty()方法时,默认有enumerable: false,需要显示声明该描述:


var x = { name: 'jh' }; Object.defineProperty(x, 'count', { value: 0, enumerable: true }); for (var key in x) { console.log(key + ' is ' + x[key]); } // name is jh // count is 0 x.propertyIsEnumerable('count'); // true

configurable

该值指定对象属性描述是否可变:


var x = {}; Object.defineProperty(x, 'count', { value: 0, writable: false }); Object.defineProperty(x, 'count', { value: 0, writable: true });

执行上述代码会报错,因为使用defineProperty()方法时默认是configurable: false,输出如图:

configurable:false

修改如下,即可:


var x = {}; Object.defineProperty(x, 'count', { value: 0, writable: false, configurable: true }); x.count = 1; console.log(x.count); // 0 Object.defineProperty(x, 'count', { writable: true }); x.count = 1; console.log(x.count); // 1

属性描述与视图模型绑定

介绍完属性描述对象,我们来看看其在现代JavaScript框架和类库上的应用。目前有很多框架和类库实现数据和DOM视图的单向甚至双向绑定,如React,angular.js,avalon.js,,Vue.js等,使用它们很容易做到对数据变更进行响应式更新DOM视图,甚至视图和模型可以实现双向绑定,同步更新。当然这些框架、类库内部实现原理主要分为三大阵营。本文以Vue.js为例,Vue.js是当下比较流行的一个响应式的视图层类库,其内部实现响应式原理就是本文介绍的属性描述在技术中的具体应用。

可以点击此处,查看一个原生JavaScript实现的简易数据视图单向绑定实例,在该实例中,点击按钮可以实现计数自增,在输入框输入内容会同步更新到展示DOM,甚至在控制台改变data对象属性值,DOM会响应更新,如图:

数据视图单向绑定实例

点击查看完整实例代码

数据视图单向绑定

现有如下代码:


var data = {}; var contentEl = document.querySelector('.content'); Object.defineProperty(data, 'text', { configurable: true, enumerable: true, get: function() { return contentEl.innerHTML; }, set: function(val) { contentEl.innerHTML = val; } });

很容易看出,当我们设置data对象的text属性时,会将该值设置为视图DOM元素的内容,而访问该属性值时,返回的是视图DOM元素的内容,这就简单的实现了数据到视图的单向绑定,即数据变更,视图也会更新。

以上仅是针对一个元素的数据视图绑定,但稍微有经验的开发者便可以根据以上思路,进行封装,很容易的实现一个简易的数据到视图单向绑定的工具类。

抽象封装

接下来对以上实例进行简单抽象封装,点击查看完整实例代码

首先声明数据结构:


window.data = { title: '数据视图单向绑定', content: '使用属性描述器实现数据视图绑定', count: 0 }; var attr = 'data-on'; // 约定好的语法,声明DOM绑定对象属性

然后封装函数批量处理对象,遍历对象属性,设置描述对象同时为属性注册变更时的回调:


// 为对象中每一个属性设置描述对象,尤其是存取器函数 function defineDescriptors(obj) { for (var key in obj) { // 遍历属性 defineDescriptor(obj, key, obj[key]); } // 为特定属性设置描述对象 function defineDescriptor(obj, key, val) { Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function() { var value = val; return value; }, set: function(newVal) { if (newVal !== val) { // 值发生变更才执行 val = newVal; Observer.emit(key, newVal); // 触发更新DOM } } }); Observer.subscribe(key); // 为该属性注册回调 } }

管理事件

以发布订阅模式管理属性变更事件及回调:


// 使用发布/订阅模式,集中管理监控和触发回调事件 var Observer = { watchers: {}, subscribe: function(key) { var el = document.querySelector('[' + attr + '="'+ key + '"]'); // demo var cb = function react(val) { el.innerHTML = val; } if (this.watchers[key]) { this.watchers[key].push(cb); } else { this.watchers[key] = [].concat(cb); } }, emit: function(key, val) { var len = this.watchers[key] && this.watchers[key].length; if (len && len > 0) { for(var i = 0; i < len; i++) { this.watchers[key][i](val); } } } };

初始化实例

最后初始化实例:


// 初始化demo function init() { defineDescriptors(data); // 处理数据对象 var eles = document.querySelectorAll('[' + attr + ']'); // 初始遍历DOM展示数据 // 其实可以将该操作放到属性描述对象的get方法内,则在初始化时只需要对属性遍历访问即可 for (var i = 0, len = eles.length; i < len; i++) { eles[i].innerHTML = data[eles[i].getAttribute(attr)]; } // 辅助测试实例 document.querySelector('.add').addEventListener('click', function(e) { data.count += 1; }); } init();

html代码参考如下:


<h2 class="title" data-on="title"></h2> <div class="content" data-on="content"></div> <div class="count" data-on="count"></div> <div> 请输入内容: <input type="text" class="content-input" placeholder="请输入内容"> </div> <button class="add" onclick="">加1</button>

Vue.js的响应式原理

上一节实现了一个简单的数据视图单向绑定实例,现在对Vue.js的响应式单向绑定进行简要分析,主要需要理解其如何追踪数据变更。

依赖追踪

Vue.js支持我们通过data参数传递一个JavaScript对象做为组件数据,然后Vue.js将遍历此对象属性,使用Object.defineProperty方法设置描述对象,通过存取器函数可以追踪该属性的变更,本质原理和上一节实例差不多,但是不同的是,Vue.js创建了一层Watcher层,在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter被调用时,会通知Watcher重新计算,从而使它关联的组件得以更新,如下图:

Vue.js响应式原理图

组件挂载时,实例化watcher实例,并把该实例传递给依赖管理类,组件渲染时,使用对象观察接口遍历传入的data对象,为每个属性创建一个依赖管理实例并设置属性描述对象,在存取器函数get函数中,依赖管理实例添加(记录)该属性为一个依赖,然后当该依赖变更时,触发set函数,在该函数内通知依赖管理实例,依赖管理实例分发该变更给其内存储的所有watcher实例,watcher实例重新计算,更新组件。

因此可以总结说Vue.js的响应式原理是依赖追踪,通过一个观察对象,为每个属性,设置存取器函数并注册一个依赖管理实例depdep内为每个组件实例维护一个watcher实例,在属性变更时,通过setter通知dep实例,dep实例分发该变更给每一个watcher实例,watcher实例各自计算更新组件实例,即watcher追踪dep添加的依赖,Object.defineProperty()方法提供这种追踪的技术支持,dep实例维护这种追踪关系。

源码简单分析

接下来对Vue.js源码进行简单分析,从对JavaScript对象和属性的处理开始:

观察对象(Observer)

首先,Vue.js也提供了一个抽象接口观察对象,为对象属性设置存储器函数,收集属性依赖然后分发依赖更新:


var Observer = function Observer (value) { this.value = value; this.dep = new Dep(); // 管理对象依赖 this.vmCount = 0; def(value, '__ob__', this); // 缓存处理的对象,标记该对象已处理 if (Array.isArray(value)) { var augment = hasProto ? protoAugment : copyAugment; augment(value, arrayMethods, arrayKeys); this.observeArray(value); } else { this.walk(value); } };

上面代码关注两个节点,this.observeArray(value)this.walk(value);

  1. 若为对象,则调用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], obj[keys[i]]); } };

    可以看到,最终设置属性描述对象是通过调用defineReactive$$1()方法。

  2. 若value为对象数组,则需要额外处理,调用observeArray()方法对每一个对象均产生一个Observer实例,遍历监听该对象属性:


    Observer.prototype.observeArray = function observeArray (items) { for (var i = 0, l = items.length; i < l; i++) { observe(items[i]); } };

    核心是为每个数组项调用observe函数:


    function observe(value, asRootData) { if (!isObject(value)) { return // 只需要处理对象 } var ob; if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { ob = value.__ob__; // 处理过的则直接读取缓存 } else if ( observerState.shouldConvert && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue) { ob = new Observer(value); // 处理该对象 } if (asRootData && ob) { ob.vmCount++; } return ob }

    调用ob = new Obse