not a better man

前端技术

Javascript响应式的最通俗易懂的解释(译)

许多javascript框架(比如:Angular,React,以及Vue)都有它们自己的响应式引擎。我们如果理解了响应式的原理,搞清楚它们是怎么工作的,我们可以提高自己的编程技巧,更有效的使用JavaScript框架。下面的视频和内容,将会帮助我们实现一个与Vue一样的响应式引擎。

响应式系统

 

当我们第一次看见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*quantity  的值,更新页面
  • 调用totalPriceWithTax 函数,更新页面

老乡等一等,我清楚你现在有很多疑惑,当price改变的时候,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中,不管是price还是quantity发生变化的时候,total都会更新数据,我们期待的结果是

>> total is 40

很悲剧的是,Javascript 是程序性的(procedural)(此处无法找一个合适的中文),而不是响应式的,这在现实生活中,不能够产生。为了使 total具有响应式。我们需要使用javascript做一些事情。()

关键点

我们需要保存计算total 的方式,当price quantity 发生变化的时候,重新运行一下。

解决方案

首先我们需要一些方式来告诉我们的应用,“我将要运行的代码,我存储在某一个地方,有时候(当数据发生改变的时候),我需要你再运行一遍”。然后我们运行代码,如果price quantity 更新的时候,再一次运行存储好的代码。

我们可以通过存储一个函数来实现这个功能,当我们需要的时候,再执行一次

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 函数来运行所有存储的target

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 = []

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

未解决的问题

如果我们使用上面的代码,我们发现代码无法重复利用。我们可以定义一个类来存储一系列targets ,当我们要再运行这些targets的时候,我们再去通知这些targets.

创建Dependency 类

我们可以将这些封装在了一个类中,其实这个类实现了一个观察者模式,所以,如果我们创建一个类,来管理我们的依赖,实现的代码如下

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
    }
}
  • 1.我们使用 subscribers 代替了 storage 来存储我们的匿名函数
  • 2. 我们使用depend函数 代替了record函数
  • 3. 我们使用了notify函数代替了replay函数

对应的代码如下

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 subscirbers
console.log(total)  // => 40 .. Now the right nubmer

上述的代码也达到了同样的效果,也解决了代码重复利用的问题了。但是上述的代码是最优的吗?我们在设置,运行 target 的时候是否觉得别扭。

碰到的问题

将来我们将为每一个变量建立一个Dep 对象。把那些需要被监听起来的匿名函数封装起来,估计会更好。我们可以建立一个watcher 函数满足这种需求。

我们可以将

target = () => { total = price * quantity }
dep.depend()
target()

使用下列代码代替

watcher(()=>{
   total = price * quantity
})

 Watcher 函数的实现

在watcher 函数中,我们干了这些事情

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
}

现在当我们运行下列代码的时候

price = 20 
console.log(total)
dep.notify()
console.log(total)

也许大家会有个疑问,为什么我们要把target设置为一个全局变量?而不是作为一个参数传进函数 。这么做的原因,在这篇文章结束的时候,你将会发现设置为全局变量的好处。

碰到的问题

我现在有个一个Dep 类,但是我们真正想要的是,为每一个变量都分配一个自己的Dep.  如下面的对象

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

我们需要每一个属性都有自己的Dep 类,如下图所示

现在当我运行

watcher(() => {
    total = data.price * data.quantity
})

因为使用到了data.price ,那我们肯定想要 price 的依赖类 能够 把匿名函数(存储在target中)保存在subscriber 数组中(通过调用dep.depend()方法),此外在这个匿名函数中还是使用了data.quantity ,那么同样的操作也需要在 quantity的dep 类中进行,如下图所示

如果我们还有宁外一个匿名函数,该匿名函数我们只用到了data.price , 那么我们只需要把这个匿名函数保存到price属性对应的依赖类中。

什么时候,我想要调用price dep 类中 dep.notify()呢?当price发生改变的时候,想要去调用对应dep.notify。在该文章的结束的时候,我们就能够实现以下的功能

>> total 
10
>> price = 20 // when this gets run it will need to call notify() on the price
>> total
40

我们需要找到方法hook住对象的属性(如 price 或quantity),当有地方使用到他们的时候,我们能够把对应target保存到对应的subscriber数组中,当属性值发生变化的时候,我们能够运行保存在sunscriber中的函数,那利用什么方法能够做到呢?

Object.defineProperty()

我们需要了解在es5中实现的Object.defineProperty() 的功能。这个方法能够允许我们为一个属性定义 setter 和getter 方法。我现在给大家展示一个简单的例子

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.call set()

我们会发现,打印了两行日志,但是我们并没有getget 任何值。 我们期待get()能返回一个值,set() 更新一个值. 我们现在来新增一个变量 internalValue 存储我们当前的price

let data = {price; 5, quantity: 2}
let internalValue = data.price // Our intital 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 call get()
data.price = 20 // This calls set()

现在我们的get和set能够正常工作了,你来猜猜console打出的是什么

现在在获取属性值,更新属性值的时候,会收到对应的通知。我们遍历对象的所有属性,那么可以为所有的属性添加上对应的钩子。遍历对象我们可以通过Object.keys(data) 来实现。

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

现在所有的属性都有getters 和setters ,在控制台上,输出为

把上述的两个想法结合起来

total = data.price * data.quantity

当代码运行起来的时候,在获取price的值得时候,我们想要price记住匿名函数(target),此外当price设置成一个新值的时候,能够触发这个匿名函数运行一遍。所有我们可以设想成这样

Get => 存储这个匿名函数,如果属性值改变的时候,我们再次运行这个匿名函数。

Set => 运行保存的匿名函数,因为我们的值刚刚改变

就 Dep 类而言

Price accesed (get) => 调用dep.depend() 来保存当前target

Price set => 调用与price有关的dep.notify() ,运行所有的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
})

现在我们运行的时候,我们看看console中打印的日志

这样真的实现我们想要的结果,price quantity真正的做到了响应式的了 ,无论是price还是quantity发生改变 target都能发生改变。

现在我们就能明白官方文档的中响应式原理图。

从图中我们可以看到紫色圆形区域的Data ,与上面的所讲解的内容很像啊。每一个组件都有一个watcher实例(蓝色的圆形区域)  在getter的时候去收集依赖(红色的虚线)当setter被调用的时候,去通知watcher, 触发组件重新渲染。下面的图是我添加了注释的响应式原理图

这样就更加清晰明了。Vue实现这个更复杂,但是我们现在明白了基本原理。

我们学到了什么

  • 怎么创建一个 收集依赖,运行所有依赖的Dep 类
  • 怎么创建一个 wacher 来管理需要运行的代码,这个代码被添加到target上作为依赖
  • 怎么使用Object.defineProperty()来创建getters和setters

接下来学什么呢?

如果你喜欢和我一起学习,那么我们接下来学习使用Proxies来实现响应式

发表评论