(通俗易懂的)基于defineProperty实现响应式原理

2,172 阅读7分钟

最近在重新梳理知识点,Vue写了那么久, 是时候给自己一个交代了.也不能每天画好玩的UI对吧.

从题目开始, 这篇文章的前置知识点有

Object.defineProperty

关于第一个Object.defineProperty我已经给了链接.

这个方法可以让我们自定义对象上属性的属性.

说起来有点绕啊.举个例子

var obj = {
    a: 1,
    b: 2
}
Object.defineProperty(obj, 'a', {
    // 枚举----你使用for-in/Object.key()的时候会影响到
    enumerable: false,
    // 可配置---是否可以修改属性的属性, 这就是这个对象里所有的内容
    configurable: false,
    // 可写---是否可以在这个对象上修改value,比如常规的赋值,删除操作
    writable: false,
    // 值---你用点运算符操作的时候,读取到的值
    value: "static"
})

obj.a // 'static' 相当于值是写死的无法改了
Object.keys(obj) // [] // 迭代器取不到key值了
for(let i in obj) {console.log(i)} // undefined // 同上
obj.a = 1
obj.a  // 'static'
Object.defineProperty(obj, 'a', {
    // 枚举----你使用for-in/Object.key()的时候会影响到
    enumerable: false,
    // 可配置---是否可以修改属性的属性, 这就是这个对象里所有的内容
    configurable: false,
    // 可写---是否可以在这个对象上修改value,比如常规的赋值,删除操作
    writable: false,
    // 值---你用点运算符操作的时候,读取到的值
    value: "1"
}) // trow Error
var unknow = obj.b
Object.defineProperty(obj, 'b', {
    // 当然我们也可以重写它的默认的get/set行为
    get() {
        return unknow * 4
    },
    set(value) {
        unknow = value
    }
})
obj.b // 8
obj.b = 2 // 2

这个前置知识我们就复习完了.

响应式

先说定义, 通俗一点说的响应式是指, 当数据a变化了, 与这个数据a相关的操作都会更新. 来看个非响应式的例子可能会好理解一点.(反向操作)

let a = 1;
let b = a * 5;

a = 2;
b; // 5 我们期望, 如果a改变,那么基于a的计算都可以同步, 我们希望b为10

那么怎么做到这一点?

我们监测变量a, 如果a触发了get操作, 那么很可能,进行了依赖性的运算, 在这个例子里就是

let b = a * 5

在这里b的计算依赖于a.噢, 所以,在这个地方我们获取了a, 并进行了计算, 那我就拿个小本子把这个操作记下来.

如果, 变量a触发了set操作, 也就是赋值操作, 那么我们就要拿出小本子, 在把上面记下来的依赖重新执行一遍就好.

其实这就是响应式的原理.

  1. 收集依赖
  2. 侦测变化
  3. 触发更新

之前我们也提到了, defineProperty, 我们通过get方法知道什么时候收集依赖, 通过set方法知道什么时候发生了变化, 触发更新.

接下来就是实操部分了.

把对象所有的属性转换为get/set

defineProperty不像proxy,它只能单个的去监听对象上的属性,而proxy这个小玩具.就很有意思.这个暂且不谈, 下次吧。万一你关注我了呢?

那么请听题: 假设你有一个对象(你在骗自己), 你希望有一个函数, 这个函数把这个对象上所有的属性转换为可监测get/set。如果触发了get需要在控制台输出

`get: ${key}${value}`

一定要自己试一下.以下是答案

  const transform = (obj, i) => {
    var unKnow = obj[i]
    Object.defineProperty(obj, i, {
      get() {
        console.log(`getting"${i}": ${unKnow}`, unKnow)
        return unKnow
      },
      set(value) {
        console.log(`setting"${i}" to: ${value}`)
        unKnow = value
      }
    })
  }

  function convert (obj) {
    // Implement this!
    for (let i in obj) {
      transform(obj, i)
    }
  }

  // test 
  let a = {
    a: 1, 
    b: 2
  }

  convert(a)

Vue中的依赖跟踪

data

每个vue的实例都会有一个watcher对象,这个对象中提供增加依赖和触发依赖更新的方法。

在getter操作时把依赖传入Watcher, 而在单个属性发生改变,也就是set的时候触发依赖更新notify, 继而Watcher执行视图更新操作。

注意, 在这里用了设计模式中的发布-订阅模式

