Vue框架源码:模拟Vue.js响应式原理-笔记

200 阅读7分钟

本任务目标是模拟一个最小版本的Vue,目的是:

  • 了解响应式原理。
  • 学习别人优秀的经验,转换成自己的经验。
  • 实际项目中出现问题,可从原理层面解决。
  • 为学习Vue源码做准备。

前置概念

数据驱动

数据响应式:

数据模型为普通JS对象,修改数据时会刷新视图,避免繁琐的DOM操作,提高开发效率。

双向绑定:

数据改变,会使试图改变;视图改变,数据也随之改变。其大量用于表单元素,例如v-model。

数据驱动:

Vue最独特的特性之一,仅需关注数据本身,不用关心数据是如何渲染到视图。也就是声明式。

响应式的核心原理

Vue2.X响应式原理

Vue官方解释:

当你把一个普通的 JavaScript 对象传入 Vue 实例作为data选项,Vue 将遍历此对象所有的 property,并使用Object.defineProperty把这些 property 全部转为getter/setter。Object.defineProperty是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。

shim是无法降级的意思。

下面来段代码示例:

// 模拟 Vue 中的 data 选项
let data = {
  msg: 'hello'
}

// 模拟 Vue 的实例
let vm = {}

// 数据劫持:当访问或者设置 vm 中的成员的时候,做一些干预操作
Object.defineProperty(vm, 'msg', {
  // 可枚举(可遍历)
  enumerable: true,
  // 可配置(可以使用 delete 删除,可以通过 defineProperty 重新定义)
  configurable: true,
  // 当获取值的时候执行
  get() {
    console.log('get: ', data.msg)
    return data.msg
  },
  // 当设置值的时候执行
  set(newValue) {
    console.log('set: ', newValue)
    if (newValue === data.msg) {
      return
    }
    data.msg = newValue
    // 数据更改,更新 DOM 的值
    document.querySelector('#app').textContent = data.msg
  }
})

当我们对vm上的msg属性进行赋值时,就会触发data对象上的对应属性更新,以及视图的更新。

思考:如果一个对象中有多个属性要处理成getter、setter该如何处理呢?

function proxyData(data) {
  // 遍历 data 对象的所有属性
  Object.keys(data).forEach(key => {
    // 把 data 中的属性,转换成 vm 的 setter/setter
    Object.defineProperty(vm, key, {
      enumerable: true,
      configurable: true,
      get () {
        console.log('get: ', key, data[key])
        return data[key]
      },
      set (newValue) {
        console.log('set: ', key, newValue)
        if (newValue === data[key]) {
          return
        }
        data[key] = newValue
        // 数据更改,更新 DOM 的值
        document.querySelector('#app').textContent = data[key]
      }
    })
  })
}

Vue2.X的响应式是基于defineProperty来做的。

Vue3.X响应式原理

基于ES6的Proxy,它是直接监听对象而非属性。IE不支持,同时它有浏览器优化,所以性能比defineProperty好。我们可以使用它创建一个代理对象,当想要对目标对象进行增删改查时,可以通过代理对象来进行操作

// 模拟 Vue 中的 data 选项
let data = {
  msg: 'hello',
  count: 0
}

// 模拟 Vue 实例
let vm = new Proxy(data, {
  // 执行代理行为的函数
  // 当访问 vm 的成员会执行
  get (target, key) {
    console.log('get, key: ', key, target[key])
    return target[key]
  },
  // 当设置 vm 的成员会执行
  set (target, key, newValue) {
    console.log('set, key: ', key, newValue)
    if (target[key] === newValue) {
      return
    }
    target[key] = newValue
    document.querySelector('#app').textContent = target[key]
  }
})

// 测试
vm.msg = 'Hello World'
console.log(vm.msg)

发布订阅模式和观察者模式

这两种模式在Vue中,有各自的应用场景,它俩本质相同,但也有区别,不能混为一谈。

发布/订阅模式,有这几个概念:

  • 订阅者
  • 发布者
  • 事件中心

展示一段代码来示意一个最简单的发布订阅模式代码:

// 事件触发器
class EventEmitter {
  constructor() {
    // { 'click': [fn1, fn2], 'change': [fn] }
    this.subs = Object.create(null)
  }

  // 注册事件
  $on(eventType, handler) {
    this.subs[eventType] = this.subs[eventType] || []
    this.subs[eventType].push(handler)
  }

  // 触发事件
  $emit(eventType, ...args) {
    if (this.subs[eventType]) {
      this.subs[eventType].forEach(handler => {
        handler(...args)
      })
    }
  }
}

其本质就是订阅者将回调存入eventMap中,当触发对应事件时则从eventMap中找到对应的事件数组,遍历执行一次。

观察者模式,Vue的响应式机制使用了观察者模式,它和发布订阅模式区别是没有事件中心, 只有发布者和订阅者。

  • 观察者(订阅者)--Watcher
    • update:当事件发生时,具体要做的事。
    • deps:可以记录多个发布者,循环遍历deps,集中进行绑定。
  • 目标(发布者)--Dep
    • subs数组:存储所有的观察者。
    • addSub:添加观察者。
    • notify:当事件发生时,调用所有观察者的update方法。
  • 没有事件中心

