实现watch

watch的实现

所谓watch,本质上就是观察一个响应式数据,当响应式数据发生变化是执行指定的回调函数,其实就是观察者模式

watch(obj, () => {
  console.log('数据变了')
})

// 修改响应数据的值,会导致回调函数执行
obj.foo++

在上边的代码中,我们希望,当执行完foo的自增的时候,控制台也会打印“数据变了!”,这就是我们希望watch的执行效果。

watch的基本实现

还是从调度器说起

上边的测试代码中,其实从effect的角度来看就是利用effect的options参数的调度器就做相应的魔改:

effect(() => {
  console.log(obj.foo)
}, {
  scheduler() {
    // 当 obj.foo 的值变化时,会执行 scheduler 调度函数
  }
})

还是从上边代码来看,我们在传入的函数参数中,访问了obj.foo,那么其实在effect的初次执行中,会触发响应式数据的get拦截操作,从而触发track去追踪依赖的副作用函数,那么当前的effect会以activeEffect形式被收集。通常情况下,当响应式数据改变的时候,effect是会在trigger中被执行。然而,当effect函数带着scheduler这个属性的时候是一个例外情况,trigger的源码所写的,此时会执行scheduler而非effect本身。计算属性是利用这个特点,watch也不例外,那么简单的watch结构如下:

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}

那么执行下边代码是可行的:

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })

watch(obj, () => {
  console.log('数据变化了')
})

obj.foo++

但是可以看到依然是一个硬编码的操作,watch中只针对了foo这个属性值的侦测。

使用一个更通用的操作

至此,我们引入一个traverse函数,来访问传入的source的属性

function watch(source, cb) {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}

function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value)
  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen)
  }

  return value
}

traverse的逻辑,显而易见是对传入的参数去做一个递归的深度优先遍历,以此代替硬编码的操作。

拓展第一个参数的类型

watch 函数除了可以观测响应式数据,还可以结构一个getter函数:

watch(
  // getter 函数
  () => obj.foo,
  // 回调函数
  () => {
    console.log('obj.foo 的值变了')
  }
)

那么此时watch的第一个参数就不再是响应式数据,而是一个getter类型函数本身。在这个函数的内部,用户可以指定watch可以依赖哪些响应式数据,只有这些指定的数据的变化才会引起回调函数执行。

function watch(source, cb) {
  // 定义 getter
  let getter
  // 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则按照原来的实现调用 traverse 递归地读取
    getter = () => traverse(source)
  }

  effect(
    // 执行 getter
    () => getter(),
    {
      scheduler() {
        cb()
      }
    }
  )
}

那么就是在内部封装了一个getter,根据传进来的第一个参数来做对应处理,但是最终都是转换成一个getter函数。当第一个参数是非函数类型时,直接将getter赋值为函数,而在函数内部去调用traverse第一个参数的结果。

获取新旧值

watch的回调函数有一个显著的特征,就是前两个参数分别是观察的数据的新值和旧值

watch(
  () => obj.foo,
  (newValue, oldValue) => {
    console.log(newValue, oldValue)  // 2, 1
  }
)

obj.foo++

那么如何获得到旧值呢,其实旧值的获取本质上需要借助一个缓存,在计算属性中我们在函数体中用一个value来进行值的缓存,当然是借助effect中的懒执行来进行的。那么我们在watch中同样可以做一个懒执行来达到这个目的。

function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }
  // 定义旧值与新值
  let oldValue, newValue
  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 在 scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn()
        // 将旧值和新值作为回调函数的参数
        cb(newValue, oldValue)
        // 更新旧值,不然下一次会得到错误的旧值
        oldValue = newValue
      }
    }
  )
  // 手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn()
}

在这里,我们依旧借助lazy和调度器scheduler。让我们来分析一下整个过程(从侦听到数据改变引发回调函数执行):

  • 在使用watch监听一个响应式数据时,在watch内部getter中读取了对应的对象或者指定的值,触发响应式数据的track去进行依赖收集。需要注意的是,如果此时传进来的只是一个对象,那么内部会把对象的所有属性深度的遍历一遍,也就是说,对象内部的所有属性(包括对象内部的非源语数据的“成员”)都会跟watch建立一个依赖关系。
  • 当监听的响应式数据发生变化时,由于lazy为true,trigger执行的调度器中,首先会将执行当前effectFn获得新值(计算属性那一节的effect中,如果lazy为true的话那么effect函数返回的会是内部的effectFn),然后执行回调函数,然后更新旧值。

立即执行的watch

关于watch主要是两个特性:立即执行和回调函数的执行时机。

一般情况下,watch的回调只会在响应式数据变化时才执行,但是,vue.js中可以指定选项参数immediate来指定回调函数是否可以立即执行:

watch(obj, () => {
  console.log('变化了')
}, {
  // 回调函数会在 watch 创建时立即执行一次
  immediate: true
})

immediate的选项存在且值为true的时候,回调函数会watch创建的时候立即执行一次。

仔细思考可以发现,立即执行的回调函数,逻辑上和后边执行的回调函数时的逻辑是一样的,那么我们可以把 scheduler调度函数的逻辑进一步抽象出来为通用函数,从而可以在初始化和变更的时候执行:

function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  // 提取 scheduler 调度函数为一个独立的 job 函数
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      // 使用 job 函数作为调度器函数
      scheduler: job
    }
  )

  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
    job()
  } else {
    oldValue = effectFn()
  }
}

immediate为true时,立即执行一次,否则oldval直接赋值一次。

其他的执行时期

以flush选项为例
watch(obj, () => {
  console.log('变化了')
}, {
  // 回调函数会在 watch 创建时立即执行一次
  flush: 'pre' // 还可以指定为 'post' | 'sync'
})

flush本质上是表明调度函数的执行时机。前文在减少过渡状态的时候使用微任务队列执行调度函数,和flush功能相同。当flush的值为‘’post“的时候,表示调度函数需要把副作用函数放到微任务队列中,并等待dom更新结束后再执行:

function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )

  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

当然当不指定flush的时候,同属性名称一样,当不指定flush的时候,等同与flush设置为“sync”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值