Vue 双向数据绑定【vue 知识汇点3】

573 阅读6分钟

Vue 三要素:

  • 响应式:例如如何监听数据变化,其中的实现方法就是我们提到的双向绑定
  • 模版引擎:如何解析模版
  • 渲染:Vue 如何将监听到的数据变化和解析后的 HTML 进行渲染

双向绑定

目前业界分为两个大的流派,一个是以 React 为首的单向数据绑定,另一个是以 Angular、Vue 为主的双向数据绑定。

可以实现双向绑定的方法有:

  • 发布者-订阅者模式:一般通过subject、pub的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set('property', value)
  • 脏值检查:anjular.js 是通过脏值检测的方法比对数据是否有变更,来决定是否更新视图,最简单的方法就是通过 setInterval() 定时轮询检测数据变动
  • 数据挟持:vue.js 采用数据挟持结合发布者-订阅者模式的方法,通过 Object.defineProperty() 来挟持各个属性的 setter、getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

数据挟持

数据挟持的优点:

  1. 无需显式调用,可以直接通知变化并驱动视图
  2. 可精确得知变化数据,我们挟持了属性的 setter,当 setter 改变时,可以精确获得变化的内容 newVal,因此在这部分不需要额外的 diff 操作。

数据挟持的思路:

  1. 利用 Proxy 或者 Object.defineProperty 生成的 Observer 针对对象/对象的属性进行“挟持”,在属性发生变化后通知订阅者
  2. 解析器 Compile 解析模板中的 Directive,收集指令所依赖的方法和数据,等待数据变化,然后渲染
  3. Watcher 属于 Observer 和 Compile 桥梁,它将收到的 Observer 产生的变化,并根据 Compile 童工的指令进行视图渲染,使数据变化促使视图变化

实现 mvvm 的双向绑定必要因素

实现 mvvm 的双向绑定,必须要实现以下几点:

  • 实现一个数据监听器 Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
  • 实现一个指令解析器 Compile,对每个元素节点的指令进行扫描和解析,根据指令模版替换数据,以绑定相应的更新函数
  • 实现一个 watcher,作为 compile 和 observer 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。
  • mvvm的入口函数,整合上面三者

实现 observer

observer需要具备的功能:1.监听数据变化 2.数据变化通知订阅者

Object.defineProperty() 来监听属性变动,那么将需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter 和 getter。

var data = {name: 'xiaohui'};
observe(data);
data.name = 'test';

function observe(data) {
	if(!data || typeof data !== 'object') {
    	return;
    }
    Object.keys(data).forEach((key) => defineReactive(data, key,data[key]))
}
function defineReactive(data, key, val) {
	observe(val) // 监听子属性
    Object.defineProperty(data, key, {
    	enumberable: true,
        configurable: false,
        get: function() {
        	return val;
        },
        set: function(newVal) {
        	console.log(`监听到值变了!${val} ----> ${newVal}`)
            val = newVal;
        }
    })
}

此时,我们就可以监听每个数据的变化了,监听之后怎么通知订阅者,就需要实现一个消息订阅器,也就是维护一个数组,用来收集订阅者,数据变动触发 notify,再调用订阅者的 update 方法

function defineReactive(data, key, val) {
	var dep = new Dep();
    observe(val);
    Object.defineProperty(data, key, {
    	// 省略
        set: function(newVal) {
        	if(val === newVal) {
            	return;
            }
            val = newVal;
            dep.notify(); //通知所有订阅者
        }
    })
}
function Dep() {
	this.subs = [];
}
Dep.prototype = {
	addSub: function(sub) {
    	this.subs.push(sub)
    },
    notify: function() {
    	this.subs.forEach(sub => {
        	sub.update();
        })
    }
}

此时我们的问题就来了,订阅者是 watcher,那怎么往 subs 里添加订阅者呢?通过 dep 添加订阅者,就必须要在闭包内操作

Object.defineProperty(data, key, {
	get: function() {
    	// 通过 Dep 定义一个全局 target 属性,暂存 watcher,用完移除
    	Dep.target && dep.addDep(Dep.target)
        return val;
    }
})
Watcher。prototype = {
	get: function(key) {
    	Dep.target = this;
        this.value = data[key] // 会触发getter,从而添加订阅者
        Dep.target = null
    }
}

实现 Compile

Compile 主要是解析模版指令,将模版中的变量替换成数据,然后初始化渲染页面视图,并经每个指令对应的节点绑定更新函数函数,添加监听数据的订阅者,一旦有数据变动,收到通知,更新视图。

// 遍历解析过程中,会多次操作 dom,为了提高性能和效率,会将 vue 实例根结点的 el 转换成 文档碎片 fragment,进行解析编译操作,解析完成,再将 fragment 添加回原来的真实 dom 节点中。

