# 响应式原理

当一个Vue实例创建时,vue会遍历data选项的属性,用 Object.defineProperty 将它们转为getter/setter并且在内部追踪相关依赖,在属性被访问和修改时通知变化。每个组件实例都有相应的watcher程序实例,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter被调用时,会通知watcher重新计算,触发相应的监听回调。从而致使它关联的组件得以更新。

vue 的响应式原理主要基于:数据劫持、依赖收集(收集 Watcher )和异步更新(发布/订阅模式),通过对象劫持来做 依赖的收集数据变化的侦测,通过维持一个队列来异步更新视图

TIP

Vue主要通过以下 4 个步骤来实现数据双向绑定的:

  • 实现一个监听器 Observer:对数据对象进行遍历,包括子属性对象的属性,利用 Object.defineProperty() 对属性都加上 settergetter。这样的话,给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变化。响应式原理的入口,根据数据类型处理观测逻辑
  • 实现一个解析器 Compile:解析 Vue 模板指令,将模板中的变量都替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,调用更新函数进行数据更新。
  • 实现一个订阅者 WatcherWatcher 订阅者是 ObserverCompile 之间通信的桥梁 ,主要的任务是订阅 Observer 中的属性值变化的消息,当收到属性值变化的消息时,触发解析器 Compile 中对应的更新函数。
  • 实现一个订阅器 Dep:订阅器采用 发布-订阅 设计模式,用来收集订阅者 Watcher,对监听器 Observer 和 订阅者 Watcher 进行统一管理。依赖收集器,属性都会有一个 Dep,方便发生变化时能够找到对应的依赖触发更新

# Observe 对数据进行观测

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

TIP

  • 为观测的属性添加 __ob__ 属性,它的值等于 this,即当前 Observe 的实例
  • 为数组添加重写的数组方法,比如:push、unshift、splice、pop、shift、sort、reverse 等方法,重写目的是在调用这些方法时,进行更新渲染
  • 观测数组内的数据,observe 内部会调用 new Observe,形成递归观测
  • 观测对象数据,defineReactive 为数据定义 getset ,即数据劫持

# 如何数据劫持

  1. getter收集依赖,setter触发依赖
