学习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方法 --- 使用了---> 描述符类型的 definePropertyobserve方法 --- 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方法 ---> definePropertyVue实例 ---> 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

©著作权归作者所有:来自51CTO博客作者mob604756e9ab63的原创作品,如需转载,请注明出处,否则将追究法律责任

更多相关文章

  1. javascript访问器属性 get,set的操作
  2. 210331 JavaScript引入方式 变量与常量 函数
  3. js 引入方式,常量,变量,函数的声明和使用----0331
  4. macOS下使用非Apple的蓝牙耳机音质差?不妨试试这方法
  5. 课程学习记录之 python高阶函数小计
  6. 函数作用域与闭包-回调函数-函数的多值返回类型方式
  7. 华为ie认证难不难
  8. 冒泡排序函数
  9. 【东哥说书】俞军产品方法论

随机推荐

  1. android textview 部分文字加颜色并可点
  2. Android图片圆角 用简单的方法实现
  3. android终端模拟器运行命令可以进行adb c
  4. Android(安卓)高手进阶教程(十三)之----A
  5. Android当导入项目时出现:ERROR: Your pro
  6. Android语音输入打字效果渐变以及纠错效
  7. android 日志文件输出
  8. android的常用控件总结【安卓入门五】
  9. 2019-03-06 水波纹
  10. Android(安卓)SQLite总结[转载]