译自官方课程:双向数据绑定的最佳解释


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 怎么知道要更新什么,它又是怎么跟踪所有内容的?

译自官方课程:双向数据绑定的最佳解释_第1张图片

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中译是“存储”

下图的storage就是保存代码的 “容器”
译自官方课程:双向数据绑定的最佳解释_第2张图片

我们可以通过记录这个函数( 原词: by recording the function )来做到这一点,这样我们就可以再次运行它。

译者:他说的记录这个函数,就是我说的,要先存储代码,然后拿出来运行,存储这个动作就是记录

按照我们上面总结的逻辑就是:

  1. 先定义变量、初始化函数(定义函数)
  2. 然后是要记录函数,
  3. 运行这个函数(肯定是记录函数的同时执行函数)

补充一点:函数定义的时候,自然会被保存在内存,但是我们所说的【容器】并不是内存,\
因为三四个函数(可以是更多)保存在内存,我们会一个一个调用,\
我们所想的【容器】是,把这个三四个函数保存在【数组】里,可以通过循环数组完成数组里所有函数的运行,而不用一个一个调用

/* 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

image.png

译者:如果上面看不太懂,我可以简化一下:\
因为数组中仅仅存储了一个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
译者:到这里理解 recordreplay 函数的作用了吗?\
record:将想要一会儿调用的函数存储在 storage 数组中\
replay:遍历storage,将 存储在 storage 数组中的函数,挨个运行

❗️问题

我们可以根据需要,继续记录(recording)目标函数们,但最好有一个更强大的解决方案来扩展我们的应用程序。也许是一个类,负责维护目标列表(译者:这里说的应该就是那个存放函数的【容器】),当我们需要它们重新运行时会收到通知。

✅ 解决方案:依赖类(A Dependency Class)

我们可以开始解决此问题的一种方法是 把这种行为封装到它自己的类中 ,这是一个实现标准编程观察者模式的依赖类。\
因此,如果我们创建一个JavaScript类来管理我们的依赖项(这更接近Vue处理事物的方式),那么它可能看起来像这样:

译者:上面的意思就是,我们可以把 把函数记录到数组运行数组里的函数 这种行为封装到一个类里面,这个类就是观察者模式里面的 依赖类(Dep)

另外我也不知道Vue处理事务的方式是什么,可能是指观察者模式?

补充说一些东西:

  • 订阅器:在这篇文章中有一张图,监听器 Observer和订阅者 Watcher中间还有一个订阅器 Dep ,这个订阅器 Dep就是上面实现的这个类。
  • 依赖: 指保存在 subscribers 数组里面的函数们。

    1. Vue文档里写到:组件渲染的时候,会将“接触”过的数据 property 记录为依赖
    2. 一个属性,以price为例,所有用到price的地方都被记录为依赖,可以是函数target,也可以是组件中的
      {{price}}
    3. 一组依赖,是一组所有用到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) 

image.png

您可能想知道为什么我们将 target 实现为全局变量,而不是在需要时将其传递给我们的函数。 \
这是有充分理由的,这将在我们文章的结尾变得显而易见。

❗️问题

我们只有一个Dep class,但我们真正想要的是每个变量都有自己的订阅器Dep。\
让我在进一步研究之前将变量化为属性( 原句:Let me move things into properties before we go any further. )

let data = { price: 5, quantity: 2 }

让我们假设我们的每个属性(price 和 quantity)都有自己的内部 Dep 类。

译自官方课程:双向数据绑定的最佳解释_第3张图片

现在让我们运行:

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(),那我们有没有什么方法能监听数据的改变呢?

译自官方课程:双向数据绑定的最佳解释_第4张图片

如果我有另一个只访问 data.price 的匿名函数,我希望将其推送到price属性 Dep 类。

译自官方课程:双向数据绑定的最佳解释_第5张图片

我希望什么时候对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)数据属性(如pricequantity),\
以便当它被访问时,我们可以将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()

image.png

如您所见,它只打印了(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 工作正常,你认为控制台会打印什么?

image.png

所以我们有办法在获取和设置值时得到通知。 通过一些递归,我们可以对数据数组中的所有项目运行它,对吧?

仅供参考: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,我们在控制台上看到了这一点。

image.png

把上面的想法放在一起

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
})

现在试试看我们的控制台会发生什么。

译自官方课程:双向数据绑定的最佳解释_第6张图片

正是我们所希望的! 价格和数量确实是响应性的! 每当价格或数量的值更新时,我们的代码就会重新运行。

译者有话说:

订阅器Dep是在“后方大本营”实现的,(相对而言)watcher是暴露在“明面儿”上的,dep收集依赖,watcher调用依赖,同一个时间只有一个watcher,也是为了防止同时操作一个数据引起的冲突。

注:都是我认为,希望大家也有自己的思考!

跳转到 Vue

Vue 文档中的这个插图现在应该开始有意义了。

译自官方课程:双向数据绑定的最佳解释_第7张图片

你看到带有 getter 和 setter 的漂亮的紫色 Data 圆圈了吗? 它应该看起来很熟悉! 每个组件实例都有一个watcher实例(蓝色),它从 getter(红线)收集依赖项。 当稍后调用 setter 时,它会 notify通知 导致组件重新渲染的观察者。 这是带有我自己注释的图像。

译自官方课程:双向数据绑定的最佳解释_第8张图片

译者注:这张图在这个页面拿的: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。

你可能感兴趣的:(vue.js)