This article is just written for my knowledge and understanding of the coolest part in Vue: the reactivity system.
Background
As we know, Vue.js team is working on 3.0 for a while. Recently it released the first Beta version. That means the core tech design is stable enough. Now I think it's time to walk through something inside Vue 3.0. That's one of my most favorite parts: the reactivity system.
What's reactivity?
For short, reactivity means, the result of calculations, which depends on some certain data, will be automatically updated, when the data changes.
In modern web development, we always need to render some data-related or state-related views. So obviously, making data reactive could give us lots of benefits. In Vue, the reactivity system always exists from its very early version till now. And I think that's one of the biggest reasons why Vue is so popular.
Let's have a look at the reactivity system in the early version of Vue first.
Reactivity in Vue from 0.x to 1.x
The first time I touched Vue is about 2014, I guess it was Vue 0.10. At that time, you could just pass a plain JavaScript object into a Vue component through data
option. Then you could use them in a piece of document fragment as its template with reactivity. Once the data
changes, the view would be automatically updated. Also you could use computed
and watch
options to benefit yourself from the reactivity system in more flexible ways. Same to the later Vue 1.x.
new Vue({
el: '#app',
template: '<div @click="x++">{{x}} + {{y}} = {{z}}</div>',
data() {
return { x: 1, y: 2 }
},
computed: {
z() { return this.x + this.y }
},
watch: {
x(newValue, oldValue) {
console.log(`x is changed from ${oldValue} to ${newValue}`)
}
}
})
You may found these APIs didn't change too much so far. Because they work the same totally.
So how does it work? How to make a plain JavaScript object reactive automatically?
Fortunately, in JavaScript we have an API Object.defineProperty()
which could overwrite the getter/setter of an object property. So to make them reactive, there could be 3 steps:
- Use
Object.defineProperty()
to overwrite getters/setters of all the properties inside a data object recursively. Besides behaving normally, it additionally injects a trigger inside all setters, and a tracker inside all getters. Also it will create a smallDep
instance inside each time to record all the calculations which depend on this property. - Every time we set a value into a property, it will call the setter, which will re-evaluate those related calculations inside the
Dep
instance. Then you may ask how could we record all the related calculations. The fact is when each time we define a calculation like awatch
function or a DOM update function, it would run once first - sometimes it runs as the initialization, sometimes it's just a dry-run. And during that running, it will touch every tracker inside the getters it depends on. Each tracker will push the current calculation function into the correspondingDep
instance. - So next time when some data changes, it will find out all related calculations inside the corresponding
Dep
instance, and then run them again. So the effect of these calculations will be updated automatically.
A simple implementation to observe data using Object.defineProperty
is like:
// data
const data = { x: 1, y: 2 }
// real data and deps behind
let realX = data.x
let realY = data.y
const realDepsX = []
const realDepsY = []
// make it reactive
Object.defineProperty(data, 'x', {
get() {
trackX()
return realX
},
set(v) {
realX = v
triggerX()
}
})
Object.defineProperty(data, 'y', {
get() {
trackY()
return realY
},
set(v) {
realY = v
triggerY()
}
})
// track and trigger a property
const trackX = () => {
if (isDryRun && currentDep) {
realDepsX.push(currentDep)
}
}
const trackY = () => {
if (isDryRun && currentDep) {
realDepsY.push(currentDep)
}
}
const triggerX = () => {
realDepsX.forEach(dep => dep())
}
const triggerY = () => {
realDepsY.forEach(dep => dep())
}
// observe a function
let isDryRun = false
let currentDep = null
const observe = fn => {
isDryRun = true
currentDep = fn
fn()
currentDep = null
isDryRun = false
}
// define 3 functions
const depA = () => console.log(`x = ${data.x}`)
const depB = () => console.log(`y = ${data.y}`)
const depC = () => console.log(`x + y = ${data.x + data.y}`)
// dry-run all dependents
observe(depA)
observe(depB)
observe(depC)
// output: x = 1, y = 2, x + y = 3
// mutate data
data.x = 3
// output: x = 3, x + y = 5
data.y = 4
// output: y = 4, x + y = 7
Inside Vue 2.x and earlier, the mechanism roughly like this above, but much better abstracted, designed, and implemented.
For supporting more complex cases like arrays, nested properties, or mutating more than 2 properties at the same time, there are more implementation and optimization details inside Vue, but basically, the same mechanism to we mentioned before.
Reactivity in Vue 2.x
From 1.x to 2.x, it was a total rewrite. And it introduced some really cool features like virtual DOM, server-side rendering, low-level render functions, etc. But the interesting thing is the reactivity system didn't change too much, however, the usage above was totally different:
- From 0.x to 1.x, the rendering logic depends on maintaining a document fragment. Inside that document fragment, there are some DOM update functions for each dynamic element, attribute, and text content. So the reactivity system mostly works between the data object and these DOM update functions. Since the functions all real DOM functions so the performance is not quite good. In Vue 2.x, this rendering logic of a Vue component became a whole pure JavaScript render function. So it would firstly return virtual nodes instead of real DOM nodes. Then it would update the real DOM based on the result of a fast mutation diff algorithm for the virtual DOM nodes. It was faster than before.
- In Vue 2.6, it introduced a standalone API
Vue.observalue(obj)
to generate reactive plain JavaScript objects. So you could use them inside arender
function or acomputed
property. It was more flexible to use.
At the same time, there are some discussions in Vue community about abstracting the reactivity system into an independent package for wider usage. However it didn't happen at that time.
Limitation of the reactivity system before 3.0
So far, Vue didn't change the reactivity mechanism. But it doesn't mean the current solution is ideally perfect. As I personally understand, there are some caveats:
- Because of the limitation of
Object.defineProperty
we couldn't observe some data changes like:- Setting array items by assigning value to a certain index. (e.g.
arr[0] = value
) - Setting the length of an array. (e.g.
arr.length = 0
) - Adding a new property to an object. (e.g.
obj.newKey = value
) So it needs some complementary APIs likeVue.$set(obj, newKey, value)
.
- Setting array items by assigning value to a certain index. (e.g.
- Because of the limitation of plain JavaScript data structure, for each reactive object there would be an unenumerable property named
__ob__
, which might lead to conflict in some extreme cases. - It didn't support more data types like
Map
andSet
. Neither other non-plain JavaScript objects. - The performance is an issue. When the data is large, making it reactive when the initialization would cost visible time. There are some tips to flatten the initial cost but a little bit tricky.
Reactivity system in Vue 3.0
For short, in Vue 3.0, the reactivity system was totally rewritten with a new mechanism and new abstraction, as an independent package. And it also supports more modern JavaScript data types.
You may be familiar with it, maybe not. No worry. Let's quickly take a look at it first by creating a Vue 3.0 project.
Create a Vue 3.0 project
Until now, there is no stable full-featured project generator, since it's still in Beta. We could try Vue 3.0 through an experimental project named "vite":
Vite ⚡
Next Generation Frontend Tooling
- 💡 Instant Server Start
- ⚡️ Lightning Fast HMR
- 🛠️ Rich Features
- 📦 Optimized Build
- 🔩 Universal Plugin Interface
- 🔑 Fully Typed APIs
Vite (French word for "quick", pronounced /vit/
, like "veet") is a new breed of frontend build tooling that significantly improves the frontend development experience. It consists of two major parts:
-
A dev server that serves your source files over native ES modules, with rich built-in features and astonishingly fast Hot Module Replacement (HMR).
-
A build command that bundles your code with Rollup, pre-configured to output highly optimized static assets for production.
In addition, Vite is highly extensible via its Plugin API and JavaScript API with full typing support.
Packages
Package
Version (click for changelogs)
vite
@vitejs/plugin-legacy
create-vite
Contribution
See Contributing Guide.
License
MIT.
Sponsors
https://github.com/vitejs/vite
Just run these commands below:
$ npx create-vite-app hello-world
$ cd hello-world
$ npm install
$ npm run dev
Then you could access your Vue 3.0 app through http://localhost:3000.
You could see there is already a Vue component App.vue
:
<template>
<p>
<span>Count is: {{ count }}</span>
<button @click="count++">increment</button>
is positive: {{ isPositive }}
</p>
</template>
<script>
export default {
data: () => ({ count: 0 }),
computed: {
isPositive() { return this.count > 0 }
}
}
</script>
There is a reactive property count
and it's displayed in the <template>
. When users click the "increment" button, the property count
would be incremented, the computed property isPositive
would be re-calculated too, and the UI would be updated automatically.
It seems nothing different to the former version so far.
Now let's try something impossible in early versions of Vue.
1. Adding new property
As we mentioned, in Vue 2.x and earlier, we couldn't observe newly added property automatically. For example:
<template>
<p>
<span>My name is {{ name.given }} {{ name.family }}</span>
<button @click="update">update name</button>
</p>
</template>
<script>
export default {
data: () => ({
name: {
given: 'Jinjiang'
}
}),
methods: {
update() {
this.name.family = 'Zhao'
}
}
}
</script>
The update
method couldn't work properly because the new property family
couldn't be observed. So when adding this new property, the render function won't be re-calculated. If you want this work, you should manually use another complementary API as Vue.$set(this.name, 'family', 'Zhao')
.
But in Vue 3.0, it already works as well. You don't need Vue.$set
anymore.
2. Assigning items to an array by index
Now let's try to set a value into an index of an array:
<template>
<ul>
<li v-for="item, index in list" :key="index">
{{ item }}
<button @click="edit(index)">edit</button>
</li>
</ul>
</template>
<script>
export default {
data() {
return {
list: [
'Client meeting',
'Plan webinar',
'Email newsletter'
]
}
},
methods: {
edit(index) {
const newItem = prompt('Input a new item')
if (newItem) {
this.list[index] = newItem
}
}
}
}
</script>
In Vue 2.x and earlier, when you click one of the "edit" buttons in the list item and input a new piece of a text string, the view won't be changed, because setting item with an index like this.list[index] = newItem
couldn't be tracked. You should write Vue.$set(this.list, index, newItem)
instead. But in Vue 3.0, it works, too.
3. Setting the length property of an array
Also if we add another button to the example above to clean all items:
<template>
<ul>...</ul>
<!-- btw Vue 3.0 supports multi-root template like this -->
<button @click="clean">clean</button>
</template>
<script>
export default {
data: ...,
methods: {
...,
clean() { this.list.length = 0 }
}
}
</script>
it won't work in Vue 2.x and earlier, because setting the length of an array like this.list.length = 0
couldn't be tracked. So you have to use other methods like this.list = []
. But in Vue 3.0, all the ways above works.
4. Using ES Set/Map
Let's see a similar example with ES Set:
<template>
<div>
<ul>
<li v-for="item, index in list" :key="index">
{{ item }}
<button @click="remove(item)">remove</button>
</li>
</ul>
<button @click="add">add</button>
<button @click="clean">clean</button>
</div>
</template>
<script>
export default {
data: () => ({
list: new Set([
'Client meeting',
'Plan webinar',
'Email newsletter'
])
}),
created() {
console.log(this.list)
},
methods: {
remove(item) {
this.list.delete(item)
},
add() {
const newItem = prompt('Input a new item')
if (newItem) {
this.list.add(newItem)
}
},
clean() {
this.list.clear()
}
}
}
</script>
Now we use a Set
instead of an array. In Vue 2.x and earlier, fortunately it could be rendered properly for the first time. But when you remove, add, or clear, the view won't be updated, because they are not tracked. So usually we don't use Set
or Map
in Vue 2.x and earlier. In Vue 3.0, the same code would work as you like, because it totally supports them.
5. Using non-reactive properties
If we have some one-time consuming heavy data in a Vue component, probably it doesn't need to be reactive, because once initialized, it won't change. But in Vue 2.x and earlier, whatever you use them again, all the properties inside will be tracked. So sometimes it costs visible time. Practically, we have some other ways to walk-around but it's a little bit tricky.
In Vue 3.0, it provides a dedicated API to do this - markRaw
:
<template>
<div>
Hello {{ test.name }}
<button @click="update">should not update</button>
</div>
</template>
<script>
import { markRaw } from 'vue'
export default {
data: () => ({
test: markRaw({ name: 'Vue' })
}),
methods: {
update(){
this.test.name = 'Jinjiang'
console.log(this.test)
}
}
}
</script>
In this case, we use markRaw
to tell the reactivity system, the property test and its descendants properties don't need to be tracked. So the tracking process would be skipped. At the same time, any further update on them won't trigger a re-render.
Additionally, there is another "twin" API - readonly
. This API could prevent data to be mutated. For example:
import { readonly } from 'vue'
export default {
data: () => ({
test: readonly({ name: 'Vue' })
}),
methods: {
update(){
this.test.name = 'Jinjiang'
}
}
}
Then the mutation to this.test
would be failed.
So far we see the power and magic of the reactivity system in Vue 3.0. Actually there are more powerful ways to use it. But we won't move on immediately, because before mastering them, it's also great to know how it works behind Vue 3.0.
How it works
For short, the reactivity system in Vue 3.0 suits up with ES2015!
First part: simple data observer
Since ES2015, there are a pair of APIs - Proxy
and Reflect
. They are born to reactivity systems! Vue 3.0 reactivity system just be built based on that.
With Proxy
you could set a "trap" to observe any operation on a certain JavaScript object.
const data = { x: 1, y: 2 }
// all behaviors of a proxy by operation types
const handlers = {
get(data, propName, proxy) {
console.log(`Get ${propName}: ${data[propName]}!`)
return data[propName]
},
has(data, propName) { ... },
set(data, propName, value, proxy) { ... },
deleteProperty(data, propName) { ... },
// ...
}
// create a proxy object for the data
const proxy = new Proxy(data, handlers)
// print: 'Get x: 1' and return `1`
proxy.x
With Reflect
you could behave the same as the original object.
const data = { x: 1, y: 2 }
// all behaviors of a proxy by operation types
const handlers = {
get(data, propName, proxy) {
console.log(`Get ${propName}: ${data[propName]}!`)
// same behavior as before
return Reflect.get(data, propName, proxy)
},
has(...args) { return Reflect.set(...args) },
set(...args) { return Reflect.set(...args) },
deleteProperty(...args) { return Reflect.set(...args) },
// ...
}
// create a proxy object for the data
const proxy = new Proxy(data, handlers)
// print: 'Get x: 1' and return `1`
proxy.x
So with Proxy
+ Reflect
together, we could easily make a JavaScript object observable, and then, reactive.
const track = (...args) => console.log('track', ...args)
const trigger = (...args) => console.log('trigger', ...args)
// all behaviors of a proxy by operation types
const handlers = {
get(...args) { track('get', ...args); return Reflect.get(...args) },
has(...args) { track('has', ...args); return Reflect.set(...args) },
set(...args) { Reflect.set(...args); trigger('set', ...args) },
deleteProperty(...args) {
Reflect.set(...args);
trigger('delete', ...args)
},
// ...
}
// create a proxy object for the data
const data = { x: 1, y: 2 }
const proxy = new Proxy(data, handlers)
// will call `trigger()` in `set()`
proxy.z = 3
// create a proxy object for an array
const arr = [1,2,3]
const arrProxy = new Proxy(arr, handlers)
// will call `track()` & `trigger()` when get/set by index
arrProxy[0]
arrProxy[1] = 4
// will call `trigger()` when set `length`
arrProxy.length = 0
So this observer is better than Object.defineProperty because it could observe every former dead angle. Also the observer just needs to set up a "trap" to an object. So less cost during the initialization.
And it's not all the implementation, because in Proxy
it could handle ALL kinds of behaviors with different purposes. So the completed code of handlers in Vue 3.0 is more complex.
For example if we run arrProxy.push(10)
, the proxy would trigger a set
handler with 3
as its propName
and 10
as its value
. But we don't literally know whether or not it's a new index. So if we would like to track arrProxy.length
, we should do more precise determination about whether a set or a deleteProperty
operation would change the length.
Also this Proxy
+ Reflect
mechanism supports you to track and trigger mutations in a Set
or a Map
. That means operations like:
const map = new Map()
map.has('x')
map.get('x')
map.set('x', 1)
map.delete('x')
would also be observable.
Second: more reactivity APIs
In Vue 3.0, we also provide some other APIs like readonly
and markRaw
. For readonly
what you need is just change the handlers like set
and deleteProperty
to avoid mutations. Probably like:
const track = (...args) => console.log('track', ...args)
const trigger = (...args) => console.log('trigger', ...args)
// all behaviors of a proxy by operation types
const handlers = {
get(...args) { track('get', ...args); return Reflect.get(...args) },
has(...args) { track('has', ...args); return Reflect.set(...args) },
set(...args) {
console.warn('This is a readonly proxy, you couldn\'t modify it.')
},
deleteProperty(...args) {
console.warn('This is a readonly proxy, you couldn\'t modify it.')
},
// ...
}
// create a proxy object for the data
const data = { x: 1, y: 2 }
const readonly = new Proxy(data, handlers)
// will warn that you couldn't modify it
readonly.z = 3
// will warn that you couldn't modify it
delete readonly.x
For markRaw
, in Vue 3.0 it would set a unenumerable flag property named __v_skip
. So when we are creating a proxy for data, if there is a __v_skip
flag property, then it would be skipped. Probably like:
// track, trigger, reactive handlers
const track = (...args) => console.log('track', ...args)
const trigger = (...args) => console.log('trigger', ...args)
const reactiveHandlers = { ... }
// set an invisible skip flag to raw data
const markRaw = data => Object.defineProperty(
data,
'__v_skip',
{ value: true }
)
// create a proxy only when there is no skip flag on the data
const reactive = data => {
if (data.__v_skip) {
return data
}
return new Proxy(data, reactiveHandlers)
}
// create a proxy object for the data
const data = { x: 1, y: 2 }
const rawData = markRaw(data)
const reactiveData = readonly(data)
console.log(rawData === data) // true
console.log(reactiveData === data) // true
Additionally, a trial of using WeakMap to record deps and flags
Although it's not implemented in Vue 3.0 finally. But there was another try to record deps and flags using new data structures in ES2015.
With Set
and Map
, we could maintain the relationship out of the data itself. So we don't need flag properties like __v_skip
inside data any more - actually there are some other flag properties like __v_isReactive
and __v_isReadonly
in Vue 3.0. For example:
// a Map to record dependets
const dependentMap = new Map()
// track and trigger a property
const track = (type, data, propName) => {
if (isDryRun && currentFn) {
if (!dependentMap.has(data)) {
dependentMap.set(data, new Map())
}
if (!dependentMap.get(data).has(propName)) {
dependentMap.get(data).set(propName, new Set())
}
dependentMap.get(data).get(propName).add(currentFn)
}
}
const trigger = (type, data, propName) => {
dependentMap.get(data).get(propName).forEach(fn => fn())
}
// observe
let isDryRun = false
let currentFn = null
const observe = fn => {
isDryRun = true
currentFn = fn
fn()
currentFn = null
isDryRun = false
}
Then with Proxy
/Reflect
together, we could track data mutation and trigger dependent functions:
// … handlers
// … observe
// make data and arr reactive
const data = { x: 1, y: 2 }
const proxy = new Proxy(data, handlers)
const arr = [1, 2, 3]
const arrProxy = new Proxy(arr, handlers)
// observe functions
const depA = () => console.log(`x = ${proxy.x}`)
const depB = () => console.log(`y = ${proxy.y}`)
const depC = () => console.log(`x + y = ${proxy.x + proxy.y}`)
const depD = () => {
let sum = 0
for (let i = 0; i < arrProxy.length; i++) {
sum += arrProxy[i]
}
console.log(`sum = ${sum}`)
}
// dry-run all dependents
observe(depA)
observe(depB)
observe(depC)
observe(depD)
// output: x = 1, y = 2, x + y = 3, sum = 6
// mutate data
proxy.x = 3
// output: x = 3, x + y = 5
arrProxy[1] = 4
// output: sum = 8
Actually in early beta version of Vue 3.0, it uses WeakMap
instead of Map
so there won't be any memory leak to be worried about. But unfortunately, the performance is not good when data goes large. So later it changed back to flag properties.
Btw, there is also a trial of using Symbol
s as the flag property names. With Symbol
s the extreme cases could also be relieved a lot. But the same, the performance is still not good as normal string property names.
Although these experiments are not preserved finally, I think it's a good choice if you would like to make a pure (but maybe not quite performant) data observer on your own. So just mention this a little bit here.
Quick summary
Anyway we make data reactive first, and observe functions to track all the data they depend on. Then when we mutate the reactive data, relevant functions would be triggered to run again.
All the features and their further issues above have already been completed in Vue 3.0, with the power of ES2015 features.
If you would like to see all the live version of the code sample about explaining main mechanism of reactivity system in Vue from 0.x to 3.0. You could check out this CodePen and see its "Console" panel:
https://codepen.io/Jinjiang/pen/abvMyQa
Now we have already known the basic usage of it - that's passing something into the data
option into a Vue component, and then using it into other options like computed
, watch
, or the template
. But this time, in Vue 3.0, it provides more util APIs, like markRaw
we mentioned before. So let's take a look at these util APIs.
Encapsulation
1. Proxy for objects
1.1 Basic: reactive(data)
, readonly(data)
, markRaw(data)
First let me introduce reactive(data)
. Just as the name, this API would create a reactive proxy for the data. But here maybe you don't need to use this directly, because the data object you return from the data
option will be set up with this API automatically.
Then if you just would like:
- Some pieces of data immutable, then you could use
readonly(data)
. - Some pieces of data not reactive, then you could use
markRaw(data)
.
For example:
import { reactive, readonly, markRaw } from 'vue'
const ComponentFoo = {
data() {
return {
reactiveX: { x: 1 },
reactiveXInAnotherWay: reactive({ x: 1 }),
immutableY: readonly({ y: 2 }),
needntChangeReactivelyZ: markRaw({ z: 3 })
}
},
// ...
}
In this case:
- If the properties in
reactiveX
orreactiveXInAnotherWay
changed, the view using them in the template will be re-rendered automatically. - If you modify the properties in
immutableY
, there would be an error thrown. At the same time the view won't be re-rendered. - If you modify the properties in
needntChangeReactivelyZ
, the view won't be re-rendered.
Also for marking as raw data, you could mark the data, and then use it anywhere else:
const { markRaw } from 'vue'
const obj = { x: 1 }
const result = markRaw(obj)
console.log(obj === result) // true
const ComponentFoo = {
data() {
return {
obj,
result
}
},
// ...
}
Here the properties in this.obj
and this.result
are both non-reactive.
1.2 Utils: isReactive(data)
, isReadonly(data)
, isProxy(data)
, toRaw(data)
Then you may need some util APIs to help you do the job better.
- For the reactive data proxy, then both
isProxy(data)
andisReactive(data)
would betrue
. - For the readonly data proxy, then both
isProxy(data)
andisReadonly(data)
would betrue
. - For the original data, whether or not it is marked as raw, then all the
isProxy(data)
andisReactive(data)
andisReadonly(data)
would befalse
. - For the reactive or readonly data proxy, you could use
toRaw(data)
to get the raw data back.
1.3 Advanced: shallowReactive(data)
, shallowReadonly(data)
With these 2 APIs, you could create a "shallow" data proxy, which means they won't setting traps deeply. Only the first-layer properties in these data proxies would be reactive or readonly. For example:
import { shallowReactive, shallowReadonly } from 'vue'
const ComponentFoo = {
data() {
return {
x: shallowReactive({ a: { b: 1 } }),
y: shallowReadonly({ a: { b: 1 } })
}
}
}
In this case, this.x.a
is reactive, but this.x.a.b
is not; this.y.a
is readonly, but this.y.a.b
is not.
If you only consume reactive data inside its own component, I think these APIs above are totally enough. But when things come to the real world, sometimes we would like to share states between components, or just abstract state out of a component for better maintenance. So we need more APIs below.
2. Ref for primitive values
A ref could help you to hold a reference for a reactive value. Mostly it's used for a primitive value. For example, somehow we have a number variable named counter
in an ES module, but the code below doesn't work:
// store.js
// This won't work.
export const counter = 0;
// This won't works neither.
// import { reactive } from 'vue'
// export const counter = reactive(0)
<!-- foo.vue -->
<template>
<div>
{{ counter }}
</div>
</template>
<script>
import { counter } from './store.js'
export {
data() {
return { counter }
}
}
</script>
<!-- bar.vue -->
<template>
<button @click="counter++">increment</button>
</template>
<script>
import { counter } from './store.js'
export {
data() {
return { counter }
}
}
</script>
… because primitive values are immutable. When importing and exporting primitive values, we lose the track. To do this, we could use a ref instead.
2.1 Basic: ref(data)
To support the previous example, let's introduce ref(data)
:
// store.js
import { ref } from 'vue'
export const counter = ref(0)
Then it would work properly.
There is one thing to notice: if you would like to access the value of refs out of a template, you should access its value
property instead. For example, if we'd like to modify bar.vue
to avoid data
option, we could add an increment
method to do this, with counter.value
:
<!-- bar.vue -->
<template>
<button @click="increment">increment</button>
</template>
<script>
import { counter } from './store.js'
export {
methods: {
increment() { counter.value++ }
}
}
</script>
For more caveats, we could do some quick tests later.
2.2 Utils: isRef(data)
, unref(data)
I think these 2 util APIs are easy to understand:
-
isRef(data)
: check a value is a ref or not. -
unref(data)
: return the value of a ref.
2.3 Proxy to ref: toRef(data, key)
, toRefs(data)
These 2 util APIs are used for get refs from proxy data:
import { reactive, toRef, toRefs } from 'vue'
const proxy = reactive({ x: 1, y: 2 })
const refX = toRef(proxy, 'x')
proxy.x = 3
console.log(refX.value) // 3
const refs = toRefs(proxy)
proxy.y = 4
console.log(refs.x.value) // 3
console.log(refs.y.value) // 4
As the example above, the typical usage of these APIs is spreading a reactive object into several sub variables and keep the reactivity at the same time.
2.4 Advanced: shallowRef(data)
Only trigger update when the ref.value
is assigned by another value. For example:
import { shallowRef } from 'vue'
const data = { x: 1, y: 2 }
const ref = shallowRef(data)
// won't trigger update
ref.value.x = 3
// will trigger update
ref.value = { x: 3, y: 2 }
Case: computed(…)
Similar idea to computed
option inside a Vue component. But if you would like to share a computed state out of a component, I suggest you try this API:
// store.js
import { ref, computed } from 'vue'
export const firstName = ref('Jinjiang')
export const lastName = ref('Zhao')
// getter only version
export const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// getter + setter version
export const fullName2 = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (v) => {
const names = v.split(' ')
if (names.length > 0) {
firstName.value = names[0]
}
if (names.length > 1) {
lastName.value = names[names.length - 1]
}
}
})
// another-file.js
import { firstName, lastName, fullName, fullName2 } from './store.js'
console.log(fullName.value) // Jinjiang Zhao
firstName.value = 'Evan'
lastName.value = 'You'
console.log(fullName.value) // Evan You
fullName2.value = 'Jinjiang Zhao'
console.log(firstName.value) // Jinjiang
console.log(lastName.value) // Zhao
Case: customRef(…)
This API is my best favorite API in Vue 3.0. Because with this API, you could define how and when to track/trigger your data, during getting or setting the value, that's totally mind-blowing!
For example:
<template>
<input v-model="email" />
</template>
<script>
import { customRef } from 'vue'
import { validate } from 'isemail'
export default {
data() {
return {
email: customRef((track, trigger) => {
const value = ''
return {
get() {
track()
return value
},
set(v) {
if (validate(v)) {
value = v
trigger()
}
}
}
})
}
}
}
</script>
That makes real-world user input much easier to handle.
3. Watch for effects
watchEffect(function)
, watch(deps, callback)
In a Vue component, we could watch data mutations by watch
option or vm.$watch()
instance API. But the same question: what about watching data mutations out of a Vue component?
Similar to computed
reactivity API vs. computed
option, we have 2 reactivity APIs: watchEffect
and watch
.
// store.js
import { ref, watch, watchEffect } from 'vue'
export const counter = ref(0)
// Will print the counter every time it's mutated.
watchEffect(() => console.log(`The counter is ${counter.value}`))
// Do the similar thing with more options
watch(counter, (newValue, oldValue) =>
console.log(`The counter: from ${oldValue} to ${newValue}`)
)
4. Standalone package & usage
Also in Vue 3.0, we have a standalone package for these. That is @vue/reactivity
. You could also import most of the APIs we mentioned above, from this package. So the code is almost the same to above:
import { reactive, computed, effect } from '@vue/reactivity'
const data = { x: 1, y: 2 }
const proxy = reactive(data)
const z = computed(() => proxy.x + proxy.y)
// print 'sum: 3'
effect(() => console.log(`sum: ${z.value}`))
console.log(proxy.x, proxy.y, z.value) // 1, 2, 3
proxy.x = 11 // print 'sum: 13'
console.log(proxy.x, proxy.y, z.value) // 11, 2, 13
The only difference is there is no watch
and watchEffect
. Instead there is another low-level API named effect
. Its basic usage is just similar to watchEffect
but more flexible and powerful.
For more details, I suggest you to read the source code directly:
https://github.com/vuejs/vue-next/tree/master/packages/reactivity
So you could even use these APIs in non-Vue related projects as you like.
From now on, you could think about it: with reactivity APIs, what else amazing stuff could you make? 😉
Benefit & caveats
So far we know how reactivity APIs work in Vue 3.0. Comparing to 2.x and earlier version, it:
- Fully covers all kinds of mutations of data, like adding a new property to an object, setting a value to an
index
of an array, etc. - Fully support all new data structures, like
Map
andSet
. - Has better performance.
- It could be used as a standalone package.
So if you really need or love any of the above, maybe it's time to try.
At the same time, there are some caveats for you:
- It only works on ES2015+
- DO use refs for primitive values for keeping the reactivity.
- The reactive proxy doesn't equal to the original data in JavaScript.
For more details, I prepared a cheat sheet on Gist below:
https://gist.github.com/Jinjiang/f795b943d4315a42077b7261caf25187
Also there are 2 more casual Codesandbox projects I test for myself previously. Maybe it's somehow a little bit useful:
- for
reactive
,readonly
, andmarkRaw
: https://codesandbox.io/s/vue-reactivity-tests-1-jm3d4 - for
ref
andcomputed
: https://codesandbox.io/s/vue-reactivity-tests-2-vyykh
Further use cases
So far we know a lot of things about the reactivity system in Vue, from the early version to 3.0. Now it's time to show some use cases based on that.
Composition API
The first thing is definitely the Vue Composition API, which is new in 3.0. With reactivity APIs, we could organize our code logic more flexibly.
import { ref, reactive, readonly, markRaw, computed, toRefs } from 'vue'
export default {
setup(props) {
const counter = ref(0)
const increment = () => counter.value++
const proxy = reactive({ x: 1, y: 2 })
const frozen = readonly({ x: 1, y: 2 })
const oneTimeLargeData = markRaw({ ... })
const isZero = computed(() => counter.value === 0)
const propRefs = toRefs(props)
// could use a,b,c,d,e,f in template and `this`
return {
a: counter,
b: increment,
c: proxy,
d: frozen,
e: oneTimeLargeData,
f: isZero,
...propRefs
}
}
}
I don't wanna show more demos about that because they are already everywhere. But IMO, for a further benefit few people talking about is, previously in Vue 2.x and earlier, we are used to putting everything on this
, when we:
- Create reactive data for a component instance.
- Access data/functions in the template.
- Access data/functions outside the component instance, mostly it happens when we set a template ref on a sub Vue component.
All 3 things always happen together. That means maybe we just:
- Would like to access something in the template, but don't need reactivity.
- Would like to create reactive data, but don't use that in the template.
Vue Composition API elegantly decouples them out by 2 steps:
- create reactive data;
- decide what the template needs.
Btw, for public instance members, I think the potential problem is still there. However, it's not a big matter so far.
Also, there are some other benefits, including but not limited to:
- Maintain reusable code without worrying about the naming conflict.
- Gathering logically related code together, rather than gathering instance members together with the same option type.
- Better and easier TypeScript support.
Also in Composition API, there are more APIs like provide()
/inject()
, lifecycle hooks, template refs, etc. For more about Composition API, please check out this URL: https://composition-api.vuejs.org/.
Cross-component state sharing
When sharing data between components. Reactivity APIs is also a good choice. We could even use them out of any Vue component, and finally use them into a Vue app, for example, with the composition APIs provide
and inject
:
// store.js
import { ref } from 'vue'
// use Symbol to avoid naming conflict
export const key = Symbol()
// create the store
export const createStore = () => {
const counter = ref(0)
const increment = () => counter.value++
return { counter, increment }
}
// App.vue
import { provide } from 'vue'
import { key, createStore } from './store'
export default {
setup() {
// provide data first
provide(key, createStore())
}
}
// Foo.vue
import { inject } from 'vue'
import { key } from './store'
export default {
setup() {
// you could inject state with the key
// and rename it before you pass it into the template
const { counter } = inject(key)
return { x: counter }
}
}
// Bar.vue
import { inject } from 'vue'
import { key } from './store'
export default {
setup() {
// you could inject state with the key
// and rename it before you pass it into the template
const { increment } = inject(key)
return { y: increment }
}
}
https://codesandbox.io/s/vue-reactivity-shared-state-nkfc0
So once user call y() in Bar.vue, the x in Foo.vue would be updated as well. You don't even need any more state management library to do that. That's quite easy to use.
Remember vue-hooks?
It's not an active project anymore. But I remember after React Hooks first time announced, Evan, the creator of Vue, just gave a POC under Vue in 1 day with less than 100 lines of code.
Here is the live demo in Codesandbox:
https://codesandbox.io/s/jpqo566289
Why it could be done so easily with Vue. I think mostly because of the reactivity system in Vue. It already helps you done most of the job. What we need to do is just encapsulate them into a new pattern or more friendly APIs.
Writing React with Vue reactivity system
So let's try one more step POC. How about using Reactivity APIs in React to create React components?
import * as React from "react";
import { effect, reactive } from "@vue/reactivity";
const Vue = ({ setup, render }) => {
const Comp = props => {
const [renderResult, setRenderResult] = React.useState(null);
const [reactiveProps] = React.useState(reactive({}));
Object.assign(reactiveProps, props);
React.useEffect(() => {
const data = { ...setup(reactiveProps) };
effect(() => setRenderResult(render(data)));
}, []);
return renderResult;
};
return Comp;
};
const Foo = Vue({
setup: () => {
const counter = ref(0);
const increment = () => {
counter.value++;
};
return { x: counter, y: increment };
},
render: ({ x, y }) => <h1 onClick={y}>Hello World {x.value}</h1>
});
https://codesandbox.io/s/react-vue-reactivity-evdll
I did a little test like above, it's not a full implementation. But somehow we could maintain a basic React component with 2 parts:
- Pure data logic with reactivity.
- Any data update would be observed and trigger component re-render.
Those correspond to setup
and render
functions as a Vue component does.
And there is no way to worry about whether or not I write a React hook outside a React component or inside a conditional block. Just code it as you like and make it happen as you imagine.
Final final conclusions
So that's all about the reactivity system in Vue, from early version to the latest 3.0 Beta. I'm still learning a lot of new stuff like programming languages, paradigms, frameworks, and ideas. They are all great and shining. But the reactivity system is always a powerful and elegant tool to help me solve all kinds of problems. And it's still keeping evolved.
With ES2015+, the new Reactivity APIs and its independent package, Composition APIs, Vue 3.0, and more amazing stuff in the ecosystem and community. Hope you could use them or get inspired from them, to build more great things much easier.
Hope you could know Vue and its reactivity system better through this article.
All the code samples in this article: https://gist.github.com/Jinjiang/f9b6f968af980cfd21cfc713e59db91b
Top comments (10)
I must login in Devto to like and comment this post! It's .... I don't know :v I will share with my colleagues about this post. You deserves more than this, bro.
Really impressive post! Thanks!
I am totally confused by this metaphor. I have an ajax function, it alters the data, and nothing I try updates the page.
In the axios call above, menu variable is really getting changed to the new item. But the rendering stays stuck on the default menu that was displayed until the query confirmed I could show other things.
The menu of course is a nested object. The other simpler properties do what is expected when they change, but this doesn't.
I guess what you want to do in line 11 should be something like
menu.value = filterMenu()
is 'Dep' short for 'dependence'? why call it 'Dep'? it just looks like a subscribers collection.
Yes short for dependence. And it just comes from the source code. github.com/vuejs/vue/blob/dev/src/...
Thanks so much bz this article!
This is a MASSIVE post! Wow! Still haven't read all of it, but will definitely use it as a first step when learning Vue 3. Thanks!
Freaking aws, thank you
Wow! it's better than the documentation.