vue源码之数据响应式原理

vue 简介

渐进式框架:就是把框架分层。

最核心的是视图层渲染,然后往外是组件机制,在这个基础上加入路由机制,再加入状态管理,以及最外层的构建工具。

所谓分层:就是说既可以用最核心的视图层渲染来开发一些需求,也可以用vue全家桶来开发大型应用。可以更具自己的需求来选择不同的层级。

数据监听(Object)

有两种方法可以侦测到变化:使用Object.definePropertyES6Proxy

    function defineReactive(data, key ,val) {
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function() {
                return val
            },
            set: function(newVal) {
                if(val === newVal) {
                    return;
                }
                val = newVal
            }
        })
    }

这里的函数defineReactive 用来对Object.defineProperty 进行封装。从函数的名字可以看出,其作用是定义一个响应式数据。也就是在这个函数中进行变化追踪,封装后只需要传递datakeyval 就行了。

封装好之后,每当从datakey 中读取数据时,get 函数被触发;每当往datakey 中设置数据时,set 函数被触发。

如何收集依赖

如果只是把Object.defineProperty 进行封装,那其实并没什么实际用处,真正有用的是收集依赖。

思考一下,我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。

    <template>
        <h1>{{ name }}</h1>
    </template>

该模板中使用了数据name,所以当它发生变化时,要向使用了它的地方发送通知。

注意:在Vue.js 2.0 中,模板使用数据等同于组件使用数据,所以当数据发生变化时,会将通知发送到组件,然后组件内部再通过虚拟DOM重新渲染。

对于上面的问题,先收集依赖,即把用到数据name 的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。

总结起来,其实就一句话,在getter 中收集依赖,在setter 中触发依赖。

依赖收集在哪里

思考一下,首先想到的是每个key 都有一个数组,用来存储当前key 的依赖。假设依赖是一个函数,保存在window.target 上,现在就可以把defineReactive 函数稍微改造一下:

    function defineReactive(data, key, val) {
        let dep = [];
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                dep.push(window.target) // 新增
                return val
            },
            set(newVal) {
                if(val === newVal) {
                    return;
                }
                // 新增
                for (let i = 0; i < dep.length; i++) {
                    dep[i](newVal, val)
                }
                val = newVal
            }
        })
    }

这里我们新增了数组dep,用来存储被收集的依赖。

然后在set 被触发时,循环dep 以触发收集到的依赖。

但是这样写有点耦合,我们把依赖收集的代码封装成一个Dep 类,它专门帮助我们管理依赖。使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。其代码如下:

    export default class Dep {
        constructor() {
            this.subs = []
        }
        addSub (sub) {
            this.subs.push(sub)
        }
        removeSub (sub) {
            remove(this.subs, sub)
        }
        depend () {
            if (window.target) {
                this.addSub(window.target)
            }
        }
        notify() {
            const subs = this.subs.slice();
            for(let i = 0, l = subs.length; i < l; i++) {
                subs[i].update()
            }
        }
    }
    
    function remove (arr, item) {
        if (arr.length) {
            const index = arr.indexOf(item)
            if (index > -1) {
                return arr.splice(index, 1)
            }
        }
    }

之后再改造下defineReactive:

    function defineReactive (data, key, val) {
        let dep = new Dep() // 修改
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                dep.depend() // 修改
                return val
            },
            set: function (newVal) {
                if(val === newVal){
                    return
                }
                val = newVal
                dep.notify() // 新增
            }
        })
    }

依赖是谁

在上面的代码中,我们收集的依赖是window.target,那么它到底是什么?我们究竟要收集谁呢?

收集谁,换句话说,就是当属性发生变化后,通知谁。

我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。所以,我们要抽象的这个东西需要先起一个好听的名字。嗯,就叫它 Watcher 吧。

现在就可以回答上面的问题了,收集谁?Watcher

什么是Watcher

Watcher 是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。

关于Watcher,先看一个经典的使用方式:

    // keypath
    vm.$watch('a.b.c', function (newVal, oldVal) {
    // 做点什么
    })

这段代码表示当data.a.b.c 属性发生变化时,触发第二个参数中的函数。

思考一下,怎么实现这个功能呢?好像只要把这个watcher 实例添加到data.a.b.c 属性的Dep 中就行了。然后,当data.a.b.c 的值发生变化时,通知Watcher。接着,Watcher 再执行参数中的这个回调函数。

export default class Watcher {
    constructor (vm, expOrFn, cb) {
        this.vm = vm
        // 执行this.getter(),就可以读取data.a.b.c 的内容
        this.getter = parsePath(expOrFn)
        this.cb = cb
        this.value = this.get()
    }
    get() {
        window.target = this
        let value = this.getter.call(this.vm, this.vm)
        window.target = undefined
        return value
    }
    update () {
        const oldValue = this.value
        this.value = this.get()
        this.cb.call(this.vm, this.value, oldValue)
    } 
}

这段代码可以把自己主动添加到data.a.b.cDep 中去,是不是很神奇?

因为我在 get 方法中先把 window.target 设置成了this,也就是当前watcher 实例,然后再读一下data.a.b.c 的值,这肯定会触发getter

触发了getter,就会触发收集依赖的逻辑。而关于收集依赖,上面已经介绍了,会从window.target 中读取一个依赖并添加到Dep 中。