每个vue实例就是订阅者, 这些订阅者的更新事件都放到watcher对象里管理。当我们增加一个依赖项的时候把依赖项放入订阅队列, 然后在每次更新的时候区触发对应的事件。完成更新

来看代码

// 一个依赖项就是一个watch
// watch 中的subscibers是订阅者的一个队列,里面存放每个依赖事件
// 方法: 
// depend: 为该属性增加依赖项
// notify: 数据更新时通知依赖项更新
// 2. 依赖项
class Dep {
  constructor() {
    this.taskList = new Set()
  }

  getDep() {
    if (activeUpdate) { 
      // 为啥这个要使用外部变量不用传参的方式传进来呢?
      // 依赖收集的时候,在get方法内部, 在内部我们怎么访问到
      this.taskList.add(activeUpdate)
    }
  }

  notify() {
    this.taskList.forEach(item => item())
  }
}

let activeUpdate = null
// 上面的class部分简单易懂, 不做赘述
// 这个函数一定要明白
function autorun (update) {
  const wrappedUpdate = () => {
    activeUpdate = wrappedUpdate 
    update() 
    // update里面触发数据监听, 会先触发get, 而我们在get里做依赖收集,此时activeUpdate存的是 整个函数体
    // 通过这种方式完成了依赖收集, 并且把activeUpdate置空,为下次使用做准备
    activeUpdate = null
  }
  wrappedUpdate()
}

使用get/set方法与依赖收集更新结合, 完成小型的观察者模式

这个可以先自己实现后再往下看.啊?没时间啊?没关系啊, 就...就点收藏就行了啊.

// 2. 依赖项
class Dep {
  constructor() {
    this.taskList = new Set()
  }

  getDep() {
    if (activeUpdate) { 
      // 为啥这个要使用外部变量不用传参的方式传进来呢?
      // 依赖收集的时候,在get方法内部, 在内部我们怎么访问到
      this.taskList.add(activeUpdate)
    }
  }

  notify() {
    this.taskList.forEach(item => item())
  }
}

let activeUpdate = null

function autorun (update) {
  const wrappedUpdate = () => {
    activeUpdate = wrappedUpdate // 这个就是一个依赖, 注意,这个其实是是外层函数, 在dep类里, 我们会把它存进taskList, 供通知的时候使用
    update() // update里面要执行一个依赖收集
    activeUpdate = null
  }
  wrappedUpdate()
}

// 3. 结合数据变化检测 + 依赖项收集
function scan (obj) {
  Object.keys(obj).forEach(key => {
    let internalValue = obj[key]
    // 在进行响应式的同时初始化依赖项实例, 之后再对应的getter/setter方法中形成闭包, 把依赖状态持久化
    const dep = new Dep()
    Object.defineProperty(obj, key, {
      get () {
        // 每次进行get方法的时候, 都进行一次依赖收集
        dep.getDep()
        return internalValue
      },
      set (newVal) {
        // 检测值是否改变, 如果没有改变, 那么不做处理(为了性能)
        const changed = internalValue !== newVal
        internalValue = newVal
        // 如果发生了改变, 那么在依赖class触发依赖项的更新
        if (changed) {
          dep.notify()
        }
      }
    })
  })
  return obj
}



var state = {
  count: 0
}

scan(state)

autorun(() => {
  // 在state.count就触发了getter操作, 继而触发了依赖收集
  a = state.count // 0
})

// 对state.count = 1触发了setter操作, 继而触发了依赖更新
state.count = 1

a // 1

state.count = 2

a // 2

看完的时候觉得真的, 尤雨溪太他妈帅了。这东西写得好精巧啊。

总结

重新理一下,如果你要做到响应式.那么你应该有什么?

  1. 你要有监测机制, 因为如果你不知道什么时候改变,那么你就不会知道啥时候响应
  2. 你要有依赖收集, 因为你不能预知依赖到底有多少, 那么你就得管理依赖项
  3. 你要有响应机制, 你检测到了更新, 继而触发依赖收集, 下一步就是在数据更新的时候, 根据收集到的依赖, 去触发响应, 更新依赖项

以上这三点, 的实现分别为:

  • 检测机制用 get/set方法进行检测, 作为依赖收集, 触发响应的事件分发点
  • 依赖收集和响应机制我们使用dep这个类来完成, 供检测机制调用
  • 使用autorun包裹存在依赖的操作, 并生成引用, 供dep类的getDep依赖收

完结, 撒花, 满地打滚求点赞.