Vue响应式原理

Reactive-in-Depth.png

Vue数据劫持的实现,做一个自己的理解&简单总结。虽然Vue3.0即将到来,我想Vue2.x也不至于马上过时。

今天就从Vue2.x 与 Vue.3.0 数据劫持如何实现数据双向绑定。

数据劫持: 指的是在访问或者修改对象的某个属性时,通过一段代码拦截这个行为,进行额外的操作或者修改返回结果。

Vue2.x 选择的 Object.defineProperty

Object.defineProperty 对大家都来说应该不陌生了。算是面试的一道必考题?(细品:那掌握好了是不是就是一道送分题呢?)可以点击这里回顾一下 Object.defineProperty文档

我们来认清Object.defineProperty的几个局限性

  • 兼容性是IE8+,这也就是为什么Vue不支持IE8及以下版本的原因
  • 不能监听数组的变化,Vue通过重写数组原型的方法来实现数据劫持。
  • 对于深层次嵌套对象需要做递归遍历。
  • 必须遍历对象的每个属性。如果要扩展该对象,就必须手动去为新的属性设置setter、getter方法。 这也就是为什么Vue开发中的不在 data 中声明的属性无法自动拥有双向绑定效果的原因。需要我们手动去调用Vue.set()

我们做个类似Vue简易的数据劫持

  1. 视图更新触发的函数
// 当我们监听的数据发生变化后调用改函数
function update() {
    console.log('数据变化啦,更新视图')
}

  1. 通过 Object.defineProperty 处理 data 中的每个属性
// 通过 Object.defineProperty 处理 target 中的每个属性 key
function defineReactive(target, key, value) {
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(val) {
            // 如果改变的数据和原来一样将不做任何处理
            if (val !== value) {
                 // 数据更新了,调用update
                 update();
                 value = val;
            } 
        }
    })
}
  1. 监听data的函数
function observer(target) {
    // 如果不是对象,直接返回;如果是null也直接返回
    if (typeof target !== 'object' || !target) return target;
    
    // 遍历对象obj的所有key,完成属性配置
    Object.keys(target).forEach(key => defineReactive(target, key, target[key]))
}
  1. 测试步骤1、2、3
// 需要监听的data对象
const data = {
    level: 1,
    info: {
        name: 'cc'
    }
}

// 调用监听函数监听 data
observer(data)

// 修改data的值 视图更新
data.level = 2

// 看到视图确实更新了

// 我们不妨尝试了一下data深层次对象的修改
data.info.name = 'yy'

// 控制台什么都是没有

  1. 想必你也发现了,监听data只到了对象的第一层。data深层次的数据,并没有被监听。所以我们需要对data做一个逐层遍历(递归),直到把每个对象的每个属性都调用 Object.defineProperty() 为止。
// 改改步骤二的代码
function defineReactive(target, key, value) {
    // 在这里新增代码
    // 当value为object我们再做一次数据监听,直到value不是object为止
    if (typeof value === 'object') {
        observer(value)
    }
    
    // 以下代码和步骤2没有区别
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(val) {
            // 如果改变的数据和原来一样将不做任何处理
            if (val !== value) {
                 // 数据更新了,调用update
                 update();
                 value = val;
            } 
        }
    })
}
  1. 再对步骤5的修改做一次测试
const data = {
    level: 1,
    info: {
        name: 'cc'
    },
    a: {
        a: {
            a: {
                a: 1
            }
        }
    }
}

// 我们尝试改变data.info.name的值
data.info.name = 'xy'  // 视图更新了!

// 我们尝试跟深层次的修改
data.a.a.a.a = 2  // ok 视图也更新了

// 那么我再试试其他方式
// 先修改data.info的值
data.info = { name: 'cc' } // 没毛病,视图更新了,但此时data.info的指向已经发生了变化
// 然后再修改data.info.name
data.info.name = 'xy' // emmmmmm... 又是什么都没有
  1. 我们针对步骤5再做一次修改
// 修改步骤5的代码
function defineReactive(target, key, value) {
    if (typeof value === 'object') {
        observer(value)
    }
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(newVal) {
            // 如果改变的数据和原来一样将不做任何处理
            if (newVal !== value) {
                // 在这里新增代码
                // 如果设置newVal是object,对newVal做监听
                if (typeof newVal === 'object') {
                    observer(newVal)
                }
                 // 数据更新了,调用update
                 update();
                 value = newVal;
            } 
        }
    })
}
  1. 再对步骤7的修改做一次测试
const data = {
    level: 1,
    info: {
        name: 'cc'
    }
}

// 先修改data.info的值
data.info = { name: 'cc' } // 没毛病,视图更新了
// 然后再修改data.info.name
data.info.name = 'xy' // 也没毛病,视图更新了
  1. 我们都知道typeof 数据返回的也是object
const data = {
    arr: []
}

// 尝试对数组做更改
arr.push(1); // 然鹅,并没有任何输出
  1. 前面有说明Object.defineProperty 对数组是起不到任何作用的。那Vue如何实现的呢? Vue是通过修改数组的原型方法来实现数据劫持(做一些视图更新、渲染的操作)。
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']

