Introduction
What are the basic principles of reactive view updates? Let's explore in this article.
1. Origin
We want the value of b to change with a.
let a = 10
let b = a + 10
console.log(b)
//
a = 20 // b should also change accordingly
b = a + 10
console.log(b)
Let's wrap the part where b changes relative to a in an update function.
let a = 10
let b
function update() {
b = a + 10
console.log(b)
}
update()
a = 20
update()
2. Manual Dependency Collection and Update
Is there a way to "smarten up" so that the update method is automatically executed when a's value changes? We thought of the publish-subscribe pattern.
class Dep {
constructor(value) {
this._value = value
this.effects = new Set()
}
depend(effect) {
this.effects.add(effect)
}
notice() {
this.effects.forEach((effect) => {
effect()
})
}
get value() {
return this._value
}
set value(val) {
this._value = val
this.notice()
}
}
let a = new Dep(10)
let b
function update() {
b = a.value + 15
console.log('b', b)
}
a.depend(update)
a.value = 20 // Console output: b 35
The implementation above has two advantages:
- Dependencies are automatically updated when a is updated.
- It can handle multiple dependencies like b, c on a efficiently.
3. Introducing Proxy for Automatic Dependency Collection and Update
The implementation above has some flaws:
Dependency collection needs to be manually called with depend, which isn't smart enough.
If a is an object and b depends on a property of a, it won't change.
let a = new Dep({ age: 10, }) let b function update() { b = a.value.age + 15 console.log('b', b) } a.depend(update) a.value.age = 20 // b does not change
Is there a way to automatically create dependencies and collect them? We can borrow from Vue 3's implementation and use Proxy.
Let's look at the API.
let a = reactive({
age: 10,
})
let b
effect(() => {
b = a.age + 10
console.log(b)
})
a.age = 20 // Console output: 30
By referencing Vue's implementation, we can answer:
- When to collect dependencies and when to trigger them? => Proxy intercepts getter and setter, collecting dependencies on get and updating on set.
- Where to collect dependencies? => depsMap, which stores the dependencies for each target.
- How to avoid duplicate dependency collection? => Use a currentEffect flag.
- The relationship between reactive, Dep, and effect functions?
Implementation:
class Dep {
constructor() {
this.effects = new Set()
}
depend() {
if (currentEffect) {
this.effects.add(currentEffect)
}
}
notice() {
this.effects.forEach((effect) => {
effect() // No need to pass dep.value when calling effect
})
}
}
let targetMap = new Map()
function getDeps(target, key) {
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Dep()
depsMap.set(key, dep)
}
return dep
}
export function reactive(target) {
return new Proxy(target, {
get(target, key) {
// Collect dependencies for each key
let dep = getDeps(target, key)
// Collect dependency
dep.depend()
// Return value
return Reflect.get(target, key)
},
set(target, key, value) {
let dep = getDeps(target, key)
let result = Reflect.set(target, key, value)
// Notify
dep.notice()
return result
},
})
}
let currentEffect
export function effectWatch(effect) {
currentEffect = effect
effect()
currentEffect = null
}
4. update and render
If we understand the change of b corresponding to a as the change of the view corresponding to the data, the prototype of MVVM is coming out.
The relationship between the view and the data can be represented by a simple function.
View = render(state)
Using the reactive and effectWatch from above, let's assume we have an App object.
import { effectWatch, reactive } from './core/reactivity/index.js'
const App = {
render(context) {
// state => view
effectWatch(() => {
document.body.innerHTML = ``
const node = document.createTextNode(context.value)
document.body.append(node)
})
},
setup() {
// Initialize state
const state = reactive({
value: 0,
})
window.state = state // Convenient for updating data in the console
return state
},
}
App.render(App.setup()) // View = render(state)
export default App
Create an HTML file in the project folder for testing.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue-tiny</title>
</head>
<body>
<script type="module" src="./App.js"></script>
</body>
</html>
The ES6 module used in the browser will throw a cross-origin error when accessed directly with the file protocol. Therefore, install an http-server
package to start a local server.
yarn global add http-server
http-server -p=1234
When accessing, make sure all imports have the js suffix, otherwise it will throw an error.
Failed to load module script: The server responded with a non-JavaScript MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
Enter the following in the console to see the view change.
state.value++
Organize the code and change it to the same format as Vue 3:
import { effectWatch } from './reactivity/index.js'
export default function createApp(rootComponent) {
return {
mount(rootContainer) {
let context = rootComponent.setup()
effectWatch(() => {
let ele = rootComponent.render(context)
rootContainer.appendChild(ele)
})
},
}
}
App function (component)
import { reactive } from './core/reactivity/index.js'
const App = {
render(context) {
return document.createTextNode(context.value)
},
setup() {
const state = reactive({
value: 0,
})
return state
},
}
export default App
Usage:
import createApp from './core/index.js'
import App from './App.js'
createApp(App).mount(document.querySelector('#root'))
At this point, our data update to template update is basically complete.
This article was first published on my personal blog Frontend Development Notes. Due to my limited ability, there may be omissions in the article. Corrections are welcome.