vue原理学习系列(一):响应性

1,266 阅读7分钟

前言

学了一段时间vue了,一直对vue的运作原理十分感兴趣,但苦于vue源码太高深,很难看懂,偶然间发现vue advanced workshop with Evan You这门课程,尤大这门课程的视频时间不长,但清楚地讲明白了vue的核心原理。

接下来我将通过vue原理学习系列来分享在课程中所学的知识,本期分享的是vue中响应性原理,欢迎大家参考和提出意见。

中文翻译的视频地址:尤雨溪教你写vue

「内容速览」

  • 什么是响应性
  • 响应性的举例思考
  • getter和setter
  • 依赖跟踪
  • 实现迷你响应性系统

下面的内容是基于vue2来进行展开的

什么是响应性

响应性表示当状态变更时,系统会自动更新关联状态。在Web的场景下,就是不断变化的状态反应到DOM上的变化。

响应性是vue最核心的特性之一,在vue中数据模型只是一个普通javascript对象,当数据发生改变时,视图会自动更新,这使得状态管理非常简单直接。

vue-reactivity

上面是一张vue响应式系统的流程图,可以看到整个流程形成了一个闭环:

当调用组件渲染函数的时候,vue会利用data属性的getter来收集它们的依赖项,当数据改变时,setter方法会发出通知,触发组件渲染函数,生成新的虚拟DOM。(注:组件渲染函数和虚拟DOM后续文章会讲)

整个流程是自动的,我们需要做的只是去更新数据等待视图渲染,实现这一功能首先关键点在于怎么去监听数据的变化

响应性的举例思考

在继续深入了解之前,尤大课程中有一个很好的例子,能够帮我们更好理解响应性:

假设现在有一个变量a和一个变量b,b永远是a的10倍(b = a * 10),当a改变时,b同时也改变,那我们该如何去实现它呢?

以这个代码为例,毫无疑问b不会随着a更新而跟新:

let a = 1;
let b = a * 10;
console.log(a); // 10
a = 2;
console.log(b); // 10
// 怎么样才可以变成20呢?

这时我们就希望有一个神奇的函数,能够自动帮助我们执行需要更新的代码,我们暂时把这个函数称为autorun(),它接受一个函数,函数中就是我们要自动更新的代码。

function autorun() {
	...
}

// 这里我们需要自动更新的代码就是b = a * 10;
autorun(() => {
	b = a * 10;
})

如果再抽象点,这时候a更新了,我要自动更新的不是b,而是用到a数据的视图节点,这时是不是就能够理解成视图的自动化更新。

<div class="test"></div>

function autorun() {
	...
}

// 这里我们需要自动更新的代码就是b = a * 10;
autorun(() => {
	document.querySelector('.test').textContent = a * 10;
})

getter和setter

首先关键的一步就是要监听属性的变更,那么vue是如何去做这件事呢?

vue2是利用ES5的Object.defineProperty提供监听属性变更的功能,如果有不了解这个方法同学可以看Object.defineProperty - MDN上的介绍。

vue通过遍历data对象所有属性,使用ES5的Object.defineProperty对这些属性的getter/setter进行修改,实现响应式系统所需要的功能,即让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更

下面根据视频课程要求实现关于访问、修改对象属性时打印日志的功能,具体代码如下:

// 判断data是否为对象
function isObject(obj) {
  return typeof obj === 'object'
    && !Array.isArray(obj)
    && obj !== null
    && obj !== undefined;
}
// convert函数对传入的对象属性进行修改
function convert(obj) {
  if(!isObject(obj)) {
    throw new TypeError();
  }
  Object.keys(obj).forEach(key => {
    let internalValue = obj[key];
    // 修改对象属性的getter和setter
    Object.defineProperty(obj, key, {
      get() {
        console.log(`getting key "${key}": ${internalValue}`)
        return internalValue
      },
      set(newValue) {
        console.log(`setting key "${key}" to: ${newValue}`)
        internalValue = newValue
      }
    })
  })
}

// 测试代码
var obj = {
	a: 123,
  b: 456
}
convert(obj);
obj.a // getting key "a": 123
obj.b = 789; // setting key "b" to: 789

依赖跟踪

现在我们知道了如何去监听数据中属性的变化,然后执行相应的操作,那么问题来了,我们怎样知道有谁依赖于这个数据。

换句话说:我们如何找出视图中依赖这个数据的所有节点(例如obj.a视图中有2个地方被使用到,obj.b视图中有3个地方被使用到)。

这时候就需要进行依赖追踪(dependency-tracking)

