学习vue实现双向绑定【附源码下载地址】
嘉宝 web前端开发
问题:我怎么才能收到你们公众号平台的推送文章呢?
答案:只需要点击标题下面的蓝色字【web前端开发】关注即可。
写在前面
几乎是所有人都知道 Vue 是一个双向绑定的前端框架,但只有部分人知道实现 Vue-1 (下文中的Vue 均为Vue1 )是利用 defineProperty 来实现双向绑定的。
再次但是,只有部分的部分人,才知道 Vue 到底是如何利用 defineProperty 实现双向绑定的。
本文会带有一点解释,并用简单的例子实现一个 defineProperty 双向绑定。本文中可能会使用【可疑】这个字眼,代表这个函数很值得关注,别无他意。
先看看 Vue 是在哪里使用了defineProperty?
在源码中,发现了一个这样的函数,def()。这个函数里面包裹着我们最重要的api -- defineProperty。
/** * Define a property. * * @param {Object} obj * @param {String} key * @param {*} val * @param {Boolean} [enumerable] */function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});}
defineProperty 的参数都是什么意思?
可是我们并看不懂源码里的defineProperty的参数是什么意思。所以我们去mdn上看看。
Object.defineProperty(obj, prop, descriptor)// obj: 需要定义的对象// prop: obj对象中,可能需要被定义(get)或修改(set)的属性名字// descriptor: 要定义(get)或修改(set)的obj的属性描述符// return : 这个方法 return 一个被传递给函数的对象,即 obj
展开说说 descriptor
什么是 descriptor 属性描述符?
属性描述符有两种主要形式:数据描述符和存取描述符。
数据描述符 是指一个具有值 (任意js的数据类型、数组或函数) 的属性,该值可能是可写的,也可能是不可写的。如何记忆呢?其实很简单,顾名思义,数据描述符就是通过 直接设定 value 的值,直接使得 obj 的某个属性有了值。
存取描述符 是指用 getter 或 setter 函数来定义的属性。如何记忆呢?其实很简单,顾名思义,存取描述符就是通过 存(set) 和取(get) ,使得 obj 的某个属性有了值。
描述符必须是这两种形式之一,但两者不能同时存在。
既属于数据描述符,又属于存取描述符的属性
属于数据描述符的属性
属于存取描述符的属性
正确的使用数据描述符的例子
var obj = { test: 'hi' };console.log(obj.test); // 'hi'// 在调用defineProperty的时候 'hi' 已经被 'hello' 覆盖Object.defineProperty(obj, 'test', {
value: 'hello',
writable : true,
enumerable : true,
configurable : true})console.log(obj.test); // 'hello'obj.test = 'ohYeah...';console.log(obj.test); // 'ohYeah...'
正确的使用存取描述符的例子
var obj = { test: 'hi' };var Value = 'yoho';console.log(obj.test); // 'hi'// 在调用defineProperty的时候 'hi' 已经被 'yoho' 覆盖Object.defineProperty(obj, 'test', {
get: function() {
// 每次调用obj.test的时候,就会取到 Value 当前的值
return Value;
},
set: function(newValue) {
// 在每次 obj.test = newValue 赋值的时候,其实就是给全局变量 Value 赋值。
// 以便下次调用 get 函数的时候能够取到当前最新的Value
Value = newValue;
},})console.log(obj.test); // 'yoho'obj.test = 'ohYeah...';console.log(obj.test); // 'ohYeah...'
同时存在数据描述符和存取描述符的错误例子
var Value = 'yoho';Object.defineProperty(obj, 'test', {
// 如果 value 属性同时和 get、set 使用,会报错如下
value: 'hello',
get: function() {
return Value;
},
set: function(newValue) {
Value = newValue;
},})
Uncaught TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute, #at Function.defineProperty (<anonymous>) at defineproperty.html?_ijt=n8sr3j2ihna97pcm6gsgj9kk1m:29
意思就是,descriptor不合法,不能同时指定存取描述符和value。
回到 def 源码,重新认识 defineProperty
在源码中,有一个这样的函数,def()。这个函数里面包裹着我们最重要的api -- defineProperty。
// 利用了数据描述符的方式来定义一个对象 obj 的 key 属性的值为 val// 并且明确知道这个属性是可以被赋值运算符改变,并且是可删除、可修改的function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});}
还有哪里也有 Object.defineProperty
在搜索的过程中,还发现了一个 defineReactive 函数里也有使用到 defineProperty,明显这个函数很可疑,因为它的名字中也有define,这个函数如下。
function defineReactive(obj, key, val) {
// 这里提到了一个 Dep 方法,他的实例 dep 在源码中频繁出现,注意点①
var dep = new Dep();
// .... 很多东西
// 这里提到了一个 observe 方法,看上去也是一个重要的监听函数,注意点②
var childOb = observe(val);
// 在这里使用了 defineProperty
Object.defineProperty(obj, key, {
// 定义了对象属性的可枚举,可修改或可删除的属性
enumerable: true,
configurable: true,
// 定义了存取描述符 get 和 set 函数的实现
get: function reactiveGetter() {
var value = getter ? getter.call(obj) : val;
// .... 一些判断后,最后得到了value
return value;
},
set: function reactiveSetter(newVal) {
var value = getter ? getter.call(obj) : val;
// 如果新值没有改变,则return;
if (newVal === value) { return; }
if (setter) {
setter.call(obj, newVal);
} else {
// 把新值赋值给 val
val = newVal;
}
// 调用了一个可以的名字为【观察】的可疑函数,并把新值传递出去
childOb = observe(newVal);
// 这个可疑的实例,调用了一个看上去是通知的方法
dep.notify();
}
});}
寻找注意点①,一个 Dep 构造函数
在源码中找到了 Dep 的实现过程:
var uid$1 = 0;// 每个 dep 实例都是可以显示观察到实例的变化的// 一个实例可以有多个订阅的指令function Dep() {
this.id = uid$1++;
// subs 用来记录订阅了这个实例的对象,
// 也就是说某个被监听的对象一发生变化,subs 里面的所有订阅者都会收到变化
this.subs = [];}// 当前这个的 target 是null,target 是全局的,而且是独一无二的// 可以通过 watcher 随时更新 target 的值Dep.target = null;// 接下来,这个实例有4个重要的方法,addSub removeSub depend notify// 根据大神对方法的命名能够很容易猜测出方法的功能// 实现一个添加订阅者的方法 addSubDep.prototype.addSub = function (sub) {
this.subs.push(sub);};// 实现一个移除订阅者的方法 removeSubDep.prototype.removeSub = function (sub) {
this.subs.$remove(sub);};// 为target绑定 this 指向的方法 dependDep.prototype.depend = function () {
Dep.target.addDep(this);};// 通知所有订阅者新值更新的方法 notifyDep.prototype.notify = function () {
var subs = toArray(this.subs);
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}};
在这里,我注意到了 Dep.target 这个属性。在源码中的注释中,我们了解到“可以通过 watcher 随时更新 target 的值”。所以我们来看看什么是watcher。
线索延伸,寻找 watcher
通过全局搜索 watcher,我发现搜索结果实在是太多了。所以我搜索了function watcher。此时答案只有一个,那就是 Watcher 构造函数。粗略的看了看,大概有一些get、set、beforeGet、addDep、afterGet、update、run 等等方法,相当复杂。但确实发现了 Watcher 能够修改 Dep.target 的方法。
Watcher.prototype.beforeGet = function () {
Dep.target = this;};
寻找观察点②,一个观察者的类 Observe
/** * Observe 类会和每一个需要被观察的对象关联起来, 一旦产生关联, 被观察对象的属性值就会被 getter/setters 获取或更新 * 所以, 我们猜测这个类里一定会调用 Object.defineProperty */function Observer(value) {
this.value = value;
this.dep = new Dep();
// 这里调用了 def 函数,应证了我们的猜测,确实调用了 Object.defineProperty
def(value, '__ob__', this);
// ... 更多处理}// 接下来,这个实例有3个重要的方法,walk observeArray convert// walk 遍历对象,并将对象的每个属性关联到 getter/setters,// 这个方法只有在参数是一个对象时才能被正确调用。Observer.prototype.walk = function (obj) {
var keys = Object.keys(obj);
for (var i = 0, l = keys.length; i < l; i++) {
this.convert(keys[i], obj[keys[i]]);
}};// observeArray 遍历数组,监听数组里的每一个元素。Observer.prototype.observeArray = function (items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}};// convert 把传入的key value 和 getter/setter 关联起来,// 这样能在获取或更新属性的时候及时发送观察到的结果Observer.prototype.convert = function (key, val) {
// 在这里看到似曾相识的函数,可以回去前2个小节看看
defineReactive(this.value, key, val);};// ... more, 另外还有两个函数
但是全局搜索的时候,还发现了一个 observe 函数,也很可疑:
function observe(value, vm) {//....
ob = new Observer(value);// .... 最后return了一个 Observer 类的值
return ob;}
缺少一个导火索把一切线索串通起来
到现在为止,可以发现源码里的这些函数相互关联。线索就是按照下面的亮条路线串起来的。
observe方法 --- new ---> Observer类 --- 调用 ---> def方法 --- 使用了---> 描述符类型的 defineProperty
observe方法 --- new ---> Observer类,convert方法 --- 调用 ---> defineReactive方法 --- 使用了---> 存取描述符的defineProperty --- 同时实例化了dep ---> new Dep() ---> 可以被 Watcher 修改
到这里,就把刚刚解读的4段源码串了起来。他们的作用就是:① observe 负责监听数据的变化② 数据的获取和更新都使用 defineProperty③ Dep 负责管理订阅和发布
但还是少点什么,对,就是【数据从哪里来的?】,没有数据来源,有再完美的双向绑定也没用。
所以,我们来看看 Vue 的 data 部分会不会涉及到 observe我猜,就是 data --- 调用了---> observe方法
找到导火索 data
这里有一个小插曲,当你在 Vue 的文档中全局搜索 “data”, 或者 “ vue” 这样的关键字的时候,你会发现 data 有140个记录,vue 有203个记录。这么找下去,真是无从下手。
由于我们前面预测了,是 data 去引发了线索,所以我推测,data 调用了 observer。所以我决定把搜索条件改成 “observer”。就容易多了,很快发现了一个可疑的函数 initdata。源码如下:
/*** Initialize the data. data的初始化*/Vue.prototype._initData = function () {
var dataFn = this.$options.data;
var data = this._data = dataFn ? dataFn() : {};
// ... 很多很多,对组件内外的prop、data做了各种规范和处理
// 重点出现了,调用了observe, 监听 data
observe(data, this);};
这个 _initData 在 _initState 被使用:
/** * 给实例构造一个作用域, 其中包括: * - observed data 监听data * - ..... */Vue.prototype._initState = function () {
// ...
this._initData();
// ...};
这个 _initState 在 _init 被使用:
Vue.prototype._init = function(options) {
// ...
// 初始化数据监听,并初始化作用域
this._initState(); }
最后 _init 被 Vue 调用,
function Vue(options) {
this._init(options);}
到此为止,我们得到了最终的结论
Vue实例 ---> data ---> observe方法 ---> Observer类 ---> def方法 ---> defineProperty
Vue实例 ---> data ---> observe方法 ---> Observer类-convert方法 ---> defineReactive方法 ---> defineProperty ---> new Dep() 订阅类 ---> 可以被 Watcher 修改
模仿思路,实现一个简陋的双向绑定
先模仿 Vue 创建一个构造函数
回忆一下 Vue 是如何实例化的?
var V = new Vue({
// el 简化为所指定的id
el: 'app',
data: { ... }})
由此可见,在实例化的时候,有两个重要的参数,el 和 data。所以,先虚拟一个构造函数。
function Vue(options) {
this.data = options.data;
var id = options.el;}
构造函数的参数有了,但是构造函数有什么功能呢?第一个功能应该能够解析指令,编译dom。回想一下平时写dom的时候,v-model,v-show,v-for。这些都是最常用的指令,并且直接写在dom上,但是实际渲染的html上并不会出现这些指令,为什么呢?因为被编译了。 谁编译了?Vue的构造函数负责编译。
给构造函数增加一个编译的方法
function Vue(options) {
// ... 一些参数
var id = options.el;
// 利用 nodeToFragment 生成编译后的dom
var dom = nodeToFragment(document.getElementById(id), this);
// 把生成好的 dom 插入到指定 id 的 dom 中去(这里简化id的处理)
document.getElementById(id).appendChild(dom);}
上文中提到了一个 nodeToFragment 方法,这个方法其实是利用createDocumentFragment来创造一个代码片段。不了解 Fragment 的同学可以自行搜索了解一下。
function nodeToFragment (node, vm) {
var flag = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, vm); // 调用 compile 解析 dom 属性
flag.appendChild(child); // flag 不断填充新的 child 子节点
}
return flag;}function compile (node, vm) {
if (node.nodeType === 1) {
// 如果 node 是一个元素,解析他的所有属性
var attr = node.attributes;
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'v-model') {
// 简化,只对 v-model 属性做处理,对这个 dom 赋值
var name = attr[i].nodeValue;
node.value = vm.data[name];
node.removeAttribute('v-model');
}
}
}
if (node.nodeType === 3) {
// 如果 node 是文本节点,并且使用 {{...}} 赋值(简化操作),则对文本节点赋值
if (/\{\{(.*)\}\}/.test(node.nodeValue)) {
var name = RegExp.$1; // 获取到正则的第一个捕获组的值
name = name.trim();
node.nodeValue = vm.data[name]; // 将data 赋值给 该文本节点
}
}}
对比一下 dom 的编译前后。
根据之前的线索,构造一个 observe 方法
根据前面的结论,我们知道 observe 方法实际上就是一个监听函数。应该在data被确定后调用,所以在 Vue 的构造函数里。
function Vue(options) {
this.data = options.data;
var data = this.data;
// 调用 observe 方法来监听 data 里的数据
observe(data, this);
// ...}
observe 方法接受两个参数。遍历 data,获得属性,调用 defineReactive
function observe(objs, vm) {
Object.keys(objs).forEach(function (key) {
defineReactive(vm, key, objs[key]);
})}
实现一个 defineReactive 方法
defineReactive 在本文的比较前面提到,这个方法是使用了defineProperty 这个方法的可疑函数。我们的 observe 中调用了它,所以现在也需要实现一下。
function defineReactive (obj, key, val) {
// 这个函数就一个作用,调用了Object.defineProperty
Object.defineProperty(obj, key, {
get: function() {
return val;
},
set: function (newVal) {
if (newVal === val) return;
val = newVal;
}
})}
我们知道,只要 obj 的 key 的值被赋值了,就会触发 set 方法。所以,当一个被 v-model 绑定了的 input 的值在变化时,应该就是出发 set 的最佳时机。那么在编译 dom 的时候,就需要提前给 dom 绑定事件。
function compile (node, vm) {
if (node.nodeType === 1) {
var attr = node.attributes;
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'v-model') {
var name = attr[i].nodeValue;
// 简化操作,明知只有 input 一个dom, 直接绑定
// 给相应的data属性复制, 从而触发defineProperty的set
node.addEventListener('input', function (e) {
vm[name] = e.target.value;
})
// 将data的值赋给该node
node.value = vm[name];
node.removeAttribute('v-model');
}
}
}
// ....}
根据之前的线索,需要一个订阅者的类 Dep
function Dep () {
this.subs = [];}// 主要实现两个方法: 新增订阅者 & 通知订阅者Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
},}
需要在 defineProperty 的时候设置订阅者。如果每次新增一个双向绑定的 get,都需要新增订阅者,每一次被双向绑定的 set 一次,就需要通知所有订阅者。所以需要修改一下 defineReactive 方法。
function defineReactive (obj, key, val) {
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
// 增加一个订阅者
if (Dep.target) dep.addSub(Dep.target);
return val;
},
set: function (newVal) {
if (newVal === val) return;
val = newVal;
// 作为发布者发出通知
dep.notify();
}
})}
此时,我们还需要补充一下 Watcher 类。专门用来改变 Dep.target 的指向。
function Watcher(vm, node, name, nodeType) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.nodeType = nodeType;
this.update();
Dep.target = null;}Watcher.prototype = {
get: function () {
this.value = this.vm[this.name];
},
update: function () {
this.get();
// 简化操作,在编译函数中传入写死的参数
if (this.nodeType == 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType == 'input') {
this.node.value = this.value;
}
}}
这个 Watcher 的作用就是,实际上实现 被订阅者的获取 和 订阅者的更新 的方法。
function compile (node, vm) {
if (node.nodeType === 1) {
//...
// vm: this 指向; node: dom节点;
// name: v-model绑定的属性名字; 'input': 简化操作,写死这个dom的类型
new Watcher(vm, node, name, 'input');
}
if (node.nodeType === 3) {
if (/\{\{(.*)\}\}/.test(node.nodeValue)) {
//...
// 原本给文本节点赋值的方式是利用了 defineProperty 的 get
// node.nodeValue = vm[name]; // 将data 赋值给 该文本节点
// 现在改为利用 Watcher,如果被订阅者变化了,直接update
// 其中,name: {{}} 指定渲染绑定的属性; 'text': 简化操作,写死文本节点的类型
new Watcher(vm, node, name, 'text');
}
}}
模仿后的总结
我们的模仿大约经历了以下几个过程第一步:创建一个构造函数Vue,并在构造函数中定义参数第二步:构建一个函数nodeToFragment, 能够把带指令的 dom 转化为 html5 的 dom第三步:nodeToFragment实际上是调用了compile, compile方法解析指令的属性并就进行赋值第四步:在构造函数Vue中增加一个监听方法observe,它接受构造函数Vue中的data作为参数,并为每个参数实现双向绑定。第五步:observe中调用了defineReactive,这个方法使用了 Object.defineProperty 来设置的数据的getter、setter。第六步:需要在compile触发setter,所以在compile中给输入框绑定事件第七步:虽然能够触发setter,但是显示的数据并没有触发getter。所以需要构造一个订阅类Dep,主要实现 增加订阅者 & 通知订阅者 两个方法。以便在 Object.defineProperty 的 setter 中触发通知函数 notify第八步:实现Dep的通知订阅者方法(notify),需要借助Watcher类,Watcher 中的 updata方法为每一个订阅者提供更新操作。第九步:需要在compile的时候为每一个订阅者实例化Watcher,所以,需要在compile中触发Watcher。传入相应的参数,让Watcher能够在update的时候正确赋值。
源码下载地址:链接: https://pan.baidu.com/s/1ggWkh3d 密码: 97kk