youngwind / blog

梁少峰的个人博客
4.66k stars 385 forks source link

vue早期源码学习系列之一:如何监听一个对象的变化 #84

Open youngwind opened 7 years ago

youngwind commented 7 years ago

前言

我们都知道,要想精通前端领域,研究分析成熟的框架是必不可少的一步。很多人可能都有这样的体会:“很努力地去阅读一些热门框架的源码,但是发现难度太高,花了很多时间却得不到什么,最终不得不放弃。” 我也一直被这个问题困扰,直到我想到了这样的一个方法。 从成熟框架的早期源码开始看起,从作者的第一个commit开始看起,然后逐个的往前翻。这样一开始的代码量不多,多看几遍还是可以理解的。而且在这个过程中,就像电影回放一样,我们可以看到作者先写什么,后写什么,在哪些地方进行了什么样的改良,其中又不小心引入了什么bug,等等。 这真的是一个很好的办法。所以,我就用这个方法来研究vue的源码。

目标

我checkout到的版本是这个位置,在这个版本中,我们可以发现:代码中主要是observer和emitter这些东西,这些是以后实现数据绑定以及$watch的关键基础。所以我们把本篇的学习目标定位为:“如何监听一个对象的变化” 具体要实现效果如下图所示。 demo

注:这个版本build出来的vue代码运行是会报错了,根据错误提示,很容易定位到错误原因是src/internal/init.js文件头部少引了observer和util,添加上就好了。

思路

我们有两个难点需要解决。

第一:当对象的某个属性变化的时候,如何触发自定义的回调函数? 答案:ES5中新添加了一个方法:Object.defineProperty,通过这个方法,可以自定义getter和setter函数,从而在获取对象属性和设置对象属性的时候能够执行自定义的回调函数。

第二:对象往往是一个深层次的结构,对象的某个属性可能仍然是一个对象,这种情况怎么处理? 比如说

let data = {
    user: {
        name: "liangshaofeng",
        age: "24"
    },
    address: {
        city: "beijing"
    }
};

答案:递归算法,也就是下面代码中的walk函数。如果对象的属性仍然是一个对象的话,那么继续new一个Observer,直到到达最底层的属性位置。

下面是实现的具体代码。

代码

// 观察者构造函数
function Observer(data) {
    this.data = data;
    this.walk(data)
}

let p = Observer.prototype;

// 此函数用于深层次遍历对象的各个属性
// 采用的是递归的思路
// 因为我们要为对象的每一个属性绑定getter和setter
p.walk = function (obj) {
    let val;
    for (let key in obj) {
        // 这里为什么要用hasOwnProperty进行过滤呢?
        // 因为for...in 循环会把对象原型链上的所有可枚举属性都循环出来
        // 而我们想要的仅仅是这个对象本身拥有的属性,所以要这么做。
        if (obj.hasOwnProperty(key)) {
            val = obj[key];

            // 这里进行判断,如果还没有遍历到最底层,继续new Observer
            if (typeof val === 'object') {
                new Observer(val);
            }

            this.convert(key, val);
        }
    }
};

p.convert = function (key, val) {
    Object.defineProperty(this.data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            console.log('你访问了' + key);
            return val
        },
        set: function (newVal) {
            console.log('你设置了' + key);
            console.log('新的' + key + ' = ' + newVal)
            if (newVal === val) return;
            val = newVal
        }
    })
};

let data = {
    user: {
        name: "liangshaofeng",
        age: "24"
    },
    address: {
        city: "beijing"
    }
};

let app = new Observer(data);

遗留问题

上面实现的代码还有很多问题。 比如:

  1. 只监听的对象的变化,没有处理数组的变化。
  2. 当你重新set的属性是对象的话,那么新set的对象里面的属性不能调用getter和setter。比如像下图所示,重新设置的job属性就不在带有自定义的getter和setter了,不再提示“你访问了job"这些字样。 bug

