preface

As you all know, Vue2’s responsivity is a bit like a half-complete, with nothing to do with new attributes on an object, and intercepting its prototypical methods for arrays.

Here’s an example:

let vm = new Vue({
  data() {
    return {
        a: 1}}})// ❌ oops, no response!
vm.b = 2 
Copy the code
let vm = new Vue({
  data() {
    return {
        a: 1}},watch: {
    b() {
      console.log('change !! ')}}})// ❌ oops, no response!
vm.b = 2
Copy the code

In this case, Vue provides an API: this.$set to make new attributes have a responsive effect.

However, many novices need to be careful to determine when to use $set and when to trigger the response directly.

Anyway, with VU3, that’s all a thing of the past. This article will take you through the convenience that proxy brings to Vue3. And I’ll show you how this is done at the source level.

Responsive warehouse

Vue3 differs from Vue2 in its source code structure. Vue3 distributes low-coupling packages in packages directories as NPM packages. This is also a very popular way of managing large projects, Monorepo.

One repository responsible for the reactive part is @vue/reactivity, which doesn’t involve any other parts of vUE and is a very, very “orthogonal” implementation.

It can even be easily integrated into React.

This enables the analysis of this paper to focus more on this warehouse and exclude other irrelevant parts.

The difference between

The way Proxy and Object.defineProperty are used seems very similar, but Proxy intercepts attribute changes in a “higher dimension”.

In Vue2, for a given data, such as {count: 1}, intercept “modify data.count” and “read data.count” according to the specific key, that is, count

Object.defineProperty(data, 'count', {
  get() {},
  set() {},
})
Copy the code

You must know in advance what key to intercept, which is why Vue2 does nothing about new attributes on objects.

The Proxy used by Vue3 intercepts like this:

new Proxy(data, {
  get(key) { },
  set(key, value) { },
})
Copy the code

As you can see, there is no need to care about the specific key, it intercepts “modify any key on data” and “read any key on data”.

So, no matter the existing key or the new key, it can not escape its grasp.

But proxies are even more powerful because they can intercept more operators than just get and set.

A simple example is 🌰

Write a minimal case of Vue3 responsiveness. The cases in this article will only use the REACTIVE and Effect apis. If you’ve seen useEffect in React, you’ll be familiar with the concept. Vue3’s effect is simply an evolutionary useEffect without the manual declaration dependency.

In React, the dependency [data.count] is manually declared by Vue3. When data.count is read from effect, it is already collected as a dependency.

Vue3:

// Reactive data
const data = reactive({ 
  count: 1
})

// Observe the change
effect((a)= > console.log('count changed', data.count))

// Trigger console.log('count changed', data.count) to re-execute
data.count = 2
Copy the code

React:

/ / data
const [data, setData] = useState({
  count: 1
})

// Observing changes requires manual declaration of dependencies
useEffect((a)= > {
  console.log('count changed', data.count)
}, [data.count])

// Trigger console.log('count changed', data.count) to re-execute
setData({
  count: 2
})
Copy the code

In this case, you might be wise to think of the effect callback as a view rerender, the watch callback, etc… They are also based on this reactive mechanism.

The core purpose of this paper is to explore how powerful the Proxy-based REACTIVE API can be and how much modification can be monitored by users.

So let’s talk about how it works

In fact, in the handler, the second parameter of Proxy, which is the trap operator, it intercepts all kinds of value and assignment operations, and relies on track and trigger to collect and distribute updates.

Track is used to collect dependencies at read time.

Trigger is used to trigger dependencies during updates.

track

function track(target: object, type: TrackOpTypes, key: unknown) {
  const depsMap = targetMap.get(target);
  // Create a set with the key when collecting dependencies
  let dep = new Set()
  targetMap.set(ITERATE_KEY, dep)
  // The update function is stored in the deP
  dep.add(effect)    
}
Copy the code

Target is the original object.