这就导致,只要先在window.target 赋一个this,然后再读一下值,去触发getter,就可以把this 主动添加到keypathDep 中。有没有很神奇的感觉啊?

依赖注入到Dep 中后,每当data.a.b.c 的值发生变化时,就会让依赖列表中所有的依赖循环触发update 方法,也就是Watcher 中的update 方法。而update 方法会执行参数中的回调函数,将valueoldValue 传到参数中。

所以,其实不管是用户执行的vm.$watch('a.b.c', (value, oldValue) => {}),还是模板中用到的data,都是通过Watcher 来通知自己是否需要发生变化。

这里有些小伙伴可能会好奇上面代码中的parsePath 是怎么读取一个字符串的keypath 的,下面用一段代码来介绍其实现原理:

/**
* 解析简单路径
*/
const bailRE = /[^w.$]/
export function parsePath (path) {
    if (bailRE.test(path)) {
        return
    }
    const segments = path.split('.')
    return function (obj) {
        for (let i = 0; i < segments.length; i++) {
            if (!obj) return
                obj = obj[segments[i]]
            }
            return obj
        }
   }

可以看到,这其实并不复杂。先将keypath 用 . 分割成数组,然后循环数组一层一层去读数据,最后拿到的obj 就是keypath 中想要读的数据。

递归侦测所有key

现在,其实已经可以实现变化侦测的功能了,但是前面介绍的代码只能侦测数据中的某一个属性,我们希望把数据中的所有属性(包括子属性)都侦测到,所以要封装一个Observer 类。这个类的作用是将一个数据内的所有属性(包括子属性)都转换成getter/setter 的形式,然后去追踪它们的变化:

    /**
* Observer 类会附加到每一个被侦测的object 上。
* 一旦被附加上,Observer 会将object 的所有属性转换为getter/setter 的形式
* 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
*/
export class Observer {
    constructor (value) {
        this.value = value
        if (!Array.isArray(value)) {
            this.walk(value)
        }
    }
/**
* walk 会将每一个属性都转换成getter/setter 的形式来侦测变化
* 这个方法只有在数据类型为Object 时被调用
*/
    walk (obj) {
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            defineReactive(obj, keys[i], obj[keys[i]])
        }
    }
}
function defineReactive (data, key, val) {
    // 新增,递归子属性
    if (typeof val === 'object') {
        new Observer(val)
    }
    let dep = new Dep()
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.depend()
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            val = newVal
            dep.notify()
        }
    })
}

在上面的代码中,我们定义了Observer 类,它用来将一个正常的object 转换成被侦测的object

然后判断数据的类型,只有Object 类型的数据才会调用walk 将每一个属性转换成getter/setter 的形式来侦测变化。

最后,在defineReactive 中新增new Observer(val)来递归子属性,这样我们就可以把data 中的所有属性(包括子属性)都转换成getter/setter 的形式来侦测变化。

data 中的属性发生变化时,与这个属性对应的依赖就会接收到通知。

也就是说,只要我们将一个object 传到Observer 中,那么这个object 就会变成响应式的object

关于Object的问题

有些语法即便数据发生了变化,vue.js也监测不到,比如向Object添加和删除属性。

es6 proxy方式监听数据响应的方式

    let obj = {
        a: 1,
        b: 2,
        c: 3
    }
    
    let reactive = new Proxy(obj, {
        get: function(target, key, receiver) {
            console.log(`getting ${key}`);
            return Reflect.get(target, key, receiver)
        }
        set: function(target, key, receiver) {
            console.log(`setting ${key}`);
            return Reflect.set(target, key, receiver)
        }
    })
    
    
    reactive.a      // getting a  // 1
    reactive.a = 4  // setting a
    reactive.a      // getting a  // 4

总结

变化侦测就是侦测数据的变化,当数据发生变化时,要能侦测并发送出通知。

Object可以通过Object.defineProperty将属性转换成getter/setter的形式来追踪变化。读取数据会触发getter,修改数据会触发setter。

在getter中手机有哪些依赖使用了数据。当setter被触发时,通知getter中收集到的依赖数据发生了变化

收集依赖存储的地方是创建了一个Dep,它们用来收集依赖、删除依赖和向依赖发送消息等。

依赖就是watcher,只有watcher触发的getter才会收集依赖,哪个watcher触发了getter,就把哪个watcher收集到Dep中。当数据发生变化时,会循环依赖列表,把所有的watcher都通知一遍。

watcher的原理是先把自己设置到全局唯一的指定位置(例如window.target),然后读取数据。因为读取了数据,所以会触发这个数据的getter。接着在getter中就会从全局唯一的window.target读取当前正在读取数据的watcher,并收集这个watcher到Dep中。

此外,创建一个Observe类,作用是把一个Object中所有数据都转换成响应式的。

Data、Observe、Dep和Watcher之间的关系:Data通过Observe转换成getter/setter的形式来追踪变化。当外界通过watcher读取数据时,会触发getter从而将watcher添加到依赖中。当数据发生了变化时, 会触发setter,从而向Dep中的依赖(watcher)发送通知。watcher接收到通知后,会向外界发送通知,变化通知到外界后可能触发视图更新,也有可能触发用户的某个回调函数等。

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

推荐阅读更多精彩内容