参考资料:

  1. https://segmentfault.com/a/1190000004384515
  2. http://jiongks.name/blog/vue-code-review/
physihan commented 7 years ago

怎么去用chrome浏览器的控制台查看以及修改js数据啊,好神奇,还有这个动画是怎么做的

youngwind commented 7 years ago

gif录制软件licecap @physihan

zhangguixu commented 7 years ago

你好。我想问一下如何查看一个开源项目的commit记录,之前一直以为在github上面就有。结果找半天没有找到

caiyongmin commented 7 years ago

@zhangguixu 点击此处 image

zhangguixu commented 7 years ago

thk~ 看到了。好蠢。。这么明显都没有看到。﹌○﹋ @caiyongmin

leoDreamer commented 7 years ago

你好,在vue里提供了vm.$set()和Vue.set()两个方法去在更新对象属性后监听到改变,但是在单文件组件的开发方式下如何使用呢?貌似在控制台打印出this没有$set()方法,Vue根本没有

609519669 commented 7 years ago

我想用你的方法checkout react最初的版本 发现commit 有8000多 这怎么找到最初的那个版本额 要是一页页翻简直不可能完成啊 求解~~~

youngwind commented 7 years ago

@zhouxiaoyan 放在以前,这是一件很容易的事情。因为原先github的commits页面是按照?page=100这样的参数组织分页的,所以我只需要通过url直接访问https://github.com/facebook/react/commits/master?page=100 , 就能知道第100页有没有commits。然后通过二分法便能快速定位到最后一个有commit的page是多少。

然而,现在就不容易了。因为github改版了,现在的commits分页页面是这样的https://github.com/facebook/react/commits/master?after=Y3Vyc29yOstm9cRAV8BHxA0tkrQuPluXwVcZKzM0 且这个after参数并非commit的id,这就很烦人了。

================分割线=============== 我找了一个临时性的解决方案,那就是直接调用github提供的公共api: https://api.github.com/repos/facebook/react/commits?page=261 这个api依然支持page参数,所以,我们又可以愉快地使用二分法了!

缺点:由于是公共api,所以会有调用频次的限制。(可以通过注册应用来解决,不过很麻烦) 至此,你就可以使用这个方法找到react的第一个commit了:75897c2dcd1dd3a6ca46284dd37e13d22b4b16b4

然而,当你去查看这个commit的时候,你会发现有点懵逼。。因为即便是第一个commit,那代码也至少有成千上万行。究其原因,是因为react一开始就是facebook的内部产物,人家在内部开发得差不多了,才开源到github上,所以,之前的commit都丢失了,无法追溯。

另外,要提一点,那就是分支的选择。因为commit是可以通过rebase来重置合并的,比如像vue现在的master分支,你追溯master分支最早的commit,会发现是2016年的,这显然是合并过的。要想找到最最最初的版本,有时候你还得合理地选择分支,比如选择branch 0.11,这是vue的早期版本,其commit并没有被合并,所以可以通过公共api查询到。 还有其他人发现的一些方法:

  1. http://webapps.stackexchange.com/a/59893
  2. http://webapps.stackexchange.com/a/99526

不过,这些方法并不完善,请灵活使用。

总的来说,自github改版之后,我就没有找到非常快捷又方便的方法。如果你找到了,欢迎交流。

xlelou commented 7 years ago

haonan

Larixs commented 7 years ago

关于遗留问题2,提出一个方案: 在setter里重新追踪一下新值即可,即 if(typeof newVal =="object"){ new Observer(newVal); } 有问题还望指正。

Yangfan2016 commented 7 years ago

还是没懂

RebornL commented 7 years ago

@Larixs 我也是这样去修复的,不知道这样有没有什么弊端呢???希望楼主可以解答一下,谢谢

arshelia commented 7 years ago