Type is the type of the collection, that is, the operation used to identify the type of the dependency collection. For example, the type of the dependency mentioned above is GET, which will be explained in detail later.

Key refers to the key in the data to be accessed. For example, count is the key to be collected

First there will be a targetMap globally, which is used to establish the mapping of data -> dependencies, which is a WeakMap data structure.

The targetMap gets depsMap from the target data, which holds all the reactive dependencies corresponding to that data.

Each depsMap entry is a Set data structure that holds the update function for the corresponding key.

Isn’t that a little convoluted? Let’s use a concrete example.

const target = { count: 1}
const data = reactive(target)

const effection = effect((a)= > {
  console.log(data.count)
})
Copy the code

For the dependencies in this example,

  1. globaltargetMapIs this:
targetMap: {
  { count: 1 }: dep    
}
Copy the code
  1. Dep is
dep: {
  count: Set { effection }
}
Copy the code

The target function effection corresponds to count.

trigger

So here’s the implementation of minimization, just to make sense of how it works, it’s actually a lot more complicated,

In fact, the function of type is very important, remember for the first time, we will talk about it later.

export function trigger(target: object, type: TriggerOpTypes, key? : unknown,) {
  // All update functions are found by key and executed in sequence
  const dep = targetMap.get(target)
  dep.get(key).forEach(effect= > effect())
}
Copy the code

The new attribute

Since the Proxy doesn’t care about specific keys at all, this is fine.

// Reactive data
const data = reactive({ 
  count: 1
})

// Observe the change
effect((a)= > console.log('newCount changed', data.newCount))

✅ triggers the response
data.newCount = 2
Copy the code

Array new index:

// Reactive data
const data = reactive([])

// Observe the change
effect((a)= > console.log('data[1] changed', data[1]))

✅ triggers the response
data[1] = 5
Copy the code

Array call native methods:

const data = reactive([])
effect((a)= > console.log('c', data[1]))

/ / not reaction
data.push(1)

// ✅ triggers the response because the subscript 1 value is changed
data.push(2)
Copy the code

In this case, we are just calling push, but when the second item of the array is pushed, the callback that data[1] depends on is also executed. How does this work? Write a simple Proxy.

const raw = []
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.push(1)
Copy the code

In this case, we simply print out all the get and set operations on the RAW array and call the Reflect API to process the values and assignments as they are and return them. What does the console print after arr.push(1)?

get push
get length
set 0
set length
Copy the code

A little push triggers two pairs of get and set. Imagine the flow:

  1. Read push method
  2. Read the length property of arR
  3. Assign to the 0th entry of the array
  4. Assign to the length attribute

The point here is that the third step, for the index assignment, then the next push, you can imagine, is for the first item to trigger the set operation.

In the example, when we read data[1], the dependency on the subscript 1 must be collected, which clearly explains why responsive dependency execution can be triggered accurately in push.

By the way, it’s important to remember this for the set operation on length, which we’ll also use later.

Added after traversal

// Reactive data
const data = reactive([])