function Compile(el) {
	this.$el = this.isElementNode(el) ? el : doucment.querySelector(el);
    if (this.$el) {
    	this.$fragment = this.node2Fragment(this.$el);
        this.init();
        this.$el.appendChild(thiis.$fragment);
    }
}
Compile.prototype = {
	init: function() {
    	this.compileElement(this.$fragment);
    },
    node2Fragment: function() {
    	var fragment = document.createDocumentFragment();
        var child = el.firstChild;
        // 将原生节点拷贝到fragment
        while (child) {
        	fragment.appendChild(child)
        }
        return fragment;
    },
    // 遍历所有节点以及子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定。
    compileElement: function(el) {
    	var childNodes = el.childNodes, me = this;
        [].slice.call(childNodes).forEach(node => {
        	var text = node.textContext;
            var reg = /\{\{(.*)\}\}/;
            if (me.isElementNode(node)) {
            	me.compile(node)
            } else if (me.isTextnode(node) && reg.test(text)) {
            	me.compileText(node, RegExp.$1)
            }
            if (node.childNodes && node.childNodes.length) {
            	me.compileElement(node)
            }
        })
    },
    compile: function(node) {
    	var nodeAttrs = node.attributes, me = this;
        [].slice.call(nodeAttrs).forEach(attr => {
        	var attrName = attr.name;
            if(me.isDirective(attrName)) {
            	var exp = attr.value
                // 指令以 v-xxx 命名
                var dir = attrName.subString(2)
                if (me.isEventDirective(dir)) {
                	compileUtil.eventHandler(node, me.$vm, exp, dir)
                } else {
                	compileUtil[dir] && compileUtil[dir](node, me.$vm, exp)
                }
            }
        })
    }
}
var compileUtil = {
	text: function(node, vm. exp) {
    	this.bind(node, vm, exp, 'text')
    },
    bind: function(node, vm, exp, dir) {
    	var updateFn = updater[dir + 'Updater'];
        updaterFn && updateFn(node, vm[exp]);
        new Watcher(vm, exp, function(value, oldValue) {
        	updaterFn && updaterFn(node, value, oldValue);
        })
    }
}
var updater = {
	textUpdater: function(node, value) {
    	node.textContent = typeof value === 'undefined' ? '' : value;
    }
}

实现 Watcher

watcher 订阅者作为 Observer 和 Compile 之间通信的桥梁,主要做的事情是:

  1. 在自身实例化时往属性订阅器(dep)里面添加自己
  2. 自身必须有一个 update() 方法
  3. 待属性变动,dep.notify() 通知时,能调用自身的 update() 方法,并触发 Compile 中绑定的回调
function Watcher(vm, exp, cb) {
	this.cb = cb;
    this.vm = vm;
    this.exp = exp;
    this.value = this.get();
}
Watcher.prototype = {
	update: function() {
    	this.run();
    },
    run: function() {
    	var value = this.get();
        var oldValue = this.value;
        if (value !== oldValue) {
        	this.value = value;
            this.cb.call(this.vm, value, oldVal) // 执行 Compile 中绑定的回调,更新视图
        }
    },
    get: function() {
    	Dep.target = this; // 将当前订阅者指向自己
        var value = this.vm[exp]; // 触发 getter,添加自己到属性订阅起中
        Dep.target = null; //添加完毕,重置
        return value;
    }
}

实现MVVM

MVVM 作为数据绑定的入口,整合 Observer、Compile 和 Watcher 三者,通过 Observer 来监听自己的 model 数据变化,通过 Compile 来解析编译模版指令,最终利用 Watcher 搭起 Oberver 和 Compile 之间的桥梁,达到数据变化 -> 视图更新;视图交互变化 -> 数据model 变更的双向绑定效果。

function MVVM (options) {
	this.$options = options;
    var data = this._data = this.$options.data, me = this;
    Object.keys(data).forEach(key => me._proxy(key))
    observe(data, this)
    this.$compile = new Compile(options.el || document.body, this)
}
MVVM.prototype = { 
	_proxy: function(key) {
    	var me = this;
        Object.defineProperty(me, key, {
        	configurable: false,
            enumerable: true,
            get: function proxyGetter() {
                return me._data[key];
            },
            set: function proxySetter(newVal) {
                me._data[key] = newVal;
            }
        })
    }
    
}

Object.defineProperty 和 proxy 区别

Object.defineProperty的缺陷:

  • 不能监听数组变化
  • 只能劫持对象的属性,属性值如果也是对象,需要深度遍历

Proxy 就是在被劫持的对象之前加了一层拦截,它的特性是:

  • 可以直接监听对象,而非属性
  • 可以直接监听数组的变化
  • proxy 返回一个新对象,我们可以只操作新对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改
  • 劣势是浏览器的兼容性问题,所以vue 3.0 才会用 Proxy 重写

总之,defineProperty 是劫持对象的属性,当新增属性时,需要重新劫持。proxy 是代理对象,所有的对象属性变更都能访问到,对于目前defineProperty 所存在的问题,都能提供完美的解决方案

Vue2.x 中数组和对象观察时的特殊处理

Vue 2.0 中响应式数据是通过 defineProperty 实现,因此无法检测数组/对象的新增和删除,当调用数组的push、splice、pop 等方法改变数组元素时,并不会触发数组的 setter,所以,Vue 2.0 做了一些特殊处理,使用函数挟持的方式,重写了数组的方法,vue 将 data 中的数组进行原型链重写,指向自己定义的数组原型方法。这样当调用数组的 api 时,可以通知依赖更新。如果数组中包含引用类型,会对数组中的引用类型再次递归遍历进行监控。

至于目前存在的“无法通过索引改变数组”的问题,是因为性能问题,性能代价和获得的用户体验收益不成正比