【翻译】构建响应式系统-vue

声明

本文是对于Build a Reactivity System的翻译前端

目标读者

使用过vue,而且对于vue实现响应式的原理感兴趣的前端童鞋。vue

正文

本教程咱们将使用一些简单的技术(这些技术你在vue源码中也能看到)来建立一个简单的响应式系统。这能够帮助你更好地理解Vue以及Vue的设计模式,同时可让你更加熟悉watchers和Dep class.react

响应式系统

当你第一次看到vue的响应式系统工做起来的时候可能会以为难以想象。es6

举个例子:npm

<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编程一般的工做方式

若是这对于你来讲不是很明显的话,咱们必须解决的一个大问题是编程一般不会以这种方式工做。举个例子,若是运行下面的代码:app

let price = 5
let quantity = 2
let total = price * quantity  // 10 right?
price = 20
console.log(`total is ${total}`)
复制代码

你以为这段代码最终打印的结果是多少?由于咱们没有使用Vue,因此最终打印的值是10ide

>> total is 10
复制代码

在Vue中咱们想要total的值能够随着price或者quantity值的改变而改变,咱们想要:函数

>> total is 40
复制代码

不幸的是,JavaScript自己是非响应式的。为了让total具有响应式,咱们须要使用JavaScript来让事情表现的有所不一样。

问题

咱们须要先保存total的计算过程,这样咱们才能在price或者quantity改变的时候从新执行total的计算过程。

解决方案

首先,咱们须要告诉咱们的应用,“这段我将要执行的代码,保存起来,后面我可能须要你再次运行这段代码。”这样当price或者quantity改变的时候,咱们能够再次运行以前保存起来的代码(来更新total)。

image

咱们能够经过将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上的每一个属性(pricequantity)都拥有本身的dep实例。

image

这样当咱们运行:

watcher(() => {
  total = data.price * data.quantity
})
复制代码

由于data.price的值被访问了,我想要price的dep实例能够将上面的匿名函数收集到本身的subscribers列表里面。data.quantity也是如此。

image

若是这时候有个另外的匿名函数里面用到了data.price,我也想这个匿名函数被加到price自带的dep类里面。

image

问题来了,咱们何时调用pricedep.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
})
复制代码

在控制台看下:

image
就像咱们想的同样!这时候 pricequantity都是响应式的!当 pricequantity更新的时候咱们的total都会及时地更新。

看下Vue

下面的图如今应该看起来有点感受了:

image
你看见紫色的带有getter和setter的Data圆圈没?是否是看起来很熟悉!每一个component实例都拥有一个 watcher实例用于经过getters和setters来收集依赖。当某个setter后面被调用的时候,它会通知相应地watcher从而致使组件从新渲染。下面是我添加了一些标注的图:
image
Yeah!如今你对Vue的响应式有所了解了没? 很显然,Vue内部实现会比这个更加复杂,可是经过这篇文章你知道了一些基础知识。在下个教程里面咱们会深刻Vue内部,看看源码里面的实现是否和咱们的相似。

咱们学到了什么?

  • 如何建立一个能够同来收集依赖(depend)并执行全部依赖(notify)的Dep class
  • 如何建立watcher来管理咱们当前正在执行的代码(target)
  • 如何使用Object.defineProperty()来建立getters和setters。
相关文章
相关标签/搜索