响应式框架基本原理:数据劫持/数据代理

302 阅读3分钟

下载.jpg

响应式或数据双向绑定的基本概念,这里我就不再多说了,这里我们直接来探究其行为:直观上,数据在变化时不需要开发者去手动更新视图,视图会根据变化的数据“进行”更新。而想要完成这个过程,需要做到以下几点:

  • 知道收集视图依赖了哪些数据
  • 感知被依赖数据的变化
  • 数据变化时,自动“通知”需要更新的视图变化,并进行更新。

这个过程对应的技术概念如下:

  • 依赖收集
  • 数据劫持/数据代理
  • 发布/订阅模式 这里我们先来学习其中的一个技术点——数据劫持/数据代理。

感知数据变化的方法很直接, 就是进行数据劫持或数据代理. 我们往往会通过Object.defineProperty这个方法来实现:

let data = {
    stage: 'GitChat',
    course: {
        title: '前端开发进阶',
        author: 'Lucas',
        publishTime: '2022-01-03'
     }
}
Object.keys(data).forEach(key => {
    let currentVal = data[key]
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get () {
            console.log(`getting ${key} value now, getting value is: ${currentVal}`)
            return currentVal
        },
        set (newVal) {
            currentVal = newVal
            console.log(`setting ${key} value now, setting value is: ${currentVal}`)
        }
    })
})

这段代码对data数据的getter和setter方法进行了定义拦截, 当我们读取或改变data的值时, 便可以监听到这种响应.

但是这种实现有一个问题, 例如执行以下代码后的结果并没有打印出来data.course.title的信息

data.course.title = '前端开发进阶2'
// getting course value now, getting value is: [object Object]

出现这个问题的原因是, 我们的代码只实现了一层Object.defineProperty, 或者说只对data的第一层属性执行了Object.defineProperty, 对于嵌套的引用类型数据结构data.course, 我们同样应该进行拦截.

为了达到深层拦截的目的, 可以将Object.defineProperty的逻辑抽象为observe函数, 并改用递归实现:

// tool.js
export const observe = data => {
    if (!data || typeof data !== 'object') {
        return 
    }
    Object.keys(data).forEach(key => {
        let currentVal = data[key]
        observe(currentVal)
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log(`getting ${key} value now, getting value is: ${currentVal}`)
                return currentVal 
            },
            set (newVal) {
                currentVal = newVal
                console.log(`setting ${key} value now, setting value is: ${currentVal}`)
            }
        })
    })
}
import { observe } from './tool.js'
let data = {
    stage: 'GitChat',
    course: {
        title: '前端开发进阶',
        author: 'Lucas',
        publishTime: '2022-01-03'
    }
 }
 observe(data)

注意: 我们在代理set行为时, 并没有对newValue为复杂类型的情况再次递归调用observe(newVal)方法. 如果给data.course.title赋值一个引用类型, 则无法实现对data.couse.title数据的观察.

监听数组的变化

我们将上述数据中的某一项变为数组:

import { observe } from './tool.js'
let data = {
    stage: 'GitChat',
    course: {
        title: '前端开发进阶',
        author: ['Lucas'],
        publishTime: '2022-01-03'
    }
}
observe(data)
data.course.author.push('jacky')

此时,我们只监听到了对data.course和data.course.author的读取,而数组push所产生的行为并没用被拦截。这是因为Array.prototype上挂在的方法并不能触发data.course.author属性值的settter,这并不属于做赋值操作,而是调用数组的push方法。

Vue同样存在这样的问题,它的解决方法是:将数组的常用方法进行重写。

实现逻辑如下:

const arrExtend = Object.create(Array.prototype)
const arrMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'] 
arrMethods.forEach(method => {
    const oldMethod = Array.prototype[method]
    const newMethod = function (...args) {
        oldMethod.apply(this, args) 
        console.log(`${method}方法被执行了`)
    }
    arrExtend[method] = newMethod
})

对数组的7个原生方法进行重写,核心操作还是调用原生方法oldMethod.apply(this, args),在此之外,还可以在调用oldMethod.apply(this, args)的前后加入我们需要的任何逻辑。

Object.defineProperty和Proxy

使用Proxy重构代码:

let data = {
    stage: 'GitChat',
    course: {
        title: '前端开发进阶',
        author: ['Lucas'],
        publishTime: '2022-01-03'
    }
}
const observe = data => {
    if (!data || Object.ptototype.toString.call(data) !== '[object Object]') {
        return
    }
    Object.keys(data).forEach(key => {
        let currentVal = data[key]
        if (typeof currentVal === 'object') {
            observe(currentVal)
            data[key] = new Proxy(currentVal, {
                set(target, property, value, receiver) { // 因为使用数组的push方法时会引起length属性的方法,所以调用push之后会触发两次set操作 保留一次即可
                    if (property !== 'length') {
                        console.log(`setting ${key} value now, setting value is: ${currentVal}`)
                    }
                    return Reflect.set(target, property, value, receiver)
                }
            })
        } else {
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get () {
                    console.log(`getting ${key} value now, getting value is: ${currentVal}`)
                    return currentVal
                },
                set (newVal) {
                    currentVal = newVal console.log(`setting ${key} value now, setting value is: ${currentVal}`)
                }
            })
        }
    })
} 
observe(data)

这里对于数据键值为基本类型的情况,使用Object.defineProperty;对于键值为对象类型的情况,可以继续递归调用observe方法,并通过Proxy返回的新对象对data[key]重新赋值,这个新值的getter和setter已经被添加了代理。

Proxy实现数据代理和Object.defineProperty实现数据拦截对比:

  1. Object.defineProperty不能监听数组的变化,需要对数组方法进行重写
  2. Object.defineProperty必须遍历对象的每个属性,且需要对嵌套结构进行深层遍历
  3. Proxy的代理是针对整个对象的,而不是针对象的某个属性的,因此不用遍历对象的每个属性,Proxy只需要做一层代理就可以监听同级结构下的所有属性变化,对于深层结构,还是需要递归
  4. Proxy支持代理数组的变化
  5. Proxy 的第二个参数除了可以使用set和get,还可以使用13种拦截方法
  6. Poxy性能会被底层持续优化