声明
本文是对于Build a Reactivity System的翻译
目标读者
使用过vue,并且对于vue实现响应式的原理感兴趣的前端童鞋。
正文
本教程我们将使用一些简单的技术(这些技术你在vue源码中也能看到)来创建一个简单的响应式系统。这可以帮助你更好地理解Vue以及Vue的设计模式,同时可以让你更加熟悉watchers和Dep class.
响应式系统
当你第一次看到vue的响应式系统工作起来的时候可能会觉得不可思议。
举个例子:
<div id="app">
<div>Price: ${{ price }}</div>
<div>Total: ${{ price * quantity }}</div>
<div>Taxes: ${{ totalPriceWithTax }}</div>
</div>
复制代码
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
var vm = new Vue({
el: '#app',
data: {
price: 5.00,
quantity: 2
},
computed: {
totalPriceWithTax() {
return this.price * this.quantity * 1.03
}
}
})
</script>
复制代码
上述例子中,当price改变的时候,vue会做下面三件事情:
- 更新页面上price的值。
- 重新计算表达式price * quatity,并且将计算后的值更新到页面上。
- 调用totalPriceWithTax函数并更新页面。
但是等一下,当price改变的时候vue是怎么知道要更新哪些东西的?vue是怎么跟踪所有东西的?
这不是JavaScript编程通常的工作方式
如果这对于你来说不是很明显的话,我们必须解决的一个大问题是编程通常不会以这种方式工作。举个例子,如果运行下面的代码:
let price = 5
let quantity = 2
let total = price * quantity // 10 right?
price = 20
console.log(`total is ${total}`)
复制代码
你觉得这段代码最终打印的结果是多少?因为我们没有使用Vue,所以最终打印的值是10
>> total is 10
复制代码
在Vue中我们想要total的值可以随着price或者quantity值的改变而改变,我们想要:
>> total is 40
复制代码
不幸的是,JavaScript本身是非响应式的。为了让total具备响应式,我们需要使用JavaScript来让事情表现的有所不同。
问题
我们需要先保存total的计算过程,这样我们才能在price或者quantity改变的时候重新执行total的计算过程。
解决方案
首先,我们需要告诉我们的应用,“这段我将要执行的代码,保存起来,后面我可能需要你再次运行这段代码。”这样当price或者quantity改变的时候,我们可以再次运行之前保存起来的代码(来更新total)。
我们可以通过将total的计算过程保存成函数的形式来做,这样后面我们能够再次执行它。
let price = 5
let quantity = 2
let total = 0
let target = null
target = function () {
total = price * quantity
})
record() // Remember this in case we want to run it later
target() // Also go ahead and run it
复制代码
请注意我们需要将匿名函数赋值给target变量,然后调用record函数。使用es6的箭头函数也可以写成下面这样子:
target = () => { total = price * quantity }
复制代码
record函数的定义挺简单的:
let storage = [] // We'll store our target functions in here
function record () { // target = () => { total = price * quantity }
storage.push(target)
}
复制代码
这里我们将target(这里指的就是:{ total = price * quantity })存起来以便后面可以运行它,或许我们可以弄个replay函数来执行所有存起来的计算过程。
function replay (){
storage.forEach(run => run())
}
复制代码
replay函数会遍历所有我们存储在storage中的匿名函数然后挨个执行这些匿名函数。 紧接着,我们可以这样用replay函数:
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
复制代码
很简单吧?以下是完整的代码。
let price = 5
let quantity = 2
let total = 0
let target = null
let storage = []
function record () {
storage.push(target)
}
function replay () {
storage.forEach(run => run())
}
target = () => { total = price * quantity }
record()
target()
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
复制代码
问题
上面的代码虽然也能工作,但是可能并不好。或许可以抽取个class,这个class负责维护targets列表,然后在我们需要重新运行targets列表的时候接收通知(并执行targets列表中的所有匿名函数)。
解决方案:A Dependency Class
一种解决方案是我们可以把这种行为封装进一个类里面,一个实现了普通观察者模式的Dependency Class
所以,如果我们创建个JavaScript类来管理我们的依赖的话,代码可能长成下面这样:
class Dep { // Stands for dependency
constructor () {
this.subscribers = [] // The targets that are dependent, and should be
// run when notify() is called.
}
depend() { // This replaces our record function
if (target && !this.subscribers.includes(target)) {
// Only if there is a target & it's not already subscribed
this.subscribers.push(target)
}
}
notify() { // Replaces our replay function
this.subscribers.forEach(sub => sub()) // Run our targets, or observers.
}
}
复制代码
请注意,我们这里不用storage,而是用subscribers来存储匿名函数,同时,我们不用record而是通过调用depend来收集依赖,并且我们使用notify替代了原来的replay。以下是Dep类的用法:
const dep = new Dep()
let price = 5
let quantity = 2
let total = 0
let target = () => { total = price * quantity }
dep.depend() // Add this target to our subscribers
target() // Run it to get the total
console.log(total) // => 10 .. The right number
price = 20
console.log(total) // => 10 .. No longer the right number
dep.notify() // Run the subscribers
console.log(total) // => 40 .. Now the right number
复制代码
上面的代码和之前的代码功能上是一致的,但是代码看起来更具有复用性(Dep类可以复用)。唯一看起来有点奇怪的地方就是设置和执行target的地方。
问题
后面我们会为每个变量创建个Dep实例,同时如果可以将创建匿名函数的逻辑封装起来的话就更好了,或许我们可以用个watcher函数来做这件事情。
所以我们不用通过调用以下代码来收集依赖
target = () => { total = price * quantity }
dep.depend()
target()
复制代码
而是通过调用watcher函数来收集依赖(是不是赶脚代码清晰很多?):
watcher(() => {
total = price * quantity
})
复制代码
解决方案:A Watcher Function
Watcher fucntion的定义如下:
function watcher(myFunc) {
target = myFunc // Set as the active target
dep.depend() // Add the active target as a dependency
target() // Call the target
target = null // Reset the target
}
复制代码
watcher函数接收一个myFunc,把它赋值给全局的target变量,然后通过调用dep.depend()将target加到subscribers列表中,紧接着调用target函数,然后重置target变量。
现在如果我们运行以下代码:
price = 20
console.log(total)
dep.notify()
console.log(total)
复制代码
>> 10
>> 40
复制代码
你可能会想为什么要把target作为一个全局变量,而不是在需要的时候传入函数。别捉急,这么做自然有这么做的道理,看到本教程结尾就阔以一目了然啦。
问题
现在我们有了个简单的Dep class,但是我们真正想要的是每个变量都拥有自己的dep实例,在继续后面的教程之前让我们先把变量变成某个对象的属性:
let data = { price: 5, quantity: 2 }
复制代码
让我们先假设下data上的每个属性(price和quantity)都拥有自己的dep实例。
这样当我们运行:
watcher(() => {
total = data.price * data.quantity
})
复制代码
因为data.price的值被访问了,我想要price的dep实例可以将上面的匿名函数收集到自己的subscribers列表里面。data.quantity也是如此。
如果这时候有个另外的匿名函数里面用到了data.price,我也想这个匿名函数被加到price自带的dep类里面。
问题来了,我们什么时候调用price的dep.notify()呢?当price被赋值的时候。在这篇文章的结尾我希望能够直接进入console做以下的事情:
>> total
10
>> price = 20 // When this gets run it will need to call notify() on the price
>> total
40
复制代码
要实现以上意图,我们需要能够在data的所有属性被访问或者被赋值的时候执行某些操作。当data下的属性被访问的时候我们就把target加入到subscribers列表里面,当data下的属性被重新赋值的时候我们就触发notify()执行所有存储在subscribes列表里面的匿名函数。
解决方案:Object.defineProperty()
我们需要学习下Object.defineProperty()函数是怎么用的。defineProperty函数允许我们为属性定义getter和setter函数,在我使用defineProperty函数之前先举个非常简单的例子:
let data = { price: 5, quantity: 2 }
Object.defineProperty(data, 'price', { // For just the price property
get() { // Create a get method
console.log(`I was accessed`)
},
set(newVal) { // Create a set method
console.log(`I was changed`)
}
})
data.price // This calls get()
data.price = 20 // This calls set()
复制代码
>> I was accessed
>> I was changed
复制代码
正如你所看到的,上面的代码仅仅打印两个log。然而,上面的代码并不真的get或者set任何值,因为我们并没有实现,下面我们加上。
let data = { price: 5, quantity: 2 }
let internalValue = data.price // Our initial value.
Object.defineProperty(data, 'price', { // For just the price property
get() { // Create a get method
console.log(`Getting price: ${internalValue}`)
return internalValue
},
set(newVal) { // Create a set method
console.log(`Setting price to: ${newVal}` )
internalValue = newVal
}
})
total = data.price * data.quantity // This calls get()
data.price = 20 // This calls set()
复制代码
Getting price: 5
Setting price to: 20
复制代码
所以通过defineProperty函数我们可以在get和set值的时候收到通知(就是我们可以知道什么时候属性被访问了,什么时候属性被赋值了),我们可以用Object.keys来遍历data上所有的属性然后为它们添加getter和setter属性。
let data = { price: 5, quantity: 2 }
Object.keys(data).forEach(key => { // We're running this for each item in data now
let internalValue = data[key]
Object.defineProperty(data, key, {
get() {
console.log(`Getting ${key}: ${internalValue}`)
return internalValue
},
set(newVal) {
console.log(`Setting ${key} to: ${newVal}` )
internalValue = newVal
}
})
})
total = data.price * data.quantity
data.price = 20
复制代码
现在data上的每个属性都有getter和setter了。
把这两种想法放在一起
total = data.price * data.quantity
复制代码
当上面的代码运行并且getprice的值的时候,我们想要price记住这个匿名函数(target)。这样当price变动的时候,可以触发执行这个匿名函数。
- Get => Remember this anonymous function, we’ll run it again when our value changes.
- Set => Run the saved anonymous function, our value just changed.
或者:
- Price accessed (get) => call dep.depend() to save the current target
- Price set => call dep.notify() on price, re-running all the targets
下面让我们把这两种想法组合起来:
let data = { price: 5, quantity: 2 }
let target = null
// This is exactly the same Dep class
class Dep {
constructor () {
this.subscribers = []
}
depend() {
if (target && !this.subscribers.includes(target)) {
// Only if there is a target & it's not already subscribed
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
// Go through each of our data properties
Object.keys(data).forEach(key => {
let internalValue = data[key]
// Each property gets a dependency instance
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
dep.depend() // <-- Remember the target we're running
return internalValue
},
set(newVal) {
internalValue = newVal
dep.notify() // <-- Re-run stored functions
}
})
})
// My watcher no longer calls dep.depend,
// since that gets called from inside our get method.
function watcher(myFunc) {
target = myFunc
target()
target = null
}
watcher(() => {
data.total = data.price * data.quantity
})
复制代码
在控制台看下:
就像我们想的一样!这时候 price和 quantity都是响应式的!当 price和 quantity更新的时候我们的total都会及时地更新。看下Vue
下面的图现在应该看起来有点感觉了:
你看见紫色的带有getter和setter的Data圆圈没?是不是看起来很熟悉!每个component实例都拥有一个 watcher实例用于通过getters和setters来收集依赖。当某个setter后面被调用的时候,它会通知相应地watcher从而导致组件重新渲染。下面是我添加了一些标注的图: Yeah!现在你对Vue的响应式有所了解了没? 很显然,Vue内部实现会比这个更加复杂,但是通过这篇文章你知道了一些基础知识。在下个教程里面我们会深入Vue内部,看看源码里面的实现是否和我们的类似。我们学到了什么?
- 如何创建一个可以同来收集依赖(depend)并执行所有依赖(notify)的Dep class
- 如何创建watcher来管理我们当前正在执行的代码(target)
- 如何使用Object.defineProperty()来创建getters和setters。