@RebornL 之前做练习也是这样写的,大概是重新new一个对象会导致所有数据重置 在接下来的数据监听时,原来对象$watch在这个对象上是不生效的,因为这个对象上没有注册事件监听 我的解决办法是简单粗暴地把父对象的事件传递给新对象

yrl commented 7 years ago

如何监控一个对象,我已经看懂

FrontEnd-GuoXi commented 7 years ago

大佬为什么set的那个方法中最后为什么val = newVal? 只要你这样app.data.name="oo"; val就会被更改成newVal啊 这一步算不算多余的?

frehaiku commented 7 years ago

@FrontEnd-GuoXi

Invalid property. A property cannot both have accessors and be writable or have a value

意思是getter或setter和writable不能同时设置。

so,没有设置writable属性时,writable默认为falseapp.data.name="oo";将失效,故要改变对象的值必须在setter中加上这句代码val = newVal

The usage of Object.defineProperty()

ghost commented 7 years ago

有一点不明白,set函数中val只是一个形参,它是怎么影响到属性值的?

FisNew commented 7 years ago

和楼上一样的问题,想了半天也没想通,val确实不是引用类型。

FisNew commented 7 years ago

@fishelren,不知道你有没有得到答案?起先我以为val = newVal会调用get方法,导致属性值改变,但是调试发现并没有进get方法。

ghost commented 7 years ago

@FisNew ,我后来的理解是这样的val不是改变了吗?get方法不是也要返回val吗,下次返回的值就是val的新值了,不过这么想多少都觉得有点自欺欺人。。。。。。

FisNew commented 7 years ago

@fishelren 但是单步调试会发现,他就是在赋值语句val = newVal里面改变的属性值。。。

youngwind commented 7 years ago

有一点不明白,set函数中val只是一个形参,它是怎么影响到属性值的?

这是个好问题,在理解这个问题之前,请务必先阅读阮一峰老师关于闭包的这篇文章,其中重点是第四小节:闭包的用途

我的看法如下:

  1. 此问题与是否是引用类型无关;
  2. 此问题本质上是闭包使得变量的值始终保存在内存中。详细解析:虽然 val 只是一个形参,但是其本质上是在函数 convert 的作用域内定义了一个局部变量。一般来说,此局部变量 val 在 convert 执行结束之后应该销毁才是。但是,由于在 convert 中又定义了函数getter 和 setter(函数套函数),且使用了变量 val。因此,形成闭包,使得 val 始终保留在内存中。换句话说,每一次执行 convert 函数,就会多一个 val 变量存储在内存中,且这些 val 的值各不相同。因此,当你想设置某个 key 的值得时候,只需要通过 setter 把 newVal 赋值给对应的 val;当你想读取 key 的值得时候,通过 getter 返回对应的 val 变量就是 data.key 的实际值了。

如果还不明白,让我们对比着阮一峰老师的例子看看。

// 这个函数 f1 像极我们的 convert 函数
function f1() {
    var n = 999;   // 类似于我们定义的 val
    nAdd = function () {   // 类似于我们的 setter,只不过这里固定地加1而已
        n += 1
    }
    function f2() {     // 类似于我们的 getter
        alert(n);
    }

    return f2;
}
var result = f1();    // 类似于调用 convert 方法,对 data 的 key 进行定义,data.key 初始值为 999
result(); // 999     // 类似于执行 data.key ,调用 getter,得到 n 的值,为 999
nAdd();  // 类似于执行 data.key = data.key + 1,调用 setter,使得 n++。此时,变量 n 的值为 1000
result(); // 1000    // 再次访问 data.key,返回当前 n 的值,也就是 1000

// 神奇的地方来了,现在我们需要定义 key2 了
var result2 = f1();
result2();   // 999    
// 请注意,这里输出的 n 是999,并非是上面已经加 1 的 1000 了;
// 因为此处的 n 和 上面的 n 本质上是同时存在的两个不同变量!!

不知道我有没有说明白? @fishelren @FisNew

FisNew commented 7 years ago

