深入浅出Vue.js--变化侦测

变化侦测

侦测状态变化,重新渲染页面。

拉(通知状态改变,然后暴力比对哪些节点需要重新渲染): Angular脏检查、React虚拟dom

推(明确知道哪些状态改变,细粒度,通知绑定这个状态的依赖节点更新): Vue

但,粒度越细,每个状态绑定的依赖越多,追踪开销就越大。从Vue2.0开始引入虚拟dom,绑定依赖到组件层面,而不是节点层面。状态改变,通知到组件,组件内部再使用虚拟dom进行比对。

Object变化侦测

追踪变化 Object.defineProperty 和 Proxy

收集依赖
当数据发生变化的时候,需要通知使用了该数据的地方。所以在gettter中收集依赖,在setter中触发依赖。

function defineReactive(data, key, val) {
    let dep = [];  // 用于存储被收集的依赖
    Obejct.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            dep.push(window.target)
            return val
        },
        set: function(newVal) {
            if (val === newVal) return
            for(let i=0; i<dep.length; i++) { // 遍历所有收集的依赖
                dep[i](newVal, val)
            }
            val = newVal
        },
    })
}

为了减少耦合,封装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只需要调用depend收集,notify通知更新
function defineReactive(data, key, val) {
    let dep = new Dep()
    Obejct.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。

    export default class Watcher {
        constructor (vm, expOrFn, cb) {
            this.vm = vm
            this.getter = parsePath(expOrFn)  // 读取“data.a.b.c”的值,用.分割成数组,再递归一层层查找
            this.cb = cb
            this.value = this.get()
        }
        get() {
            window.taget = 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)
        }
    }

递归侦测所有key

封装一个Observer类用于将data中的所有属性(包括子属性)都转化成getter/setter的形式。

export class Observer {
    constructor (value) {
        this.value = value
        if (!Array.isArray(value)) { // object类型时,调用walk
            this.walk(value)
         }
    }
    walk () { // 遍历,将每一个属性都变成getter/setter形式
        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') { // 如果值是object,则递归把子属性也变成getter/setter
        new Observer(val)
    }
    // ... 其余代码同上
}

getter/setter只能追踪一个属性是否被修改,但无法追踪新增和删除属性,所以另外提供了vm.set和vm.delete两个api。ES6之前。

Array变化侦测

侦测Object变化是通过getter/setter实现的,但是如果用Array原型上的方法改变数组,就无法侦测了。同setter追踪,如果可以在用户使用Array原型上的方法改变数组时,得到通知,就可以侦测变化。

我们可以用一个拦截器arrayMethods去覆盖Array.prototype,在拦截器中发送变化通知, 再执行原本的功能。改变数组自身内容的7个方法: ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
    const original = arrayProto[method] // 缓存原有方法
    Object.defineProperty(arrayMethods, method, {
        value: function mutator (...args) {
            // 发送变化通知
            return original.apply(this, args)
        },
        enumerable: false,
        writable: true,
        configuration: true,
    })
})

拦截器arrayMethods不能直接覆盖Array.prototype,会污染全局的Array。我们的拦截操作只需要针对那些被侦测了变化的数据生效,也就是说拦截器只覆盖那些响应式数组的原型。将一个数据转化成响应式,需要用到Observer。

    import { arrayMethods } from './array'
    const hasProto = '_proto_' in {}
    const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
    export class Observer {
        constructor (value) {
            this.value = value
            if (Array.isArray(value)) {
                // value._proto_ = arrayMethods // 覆盖原型上方法
                // 浏览器是否支持_proto_,支持则覆盖原型,不支持则直接复制挂载在value上
                const augment = hasProto ? protoAugment : copyAugment 
                augment(value, arrayMethods, arrayKeys)
            } else {
                this.walk(value)
            }
        }
    }
    function protoAugment (target, src, keys) {
        target._proto_ = src
    }
    function copyAugment (target, src, keys) {
        for (let i = 0; i < keys.length; i++) {
            const key = keys[i]
            def(target, key, src[key])
        }
    }

ES6用Object.getPropertyOf和Object.setPropertyOf替代了proto
每次访问数组的值,就会触发getter。所以Array在getter里收集依赖,在拦截器中触发依赖。
依赖列表dep存储在Observer中,因为getter和拦截器中都可以访问到Observer实例。
getter中访问:

    function defineReactive (data, key, val) {
        let childOb = observe(val)
        let dep = new Dep()
        Object.defineProperty(data, key {
            enumerable: true,
            configurable: true,
            get: function () {
                dep.depend()
                if (childOb) {
                    childOb.dep.depend() // 收集依赖到Observer实例(childOb)的dep中
                }
                return val
            }
        })
    }
    export function observe (value, asRootData) { // 创建响应式实例Observer
        if (!isObject(value)) {  // 如果是object,直接返回,childOb = null
            return
        }
        let ob
        if (hasOwn(value, '_ob_') && value._ob_ instanceof Observer) { // 如果已经是响应式,直接返回Observer实例
            ob = value._ob_
        } else { // 否则,创建
            ob = new Observer(value)
        }
        return ob
    }