观察者模式简单源码展示:

// 发布者-目标
class Dep {
  constructor () {
    // 记录所有的订阅者
    this.subs = []
  }
  // 添加订阅者
  addSub (sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 发布通知
  notify () {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}

// 订阅者-观察者
class Watcher {
  update () {
    console.log('update')
  }
}

// 测试
let dep = new Dep()
let watcher = new Watcher()

dep.addSub(watcher)

dep.notify()

在这对两者进行一下总结:

  • 发布订阅模式是有一个事件中心,将各类型的事件及其回调函数都收集在一个对象中。观察者模式则没有事件中心,它是每个事件都有一个回调数组,对应的回调事件都存储在对应的事件发布者中。

  • 观察者模式是由具体目标调度,比如当事件触发,Dep就会去调用观察者的方法,该模式下订阅者与发布者之间是存在依赖的。
  • 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。事件中心可以隔离发布者和订阅者,减少它们之间依赖关系,更为灵活。

Vue响应式原理模拟

在该节我们会实现一个最小版本的Vue,接下来先整体分析一下Vue。

整体分析

Vue基本结构

Vue实例观察

我们需要模拟Vue中如下属性:

  • vm实例-$data:真正监视data数据变化的地方,data对象的get、set方法有它进行监测。
  • vm实例-options:可简单认为,将构造函数的参数记录在了options:可简单认为,将构造函数的参数记录在了options中。
  • vm实例-$el:可是选择器或是DOM对象,如果是选择器则需在内部将其转为DOM对象。

在vm实例中,_开头的是私有成员,$开头的是公共成员。我们还要把data中的成员注入到Vue实例中来。

整体结构

  • 创建Vue类型,把data中的成员注入到Vue实例,并且将其成员转换成getter、setter。
    • Vue内部调用Observer方法,进行数据劫持,对数据进行监听。
    • Compiler负责解析模板中的指令和插值表达式,并替换成对应的数据。
  • Dep、Watcher很熟悉了,属于观察者模式中的概念。

Vue类的实现

这里使用ES6中类的方式实现,它简单包含这些功能:

  • 负责接收初始化的参数(选项),通过构造函数接收。
  • 负责把data中的属性注入到Vue实例,转换成getter、setter。
  • 负责调用Observer监听data中所有属性的变化。
  • 负责调用Compiler解析指令、插值表达式。

Vue类的实例代码如下所示:

class Vue {
    constructor(options) {
        // 1. 通过属性保存选项的数据
        this.$options = options || {}
        this.$data = options.data || {}
        this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el

        // 2. 把data中的成员转换成getter和setter,注入到vue实例中,方便后续使用
        this._proxyData(this.$data)

        // 3. 调用observer对象,监听数据的变化

        // 4. 调用compiler对象,解析指令和差值表达式
    }

    // 使Vue代理data中的数据
    _proxyData(data) {
        // 遍历data中的所有属性
        Object.keys(data).forEach(key => {
            // 把data的属性注入到vue实例中
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return data[key]
                },
                set(newValue) {
                    if (newValue === data[key]) {
                        return
                    }
                    data[key] = newValue
                }
            })
        })
    }
}

Observer类的实现

它主要负责如下几个功能:

  • 负责把data选项中的属性转换成响应式数据。
  • 如果data中某个属性是对象,也应该将其转换为响应式数据,对于数组我们应该要重写数组方法
  • 数据变化时,发送通知。结合观察者模式实现。

它也有两个方法,我们命名与Vue源码保持一致:

  • walk:遍历data数据
  • defineReactive:定义响应式数据

为什么defineReactive要传第三个参数--val

因为此处如果直接返回obj[key]则会无限触发在observer中所定义的get方法,导致代码的死循环,解决该问题的方法就是返回val。

同时val会在此处形成闭包,所以val也不会被释放掉。

defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: () => {
      console.log("在Observer中被get获取")
      // 这里为什么不返回obj[key]
      return obj[key]; // 这会导致无限触发observer中的get方法
    },
    set: (newVal) => {
      console.log("在Observer中被set设置")
      if (newVal === val) return
      val = newVal
      // 发送通知
    }
  })
}

在Vue3中使用了函数式编程,来减少了this的使用场景,减少this指向问题所带来的困惑。在这将完整代码贴在这:

class Observer {
    constructor(data) {
        this.walk(data)
    }

    walk(data) {
        // 1. 判断data是否是对象
        if (!data || typeof data !== 'object') {
            return
        }
        // 2. 遍历data对象的所有属性
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key])
        })
    }

    // 为什么defineReactive要传第三个参数--val
    defineReactive(obj, key, val) {
        // 如果val是对象,把val内部的属性转换成响应式数据
        this.walk(val);
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get: () => {
                console.log("在Observer中被get获取")
                // 这里为什么不返回obj[key]
                return val;
            },
            set: (newVal) => {
                console.log("在Observer中被set设置")
                if (newVal === val) return
                val = newVal
                this.walk(newVal)
                // 发送通知
            }
        })
    }
}

