能够先看一个应用实例,点击此处html
JavaScript的对象,是一组键值对的集合,能够拥有任意数量的惟一键,键能够是字符串(String)类型或标记(Symbol,ES6新增的基本数据类型)类型,每一个键对应一个值,值能够是任意类型的任意值。对于对象内的属性,JavaScript提供了一个属性描述器接口PropertyDescriptor
,大部分开发者并不须要直接使用它,可是不少框架和类库内部实现使用了它,如avalon.js,Vue.js,本篇介绍属性描述器及相关应用。vue
在介绍对象属性描述以前,先介绍一下如何定义对象属性。最经常使用的方式就是使用以下方式:react
var a = { name: 'jh' }; // or var b = {}; b.name = 'jh'; // or var c = {}; var key = 'name'; c[key] = 'jh';
本文使用字面量方式建立对象,可是JavaScript还提供其余方式,如,new Object(),Object.create(),了解更多请查看对象初始化。git
上面一般使用的方式不能实现对属性描述器的操做,咱们须要使用defineProperty()
方法,该方法为一个对象定义新属性或修改一个已定义属性,接受三个参数Object.defineProperty(obj, prop, descriptor)
,返回值为操做后的对象:github
var x = {}; Object.defineProperty(x, 'count', {}); console.log(x); // Object {count: undefined}
因为传入一个空的属性描述对象,因此输出对象属性值为undefined,当使用defineProperty()
方法操做属性时,描述对象默认值为:web
不使用该方法定义属性,则属性默认描述为:express
默认值都可被明确参数值设置覆盖。数组
固然还支持批量定义对象属性及描述对象,使用``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.
指定对象属性值:
var x = {}; Object.defineProperty(x, 'count', { value: 0 }); console.log(x); // Object {count: 0}
指定对象属性是否可变:
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
。
对象属性能够设置存取器函数,使用get
声明存取器getter函数,set
声明存取器setter函数;若存在存取器函数,则在访问或设置该属性时,将调用对应的存取器函数:
读取该属性值时调用该函数并将该函数返回值赋值给属性值;
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
当设置函数值时调用该函数,该函数接收设置的属性值做参数:
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()
方法设置属性时,一般须要在对象内部维护一个新内部变量(如下划线_
开头,表示不但愿被外部访问),做为存取器函数的中介。
注:当设置了存取器描述时,不能设置value
和writable
描述。
咱们发现,设置属性存取器函数后,咱们能够实现对该属性的实时监控,这在实践中颇有用武之地,后文会印证这一点。
指定对象内某属性是否可枚举,即便用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
该值指定对象属性描述是否可变:
var x = {}; Object.defineProperty(x, 'count', { value: 0, writable: false }); Object.defineProperty(x, 'count', { value: 0, writable: true });
执行上述代码会报错,由于使用defineProperty()
方法时默认是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', { writable: true, 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支持咱们经过data
参数传递一个JavaScript对象作为组件数据,而后Vue.js将遍历此对象属性,使用Object.defineProperty
方法设置描述对象,经过存取器函数能够追踪该属性的变动,本质原理和上一节实例差很少,可是不一样的是,Vue.js建立了一层Watcher
层,在组件渲染的过程当中把属性记录为依赖,以后当依赖项的setter
被调用时,会通知Watcher
从新计算,从而使它关联的组件得以更新,以下图:
组件挂载时,实例化watcher
实例,并把该实例传递给依赖管理类,组件渲染时,使用对象观察接口遍历传入的data对象,为每一个属性建立一个依赖管理实例并设置属性描述对象,在存取器函数get函数中,依赖管理实例添加(记录)该属性为一个依赖,而后当该依赖变动时,触发set函数,在该函数内通知依赖管理实例,依赖管理实例分发该变动给其内存储的全部watcher
实例,watcher
实例从新计算,更新组件。
所以能够总结说Vue.js的响应式原理是依赖追踪,经过一个观察对象,为每一个属性,设置存取器函数并注册一个依赖管理实例
dep
,dep
内为每一个组件实例维护一个watcher
实例,在属性变动时,经过setter通知dep
实例,dep
实例分发该变动给每个watcher
实例,watcher
实例各自计算更新组件实例,即watcher
追踪dep
添加的依赖,Object.defineProperty()
方法提供这种追踪的技术支持,dep
实例维护这种追踪关系。
接下来对Vue.js源码进行简单分析,从对JavaScript对象和属性的处理开始:
首先,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);
:
若为对象,则调用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()
方法。
若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 Observer(value);
后就回到第一种状况的结果:调用defineReactive$$1()
方法生成响应式属性。
源码以下:
function defineReactive$$1 (obj,key,val,customSetter) { var dep = new Dep(); // 管理属性依赖 var property = Object.getOwnPropertyDescriptor(obj, key); if (property && property.configurable === false) { return } // 以前已经设置了的get/set须要合并调用 var getter = property && property.get; var setter = property && property.set; var childOb = observe(val); // 属性值也多是对象,须要递归观察处理 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { var value = getter ? getter.call(obj) : val; if (Dep.target) { // 管理依赖对象存在指向的watcher实例 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 ("development" !== 'production' && customSetter) { customSetter(); } if (setter) { setter.call(obj, newVal); // 更新属性值 } else { val = newVal; // 更新属性值 } childOb = observe(newVal); // 每次值变动时须要从新观察,由于可能值为对象 dep.notify(); // 发布更新事件 } }); }
该方法使用Object.defineProperty()
方法设置属性描述对象,逻辑集中在属性存取器函数内:
watcher
存在,则递归记录依赖;dep.notify()
方法发布更新事件;Vue.js须要管理对象的依赖,在属性更新时通知watcher
更新组件,进而更新视图,Vue.js管理依赖接口采用发布订阅模式实现,源码以下:
var uid$1 = 0; var Dep = function Dep () { this.id = uid$1++; // 依赖管理实例id this.subs = []; // 订阅该依赖管理实例的watcher实例数组 }; Dep.prototype.depend = function depend () { // 添加依赖 if (Dep.target) { Dep.target.addDep(this); // 调用watcher实例方法订阅此依赖管理实例 } }; Dep.target = null; // watcher实例 var targetStack = []; // 维护watcher实例栈 function pushTarget (_target) { if (Dep.target) { targetStack.push(Dep.target); } Dep.target = _target; // 初始化Dep指向的watcher实例 } function popTarget () { Dep.target = targetStack.pop(); }
如以前,生成响应式属性为属性设置存取器函数时,get函数内调用dep.depend()
方法添加依赖,该方法内调用Dep.target.addDep(this);
,即调用指向的watcher
实例的addDep
方法,订阅此依赖管理实例:
Watcher.prototype.addDep = function addDep (dep) { var id = dep.id; if (!this.newDepIds.has(id)) { // 是否已订阅 this.newDepIds.add(id); // watcher实例维护的依赖管理实例id集合 this.newDeps.push(dep); // watcher实例维护的依赖管理实例数组 if (!this.depIds.has(id)) { // watcher实例维护的依赖管理实例id集合 // 调用传递过来的依赖管理实例方法,添加此watcher实例为订阅者 dep.addSub(this); } } };
watcher
实例可能同时追踪多个属性(即订阅多个依赖管理实例),因此须要维护一个数组,存储多个订阅的依赖管理实例,同时记录每个实例的id,便于判断是否已订阅,然后调用依赖管理实例的addSub
方法:
Dep.prototype.addSub = function addSub (sub) { this.subs.push(sub); // 实现watcher到依赖管理实例的订阅关系 };
该方法只是简单的在订阅数组内添加一个订阅该依赖管理实例的watcher
实例。
属性变动时,在属性的存取器set函数内调用了dep.notify()
方法,发布此属性变动:
Dep.prototype.notify = function notify () { // 复制订阅者数组 var subs = this.subs.slice(); for (var i = 0, l = subs.length; i < l; i++) { subs[i].update(); // 分发变动 } };
前面提到,Vue.js中由watcher
层追踪依赖变动,发生变动时,通知组件更新:
Watcher.prototype.update = function update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true; } else if (this.sync) { // 同步 this.run(); } else { // 异步 queueWatcher(this); // 最后也是调用run()方法 } };
调用run
方法,通知组件更新:
Watcher.prototype.run = function run () { if (this.active) { var value = this.get(); // 获取新属性值 if (value !== this.value || // 若值 isObject(value) || this.deep) { var oldValue = this.value; // 缓存旧值 this.value = value; // 设置新值 if (this.user) { try { this.cb.call(this.vm, value, oldValue); } catch (e) { handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\"")); } } else { this.cb.call(this.vm, value, oldValue); } } } };
调用this.get()
方法,实际上,后面会看到在该方法内处理了属性值的更新与组件的更新,这里判断当属性变动时调用初始化时传给实例的cb
回调函数,而且回调函数接受属性新旧值两个参数,此回调一般是对于watch
声明的监听属性才会存在,不然默认为空函数。
每个响应式属性都是由一个Watcher
实例追踪其变动,而针对不一样属性(data, computed, watch),Vue.js进行了一些差别处理,以下是接口主要逻辑:
var Watcher = function Watcher (vm,expOrFn,cb,options) { this.cb = cb; ... // parse expression for getter if (typeof expOrFn === 'function') { this.getter = expOrFn; } else { this.getter = parsePath(expOrFn); } this.value = this.lazy ? undefined : this.get(); };
在初始化Watcher
实例时,会解析expOrFn
参数(表达式或者函数)成拓展getterthis.getter
,而后调用this.get()
方法,返回值做为this.value
值:
Watcher.prototype.get = function get () { pushTarget(this); // 入栈watcher实例 var value; var vm = this.vm; if (this.user) { try { value = this.getter.call(vm, vm); // 经过this.getter获取新值 } catch (e) { handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\"")); } } else { value = this.getter.call(vm, vm); // 经过this.getter获取新值 } if (this.deep) { // 深度递归遍历对象追踪依赖 traverse(value); } popTarget(); // 出栈watcher实例 this.cleanupDeps(); // 清空缓存依赖 return value // 返回新值 };
这里须要注意的是对于data
属性,而非computed
属性或watch
属性,而言,其watcher
实例的this.getter
一般就是updateComponent
函数,即渲染更新组件,get
方法返回undefined,而对于computed
计算属性而言,会传入对应指定函数给this.getter
,其返回值就是此get
方法返回值。
Vue.jsdata属性是一个对象,须要调用对象观察接口new Observer(value)
:
function observe (value, asRootData) { if (!isObject(value)) { return } var ob; ob = new Observer(value); // 对象观察实例 return ob; } // 初始处理data属性 function initData (vm) { // 调用observe函数 observe(data, true /* asRootData */); }
Vue.js对计算属性处理是有差别的,它是一个变量,能够直接调用Watcher
接口,把其属性指定的计算规则传递为,属性的拓展getter
,即:
// 初始处理computed计算属性 function initComputed (vm, computed) { for (var key in computed) { var userDef = computed[key]; // 对应的计算规则 // 传递给watcher实例的this.getter -- 拓展getter var getter = typeof userDef === 'function' ? userDef : userDef.get; watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions); } }
而对于watch属性又有不一样,该属性是变量或表达式,并且与计算属性不一样的是,它须要指定一个变动事件发生后的回调函数:
function initWatch (vm, watch) { for (var key in watch) { var handler = watch[key]; createWatcher(vm, key, handler[i]); // 传递回调 } } function createWatcher (vm, key, handler) { vm.$watch(key, handler, options); // 回调 } Vue.prototype.$watch = function (expOrFn, cb, options) { // 实例化watcher,并传递回调 var watcher = new Watcher(vm, expOrFn, cb, options); }
不管哪一种属性最后都是由watcher
接口实现追踪依赖,并且组件在挂载时,即会初始化一次Watcher
实例,绑定到Dep.target
,也就是将Watcher
和Dep
创建链接,如此在组件渲染时才能对属性依赖进行追踪:
function mountComponent (vm, el, hydrating) { ... updateComponent = function () { vm._update(vm._render(), hydrating); ... }; ... vm._watcher = new Watcher(vm, updateComponent, noop); ... }
如上,传递updateComponent
方法给watcher
实例,该方法内触发组件实例的vm._render()
渲染方法,触发组件更新,此mountComponent()
方法会在$mount()
挂载组件公开方法中调用:
// public mount method Vue$3.prototype.$mount = function (el, hydrating) { el = el && inBrowser ? query(el) : undefined; return mountComponent(this, el, hydrating) };
到此为止,对于JavaScript属性描述器接口的介绍及其应用,还有其在Vue.js中的响应式实践原理基本阐述完了,此次总结从原理到应用,再到实践剖析,花费比较多精力,可是收获是成正比的,不只对JavaScript基础有更深的理解,还更熟悉了Vue.js响应式的设计原理,对其源码熟悉度也有较大提高,以后在工做和学习过程当中,会进行更多的总结分享。
本文连接地址: 从JavaScript属性描述器剖析Vue.js响应式视图