// Observe the change
effect((a)= > console.log('data map +1', data.map(item= > item + 1))

// ✅ triggers the response to print out [2]
data.push(1)
Copy the code

It’s an amazing interception, but it makes sense, and when you turn it into a real example,

If we want to request student details based on the set IDS of student ids, we simply need to write:

const state = reactive({})
const ids = reactive([1])

effect(async () => {
  state.students = await axios.get('students/batch', ids.map(id= > ({ id })))
})

✅ triggers the response
ids.push(2)
Copy the code

This way, every time various apis are called to change the IDS array, the request is re-sent to get the latest list of students.

If I call the MAP, forEach, etc API in my listener function,

That means I care about the length of this array, so it’s perfectly correct to trigger the response when I push.

But how does it work? It seems complicated.

Since the data is an empty array when effect is first executed, how can an update be triggered when effect is pushed?

Let’s use our little test to see what happens when we map.

const raw = [1.2]
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.map(v= > v + 1)
Copy the code
get map
get length
get constructor
get 0
get 1
Copy the code

What does it have in common with the push part? Vue3 has a special handle on the “add key” operation when it triggers an update. In this case, the value of 0 subscript is added, which leads to the logic in the trigger:

The source address

/ / simplified version
if (isAddOrDelete) {
  add(depsMap.get('length'))}Copy the code

Take the dependencies collected from the previous reading of length and fire the function.

This is obvious. In effect, the map operation reads length and collects the length dependency.

When a new key is added, trigger the dependency collected by length and trigger the callback function.

The same is true for the for of operation:

// Reactive data
const data = reactive([])

// Observe the change
effect((a)= > {
  for (const val of data) {
    console.log('val', val)
  }
})

✅ triggers the response to print out val 1
data.push(1)
Copy the code

For of will also trigger the reading of length.

Length is such a good comrade… It’s been a great help.

Delete or clear after traversal

Note that the judgment condition in the source code above is isAddOrDelete, so the same is true when deleting, using the dependencies collected on length.

/ / simplified version
if (isAddOrDelete) {
  add(depsMap.get('length'))}Copy the code
const arr = reactive([1])
  
effect((a)= > {
  console.log('arr', arr.map(v= > v))
})

✅ triggers the response
arr.length = 0

✅ triggers the response
arr.splice(0.1)
Copy the code

Is really any operation can respond to love love.

Get the keys

const obj = reactive({ a: 1 })
  
effect((a)= > {
  console.log('keys'.Reflect.ownKeys(obj))
})

effect((a)= > {
  console.log('keys'.Object.keys(obj))
})

effect((a)= > {
  for (let key in obj) {
    console.log(key)
  }
})

// ✅ triggers all responses
obj.b = 2
Copy the code

All of these methods can be successfully intercepted because Vue internally intercepts the ownKeys operator.

const ITERATE_KEY = Symbol( 'iterate' );

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    return Reflect.ownKeys(target);
}
Copy the code

ITERATE_KEY acts as a special identifier to indicate that this is the dependency that was collected when the key was read. It will be used as the key for the dependency collection.

So when the update is triggered, it actually corresponds to this source:

if (isAddOrDelete) {
    add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}
Copy the code

It’s actually the part of the code that we simplified when we talked about arrays. If it is not an array, the dependency for ITERATE_KEY is triggered.

The small egg:

Reflect. OwnKeys, Object. Keys, and for in actually behave differently,

Reflects. ownKeys can collect keys of type Symbol, non-enumerable keys.

For example:

var a = {
  [Symbol(2)]: 2,}Object.defineProperty(a, 'b', {
  enumerable: false,})Reflect.ownKeys(a) // [Symbol(2), 'b']
Object.keys(a) / / []
Copy the code

Looking back at the ownKeys interception mentioned earlier,

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    // Return reflect.ownkeys (target)
    return  Reflect.ownKeys(target);
}
Copy the code

OwnKeys (target) is returned directly to Reflect. OwnKeys (target). The Object.

The magic is that the result returned is still the result of object.keys.

Deleting object Properties

With the ownKeys foundation above, let’s look at this example

const obj = reactive({ a: 1.b: 2})
  
effect((a)= > {
  console.log(Object.keys(obj))
})

✅ triggers the response
delete obj['b']
Copy the code

This is also a magic operation that works by intercepting the deleteProperty operator:

function deleteProperty(target: object, key: string | symbol) :boolean {
  const result = Reflect.deleteProperty(target, key)
  trigger(target, TriggerOpTypes.DELETE, key)
  return result
}
Copy the code

The triggeroptypes.delete type is used again, which must have some special treatment based on the experience above.

This is the logic in trigger:

const isAddOrDelete = type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE
if (isAddOrDelete) {
  add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}
Copy the code