拦截器中访问:

    export class Observer {
        constructor (value) {
            this.value = value
            this.dep = new Dep()
            def(value, '_ob_', this) // value._ob_ = Observer实例
        }
        // ...
    }
    // 工具函数 def
    function def (obj, key, val, enumerable) {
        Object.defineProperty(obj, key, {
            value: val,
            enumerable: !!enumerable,
            writable: true,
            configurable: true
        })
    }

这样,就可以通过数组值的ob属性访问到Observer实例上的dep,调用改变数组内容的方法时,通知依赖。同时,收集依赖中的observe函数中通过ob来判断,数据是否已经被Observer转换成了响应式。

    ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
        const original = arrayProto[method]
        def(arrayMethods, method, function mutator (...args) {
            const result = original.apply(this, args)
            const ob = this._ob_
            ob.dep.notify // 通知依赖队列中的Watcher
            return result
        })
    })

侦测数组中元素变化

     export class Observer {
        constructor (value) {
            this.value = value
            this.dep = new Dep()
            def(value, '_ob_', this) // value._ob_ = Observer实例
            if (Array.isArray(value)) {
                const augment = hasProto ? protoAugment : copyAugment
                augment(value, arrayMethods, arrayKeys)
                this.observeArray(value)
            } else {
                this.walk(value)
            }
        }    

        observeArray (items) { // 递归侦测数组中每一项
            for (let i = 0; i < items.length; i++) {
                observe(items[i])
            }
        }
    }

侦测新增元素变化
可以新增数组元素的方法为:push、unshift 和splice,可以取出新增元素,使用observeArray方法使其变成响应式的。

    ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
        const original = arrayProto[method]
        def(arrayMethods, method, function mutator (...args) {
            const result = original.apply(this, args)
            const ob = this._ob_
            let inserted
            switch (method) {
                case 'push':
                case 'unshift':
                    inserted = args
                    break
                case 'splice':
                    inserted = args.slice(2) // 第二位到最后的参数
                    break
            }
            if (inserted) ob.observeArray(inserted) // 侦测新增元素变化
            ob.dep.notify
            return result
       })
    })

Array的变化侦测是通过拦截原型上方法实现的,所以对直接给数组某一项赋值,或者通过设置length改变数组,是侦测不到的。所以可以用api或方法代替。

// 代替vm.items[1] = 'x'
// Vue.set
vm.$set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem,1, newValue)
// 代替vm.items.length = 2
vm.items.splice(newLength)

变化侦测相关api实现原理

vm.$watch(expOrFn, callback, [options])

expOrFn: a.b.c or 函数
options: { deep, immediate }
用于观察一个表达式或computed函数在Vue实例上的变化。回调函数调用时,会从参数得到newValue和oldValue。返回一个取消观察函数,用来停止触发回调。

var unwatch = vm.$watch('a', (newVal, oldVal) => {})
unwatch() // 不再watch

deep: watch对象内部值的变化,都会触发回调
immediate: 立即以表达式的当前值触发回调

所有vm.$开头的属性,都是写在Vue.prototype上的。

原理

    Vue.prototype.$watch = function(expOrFn, cb, options) {
        const vm = this
        options = options || {}
        const watcher = new Watcher(vm, expOrFn, cb, options)
        if (options.immediate) {
            cb.call(vm, watcher, value)
        }
        return function unwatchFn() {
            watcher.teardown() // 本质是把watcher实例从dep的依赖列表中移除
        }
    }

teardown 首先需要先在Watcher中记录自己被收录进了哪些Dep中,当unwatch时,遍历自己的记录列表,从dep依赖列表中把自己删除。

    export default class Watcher {
        constructor (vm, expOrFn, cb) {
            this.vm = vm
            this.deps = [] // dep列表
            this.depIds = new Set() // 避免重复记录
            this.getter = parsePath(expOrFn)
            this.cb = cb
            this.value = this.get()
        }
        // ...
        addDep (dep) {
            const id = dep.id
            if (!this.depIds.has(id)) {
                this.depIds.add(id)
                this.deps.push(dep)
                dep.addSub(this) // 把自己订阅到dep中
            }
        }
        // 遍历dep列表,让其把自己从subs列表中删除,以后数据更新,则不再通知到
        teardown () {
            let i = this.deps.length
            while(i--) {
                this.deps[i].removeSub(this)
            }
        }
    }
    
    let uid = 0
    export default class Dep {
        constructor () {
            this.id = uid++
            this.subs = []
        }
        // ...
        depend () {
            if (window.target) {
                window.target.addDep(this)
            }
        }
        removeSub (sub) {
            const index = this.subs.indexOf(sub)
            if (index > -1) {
                return this.subs.splice(index, 1)
            }
         }
    }