依赖追踪运用的是发布订阅模式,当某个依赖数据发生变化的时候,所依赖这个数据进行渲染的视图和该数据“相关数据”会自动改变,这些都需要自动的调用相关的函数去实现。

因此我们需要实现一个类,可以收集这个数据依赖项,也可以数据改变时通知依赖项进行改变。

下面根据视频课程要求实现一个依赖跟踪类Dep,这个类的作用在于模拟收集一个属性的依赖项,并通知依赖项执行更新的函数。类有两个方法:depend方法用于收集依赖项;notify方法用于触发依赖项更新的执行,具体代码如下:

class Dep {
  constructor() {
    // 记录订阅者,Set避免重复相同的任务
    this.subscribers = new Set()
  }

  // 注册依赖项(订阅者)
  depend() {
    if(activeUpdate) {
      this.subscribers.add(activeUpdate)
    }
  }

  // 通知所有订阅者
  notify() {
    this.subscribers.forEach(subscriber => subscriber())
  }
}

// activeUpdate用于保存更新函数
let activeUpdate

// autorun也就是前面说的神奇的函数,用于注册自动更新的代码
// autorun为啥会这么写,后面会讲
function autorun(update) {
  function wrappedUpdate() {
    activeUpdate = wrappedUpdate
    update()
    activeUpdate = null
  }
  wrappedUpdate()
}

// 测试代码
const dep = new Dep()
// 利用autorun创建reactive zone
// 接受函数作为参数,在这个函数里面可以使用depend方法来注册为依赖项
autorun(() => {
  dep.depend()
  console.log('updated')
})
// "updated"
dep.notify() // "updated"

这里很多同学就会好奇为什么autorun要对真正update进行包装,用一个全局变量activeUpdate来保存包装过后的更新函数,我认为有两个目的:

  1. 首先我们需要自动更新函数中通过dep.depend()方法注册为依赖项,那么在Dep中我们需要获取这个需要自动更新函数,activeUpdate是用来保存这个函数的。

  2. activeUpdate变量会指向通过autorun中传入自动更新函数,只有通过autorun生成的reactive zone(响应区)来注册自动更新函数才可以通过dep.depend()方法被注册为依赖项。

基于上面的Dep类,现在我们已经能够对一个属性来收集它的依赖项,并在主动通知依赖项更新函数的执行。

实现迷你响应性系统

上面两节,在getter和setter这节实现了对数据属性获取和修改的监听,在依赖跟踪这节实现了对某个属性依赖项的收集和通知所有依赖项执行相应更新的函数,现在我们将它们结合起来就可以实现一个迷你的响应性系统了。

本质上就是当我们访问数据对象的一个属性时,它收集依赖,调用dep.depend;当我们赋值改变数据对象的属性值时,它调用dep.notify触发改变。

class Dep {
  constructor() {
    this.subscribers = new Set()
  }
  
  depend() {
    if(activeUpdate) {
      this.subscribers.add(activeUpdate)
    }
  }

  notify() {
    this.subscribers.forEach(subscriber => subscriber())
  }
}

let activeUpdate

function autorun(update) {
  function wrappedUpdate() {
    activeUpdate = wrappedUpdate
    update()
    activeUpdate = null
  }
  wrappedUpdate()
}

function isObject(obj) {
  return typeof obj === 'object'
    && !Array.isArray(obj)
    && obj !== null
    && obj !== undefined;
}
function observe(obj) {
  if(!isObject(obj)) {
    throw new TypeError();
  }
  Object.keys(obj).forEach(key => {
    let internalValue = obj[key];
    let dep = new Dep();
    Object.defineProperty(obj, key, {
      get() {
        dep.depend()
        return internalValue
      },
      set(newValue) {
        const isChanged = internalValue !== newValue
        if(isChanged) {
          internalValue = newValue
          dep.notify()
        }
      }
    })
  })
}

// 测试代码
const state = {
  count: 0
}
observe(state)

autorun(() => {
  console.log("updated: " + state.count)
})
// "updated: 0"

state.count++ // "updated: 1"
state.count++ // "updated: 2"

回到思考

回到刚刚的思考,有一个变量a和一个变量b,b永远是a的10倍(b = a * 10)

const state = {
    a: 1,
    b: null
}
observe(state)
autorun(() => {
  state.b = state.a * 10;
})
console.log(state.b); // 10
state.a = 2;
console.log(state.b); // 20

如果这里state.b = state.a * 10;如果更换成view = render(state)不就对视图渲染了吗!

以上都是基于个人的理解,欢迎大家提出意见~

自己前端博客:前端学习笔记