// 遍历methods数组
methods.forEach(method => {
    // 原生Array的原型方法
    const originalArray = Array.prototype[method]
    
    // 重写Array原型上对应的方式
    Array.prototype[method] = function() {
        // 做视图更新或者渲染操作
        update();
        
        // 视图更新了,调用对应的原生方法
        // arguments 将该有的参数也传进来
        originalArray.call(this, ...arguments);
    }
})
  1. 又到了验证一下步骤10的时候啦!
const data = {
    arr: []
}

data.arr.push(1) // 视图更新了
  1. 看了上面的代码,可能就有疑问了。我们明显直接修改的是 Array.prototype的方法。这样会导致一个问题。没有被监听的数组,也会触发update()。如下:
var normalArray = [];

normalArray.push(1); // wtf 竟然也触发了视图更新

结果明显不是我们想要的。我们希望的是:Array原有的方法保持不变,但是又要引用到原来的方法的实现。

我们可以简单地处理下啦。

①先修改步骤10的代码

const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayList = [] 

// 遍历methods数组
methods.forEach(method => {
    // 原生Array的原型方法
    const originalArray = Array.prototype[method]
    
    // 重写Array原型上对应的方式
    arrayList[method] = function() {
        // 做视图更新或者渲染操作
        update();
        
        // 视图更新了,调用对应的原生方法
        // arguments 将该有的参数也传进来
        originalArray.call(this, ...arguments);
    }
})

②再修改步骤7的代码

function defineReactive(target, key, value) {
    if (typeof value === 'object') {
        // 通过链去找我们定义好的方法
        if (Array.isArray(value)) {
            value.__proto__ = arrayList
        }
        observer(value)
    }
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(val) {
            // 如果改变的数据和原来一样将不做任何处理
            if (val !== value) {
                // 在这里新增代码,如果设置val是object,对val做监听
                if (typeof val === 'object') {
                    // 通过链去找我们定义好的方法
                    if (Array.isArray(val)) {
            val.__proto__ = arrayList
          }
                    observer(val)
                }
                 // 数据更新了,调用update
                 update();
                 value = val;
            } 
        }
    })
}
  1. 完整代码
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayList = []

// 遍历methods数组
methods.forEach(method => {
    // 原生Array的原型方法
    const originalArray = Array.prototype[method]

    // 重写Array原型上对应的方式
    arrayList[method] = function() {
        // 做视图更新或者渲染操作
        update();

        // 视图更新了,调用对应的原生方法
        // arguments 将该有的参数也传进来
        originalArray.call(this, ...arguments);
    }
})


// 当我们监听的数据发生变化后调用改函数
function update() {
    console.log('数据变化啦,更新视图')
}

function observer(target) {
    // 如果不是对象,直接返回
    if (typeof target !== 'object' || !target) return target;

    // 遍历对象obj的所有key,完成属性配置
    Object.keys(target).forEach(key => defineReactive(target, key, target[key]))
}


function defineReactive(target, key, value) {
    if (typeof value === 'object') {
        if (Array.isArray(value)) {
            value.__proto__ = arrayList
        }
        observer(value)
    }
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(newVal) {
            // 如果改变的数据和原来一样将不做任何处理
            if (newVal !== value) {
                // 在这里新增代码,如果设置newVal是object,对newVal做监听
                if (typeof newVal === 'object') {
                    if (Array.isArray(newVal)) {
            newVal.__proto__ = arrayList
          }
                    observer(newVal)
                }
                 // 数据更新了,调用update
                 update();
                 value = newVal;
            }
        }
    })
}


const data = {
  level: 1,
  info: {
    name: 'cc'
  },
  arr: []
}

observer(data)

// 自行打开注释行测试即可

// ①
// data.level = 2

// ②
// data.info.name = 'xy'

// ③
/*
data.info = {name: 'cc'}
data.info.name = 'xy'
*/

// ④
// data.arr.push(1)

// ⑤
/*
data.arr = []
data.arr.push(1)
*/


值得注意的是:数组不支持长度的修改,也不支持通过数组的索引进行更改。例如以下方式是不会触发视图更新,只有上面列举的7个方式或者直接替换一个新的数组才会触发视图更新。数组更新检测

data.arr.length = 3
data.arr[1] = 1

Vue3.0 选择的 Proxy

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

function update() {
  console.log('数据变化啦,更新视图')
}

const data = {
  level: 1,
  info: {
    name: 'cc'
  },
  arr: []
}

const handler = {
  get(target, property) {
    // 如果值为对象,在对该值进行数据劫持
    if (typeof target[property] === 'object' && target[property] !== null) {
      return new Proxy(target[property], handler)
    }
    return Reflect.get(target, property)
  },

  set(target, property, value) {
    if (property === 'length') {
      return true
    }
    update()
    return Reflect.set(target, property, value)
  }
}

const proxy = new Proxy(data, handler)

proxy.level = 2
proxy.info.name = 'yy'
proxy.arr.push(1)
proxy.arr[1] = 1

Proxy最大的问题应该就是兼容性了,但是3.0都准备发布了,我们值得简单一试~

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