第一章 变化侦测(1)

  我们想要一个数据发生改变时,与其相关的数据、视图模型自动发生变化。首先要知道数值变化了。
  在Angular的方法是使用zone.js把如setTimeout、XHR、点击事件等可能引起模型变化的异步操作用一个wrapFn包裹起来,每当有异步操作发生时Angular就知道数据可能变化了。再遍历组件树,通知组件进行变化检测。若有变化则重新渲染页面。
  而Vue采用的方式,则是利用Object.defineProperty定义setter,再确认数据变化后,通知相关的依赖。

观察者模式

  先引入一个设计模式——观察者模式。了解观察者模式的话可以跳过直接看下文。我们假设有三种人:

  1. 好事者,他们对感兴趣的事情很上心,发生了什么事情都想第一时间知道。
  2. 消息灵通的人,他们收集信息,提供给感兴趣的好事者。
  3. 观察者,他们想狗仔队一样监视着目标,一有发现就告诉消息灵通的人。


    观察者模式

  通过这种消息传递的方式,使得观察者和好事者解耦,观察者只管观察,好事者只管八卦。

接下来我们来抄袭Vue实现变化侦测,Vue是基于观察者模式实现的。

1.观察者

  在js中,有两种方式可以获取对象的变化:Object.definePropertyProxy。Object.defineProperty只能获取对象属性的读取,是ES5规范内容浏览器兼容性好。Proxy更强大,可以拦截对对象的各种访问、改变,但是ES6内容所以兼容较差。由于我们只是为了了解响应式框架的原理,不是做实用轮子,所以我们采用Proxy的方式实现。
  为了获取对象的变化,我们对读、写、删除进行拦截。这个代理,就相当于一个观察者,监视着对象的一举一动。

function defineReactive(obj: any): any {
    return new Proxy(obj, {
        get: function (target, property, receiver) {
            console.log('属性被读');
            return Reflect.get(target, property, receiver);
        },
        set: function (obj, prop: (keyof Object), value, receiver) {
            if (value === obj[prop]) {
                return false;
            }
            console.log('属性被修改');
            return Reflect.set(obj, prop, value, receiver);
        },
        deleteProperty: function (target: any, p: string | number | symbol) {
            console.log('属性被删除');
            return Reflect.deleteProperty(target, p);
        }
    })
}
2.消息灵通的人

  我们通过打印得知了对象的变化,但这并没什么卵用。我们需要谁对它感兴趣。例如,我们有如下模板时,这个视图模型就对message感兴趣,它需要知道message的值是什么它才知道要渲染成怎样的视图,即它依赖message了。

<span>{{ message }}</span>

  为此,我们要搞一个消息灵通的人来用于记录及管理感兴趣的好事者。定义一个类Dep(dep for dependency):

export class Dep {
    static target;//用来存放好事者

    public subs : Array<any>;

    constructor (){
        this.subs = [];
    }   

    public addSub(sub){
        this.subs.push(sub);
    }

    public removeSub(sub){
        //有一个好事者说不感兴趣了
    }

    public depend(){
        //假设我们用target这个全局变量存放一个好事者
        //我们把它添加到感兴趣的人群里
        this.addSub(Dep.target);
    }

    public notify(){
        for(let sub of this.subs){
            //发生变化时,通知感兴趣的好事者
            sub.update();
        }
    }
}

  如果有人读过一个object的属性,我们就认为这个人对这个object是感兴趣的。那么当这个object发生变动时,我们就要通知这些感兴趣的人。此时我们改造一下defineReactive方法:

function defineReactive(obj: any): any {
    const dep = new Dep();//创建一个依赖管理
    return new Proxy(obj, {
        get: function (target, property, receiver) {
            dep.depend();//告诉dep,有人感兴趣
            return Reflect.get(target, property, receiver);
        },
        set: function (obj, prop: (keyof Object), value, receiver) {
            if (value === obj[prop]) {
                return false;
            }
            dep.notify();//让dep通知感兴趣的人,有值被改了
            return Reflect.set(obj, prop, value, receiver);
        },
        deleteProperty: function (target: any, p: string | number | symbol) {
            dep.notify();//让dep通知感兴趣的人,有值被删除了
            return Reflect.deleteProperty(target, p);
        }
    })
}
3.好事者

  好事者会对一件事表示感兴趣,当得到这事的消息时会作出反应。举个例子,我们创建一个好事者,他表示对蔡徐坤感兴趣,而当他知道蔡徐坤开始打篮球时,会大嚷大叫:

new Watcher('蔡徐坤', (status)=>{
  if(status === '打篮球'){
    console.log('蔡徐坤来打篮球啦!!');
  }
})

  为实现这样的功能,可以写出以下代码:

class Watcher { 
    
    public cb : Function; //回调函数,这个人发现消息之后会做什么事情