// 部分vue代码
Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) {
      dep.depend()
      if (childOb) {
        childOb.dep.depend()
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
    }
    return value
  },
  set: function reactiveSetter (newVal) {
    const value = getter ? getter.call(obj) : val
    /* eslint-disable no-self-compare */
    if (newVal === value || (newVal !== newVal && value !== value)) {
      return
    }
    /* eslint-enable no-self-compare */
    if (process.env.NODE_ENV !== 'production' && customSetter) {
      customSetter()
    }
    // #7981: for accessor properties without setter
    if (getter && !setter) return
    if (setter) {
      setter.call(obj, newVal)
    } else {
      val = newVal
    }
    childOb = !shallow && observe(newVal)
    dep.notify()
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

定义一个响应式数据。也就是这个函数中进行变化追踪,封装后只需要传递data key val就行。

# 递归侦测所有数据

封装一个Observer类。这个类的作用是将一个数据内的所有属性(包括子属性)都转换成getter/setter形式,然后追踪它们的变化:

/** Observer类会附加到每一个被侦测到的object上。一旦被附加上,Observer会将object的所有属性转换为getter/setter的形式
*  来收集属性的依赖,并且当属性发生变化时会通知这些依赖
*
*/
export class Observer {
  constructor (value: any) {
    this.value = value
    if (!Array.isArray(value)) {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }
}

function defineReactive(data, key,val) {
  // 新增 递归子属性
  if(typeof val === 'object') {
    new Observer(val)
  }
  let dep = new Dep(); // 为每个属性创建Dep依赖搜集
  // Object.defineProperty 代码
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# Dep 为数据收集依赖

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

TIP

  • 数据收集依赖的主要方法,Dep.target 是一个 watcher 实例
  • 添加 watcher 到数组中,也就是添加依赖
  • 属性在变化时会调用 notify 方法,通知每一个依赖进行更新
  • Dep.target 用来记录 watcher 实例,是全局唯一的,主要作用是为了在收集依赖的过程中找到相应的 watcher
  • pushTargetpopTarget 这两个方法显而易见是用来设置 Dep.target 的。Dep.target 也是一个关键点

# 收集谁(谁是依赖)-- 订阅者

Watcher,换句话说是当依赖发生变化通知Watcher

依赖注入到Dep中后,每当数据发生变化时,就会让依赖列表中的所有依赖循环触发update方法,也就是Watcher中的update方法。而update方法会执行参数中的回调函数,将value/oldValue传到参数中。Watcher的原理是先把自己设置到全局唯一的指定位置(如window.target,这里是Dep.target)然后读取数据。因为读取了数据所以会触发这个数据的getter。接着在getter中就会从全局唯一的那个位置读取当前正在读取数据的Watcher,并把这个Watcher收集到Dep中去。通过这样的方式,Watcher可以主动去订阅任意一个数据的变化

let id = 0
export class Watcher {
  constructor(vm, exprOrFn, cb, options){
    this.id = ++id  // watcher 唯一标识
    this.vm = vm
    this.cb = cb
    this.options = options
    // 1
    this.getter = exprOrFn
    this.deps = []
    this.depIds = new Set()

    this.get()
  }
  run() {
    this.get()
  }
  get() {
    pushTarget(this)
    this.getter()
    popTarget(this)
  }
  // 2
  addDep(dep) {
    // 防止重复添加 dep
    if (!this.depIds.has(dep.id)) {
      this.depIds.add(dep.id)
      this.deps.push(dep)
      dep.addSub(this)
    }
  }
  // 3
  update() {
    queueWatcher(this)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

TIP

  • this.getter 存储的是更新视图的函数
  • watcher 存储 dep,同时 dep 也存储 watcher,进行双向记录
  • 触发更新,queueWatcher 是为了进行异步更新,异步更新会调用 run 方法进行更新页面

# 响应式原理流程

# 数据观测

数据在初始化时会通过 observe 方法调用 Observe

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

在初始化时,observe 拿到的 data 就是我们在 data 函数内返回的对象。

  • observe 函数只对 object 类型数据进行观测
  • 观测过的数据都会被添加上 __ob__属性,通过判断该属性是否存在,防止重复观测
  • 创建 Observe 实例,开始处理观测逻辑

# 对象观测

defineReactive 方法内部使用 Object.defineProperty 对数据进行劫持,是实现响应式原理最核心的地方。

  1. 由于值可能是对象类型,这里需要调用 observe 进行递归观测
  2. 这里的 dep 就是上面讲到的每一个属性都会有一个 dep,它是作为一个闭包的存在,负责收集依赖和通知更新
  3. 在初始化时,Dep.target 是组件的渲染 watcher,这里 dep.depend 收集的依赖就是这个 watcherchildOb.dep.depend 主要是为数组收集依赖
  4. 设置的新值可能是对象类型,需要对新值进行观测
  5. 值发生改变,dep.notify 通知 watcher 更新,这是我们改变数据后能够实时更新页面的触发点

通过上面的逻辑,也能得出为什么 Vue3.0 要使用 Proxy 代替 Object.defineProperty 了。Object.defineProperty 只能对单个属性进行定义,如果属性是对象类型,还需要递归去观测,会很消耗性能。而 Proxy 是代理整个对象,只要属性发生变化就会触发回调。

# 数组观测

对于数组类型观测,会调用 observeArray 方法

observeArray (items: Array<any>) {
  for (let i = 0, l = items.length; i < l; i++) {
    observe(items[i])
  }
}
1
2
3
4
5

与对象不同,它执行 observe 对数组内的对象类型进行观测,并没有对数组的每一项进行 Object.defineProperty 的定义,也就是说数组内的项是没有 dep 的。我们通过数组索引对项进行修改时,是不会触发更新的。但可以通过 this.$set 来修改触发更新。

# 数组方法重写

当数组元素新增或删除,视图会随之更新。这并不是理所当然的,而是 Vue 内部重写了数组的方法,调用这些方法时,数组会更新检测,触发视图更新。 回到 Observe 的类中,当观测的数据类型为数组时,会调用 protoAugment 方法。 这个方法里把数组原型替换为 arrayMethods ,当调用改变数组的方法时,优先使用重写后的方法。

if (Array.isArray(value)) {
  if (hasProto) { // 判断有无 __proto__ const hasProto = '__proto__' in {}
    protoAugment(value, arrayMethods)
  } else {
    copyAugment(value, arrayMethods, arrayKeys)
  }
  this.observeArray(value) // 观察数组
} else {
  this.walk(value) // 观察对象
}

function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src // 覆盖target原型
  /* eslint-enable no-proto */
}

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

// arrayMethods 实现
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
  • 将数组的原型保存起来,因为重写的数组方法里,还是需要调用原生数组方法的
  • arrayMethods 是一个对象,用于保存重写的方法,这里使用 Object.create(arrayProto) 创建对象是为了使用者在调用非重写方法时,能够继承使用原生的方法
  • 调用原生方法,存储返回值,用于设置重写函数的返回值
  • inserted 存储新增的值,若 inserted 存在,对新值进行观测
  • ob.dep.notify 向数组的依赖发送通知 watcher,执行 update

__proto__ 其实是 Object.getPrototypeOfObject.setPrototypeOf的早期实现,所以使用 ES6 的 Object.setPrototypeOf 来代替 __proto__ 完全可以,只是目前浏览器支持效果不是很理想。

copyAugment 逻辑非常粗暴:如果不能使用 __proto__,就直接降 arrayMethods身上这些方法设置到被观测的数组上。因为当访问一个对象的方法时,只有其身上不存在这个方法,才会去它的原型上找这个方法

Array收集依赖的方式和Object一样,都是在 getter 中收集。但是由于使用依赖的位置不同,数组要在拦截器中向依赖发送消息,所以依赖不能像Object那样保存在 defineReactive 中,而是把依赖保存在 Observer 实例上

Observer 中,我们对每个侦测了变化的数据都标上印记 __ob__,并把 this(Observer 实例)保存在 __ob__上。主要两个作用:一方面为了标记数据是否被侦测了变化(保证同一个数据只被侦测一次),另一方面可以方便通过数据取到__ob__,从而拿到保存在Observer 实例上的依赖,当拦截到数组发生变化时,向依赖发送通知。

# 依赖收集

  1. 首先初始化数据,调用 defineReactive 函数对数据进行劫持。
  2. 初始化将 watcher 挂载到 Dep.targetthis.getter 开始渲染页面。渲染页面需要对数据取值,触发 get 回调,dep.depend 收集依赖。
  3. Dep.targetwatcher,调用 addDep 方法,并传入 dep 实例。
  4. addDep 中添加完 dep 后,调用 dep.addSub 并传入当前 watcher 实例。
// 删减版代码
export class Watcher {
  constructor(vm, exprOrFn, cb, options){
    this.getter = exprOrFn
    this.get()
  }
  get() {
    pushTarget(this)
    this.getter()
    popTarget(this)
  }
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
}

class Dep{
  constructor() {
    this.id = id++
    this.subs = []
  }
  depend() {
    Dep.target.addDep(this)
  }
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

通常页面上会绑定很多属性变量,渲染会对属性取值,此时每个属性收集的依赖都是同一个 watcher,即组件的渲染 watcher

TIP

Vue中通过Object.defineProperty来将对象的key转换成getter/setter,但是只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。

  • Data通过Observer转换成getter/setter形式追踪变化。
  • 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
  • 当数据发生变化时,会触发setter,从而向Dep中的依赖Watcher发送通知。
  • Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数。

模板渲染解析时对应绑定指令,此时会调用订阅者初始化(watcher中的get()方法,去触发对应属性在发布者observergetter,发布者会判断是不是通过订阅者初始化调用的,只有是才有通过Dep收集依赖。发布者通过depend通知Dep类收集。此后每次的数据更新都会通过发布者的setter去触发Dep类的回调update执行收集依赖的所有方法,更新订阅者的所有状态及更新视图。即将来data中数据⼀旦发生变化,会首先找到对应的Dep,通知所有Watcher执行更新函数

img

# 手写一个vue双向绑定

# 执行初始化

// 执行初始化,对data执行响应式处理
class Vue {
  constructor(options) {
    this.$options = options;
    this.$data = options.data;

    //对data进行响应处理
    observe(this.$data)

    //代理data到vm上
    proxy(this)

    //执行编译
    new Compile(options.el,this)
  }
}

// 对data选项执行响应式操作
function observe(obj) {
  if(typeof obj !== 'object' || obj == null) {
    return;
  }
  new Observe(obj)
}
class Observe {
  constructor(value) {
    this.value = value;
    this.walk(value)
  }
  walk(obj) {
    Object.keys(obj).forEach((key) => {
      defineReactive(obj,key,obj[key])
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 编译Compile

img

// 对每个元素节点的指令进行扫描跟解析,根据指令模板替换数据,以及绑定相应的更新函数
compile(el) {
  const childNodes = el.childNodes;
  Array.from(childNodes).forEach((node) => {
    if(this.isElement(node)) {
      // 判断是否为节点
      console.log('编译元素'+ node.nodeName)
    } else if(this.isInterpolation(node)) {
      // 判断是否为插值文本
      console.log('编译插值文本'+node.textContent)
    }
    if(node.childNodes && node.childNodes.length > 0) {
      // 判断是否有子元素
      this.compile(node)// 对子元素进行递归遍历
    }
  })
}
isElement(node) {
  return node.nodeType == 1;
}
isInterpolation(node) {
  return node.nodeType == 3 && /\{\{(.*)\}\}/.test(node.textContent)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 依赖收集

img

视图中会用到data中某key,这称为依赖。同⼀个key可能出现多次,每次都需要收集出来用⼀个Watcher来维护它们,此过程称为依赖收集 多个Watcher需要⼀个Dep来管理,需要更新时由Dep统⼀通知

  • 实现思路:
    • defineReactive时为每个key创建一个Dep实例
    • 初始化视图时读取某个key,例如name1,创建一个watcher1
    • 由于触发name1getter方法,便将watcher1添加到name1对应的Dep
    • name1更新,setter触发时,便可通过对应Dep通知其管理所有Watcher更新
// 负责更新视图
class Watcher {
  constructor(vm,key,updater) {
    this.vm = vm
    this.key = key
    this.updaterFn = updater
    // 创建实例时,把当前实例指定到Dep.target静态属性上
    Dep.target = this
    // 读一下key,触发get
    vm[key]
    //置空
    Dep.target = null
  }
  // 未来执行dom更新函数,由Dep调用
  update() {
    this.updaterFn.call(this.vm,this.vm[this.key])
  }
}
// 声明Dep
class Dep {
  constructor() {
    this.deps = [] //依赖管理
  }
  addDept(dep) {
    this.deps.push(dep)
  }
  notify() {
    this.deps.forEach((dep) => dep.update())
  }
}
// 创建watcher时触发getter
class Watcher {
  constructor(vm,key,updaterFn) {
    Dep.target = this;
    this.vm[this.key]
    Dep.target = null
  }
}
// 依赖收集,创建Dep实例
function defineReactive(obj,key,val) {
  this.observe(val)
  const dep = new Dep()
  Object.defineProperty(obj,key,{
    get() {
      Dep.target && dep.addDept(Dep.target) // Dep.target也就是Watcher实例
      return val
    },
    set(newVal) {
      if(newVal === val) return;
      dep.notify() // 通知dep执行更新方法
    }
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

# 双向绑定的优点

  • 基于数据劫持/依赖收集 的双向绑定优点:
    • 不需要显示调用,利用数据劫持+发布订阅,可以直接通知变化并且驱动视图更新
    • 直接得到精确的变化数据,劫持了属性setter,属性值改变我们可以精确获得变化的内容newVal,不需要额外的diff操作。

# vue2.0对比vue3.0

  • Object.defineProperty缺点:

    • 不能监听数组,没有getter/setter。数组长度太长性能负担大。
    • 只能监听属性,而不是整个对象。需要遍历属性。
    • 只能监听属性变化,不能监听增删属性。
  • proxy优缺点:

    • 可以监听数组

    • 监听整个对象

    • 13种拦截方法

    • 返回新对象而不是直接修改原对象,更符合immutable

    • 兼容性不好,无法用polyfill磨平