@youngwind,感谢解答!明白了。以前看过阮一峰老师这篇文章,但是没有理解到,现在算是真正的理解闭包了。

ghost commented 7 years ago

@youngwind 非常感谢老师的指点!很喜欢老师的源码解析系列文章~

wangshengkun commented 7 years ago

有一个疑惑,getter函数为什么要返回val值(return val)?从代码流程上来看这里只是单纯的返回一个对应的值,可是为什么要这么做?将该语句注释后运行data.user.name,浏览器直接报错,FF显示"TypeError: data.user is undefined",chrome显示"Uncaught TypeError: Cannot set property 'name' of undefined",可是name属性确实存在啊,希望老师解惑。

FisNew commented 7 years ago

@wangshengkun ECMAScript中指定了两种属性:数据属性和访问器属性。 数据属性包含四个特性:[[Configurable]],[[Enumerable]],[[Writable]],[[Value]],我们直接通过字面量形式声明一个对象 var person = { name: "Nill" } 通过person.name //Nill 访问对象属性时,其实直接读取的是[[Value]]上的值,同理,通过person.name = "Nike" 设置对象属性时,其实是直接[[Value]]被设置为了指定的值。 访问器属性也有四个特性:[[Configurable]],[[Enumerable]],[[Get]],[[Set]]。不同于数据属性,[[Get]]是用于读取对象属性的函数,[[Set]]是用于设置对象属性的函数。在JavaScript中我们使用Object.defineProperty来定义访问器属性: Object.defineProperty(person,"name",{ get:function () { return this.name; }, set:function (newVal) { this.name = newVal; } }); 此时我们再执行person.name // Nill ,现在就调用的是get函数了。同理person.name = "Nike"调用的是set函数。set和get都不是必须的。两者都不存在的时,当然按数据属性处理。当只指定get时,表示属性不能写,只能读。此时尝试去写入时会抛出错误。当只指定set时,表示属性只能写,不能读。此时尝试读取,严格模式下会报错,非严格模式下会返回undefined。注释掉get中的返回语句就相当于属性只写。

Junx123 commented 7 years ago

@youngwind 老师,我觉得你举的例子好像有点问题,我改了下了一下代码,这跟你写的p.convert函数一样,变量值使用的是形参,而不是闭包搜索n的值,但结果是作为形参传进去的值n执行完操作是不会修改原n值的。你写的p.convert是通过形参传递的参数,而不是通过闭包,所以val是怎么赋上的新值我还不太明白。就算是通过闭包,你在闭包作用域内定义的let val = obj[key],obj[key]是基本类型,通过函数修改闭包作用域内的值val,也不会改变obj[key]的值吧?不知道是不是我理解错了,有点混乱。

function f1() {
    var n = 999; 
    nAdd = function (n) {  //修改为使用形参传递n的值
        n += 1
    }
    function f2() { 
    nAdd(n);  //n作为形参传递进去
        alert(n);
    }

    return f2;
}
var result = f1();    
result(); // 999    //输出的是999,说明闭包作用域里的基本类型通过形参的方式进行修改,值不会改变。
qianlongo commented 7 years ago

好赞 这个读源码的思路,准备把你写的系列vue源码分析文章看完

zoubingwu commented 7 years ago

@Junx123 简单类型的参数的确是pass by value,或者说参数传递进去的总是栈内存中的值,在函数内部这个值和传递的参数在内存中的地址是不同的,可以理解为由一个函数内作用域中的同名变量保存着这个值,所以set和get函数中指向的同一个val在一处被修改会影响另一个处,但去修改原来的函数外的变量并不会影响,因为这个val和函数外的val在内存中的位置是不一样的。

miloers commented 6 years ago

@Junx123 你的函数在内部执行nAdd(n)时,n(1000)不是闭包对象。 1 原测试函数

2

hitao123 commented 6 years ago