    public vm : ViewModel;
    private getter: Function;//用来获取感兴趣的消息
    private value: any;//消息

    constructor (
        expOrFn : string | Function, 
        cb : Function
    ){
        this.cb = cb;
        if(typeof expOrFn === 'function'){
            this.getter = expOrFn;
        }else{
            this.getter = parsePath(expOrFn);
        }

        //get()方法会去访问expOrFn对应的值,会触发proxy中的get
        //进而将这个watcher添加到dep里 即让消息灵通人的知道我感兴趣
        this.value = this.get();
    }   

    public get(){
        Dep.target = this;//记录自己,用于让上文中的dep知道好事者是谁
        const value = this.getter.call(this.vm, this.vm);//触发了proxy的get!
        Dep.target = undefined;
        return value;
    }

    public update(){
        const oldValue = this.value;
        this.value = this.get();
        this.cb.call(this.vm, this.value, oldValue);
    }
}

/**
 * \w为 a-z A-Z 0-9
 * [^]是排除字符组 
 * 这个正则意思是 排除字母 数组 . $
 */
const bailRE = /[^\w.$]/;
/**
 * 将路径字符串解析成对应的对象
 */
export function parsePath (path: string): any {
  if (bailRE.test(path)) {//即如果路径包含字母 数字 . $ 以外字符,为非法路径
    return
  }
  const segments = path.split('.')
  return function (obj : any) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}
Observer

  到这里,整个流程已经完成了。但现在defineReactive只拦截了对象的属性。但当对象的属性的属性发生变化时,是侦测不到的。例如下面这种情况:

let a = {
  b : {
    c : 'hello'
  }
};

a.b.c = 'world';

我们可以定义一个Observer,创建观察者来观察传入的值。并遍历传入值的子属性,将他们的行为都拦截下来:

class Observer {

    public value : any;
    public dep : Dep;

    constructor(value : any){
        this.value = value;
        this.dep = new Dep();
        
        def(value, '__ob__', this);//将value和observer关联起来

        if(!Array.isArray(value)){
            this.value = defineReactive(value);
        }
    }

}

export function observe(value: any): any{
    //如果这个值已经被观察了,就无需再新建Observer 防止循环嵌套对象无限递归
    if(hasOwn(value, '__ob__') && value.__ob__ instanceof Observer){
        return;
    }else{
        return (new Observer(value)).value;
    }
}

export function defineReactive(obj: any ): any{
    const dep = new Dep();

    const keys = Object.keys(obj);
    for(let key of keys){
        if(typeof obj[key] === 'object'){
            //如果子属性是对象,我们需要递归添加代理
            obj[key] = observe(obj[key]);
        }
    }

    return new Proxy(obj, {
        get: function (target, property, receiver) {
            dep.depend();
            return Reflect.get(target, property, receiver);
        },
        set: function(obj, prop: (keyof Object), value, receiver){
            if(value === obj[prop]){//值无变化
                return false;
            }
            const result = Reflect.set(obj, prop, value, receiver);
            dep.notify();
            return result;
        },
        deleteProperty: function(target: any, p: string | number | symbol){
            return Reflect.deleteProperty(target, p);
        }
    });
}

我们可以写一个测试代码测试一下:

test('observe a object', () => {
    const obj = {
        a : "123",
        b : {
            test : {
                text : "hello"
            }
        }
    }
    new Observer(obj);

    expect(hasOwn(obj, '__ob__')).toBe(true);
    expect(hasOwn(obj.b, '__ob__')).toBe(true);
    expect(hasOwn(obj.b.test, '__ob__')).toBe(true);
});
vm.$watch

  最后,我们利用上面做好的这套东西,实现一个不完整的vm.$watch。首先,我们会用 new ViewModel({data : {}}),这样的方式创建一个vm对象,并将data加载到vm上。

class ViewModel{

    public _data : Object = {};
    public _watchers : Array<Watcher> = [];

    public $options : any;

    constructor(options: any){
        this.$options = options;
        this._data = this.$options.data;
    }
}

  我们希望,可以通过vm.key这种方式来访问到vm._data.key。同理用Proxy来实现:

new Proxy(vm, {
        get: function (target, property, receiver) {
            if( property in target._data){//如果_data里有同名的属性,则读取_data里的值
                return Reflect.get(target._data, property, receiver);
            }
            return Reflect.get(target, property, receiver);
        },
        set: function(target, property: (keyof Object), value, receiver){
            if( property in target._data){
                return Reflect.set(target._data, property, value);
            }
            return Reflect.set(target, property, value, receiver);
        },
        deleteProperty: function(target, property){
            if( property in target._data){
                return Reflect.deleteProperty(target._data, property);
            }
            return Reflect.deleteProperty(target, property);
        }
    })

  最后,我们希望data的值是响应式的,且vm提供$watch方法使得data的值可以被监控。组合以上代码可以得到:

class ViewModel{

    public _uid : number;
    public _data : Object = {};
    public _watchers : Array<Watcher> = [];

    public $options : any;

    constructor(options: any){
        this._uid = _vmUid++;
        this.$options = options;
        return initState(this);
    }

    public $watch(expOrFn : string | Function, cb : Function){
        const watcher = new Watcher(this, expOrFn, cb);
        this._watchers.push(watcher);
    }
}

export function initState(vm: ViewModel) {
    const opts = vm.$options;
    if(opts.data){
        vm = initData(vm);
    }
    return vm;
}

function initData(vm: ViewModel) {
    let data = vm.$options.data;

    vm._data = defineReactive(data);//将data变为响应式的

    return new Proxy(vm, {
        get: function (target, property, receiver) {
            if( property in target._data){
                return Reflect.get(target._data, property, receiver);
            }
            return Reflect.get(target, property, receiver);
        },
        set: function(target, property: (keyof Object), value, receiver){
            if( property in target._data){
                return Reflect.set(target._data, property, value);
            }
            return Reflect.set(target, property, value, receiver);
        },
        deleteProperty: function(target, property){
            if( property in target._data){
                return Reflect.deleteProperty(target._data, property);
            }
            return Reflect.deleteProperty(target, property);
        }
    })
}

现在我们好像已经完成一个简单的变化侦测了。但如果执行代码,会发生什么事情呢?程序会进行一次正确打印之后无限打印'text changed!'!思考一下为什么。

const vm = new ViewModel({
    data: {
        text: 'hello world!'
    }
});

vm.$watch('text',(value : any, oldValue : any)=>{    
    console.log(value);    
    console.log(oldValue);    
});

(vm as any)['text'] = 'text changed!';

  这一节完整的代码在github 可以看到哦。
  最后的最后,编写测试代码验证结果:

test('watch', async ()=>{
    const vm = new ViewModel({
        data: {
            text: 'hello world!'
        }
    });

    const result = await watchChanged() as any;
    
    expect(result.oldValue).toBe('hello world!');
    expect(result.value).toBe('text changed!');

    function watchChanged(){
        return new Promise((resolve)=>{            
            vm.$watch('text',(value : any, oldValue : any)=>{
                console.log(value, oldValue);
                resolve({
                    value,
                    oldValue
                })
            });
            
            (vm as any)['text'] = 'text changed!';
        })
    }
})
vm.$set、vm.$delete

  由于Vue采用的Object.defineProperty对属性进行读写的拦截。所以它不能侦测到属性的删除以及data添加新属性。所以Vue提供了set和delete属性来满足这种需求。但由于我们采用代理的方式实现,这些行为都能被拦截,则不需要另外添加两个方法来实现需求了。
老规矩上测试代码:

test('watch add property', async ()=>{
    const vm = new ViewModel({
        data: {
            message : {}
        }
    });

    const result = await watchChanged() as any;
    
    expect(result.oldValue).toBe(undefined);
    expect(result.value).toBe('hello!');

    function watchChanged(){
        return new Promise((resolve)=>{            
            vm.$watch('message.a',(value : any, oldValue : any)=>{
                resolve({
                    value,
                    oldValue
                })
            });
            
            (vm as any).message.a = 'hello!';
        })
    }
})

test('watch delete property', async ()=>{
    const vm = new ViewModel({
        data: {
            message : {
                a : 'hello!'
            }
        }
    });

    const result = await watchChanged() as any;
    
    expect(result.oldValue).toBe('hello!');
    expect(result.value).toBe(undefined);

    function watchChanged(){
        return new Promise((resolve)=>{            
            vm.$watch('message.a',(value : any, oldValue : any)=>{
                resolve({
                    value,
                    oldValue
                })
            });
            
            delete (vm as any).message.a;
        })
    }
})
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容

  • 最近总会忘记写东西,看来还是不够重视。周六打算练一下计算机的题目,周日又要去超级数学声当工作人员,部门打算期末考完...
    数学是我的命阅读 153评论 0 0
  • 本次选择的分析的产品是Faceu和B612,两者在功能方面高度重合,下面主要从产品框架、页面布局、流程操作、交互细...
    早羽说阅读 1,159评论 0 6
  • dd魔武双修并没有什么冲突。 一些才华横溢同时功法适合的人也可以同时进行冥想和打坐修炼。 能量积累上基本没有影响。...
    时光勿念阅读 979评论 0 0
  • 闭上双眼 聆听春的脚步 草坪上 三两鸟儿 从容优雅 在寻觅 也在播种 振翅声时远时近 纵情高飞拥抱蓝天 肆意低旋亲...
    观山观水阅读 709评论 5 22