highlight: vs2015
译自这篇文章:Build a Reactivity System,在我的理解上加工了那么一丢丢
建立响应系统
在本课中,我们将使用与 Vue 源代码中相同的技术构建一个简单的响应系统。 这将使您更好地理解 Vue.js 及其设计模式,并让您熟悉 观察者watcher 和 Dep 类。
响应系统
当您第一次看到 Vue 的反应系统工作时,它看起来就像是魔法一样。
拿这个简单的应用程序为例:
Price: ${{ price }}
Total: ${{ price * quantity }}
Taxes: ${{ totalPriceWithTax }}
不知道为什么,Vue 只知道如果价格发生变化,它应该做三件事:
- 更新网页上的价格
price
。 - 重新计算
price * quantity
(价格 * 数量),并更新页面。 - 再次调用
totalPriceWithTax
(含税总价格)函数并更新页面。
但是,等等!我听到你想知道,当价格变化时,Vue 怎么知道要更新什么,它又是怎么跟踪所有内容的?
JavaScript编程通常不是这样工作的
如果您不清楚,那么我们必须解决的一个大问题是,编程(programming)
通常不会以这种响应式的方式工作:\
例如,如果我运行以下代码:
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中,我们希望在价格 price
或数量 quantity
更新时更新总计 total
。我们希望:
>> total is 40
不幸的是,JavaScript是过程性(谷歌:程序性的,原词:procedural
)的,不是响应性的(原词:reactive
),所以在现实生活中这不起作用。为了使total
变得更具响应性,我们必须用 JavaScript 做些操作,使事情表现得不同。
❗️问题
首先我们需要 保存下来 计算 总数 total
的 方法(函数),以便在 价格 price
或 数量 quantity
发生变化时重新运行。
✅ 解决方案
首先,我们需要一些方法来告诉我们的应用程序,“我即将运行的代码,存储一下这个代码,我可能需要你在另一个时间运行它。”\
然后我们要运行代码,如果价格 price
或数量 quantity
变量得到更新,请再次运行存储的代码。
译者:我们可能遇到两种场景,一种是创建函数之后立即运行,还有一种就是定义之后过一阵子再运行这个函数。下图就是这两种场景的演示
不管是立即运行还是要一会儿运行,代码要保存在一个容器里面,然后从容器里面拿出函数运行。
storage中译是“存储”
我们可以通过记录这个函数( 原词: by recording the function )
来做到这一点,这样我们就可以再次运行它。
译者:他说的记录这个函数,就是我说的,要先存储代码,然后拿出来运行,存储这个动作就是记录
按照我们上面总结的逻辑就是:
- 先定义变量、初始化函数(定义函数)
- 然后是要记录函数,
- 运行这个函数(肯定是记录函数的同时执行函数)
补充一点
:函数定义的时候,自然会被保存在内存,但是我们所说的【容器】并不是内存,\
因为三四个函数(可以是更多)保存在内存,我们会一个一个调用,\
我们所想的【容器】是,把这个三四个函数保存在【数组】里,可以通过循环数组完成数组里所有函数的运行,而不用一个一个调用
/* 1. 定义变量、函数 */
let price = 5 // 价格
let quantity = 2 // 数量
let total = 0 // 总价:价格*数量
let target = null // 存储了 【总价 = 价格*数量】计算式 的函数
target = function () {
total = price * quantity
}
/* 2. 记录函数 */
record() // 记录这个函数,如果我们想稍后运行它
// 译者:record函数 的内部逻辑就是 将target函数 保存到【容器】里
/* 3. 运行函数 */
target() // 同时也运行它
请注意,我们在target
变量中存储匿名函数,然后调用record
函数。使用ES6箭头语法,我也可以写成:
译者:上面这句话意思:就是使用函数表达式的方式声明一个 target 函数
target = () => { total = price * quantity }
record
函数的定义也很简单:
let storage = [] // 我们将把 target 函数保存在这个数组里
function record () { // target = () => { total = price * quantity }
storage.push(target)
}
译者:
record()
里面的storage
数组就是我上面说的【容器】,这个里面将存放很多函数,大概类似这个形式:storage = [fun1(),fun2(),fun3()...] storage[0] === fun1() // true
我们正在存储target
函数,(target = ()=>{ total = price * quantity }
),以便于我们可以稍后运行它。
我们也可以使用一个 replay
函数来运行我们记录的所有内容。
译者:这就是用replay()
函数遍历数组,运行保存在容器里面的函数们
function replay (){
storage.forEach( run => run() )
}
这将遍历我们存储在storage
数组中的所有匿名函数并执行每个函数。
然后在我们的代码中,我们可以:
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 = [] // 存储 target 的数组
// 定义record函数:将 target 存储在 storage 数组中,以便在合适的时机拿出来运行
function record () {
storage.push(target)
}
// 定义replay函数:运行存储在storage中的每一个函数
function replay () {
storage.forEach(run => run())
}
// 定义target函数:存放【总价格 = 价格*数量】的函数
target = () => { total = price * quantity }
record() // 向 storage 数组中存放一个 target 函数
target() // 运行一次 target 函数--计算 总价格total
price = 20 // 修改一次 价格price 的值
console.log(total) // => 10
// 输出一下总价格total,因为价格虽然修改了,
// 但是在上面运行target函数的时候,total已经被赋值,
// 上面那一行仅仅修改了价格price,但是没有重新运行target函数,所以total的值没有改变。
replay()
// 运行存储在 storage 数组中的所有函数,
// 此时数组中仅有一个元素,元素的值是target函数,
// 换句话说,storage中仅仅保存着一个target函数,
// 所以这里就相当于运行一次 target()
console.log(total) // => 40
译者:如果上面看不太懂,我可以简化一下:\
因为数组中仅仅存储了一个target(仅仅需要记录一个函数一次),所以上面那个情景,可以等价替换为:
let price = 5
let quantity = 2
let total = 0
let target = null
// 去掉了 storage 数组
// 去掉了 record 和 replay 函数
target = () => { total = price * quantity }
target()
price = 20
console.log(total) // => 10
target() // 将 replay() 替换为 target
console.log(total) // => 40
译者:到这里理解record
和replay
函数的作用了吗?\record
:将想要一会儿调用的函数存储在storage
数组中\replay
:遍历storage,将 存储在storage
数组中的函数,挨个运行
❗️问题
我们可以根据需要,继续记录(recording)
目标函数们,但最好有一个更强大的解决方案来扩展我们的应用程序。也许是一个类,负责维护目标列表(译者:这里说的应该就是那个存放函数的【容器】)
,当我们需要它们重新运行时会收到通知。
✅ 解决方案:依赖类(A Dependency Class)
我们可以开始解决此问题的一种方法是 把这种行为封装到它自己的类中 ,这是一个实现标准编程观察者模式的依赖类。\
因此,如果我们创建一个JavaScript类来管理我们的依赖项(这更接近Vue处理事物的方式),那么它可能看起来像这样:
译者:上面的意思就是,我们可以把
把函数记录到数组
和运行数组里的函数
这种行为封装到一个类里面,这个类就是观察者模式里面的依赖类(Dep)
另外我也不知道Vue处理事务的方式是什么,可能是指观察者模式?
补充说一些东西:
订阅器
:在这篇文章中有一张图,监听器Observer
和订阅者Watcher
中间还有一个订阅器Dep
,这个订阅器Dep
就是上面实现的这个类。
依赖
: 指保存在 subscribers 数组里面的函数们。
- Vue文档里写到:组件渲染的时候,会将“接触”过的数据 property 记录为依赖。
- 一个属性,以
price
为例,所有用到price
的地方都被记录为依赖,可以是函数target
,也可以是组件中的
{{price}}- 一组依赖,是一组所有用到
price
属性的地方。所以说,一个属性会new
一个订阅器Dep
,订阅器里面保存有price
属性的一组依赖订阅
:依赖如果在 subscribers 数组里面,就是被订阅了,如果没在,就是没有被订阅。这篇文章写到:\
“发布订阅者”模式,数据变化为“发布者”,依赖对象为“订阅者”
。\
消息订阅器Dep
,用来容纳所有的“订阅者”。订阅器Dep
主要负责收集订阅者,然后当数据变化的时候后执行对应订阅者的更新函数。OK,我们来看,这是target函数
target = () => { total = price * quantity }
为什么target函数会成为订阅者?\
首先看target是做什么的:是计算total
的值的,而total
的值依托于price 和 quantity
计算而来,所以target
函数就要订阅:“如果price 或者 quantity
发生变化,你就要通知我!我来更新total
的值”
class Dep { // 订阅器,保存依赖(依赖的集合)
constructor () {
this.subscribers = [] // 依赖的目标(原句:The targets that are dependent),
// 应该在调用 notify() 的时候运行(译者:指的是这个里面保存的函数们,会在notify()里面被遍历运行)
}
depend() { // 这替换了 record() 函数
if (target && !this.subscribers.includes(target)) {
// 只有在有 target 并且尚未订阅的情况下
this.subscribers.push(target)
}
}
notify() { // 这替换了 replay() 函数
this.subscribers.forEach(sub => sub()) // 运行我们的 targets() 或观察者 observers()。
}
}
请注意,我们现在将匿名函数存储在 subscribers
中,而不是 storage
。\
我们现在调用 depend()
而不是我们的 record()
函数,\
我们现在使用 notify()
而不是replay()
。
【中途插播】译者有话说:
depend()
「中译:依赖」用于添加依赖,notify()
「中译:通知」用于通知订阅者Watcher
(??)以便后续通知依赖更新(执行【subscribers中的函数等】)
要让它运行:
const dep = new Dep()
let price = 5
let quantity = 2
let total = 0
let target = () => { total = price * quantity }
dep.depend() // 将 target() 存储在 subscribers 数组中
target() // 执行 target() 函数 得到 total
console.log(total) // => 10 .. 正确的数字(结果)
price = 20
console.log(total) // => 10 ..不是正确的数字了
dep.notify() // 运行 subscribers 数组中存放的函数
console.log(total) // => 40 .. 现在又是正确的结果了
它仍然能运行,现在我们的代码感觉更可重用。 唯一仍然感觉有点奇怪的是target()
的设置和运行。
❗️问题
将来我们将为每个变量设置一个 Dep
类,封装 创建需要监视更新的匿名函数 的行为会很好。也许watcher()
函数可能是为了照顾这种行为。
译者有话说:
关于翻译:and it’ll be nice to encapsulate the behavior of creating anonymous functions that need to be watched for updates.
谷歌百度是上面:封装创建需要监视更新的匿名函数的行为会很好\
感觉有些拗口,大概意思我理解的是:封装一个匿名函数的行为很好,这个匿名函数需要被监视,监视是为了更新(数据)。
所以不要这样调用:
// target的三步操作
let target = () => { total = price * quantity } // 定义 target() 函数,返回 total 值
dep.depend() // 将 target() 存储在 subscribers 数组中
target() // 执行 target() 函数 得到 total
(这只是上面的代码)
我们可以改为调用:
watcher( () => {
total = price * quantity // 译者:在watcher中得到total值
} )
// 等价于
// let target = () => { total = price * quantity }
// watcher( target )
译者有话说:就是将之前的这三行再次封装、更加精简
让 target 函数作为 watcher 的参数。在 watcher 里面完成对 target 的三步操作。
✅ 解决方案:观察者函数(A Watcher Function)
在我们的 Watcher 函数中,我们可以做一些简单的事情:
function watcher(myFunc) {
target = myFunc // 设置为活动目标( Set as the active target)
// 译者:因为在【dep.depend()】中,是将target函数push进依赖数组中的
dep.depend() // 将target添加为依赖项 (添加进入依赖数组 ---subscribers)
target() // 运行 target目标函数
target = null // 重置 target目标函数
// 译者:我也不清楚为什么要重置,但是暂时先往下看吧
}
如您所见,观察者函数(watcher()
)接受一个 myFunc
参数,将其设置为我们的全局target
属性,调用 dep.depend()
将我们的target
添加为订阅者(添加进入依赖数组subscribers
),调用target
函数并重置target
。
现在,当我们运行以下命令时:
price = 20
console.log(total)
dep.notify()
console.log(total)
您可能想知道为什么我们将 target 实现为全局变量,而不是在需要时将其传递给我们的函数。 \
这是有充分理由的,这将在我们文章的结尾变得显而易见。
❗️问题
我们只有一个Dep class
,但我们真正想要的是每个变量都有自己的订阅器Dep
。\
让我在进一步研究之前将变量化为属性( 原句:Let me move things into properties before we go any further. )
。
let data = { price: 5, quantity: 2 }
让我们假设我们的每个属性(price
和 quantity
)都有自己的内部 Dep
类。
现在让我们运行:
watcher(() => {
total = data.price * data.quantity
})
由于访问了 data.price
值,我希望 price
属性的 Dep
类将我们的匿名函数(存储在 target
中)。通过调用 dep.depend()
推送到它的订阅者数组(subscriber array)
。
由于访问了 data.quantity
,我还希望quantity
属性 Dep
类将此匿名函数(存储在target
中)推送到其 订阅者数组subscriber array
中。
译者有话说:每一个属性都会有一个
订阅者watcher
,来监视它的状态变化,同一个时间只会有一个订阅者,防止的就是两个人同时修改一个数据的冲突。我们之前说到,每一个属性都会
new
一个订阅器Dep
,订阅器里面保存着所有用到这个属性的函数
、或者html中{{这个属性}}
的地方下面这段意思就是,希望在每一个属性被改变的时候调用一次
dep.notify()
,那我们有没有什么方法能监听数据的改变呢?
如果我有另一个只访问 data.price
的匿名函数,我希望将其推送到price
属性 Dep
类。
我希望什么时候对price
的订阅者调用 dep.notify()
? 我希望在设定price
时调用它们。 在文章结束时,我希望能够进入控制台并执行以下操作:
>> total
10
>> price = 20 // When this gets run it will need to call notify() on the price
// 当它运行时,它需要在 price(的Dep类上,即通过订阅器) 上调用 notify()
>> total
40
我们需要一些方法来绑定(hook
)数据属性(如price
或quantity
),\
以便当它被访问时,我们可以将target
保存到我们的订阅者数组(subscriber array
)中,\
当它改变时,运行存储在订阅者数组中的函数。
✅ 解决方案: Object.defineProperty()
我们需要了解 Object.defineProperty() 函数,它是纯 ES5 JavaScript。 它允许我们为属性定义 getter 和 setter 函数。 在我向您展示我们将如何在我们的 Dep 类中使用它之前,让我向您展示非常基本的用法。
let data = { price: 5, quantity: 2 }
Object.defineProperty(data, 'price', { // 只有price
get() { // 创建一个get方法
console.log(`I was accessed`)
},
set(newVal) { // 创建一个set方法
console.log(`I was changed`)
}
})
data.price // 这里调用 get()
data.price = 20 // 这里调用 set()
如您所见,它只打印了(logs
)两行。 但是,它实际上并没有获取或设置任何值,因为我们过度使用(over-rode
)了该功能。 我们现在把它加上(译者:指的是把获取值、设置值的操作加上)
。 get()
期望返回一个值,而 set()
仍然需要更新一个值,所以让我们添加一个 internalValue
变量来存储我们当前的price
值。
let data = { price: 5, quantity: 2 }
let internalValue = data.price // 我们的初始值。
Object.defineProperty(data, 'price', { // 只操纵价格属性
get() { // 创建一个get方法
console.log(`Getting price: ${internalValue}`)
return internalValue
},
set(newVal) { // 创建一个set方法
console.log(`Setting price to: ${newVal}` )
internalValue = newVal
}
})
total = data.price * data.quantity // 这里调用 get()
data.price = 20 // 这里调用 set()
现在我们的 get 和 set 工作正常,你认为控制台会打印什么?
所以我们有办法在获取和设置值时得到通知。 通过一些递归,我们可以对数据数组中的所有项目运行它,对吧?
仅供参考:Object.keys(data)
返回对象键的数组。
let data = { price: 5, quantity: 2 }
Object.keys(data).forEach(key => { // 我们现在正在为数据中的每个属性(item)运行它
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
现在一切都有(译者:对象中每一个属性都绑定了)
getter 和 setter,我们在控制台上看到了这一点。
把上面的想法放在一起
total = data.price * data.quantity
当这样一段代码运行并获取(gets) price
的值时,我们希望 price
记住这个匿名函数(target
)。 这样,如果 price
发生变化或 (设置sets) 为新值,它将触发此函数重新运行,因为它知道这行代码(this line
)依赖于它。 所以你可以这样想。
Get => 记住这个匿名函数,当我们的值改变时我们会再次运行它。
Set => 运行保存的匿名函数,我们的值刚刚改变。
或者在我们的 Dep Class 的情况下:
Price accessed 获取价格 (get
) => 调用 dep.depend()
保存当前目标
Price set 设置价格 => 在价格上调用 dep.notify()
,重新运行所有目标
让我们结合这两个想法,并运行我们的最终代码。
let data = { price: 5, quantity: 2 }
let target = null
// 这是完全相同的 Dep 类
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())
}
}
// 遍历我们对象的每个属性
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() // <-- 记住我们正在运行的目标
return internalValue
},
set(newVal) {
internalValue = newVal
dep.notify() // <-- 重新运行存储的函数
}
})
})
// 我的观察者不再调用 dep.depend,
// 因为它是从我们的 get 方法内部调用的。
function watcher(myFunc) {
target = myFunc
target()
target = null
}
watcher(() => {
data.total = data.price * data.quantity
})
现在试试看我们的控制台会发生什么。
正是我们所希望的! 价格和数量确实是响应性的! 每当价格或数量的值更新时,我们的代码就会重新运行。
译者有话说:
订阅器Dep
是在“后方大本营”实现的,(相对而言)watcher
是暴露在“明面儿”上的,dep收集依赖,watcher调用依赖,同一个时间只有一个watcher,也是为了防止同时操作一个数据引起的冲突。注:都是我认为,希望大家也有自己的思考!
跳转到 Vue
Vue 文档中的这个插图现在应该开始有意义了。
你看到带有 getter 和 setter 的漂亮的紫色 Data 圆圈了吗? 它应该看起来很熟悉! 每个组件实例都有一个watcher
实例(蓝色),它从 getter(红线)收集依赖项。 当稍后调用 setter 时,它会 notify通知
导致组件重新渲染的观察者。 这是带有我自己注释的图像。
译者注:这张图在这个页面拿的:JavaScript 响应性的最佳解释
译者有话说:
按我的理解捋一遍:
首先Vue在刚开始渲染的时候,就会用数据劫持(Object.defineProperty())来重新定义一下属性的getter、setter方法(数据被获取的时候触发的函数、数据被改变的时候触发的函数)
每一个属性都会有一个订阅器Dep,订阅器里面保存着所有用到这个属性的地方(就是属性的依赖)。
渲染之初,会获取组件哪data里面的每个属性,触发属性的getter函数,从而触发订阅器的【dep.depend()】函数,将属性的依赖保存进订阅器里面。
当属性被改变的时候就会触发setter函数,在setter函数里面,通知【dep.notify() 】所有依赖,更新内容。
渲染完毕之后,页面稳定,如果数据被改变,则触发watcher函数,用下面这段代码举例,调用
() => { data.total = data.price * data.quantity }
,然后total
触发setter
,通知watcher,重新渲染数据,实现绑定。watcher(() => { data.total = data.price * data.quantity })
(关于watcher我突然感觉有点云里雾里,为什么是说setter通知watcher,但是看代码反而是通知订阅器Dep)
是的,这现在不是更有意义了吗?
显然 Vue 如何在幕后做到这一点更复杂,但你现在知道了基础知识。 在下一课中,我们将深入了解 Vue,看看我们是否可以在源代码中找到这种模式。
所以我们学了什么?
- 如何创建一个收集依赖项(
depend()
)并重新运行所有依赖项(notify()
)的 Dep 类。 - 如何创建一个观察者来管理我们正在运行的代码,这可能需要添加(
target()
)作为依赖项。 - 如何使用 Object.defineProperty() 创建 getter 和 setter。