@youngwind 找到库作者的第一次提交,对于一些比较流行的库,对于打了tag,可以通过最早打的tag来查看第一次commit , 就算不能看到最真实的第一次提交,从学习的角度,基本能满足需求了

youngwind commented 6 years ago

嗯,有早期 tag 的话,会更加方便的。tag 所处状态,功能往往会比较完整,文档也会相对完善,易于学习。 @hitao123

mygaochunming commented 6 years ago

@Junx123 这个是作用域链的问题,推荐一篇文章:

http://bubkoo.com/2014/06/01/ecma-262-3-in-detail-chapter-4-scope-chain/

StudentWan commented 6 years ago

感谢博主的文章,受益匪浅。 不过个人认为,示例代码的注释没有把监听数据对象变化的细节流程展示出来,所以我在自己写代码的时候添加了一些注释,希望可以对后来的学习者有帮助:

// Observer构造函数
// Observer构造函数为参数对象设置访问器属性,以实现数据观察
function Observer(data) {
    this.data = data
    this.walk(data)
}

const proto = Observer.prototype

proto.walk = function (data) {
    let val
    for (const key in data) {
        if (data.hasOwnProperty(key)) {
            // 以对象属性的初始值设置val
            val = data[key]
            if (typeof val === 'object') {
                // 如果val仍是对象,以val为参数调用Observer构造函数
                // 该构造函数调用为val对象中的属性设置getter和setter
                new Observer(val)
            }
            else {
                // 调用convert函数,将对对象数据属性的访问转换为对访问器属性的访问
                this.convert(val, key)
            }
        }
    }
}

proto.convert = function (val, key) {
    // 访问器实际上访问和修改的值是闭包val,仅仅用对象数据属性进行初始化
    Object.defineProperty(this.data, key, {
        configurable: true,
        enumerable: true,
        get: function () {
            console.log('你访问了:' + key)
            return val
        },
        set: function (v) {
            console.log('你设置了:' + key)
            console.log(key + ' = ' + v)
            if (v !== val) {
                val = v
            }
        }
    })
}
niudiewei commented 5 years ago

请问

   function Observer(data) {
        this.data = data;
        this.walk(data);
    }

这里 this.data = data 的含义。

Sphinm commented 5 years ago

把 data 缓存起来

在 2018-08-19 17:53:02,"niudiewei" notifications@github.com 写道:

请问

function Observer(data) { this.data = data; this.walk(data); }

这里 this.data = data 的含义。

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub, or mute the thread.

zhongdeming428 commented 5 years ago

@Junx123 这里确实是闭包的原因导致修改val会影响对象的值,这里的闭包应该是set和get 函数,虽然没有明显的return这两个函数到convert之外,但是JavaScript将这两个函数暴露出去了,当你访问或者赋值app对象的时候,就会调用get或者set函数,而这两个函数都访问了val这个值,所以set会影响get。

HuoXiaoYe commented 5 years ago

@youngwind 您好,在下对 convert这个函数有一些小疑问,在set函数中 为什么 不给 key赋值,而给val赋值,我的意思是 为什么 不用 key = newval, 我尝试后发现这样是行不通的 求解答?

muzea commented 5 years ago

@HuoXiaoYe 这里是一个setter,赋值就是给value的,为什么要赋值给key呢? defineProperty第一个参数是一个对象,第二个参数是这个对象上的一个key,见 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

HuoXiaoYe commented 5 years ago

@muzea 非常感谢您的回答,我自己对Object.defineProperty()方法存在了误解,现在大致明白了。再次感谢您的指导。

bobohuochai commented 4 years ago

对 convert 的函数 写成这样是不是好理解些 p.convert = function(key, val) { let _value = val; Object.defineProperty(this.data, key, { enumerable: true, configurable: true, get: function() { console.log("你访问了" + key); return _value; }, set: function(newVal) { console.log("你设置了" + key); console.log("新的" + key + " = " + newVal); if (newVal === _value) return; _value = newVal; } }); };