专栏名称: OSC开源社区
OSChina 开源中国 官方微信账号
目录
相关文章推荐
程序员小灰  ·  这款AI编程工具,将会取代Cursor! ·  18 小时前  
程序猿  ·  清晰的、模块化的编码风格 ·  昨天  
程序员小灰  ·  疯了!下载 DeepSeek 最高判20年! ·  3 天前  
OSC开源社区  ·  《7天精通DeepSeek实操手册》 ·  4 天前  
程序员小灰  ·  第一期AI特训,我们要发车了! ·  5 天前  
51好读  ›  专栏  ›  OSC开源社区

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

OSC开源社区  · 公众号  · 程序员  · 2017-06-17 08:32

正文


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



前言


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



定义对象属性


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


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

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer


Object.defineProperty()


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

• obj, 待操作对象

• 属性名

• 操作属性的属性描述对象


由于传入一个空的属性描述对象,所以输出对象属性值为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()方法,如:


读取属性描述对象


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


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



属性描述对象


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


value

指定对象属性值:


writable

指定对象属性是否可变:


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


存取器函数(getter/setter)

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


get

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


set

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


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


上述代码在设置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描述。


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


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,输出如图:


修改如下,即可:

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是当下比较流行的一个响应式的视图层类库,其内部实现响应式原理就是本文介绍的属性描述在技术中的具体应用。


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


☞ 查看完整实例代码: https://github.com/codingplayboy/web_demo/blob/master/smallcase/data-dom.html


数据视图单向绑定

现有如下代码:

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元素的内容,这就简单的实现了数据到视图的单向绑定,即数据变更,视图也会更新。


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


抽象封装

接下来对以上实例进行简单抽象封装,


☞查看完整实例代码: https://github.com/codingplayboy/web_demo/blob/master/smallcase/data-dom.html


首先声明数据结构:

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代码参考如下:

请输入内容:



Vue.js 的响应式原理


上一节实现了一个简单的数据视图单向绑定实例,现在对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对象和属性的处理开始:


观察对象(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 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()方法设置属性描述对象,逻辑集中在属性存取器函数内:

1. get: 返回属性值,如果watcher存在,则递归记录依赖;

2. set: 属性值发生变更时,更新属性值,并调用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方法,通知组件更新:







请到「今天看啥」查看全文


推荐文章
程序员小灰  ·  这款AI编程工具,将会取代Cursor!
18 小时前
程序猿  ·  清晰的、模块化的编码风格
昨天
程序员小灰  ·  疯了!下载 DeepSeek 最高判20年!
3 天前
OSC开源社区  ·  《7天精通DeepSeek实操手册》
4 天前
程序员小灰  ·  第一期AI特训,我们要发车了!
5 天前
摄影世界  ·  用前景打造高水准人像的4种方法
7 年前
悦读文摘  ·  好好爱那个发脾气的孩子
7 年前