在这里,当属性值或新设置的值是对象时,都会将它重新设置为响应式的属性。

Compiler类的实现

它主要负责如下几个功能:

  • 负责模板编译,解析指令和插值表达式
  • 负责页面首次渲染
  • 当数据变化后重新渲染视图

简而言之,它就是负责操作DOM。它有2个属性和6个方法:

  • el:DOM对象,也就是模板
  • vm:实例,会用到里面的数据

而它的方法都是在做DOM操作:

  • compile(el):进行节点的遍历,针对不同节点类型做不同的解析。
  • isTextNode(node):判断是否为文本节点。
  • isElementNode(node):判断是否为元素节点。
  • compileElement(node):对于元素节点,解析元素中指令
  • compileText(node):对于文本节点,解析差值表达式
  • isDirective(attrName):判断是否是指令,在compileElement中调用

compileText方法实现

// {{ msg }}
const reg = /{{(.+?)}}/;

简单说说这里的正则表达式,{{}}是固定的,同时在正则中需要转义;.+是匹配任意字符,多个长度;?是代表非贪婪匹配。同时我们需要拿到属性的名称,可以通过添加()的方式去提取,它在正则中是分组的含义,通过RegExp.$1可以获取第一个分组的内容。

compileElement方法实现

通过遍历所有的属性节点,来找出v-开头的属性,并根据这些属性指令,对其执行不同的指令方法。在attributes属性中能获得属性名称及其值,从而拿到所对应的data值。

同时为方便处理不同指令的方法,这里没有采用if else或者switch的方式,还是采用函数名拼接,精简了代码。

const updateFn = this[attrName + 'Updater']

Vue响应式机制的实现

先来对Vue响应式机制的整体流程图分析下,目前已实现了Vue类、Observer类、Compiler类

Dep类的实现

这里的作用是创建一个实例,在getter中收集依赖,添加观察者;在setter中根据依赖变化,调用notify方法通知观察者。

class Dep {
  constructor() {
    // 存储所有的观察者
    this.subs = []
  }

  // 添加观察者
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }

  // 发送通知
  notify(...args) {
    this.subs.forEach(sub => {
      sub.update(...args)
    })
  }
}

Watcher类的实现

  • 当数据变化时触发监听,dep通知所有的Watcher实例更新视图。
  • 自身实例化时,往dep对象的subs中将自己添加进去。
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    // data中的属性名称
    this.key = key;
    // 回调函数负责更新视图
    this.cb = cb;
    // 把watcher对象记录到Dep类的静态属性target,
    Dep.target = this
    // 使用oldValue存储实例中对应属性的内存地址,这里会触发get方法,而此时在get方法中则会触发addSub方法,将该Watcher添加到dep的subs中
    this.oldValue = vm[key]
    // 在此处释放,防止被多次重复添加
    Dep.target = null
  }

  // 当数据发生变化的时候更新视图
  update() {
    let newValue = this.vm[this.key]
    if (this.oldValue === newValue) {
      return
    }
    this.cb(newValue)
  }
}

何处创建Watcher对象

我们需要在涉及页面数据改变的地方创建Watcher对象。它的本质是操作DOM,所以我们在textUpdater、modelUpdater、compileText涉及文本操作的地方创建了Watcher对象

// 处理 v-text 指令
textUpdater(node, value, key) {
  node.textContent = value
  // 创建watcher对象,当数据改变更新视图
  new Watcher(this.vm, key, (newValue) => {
    node.textContent = newValue
  })
}

// 处理 v-model 指令
modelUpdater(node, value, key) {
  node.value = value
  // 创建watcher对象,当数据改变更新视图
  new Watcher(this.vm, key, (newValue) => {
    node.value = newValue
  })
  // 双向绑定
  node.addEventListener('input', () => {
    this.vm[key] = node.value
  })
}

// 编译文本节点,处理差值表达式
compileText(node) {
  const reg = /{{(.+?)}}/;
  const value = node.textContent;
  if (reg.test(value)) {
    const key = RegExp.$1.trim();
    node.textContent = value.replace(reg, this.vm[key])

    // 创建watcher对象,当数据改变更新视图
    new Watcher(this.vm, key, (newValue) => {
      node.textContent = newValue
    })
  }
}

同时我们给绑定v-model的文本输入框添加了input事件,完善双向绑定机制。

调试-首次渲染

接下来通过调试来加深对代码的理解:

  1. 调试页面首次渲染的过程。
  2. 调试数据改变更新视图的过程。

  1. 我们对vm实例已有属性重新赋值为对象,这个对象内部属性是响应式的。
  2. 我们给vm实例添加新属性,这个新属性不是响应式的,Vue官方解决方案是使用Vue.set实例方法来在vm创建一个对象,增加相应式数据。