logo

Watch with @vue/reactivity

Sep 18, 2020ยท12min

As you probably know, the things I excited most in Vue 3 are the Composition API and the reactivity system. With the Composition API we can reuse logics and states across components or even apps. What's better? The underhood reactivity system is decoupled from Vue, which means you can use it almost everywhere, even without UI.

Here are some proof of concepts for using the reactivity system outside of Vue:

  • @vue/lit is a minimal framework wrote by Evan combining @vue/reactivity and lit-html. It can run directly in browser, with the almost identical experience as Vue Composition API.
  • ReactiVue ports Vue Composition API to React. It also provides React's lifecycles in the Vue style.

Furthermore, you can even use Vue's libraries in them. Tested with VueUse and pinia in ReactiVue, and they just work. You can find more details and examples here.

I am also experimenting more possibility of Vue reactivity in other scenarios, for example reactive file system, in a project called tive. It's currently a WIP private repo, but keep tuned, I get more to come ๐Ÿ˜‰!

Understanding @vue/reactivity

"reactive objects" returned by ref() or reactive() are actually Proxies. Those proxies will trigger some actions to track the changes on properties accessing or writing.

For a simplified example,

const reactive = (target) => new Proxy(target, {
  get(target, prop, receiver) {
    track(target, prop)
    return Reflect.get(...arguments) // get the original data
  },
  set(target, key, value, receiver) {
    trigger(target, key)
    return Reflect.set(...arguments) // set the original data
  }
})

const obj = reactive({
  hello: 'world'
})

console.log(obj.hello) // `track()` get called
obj.hello = 'vue' // `trigger()` get called

So in this way, vue can be notified when those properties get accessed or when they be modified.

For more detailed explanations, check out the official docs

Computed

Since we are able to know those events, we can start diving into the computed which is where the "reactive" magic start shining.

computed is like a getter that auto collects the reactive dependencies source and auto re-evaluate when they get changed.

For example,

const counter = ref(1)
const multiplier = ref(2)

const result = computed(() => counter.value * multiplier.value)

console.log(result.value) // 2
counter.value += 1
console.log(result.value) // 4

To know how the computed work, we need to dig into the lower level API effect first.

Effect

effect is a new API introduced in Vue 3. Underneath, it's the engine powers the "reactivity" in computed and watch. For the most of the time, you don't need to directly use it. But knowing it well helps you understand the reactivity system much easier.

effect takes the first argument as the getter and a second argument for the options. The getter is the function that collect its deps on each run via their track() hooks. The field scheduler in options provides a way to invoke a custom function when the deps change.

So basically, you can write a simple computed on your own like:

const computed = (getter) => {
  let value
  let dirty = true
  
  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true // deps changed
    }
  })
  
  // return should be a `Ref` in real world, simplified here
  return {
    get value() {
      if (dirty) {
        value = runner() // re-evaluate
        dirty = false
      }
      return value
    }
  }
}

If you really interested in how it works in Vue, check out the source code here

Build yourself a watch

We have done the most important APIs in @vue/reactivity now, which is ref reactive effect computed.

Oh wait, we are missing the watch here!

import { watch } from '@vue/reactivity' // does NOT exist!

If you take a look at Vue 3's source code, you will find that watch is actually implemented in @vue/runtime-core, along with the Vue's component model and lifecycles. The main reason for this is that watch is deep bound with the component's lifecycles (auto dispose, invalidate, etc.). But it shouldn't be the thing to keep you from using it outside of Vue.

Let's implement the watch our own!

The Basic

Let's take a look at Vue's watch interface first

const count = ref(0)

watch(
  () => count.value,
  (newValue) => {
    console.log(`count changed to: ${newValue}!`)
  }
)

count.value = 2
// count changed to: 2!

With the knowledge of effect, it's quite straight forward to implement

const watch = (getter, fn) => {
  const runner = effect(getter, {
    lazy: true,
    scheduler: fn
  }
  
  // a callback function is returned to stop the effect
  return () => stop(runner)
}

Watch is lazy by default in Vue, you can add the third options to give control to the users.

Watch for Ref

You may also notice that the Vue's watch also allows passing the ref directly to it.

watch(
  count,
  () => { /* onChanged */ }
)

For that, just wrap it into a getter will do

const watch = (source, fn) => {
  const getter = isRef(source)
    ? () => source.value
    : source

  const runner = effect(getter, {
    lazy: true,
    scheduler: fn
  }

  return () => stop(runner)
}

Watch Deeply

One other great feature about watch is that it allows you to watch on deep changes.

const state = reactive({
  info: {
    name: 'Anthony',
  }
})

watch(state, () => { console.log('changed!') }, { deep: true })

state.info.name = 'Anthony Fu'
// changed!

To implement this feature, you need to collect the track() events on every nested property. We can achieve that with a traverse function.

function traverse(value, seen = new Set()) {
  if (!isObject(value) || seen.has(value))
    return value

  seen.add(value) // prevent circular reference 
  if (isArray(value)) {
    for (let i = 0; i < value.length; i++)
      traverse(value[i], seen)
  }
  else {
    for (const key of Object.keys(value))
      traverse(value[key], seen)
  }
  return value
}

const watch = (source, fn, { deep, lazy = true }) => {
  let getter = isRef(source)
    ? () => source.value
    : isReactive(source) 
      ? () => source
      : source
    
  if (deep)
    getter = () => traverse(getter())
    
  const runner = effect(getter, {
    lazy,
    scheduler: fn
  }

  return () => stop(runner)
}

Done! The thing left to do is to polish, adding overloads to make it more flexible, add more options to get better control, and handle some edge cases. Then you should get yourself a good start for using a custom watch!

Lifecycles

In Vue, computed and watch will automatically bind their effect runner to the current component instance. When the component get unmounted, the effects bond to it will be auto disposed. More specially, you can read the source code here.

Since we don't have an instance, if you want to stop those effects, you have to do them manually. When you have multiple effects in used, to stop them together, you have to manually collect them together. One easier way is to mock similar lifecycles like Vue. This requires some amount of works, I will explain that in another blog post, please keep tuned.

Take Away

Thanks for reading! And hope it helpful for you to understand and better play with the Vue reactivity system. If you want to have the watch out side of Vue, I made one for you (much more robust than the examples above for sure).

npm i @vue-reactivity/watch

Have fun ;P