Open MrErHu opened 6 years ago
首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
国内前端算是属于Vue与React两分天下,提到Vue,最令人印象深刻的就是双向绑定了,想要深入的理解双向绑定,最重要的就是明白响应式数据的原理。这篇文章不会去一字一句的分析Vue中是如何实现响应式数据的,我们只会从原理的角度去考量如何实现一个简单的响应式模块,希望能对你有些许的帮助。
响应式数据不是凭空出现的。对于前端工程而言,数据模型Model都是普通的JavsScript对象。View是Model的体现,借助JavaScript的事件响应,View对Model的修改非常容易,比如:
var model = { click: false }; var button = document.getElementById("button"); button.addEventListener("click", function(){ model.click = !model.click; })
但是想要在修改Model时,View也可以对应刷新,相对比较困难的。在这方面,React和View提供了两个不同的解决方案,具体可以参考这篇文章。其中响应式数据提供了一种可实现的思路。什么是响应式数据?在我看来响应式数据就是修改数据的时候,可以按照你设定的规则触发一系列其他的操作。我们想实现的其实就是下面的效果:
var model = { name: "javascript" }; // 使传入的数据变成响应式数据 observify(model); //监听数据修改 watch(model, "name", function(newValue, oldValue){ console.log("name newValue: ", newValue, ", oldValue: ", oldValue); }); model.name = "php"; // languange newValue: php, oldValue: javascript
从上面效果中我们可以看出来,我们需要劫持修改数据的过程。好在ES5提供了描述符属性,通过方法Object.defineProperty我们可以设置访问器属性。但是包括IE8在内的低版本浏览器是没有实现Object.defineProperty并且也不能通过polyfill实现(其实IE8是实现了该功能,只不过只能对DOM对象使用,并且非常受限),因此在低版本浏览器中没法实现该功能。这也就是为什么Vue不支持IE8及其以下的浏览的原因。通过Object.defineProperty我们可以实现:
Object.defineProperty
Object.defineProperty(obj, "prop", { enumerable: true, configurable: true, set: function(value){ //劫持修改的过程 }, get: function(){ //劫持获取的过程 } });
根据上面的思路我们去考虑如何实现observify函数,如果我们想要将一个对象响应化,我们则需要遍历对象中的每个属性,并且需要对每个属性对应的值同样进行响应化。代码如下:
observify
// 数据响应化 // 使用lodash function observify(model){ if(_.isObject(model)){ _.each(model, function(value, key){ defineReactive(model, key, value); }); } } //定义对象的单个响应式属性 function defineReactive(obj, key, value){ observify(value); Object.defineProperty(obj, key, { configurable: true, enumerable: true, set: function(newValue){ var oldValue = value; value = newValue; //可以在修改数据时触发其他的操作 console.log("newValue: ", newValue, " oldValue: ", oldValue); }, get: function(){ return value; } }); }
上面的函数observify就实现了对象的响应化处理,例如:
var model = { name: "MrErHu", message: { languange: "javascript" } }; observify(model); model.name = "mrerhu" //newValue: mrerhu oldValue: MrErHu model.message.languange = "php" //newValue: php oldValue: javascript model.message = { db: "MySQL" } //newValue: {db: "MySQL"} oldValue: {languange:"javascript"}
我们知道在JavaScript中经常使用的不仅仅是对象,数组也是非常重要的一部分。并且中还有非常的多的方法能够改变数组本身,那么我们如何能够监听到数组的方法对数组带来的变化呢?为了解决这个问题我们能够一种替代的方式,将原生的函数替换成我们自定义的函数,并且在自定义的函数中调用原生的数组方法,就可以达到我们想要的目的。我们接着改造我们的defineReactive函数。
defineReactive
function observifyArray(array){ //需要变异的函数名列表 var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; var arrayProto = Object.create(Array.prototype); _.each(methods, function(method){ arrayProto[method] = function(...args){ // 劫持修改数据 var ret = Array.prototype[method].apply(this, args); //可以在修改数据时触发其他的操作 console.log("newValue: ", this); return ret; } }); Object.setPrototypeOf(array, arrayProto); } //定义对象的单个响应式属性 function defineReactive(obj, key, value){ if(_.isArray(value)){ observifyArray(value, dep); }else { observify(value); } Object.defineProperty(obj, key, { // 省略...... }); }
我们可以看到我们将数组原生的原型替换成自定义的原型,然后调用数组的变异方法时就会调用我们自定义的函数。例如:
var model = [1,2,3]; observify(model); model.push(4); //newValue: [1, 2, 3, 4]
到目前为止我们已经实现了我们的需求,其实我写到这里的时候,我考虑到是否需要实现对数组的键值进行监听,其实作为使用过Vue的用户一定知道,当你利用索引直接设置一个项时,是不会监听到数组的变化的。比如:
vm.items[indexOfItem] = newValue
如果你想要实现上面的效果,可以通过下面的方式实现:
vm.items.splice(indexOfItem, 1, newValue);
首先考虑这个是否能实现。答案是显而易见的了。当然是可以,数组其实可以看做特殊的数组,而其实对于数组而言,数值类型的索引都会被最终解析成字符串类型,比如下面的代码:
var array = [0,1,2]; array["0"] = 1; //array: [1,1,2]
那要实现对数值索引对应的数据进行修改,其实也是可以通过Object.defineProperty函数去实现,比如:
var array = [0]; Object.defineProperty(array, 0, { set: function(newValue){ console.log("newValue: ", newValue); } }); array[0] = 1;//newValue: 1
可以实现但却没有实现该功能,想来主要原因可能就是基于性能方面的考虑(我的猜测)。但是Vue提供了另一个全局的函数,Vue.set可以实现
Vue.set
Vue.set(vm.array, indexOfItem, newValue)
我们可以大致猜测一下Vue.set内部怎么实现的,对于数组而言,只需要对newValue做响应化处理并将其赋值到数组中,然后通知数组改变。对于对象而言,如果是之前不存在的属性,首先可以将newValue进行响应化处理(比如调用observify(newValue)),然后将对具体属性定义监听(比如调用函数defineReactive),最后再去做赋值,可能具体的处理过程千差万别,但是内部实现的原理应该就是如此(仅仅是猜测)。
newValue
observify(newValue)
不仅如此,在上面的实现中我们可以发现,我们并不能监听到对象不能检测对象属性的添加或删除,因此如果如果你要监听某个属性的值,而一开始这个属性并不存在,最好是在数据初始化的时候就给其一个默认值,从而能监听到该属性的变化。
上面我们讲了这么多,希望大家不要被带偏了,我们上面所做的都是希望能在数据发生变化时得到通知。回到我们最初的问题。我们希望的是,在Model层数据发生改变的时候,View层的数据相应发生改变,我们已经能够监听到数据的改变了,接下来要考虑的就是View的改变。
对于Vue而言,即使你使用的是Template描述View层,最终都会被编译成render函数。比如,模板中描述了:
Template
render
<h1>{{ name }}</h1>
其实最后会被编译成:
render: function (createElement) { return createElement('h1', this.name); }
那现在就存在下面这个一个问题,假如我的Model是下面这个样子的:
var model = { name: "MrErHu", age: 23, sex: "man" }
事实上render函数中就只用到了属性name,但是Model中却存在其他的属性,当数据改变的时候,我们怎么知道什么时候才需要重新调用render函数呢。你可能会想,哪里需要那么麻烦,每次数据改变都去刷新render函数不就行了吗。这样当然可以,其实如果朝着这个思路走,我们就朝着React方向走了。事实上如果不借助虚拟DOM的前提下,如果每次属性改变都去调用render效率必然是低下的,这时候我们就引入了依赖收集,如果我们能知道render依赖了那些属性,那么在这些属性修改的时候,我们再精准地调用render函数,那么我们的目的不就达到了吗?这就是我们所称的依赖收集。
name
依赖收集的原理非常的简单,在响应式数据中我们一直利用的都是属性描述符中的set方法,而我们知道当调用某个对象的属性时,会触发属性描述符的get方法,当get方法调用时,我们将调用get的方法收集起来就能完成我们的依赖收集的任务。
set
get
首先我们可以思考要一下,如果是自己写一个响应式数据带依赖收集的模块,我们会去怎么设计。首先我们想要达到的类似效果就是:
var model = { name: "MrErHu", program: { language: "Javascript" }, favorite: ["React"] }; //数据响应化 observify(model); //监听 watch(function(){ return '<p>' + (model.name) + '</p>' }, function(){ console.log("name: ", model.name); }); watch(function(){ return '<p>' + (model.program.language) + '</p>' }, function(){ console.log("language: ", model.program.language); }); watch(function(){ return '<p>' + (model.favorite) + '</p>' }, function(){ console.log("favorite: ", model.favorite); }); model.name = "mrerhu"; //name: mrerhu model.program.language = "php"; //language: php model.favorite.push("Vue"); //favorite: [React, Vue]
我们所需要实现的watch函数的第一个参数可以认为是render函数,通过执行render函数我们可以收集到render函数内部使用了那些响应式数据属性。然后在对应的响应式数据属性改变的时候,触发我们注册的第二个函数。这样看我们监听属性的粒度就是响应数据的每一个属性。按照单一职责的概念,我们将监听订阅与通知发布的职责分离出去,由单独的Dep类负责。由于监听的粒度是响应式数据的每一个属性,因此我们会为每一个属性维护一个Dep。与此相对应,我们创建Watcher类,负责向Dep注册,并在收到通知后调用回调函数。如下图所示:
watch
Dep
Watcher
首先我们实现Dep和Watcher类:
//引入lodash库 class Dep { constructor(){ this.listeners = []; } // 添加Watcher addWatcher(watcher){ var find = _.find(this.listeners, v => v === watcher); if(!find){ //防止重复注册 this.listeners.push(watcher); } } // 移除Watcher removeWatcher(watcher){ var find = _.findIndex(this.listeners, v => v === fn); if(find !== -1){ this.listeners.splice(watcher, 1); } } // 通知 notify(){ _.each(this.listeners, function(watcher){ watcher.update(); }); } } Dep.target = null; class Watcher { constructor(callback){ this.callback = callback; } //得到Dep通知调用相应的回调函数 update(){ this.callback(); } }
接着我们创建watcher函数并且改造之前响应式相关的函数:
// 数据响应化 function observify(model){ if(_.isObject(model)){ _.each(model, function(value, key){ defineReactive(model, key, value); }); } } //定义对象的单个响应式属性 function defineReactive(obj, key, value){ var dep = new Dep(); if(_.isArray(value)){ observifyArray(value, dep); }else { observify(value); } Object.defineProperty(obj, key, { configurable: true, enumerable: true, set: function(newValue){ observify(value); var oldValue = value; value = newValue; //可以在修改数据时触发其他的操作 dep.notify(value); }, get: function(){ if(!_.isNull(Dep.target)){ dep.addWatcher(Dep.target); } return value; } }); } // 数据响应化 function observify(model){ if(_.isObject(model)){ _.each(model, function(value, key){ defineReactive(model, key, value); }); } } //定义对象的单个响应式属性 function defineReactive(obj, key, value){ var dep = new Dep(); if(_.isArray(value)){ observifyArray(value, dep); }else { observify(value); } Object.defineProperty(obj, key, { configurable: true, enumerable: true, set: function(newValue){ observify(value); var oldValue = value; value = newValue; //可以在修改数据时触发其他的操作 dep.notify(value); }, get: function(){ if(!_.isNull(Dep.target)){ dep.addWatcher(Dep.target); } return value; } }); } function observifyArray(array, dep){ //需要变异的函数名列表 var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; var arrayProto = Object.create(Array.prototype); _.each(methods, function(method){ arrayProto[method] = function(...args){ var ret = Array.prototype[method].apply(this, args); dep.notify(this); return ret; } }); Object.setPrototypeOf(array, arrayProto); } function watch(render, callback){ var watcher = new Watcher(callback); Dep.target = watcher; render(); Dep.target = null; }
接下来我们就可以实验一下我们的watch函数了:
var model = { name: "MrErHu", message: { languange: "javascript" }, love: ["Vue"] }; observify(model); watch(function(){ return '<p>' + (model.name) + '</p>' }, function(){ console.log("name: ", model.name); }); watch(function(){ return '<p>' + (model.message.languange) + '</p>' }, function(){ console.log("message: ", model.message); }); watch(function(){ return '<p>' + (model.love) + '</p>' }, function(){ console.log("love: ", model.love); }); model.name = "mrerhu"; // name: mrerhu model.message.languange = "php"; // message: { languange: "php"} model.message = { target: "javascript" }; // message: { languange: "php"} model.love.push("React"); // love: ["Vue", "React"]
到此为止我们已经基本实现了我们想要的效果,当然上面的例子并不完备,但是也基本能展示出响应式数据与数据依赖的基本原理。当然上面仅仅只是采用ES5的数据描述符实现的,随着ES6的普及,我们也可以用Proxy(代理)和Reflect(反射)去实现。作为本系列的第一篇文章,还有其他的点没有一一列举出来,大家可以关注我的Github博客继续关注,如果有讲的不准确的地方,欢迎大家指正。
膜拜
前言
首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
国内前端算是属于Vue与React两分天下,提到Vue,最令人印象深刻的就是双向绑定了,想要深入的理解双向绑定,最重要的就是明白响应式数据的原理。这篇文章不会去一字一句的分析Vue中是如何实现响应式数据的,我们只会从原理的角度去考量如何实现一个简单的响应式模块,希望能对你有些许的帮助。
响应式数据
响应式数据不是凭空出现的。对于前端工程而言,数据模型Model都是普通的JavsScript对象。View是Model的体现,借助JavaScript的事件响应,View对Model的修改非常容易,比如:
但是想要在修改Model时,View也可以对应刷新,相对比较困难的。在这方面,React和View提供了两个不同的解决方案,具体可以参考这篇文章。其中响应式数据提供了一种可实现的思路。什么是响应式数据?在我看来响应式数据就是修改数据的时候,可以按照你设定的规则触发一系列其他的操作。我们想实现的其实就是下面的效果:
从上面效果中我们可以看出来,我们需要劫持修改数据的过程。好在ES5提供了描述符属性,通过方法
Object.defineProperty
我们可以设置访问器属性。但是包括IE8在内的低版本浏览器是没有实现Object.defineProperty
并且也不能通过polyfill实现(其实IE8是实现了该功能,只不过只能对DOM对象使用,并且非常受限),因此在低版本浏览器中没法实现该功能。这也就是为什么Vue不支持IE8及其以下的浏览的原因。通过Object.defineProperty
我们可以实现:数据响应化
根据上面的思路我们去考虑如何实现
observify
函数,如果我们想要将一个对象响应化,我们则需要遍历对象中的每个属性,并且需要对每个属性对应的值同样进行响应化。代码如下:上面的函数
observify
就实现了对象的响应化处理,例如:我们知道在JavaScript中经常使用的不仅仅是对象,数组也是非常重要的一部分。并且中还有非常的多的方法能够改变数组本身,那么我们如何能够监听到数组的方法对数组带来的变化呢?为了解决这个问题我们能够一种替代的方式,将原生的函数替换成我们自定义的函数,并且在自定义的函数中调用原生的数组方法,就可以达到我们想要的目的。我们接着改造我们的
defineReactive
函数。我们可以看到我们将数组原生的原型替换成自定义的原型,然后调用数组的变异方法时就会调用我们自定义的函数。例如:
到目前为止我们已经实现了我们的需求,其实我写到这里的时候,我考虑到是否需要实现对数组的键值进行监听,其实作为使用过Vue的用户一定知道,当你利用索引直接设置一个项时,是不会监听到数组的变化的。比如:
如果你想要实现上面的效果,可以通过下面的方式实现:
首先考虑这个是否能实现。答案是显而易见的了。当然是可以,数组其实可以看做特殊的数组,而其实对于数组而言,数值类型的索引都会被最终解析成字符串类型,比如下面的代码:
那要实现对数值索引对应的数据进行修改,其实也是可以通过
Object.defineProperty
函数去实现,比如:可以实现但却没有实现该功能,想来主要原因可能就是基于性能方面的考虑(我的猜测)。但是Vue提供了另一个全局的函数,
Vue.set
可以实现我们可以大致猜测一下
Vue.set
内部怎么实现的,对于数组而言,只需要对newValue
做响应化处理并将其赋值到数组中,然后通知数组改变。对于对象而言,如果是之前不存在的属性,首先可以将newValue
进行响应化处理(比如调用observify(newValue)
),然后将对具体属性定义监听(比如调用函数defineReactive
),最后再去做赋值,可能具体的处理过程千差万别,但是内部实现的原理应该就是如此(仅仅是猜测)。不仅如此,在上面的实现中我们可以发现,我们并不能监听到对象不能检测对象属性的添加或删除,因此如果如果你要监听某个属性的值,而一开始这个属性并不存在,最好是在数据初始化的时候就给其一个默认值,从而能监听到该属性的变化。
依赖收集
上面我们讲了这么多,希望大家不要被带偏了,我们上面所做的都是希望能在数据发生变化时得到通知。回到我们最初的问题。我们希望的是,在Model层数据发生改变的时候,View层的数据相应发生改变,我们已经能够监听到数据的改变了,接下来要考虑的就是View的改变。
对于Vue而言,即使你使用的是
Template
描述View层,最终都会被编译成render
函数。比如,模板中描述了:其实最后会被编译成:
那现在就存在下面这个一个问题,假如我的Model是下面这个样子的:
事实上
render
函数中就只用到了属性name
,但是Model中却存在其他的属性,当数据改变的时候,我们怎么知道什么时候才需要重新调用render
函数呢。你可能会想,哪里需要那么麻烦,每次数据改变都去刷新render
函数不就行了吗。这样当然可以,其实如果朝着这个思路走,我们就朝着React方向走了。事实上如果不借助虚拟DOM的前提下,如果每次属性改变都去调用render
效率必然是低下的,这时候我们就引入了依赖收集,如果我们能知道render
依赖了那些属性,那么在这些属性修改的时候,我们再精准地调用render
函数,那么我们的目的不就达到了吗?这就是我们所称的依赖收集。依赖收集的原理非常的简单,在响应式数据中我们一直利用的都是属性描述符中的
set
方法,而我们知道当调用某个对象的属性时,会触发属性描述符的get
方法,当get
方法调用时,我们将调用get
的方法收集起来就能完成我们的依赖收集的任务。首先我们可以思考要一下,如果是自己写一个响应式数据带依赖收集的模块,我们会去怎么设计。首先我们想要达到的类似效果就是:
我们所需要实现的
watch
函数的第一个参数可以认为是render
函数,通过执行render
函数我们可以收集到render
函数内部使用了那些响应式数据属性。然后在对应的响应式数据属性改变的时候,触发我们注册的第二个函数。这样看我们监听属性的粒度就是响应数据的每一个属性。按照单一职责的概念,我们将监听订阅与通知发布的职责分离出去,由单独的Dep
类负责。由于监听的粒度是响应式数据的每一个属性,因此我们会为每一个属性维护一个Dep
。与此相对应,我们创建Watcher
类,负责向Dep
注册,并在收到通知后调用回调函数。如下图所示:首先我们实现
Dep
和Watcher
类:接着我们创建watcher函数并且改造之前响应式相关的函数:
接下来我们就可以实验一下我们的
watch
函数了:到此为止我们已经基本实现了我们想要的效果,当然上面的例子并不完备,但是也基本能展示出响应式数据与数据依赖的基本原理。当然上面仅仅只是采用ES5的数据描述符实现的,随着ES6的普及,我们也可以用Proxy(代理)和Reflect(反射)去实现。作为本系列的第一篇文章,还有其他的点没有一一列举出来,大家可以关注我的Github博客继续关注,如果有讲的不准确的地方,欢迎大家指正。