deep实现原理:除了要触发当前这个被监听数据的收集依赖之外,需要把其所有子值都触发一遍收集依赖。当子数据发生变化时,可以通知当前Watcher。

    export default class Watcher {
        constructor (vm, expOrFn, cb, options) {
            this.vm = vm
            if (options) {
                this.deep = !!options.deep
            } else {
                this.deep = false
            }
            // ...
        }
        get () {
            window.target = this
            let value = this.getter.call(vm, vm)
            if (this.deep) {  // 一定要在window.target = undefined之前,保证子集收集的是当前watcher
                traverse(value)
            }
            window.target = undefined
            return value
        }
    }

    const seenObjects = new Set()
    export function traverse (val) {
        _traverse(val, seenObjects)
        seenObjects.clear()
    }
    function _traverse(val, seen) {
        let i, keys
        const isA = Array.isArray(val)
        // 如果不是Array和Object,或者已经被冻结
        if ((!isA && !isObject(val)) || Object.isFrozen(val)) {
            return
        } 
        // 收集依赖,用id避免重复收集
        if (val._ob_) {
            const depId = val._ob_.dep.id
            if (seen.has(depId)) {
                return 
            }
            seen.add(depId)
        }
        if (isA) {
            // 数组,遍历每一项递归调用_traverse
            i = val.length
            while(i--) _traverse(val[i], seen)
        } else {
            // 对象,遍历每一项的值递归调用_traverse
            keys = Object.keys(val)
            i = keys.length
            while (i--) _traverse(val[keys[i]], seen)
            // val[keys[i]]会触发getter,收集依赖,所以此时window.target不能被清空
        }
    }

vm.$set(target, key, value)

在taget上设置一个属性,如果target是响应式的,被创建的属性也是响应式的,并触发视图更新。主要用来避免vue侦测不到新增加属性的限制。

    import {set} from '../observer/index' // observer中抛出set方法
    Vue.prototype.$set = set
    export function set (target, key, val) {
        // target为array的处理
        if (Array.isArray(target) && isValidArrayIndex(key)) {
            target.length = Math.max(target.length, key)
            // splice方法将触发数组拦截器,通知侦测到的变化,从而val变成响应式的
            target.splice(key, 1, val)
            return val
        }
        // key已经存在于target中,已经为响应式,直接修改值就好
        if (key in target && !(key in Object.prototype)) {
            target[key] = val
            return val
        }
        // 新增属性
        const ob = target._ob_
        if (target._isVue || (ob && ob.vmCount)) { // target不能为vue实例或实例上根数据
            process.env.NODE_ENV !== 'production' && warn(
                'Avoid adding reactive properties to a Vue instance or its root $data' + 'at runtime - declare it upfront in the data option.'
            )
            return val
        }
        if (!ob) { // 没有_ob_,说明并不是响应式的,直接设置
            target[key] = val
            return val
        }
        defineReactive(ob.value, key, val) // 追踪新增属性setter getter
        ob.dep.notify() // 通知
        return val
    }

vm.$delete(target, key)

用于删除target对象上的key属性。如果对象是响应式的,需要确保删除能触发更新试图。主要为了避免直接使用delete无法被侦测到变化的限制。

    import {del} from '../observer/index' // observer中抛出set方法
    Vue.prototype.$delete = del
    export function del (target, key) {
        // target为array的处理
        if (Array.isArray(target) && isValidArrayIndex(key)) {
            // splice方法将触发数组拦截器,通知侦测到的变化,从而val变成响应式的
            target.splice(key, 1)
            return
        }
        const ob = target._ob_
        if (target._isVue || (ob && ob.vmCount)) { // target不能为vue实例或实例上根数据
            process.env.NODE_ENV !== 'production' && warn(
                'Avoid adding reactive properties to a Vue instance or its root $data' + 'at runtime - declare it upfront in the data option.'
            )
            return
        }
        // 如果key不是target自身属性, 直接返回
        if(!hasOwn(target, key)) {
            return
        }
        delete target[key]
        // 如果不是响应式的,则不需要通知,直接返回
        if (!ob) {
            return
        }
        ob.dep.notify() // 通知
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342