The target is not an array, so the dependencies collected by ITERATE_KEY will still be triggered, which is the dependencies collected by reading the key mentioned in the previous example.

Determines whether the attribute exists

const obj = reactive({})

effect((a)= > {
  console.log('has'.Reflect.has(obj, 'a'))
})

effect((a)= > {
  console.log('has'.'a' in obj)
})

// ✅ triggers two responses
obj.a = 1
Copy the code

This one is simple, using the interception of the HAS operator.

function has(target, key) {
  const result = Reflect.has(target, key);
  track(target, "has", key);
  return result;
}
Copy the code

performance

  1. First of all, Proxy as a new standard of browser, performance is bound to be greatly optimized by manufacturers, wait and see.
  2. For responsive data, Vue3 does not recursively define all sub-data in a responsive manner as in Vue2, but makes use of deep data when it is acquiredreactiveFurther defining the reactive can be very beneficial for initialization scenarios with large amounts of data.

For example, for

const obj = reactive({
  foo: {
    bar: 1}})Copy the code

Initialize the definition of reactive time, only will be responsive to the obj shallow definition, and read to obj. The true foo, will that a layer of object definitions responsive for foo, and simplify the source code is as follows:

function get(target: object, key: string | symbol, receiver: object) {
  const res = Reflect.get(target, key, receiver)
  // This is the lazy definition
  return isObject(res)
    ? reactive(res)
    : res
}
Copy the code

Recommended reading

In fact, Vue3 fully supports responsive data types Map and Set, and their prototype methods are also fully intercepted, which will not be covered in this article due to space limitations.

To tell the truth, Vue3’s reactive part of the code logic branch is still a bit too much, for code understanding is not very friendly, because it will also involve readonly and other read-only operations, if you read this article for Vue3’s reactive principle is very interested in words, suggest starting from a simplified version of the library to read the source code.

Here I recommend observer-util, I read the source code of this library, and the implementation principle is basically the same as Vue3! But it’s a lot easier. Small as a sparrow is, it has all the organs. The notes are also quite complete.

Of course, if you are not fluent in English, you can also see typescript-proxy-Reactive in TypeScript + Chinese annotations based on observer-util

For an explanation of this library, see my two previous posts:

Take you to thoroughly understand Vue3 Proxy responsive principle! TypeScript implements proxy-based reactive libraries from scratch.

Take you to thoroughly understand Vue3 Proxy responsive principle! Map and Set responsivity based on function hijacking

In the second article, you can also gain a source-level understanding of what Map and Set intercepts can do.

conclusion

Vue3’s Proxy is really powerful and takes out what I consider to be a heavy mental burden in Vue2. (WHEN I first started Vue, I really didn’t know when to use $set.) Its composition-API is a perfect match for React Hook, and thanks to the power of the responsive system, it is superior to it in some respects. Close reading Vue3.0 Function API

Hopefully this article has familiarized you with some of the new features of Vue3 before it officially arrives.

Further reading

The Proxy interceptor has a receiver parameter, which is not shown in this article for simplicity. What is it used for? Few domestic websites can find this information:

new Proxy(raw, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  }
})
Copy the code

Check out the Q&A on StackOverflow: What-is-a-receiver-in-javascript

You can also look at my summary Proxy and Reflect what exactly is receiver?

AD time

Xiu Yan, an excellent booklet author, has launched a zero-basis algorithm booklet for friends who want to learn algorithms, to help you master some basic algorithm core ideas or simple algorithm problems. I participated in the internal testing process of this booklet, and also put forward a lot of opinions for Xiu Yan. His goal is to do algorithm-oriented zero-based front end crowd “nanny-type service”, very intimate ~

Please thumb up

If this article is helpful to you, please give me a “like” to support it. Your “like” is the motivation for me to continue to write. Let me know that you like my article

❤️ thank you

Pay attention to the public number “front-end from advanced to hospital” can add my friends, I pull you into the “front-end advanced communication group”, we communicate and progress together.