【掌握原理】实现简易的 Vue 响应式

我心飞翔 分类:vue

前言

本文的目的是掌握 Vue2 中的 响应式原理,学习的过程是手写一个简易版的 Vue。从数据劫持,到模板编译,再到依赖收集,完完整整的自己实现整套数据响应式的流程。

一个最基础的响应式

实现一个属性的响应式

关于 defineProperty 这个方法请查看这篇文章,本文就不赘述了。

👉👉 Object.defineProperty 都能 "define" 什么?

我们首先封装一个响应式处理的方法 defineReactive,通过 defineProperty 这个方法重新定义对象属性的 getset 描述符,来实现对数据的劫持,每次 读取数据 的时候都会触发 get ,每次 更新数据 的时候都会触发 set ,所以我们可以在 set 中触发更新视图的方法 update 来实现一个基本的响应式处理。

/**
 * @param {*} obj  目标对象
 * @param {*} key  目标对象的一个属性
 * @param {*} val  目标对象的一个属性的初始值
 */
function defineReactive(obj, key, val) {
  // 通过该方法拦截数据
  Object.defineProperty(obj, key, {
    // 读取数据的时候会走这里
    get() {
      console.log('🚀🚀~ get:', key);
      return val
    },
    // 更新数据的时候会走这里
    set(newVal) {
      // 只有当新值和旧值不同的时候 才会触发重新赋值操作
      if (newVal !== val) {
        console.log('🚀🚀~ set:', key);
        val = newVal
        // 这里是触发视图更新的地方
        update()
      }
    }
  })
}

我们写点代码来测试一下,每 1s 修改一次 obj.foo 的值 , 并定义一个 update 方法来修改 app 节点的内容。

// html
<div id='app'>123</div>

// js
// 劫持 obj.foo 属性
const obj = {}
defineReactive(obj, 'foo', '')

// 给 obj.foo 一个初始值
obj.foo = new Date().toLocaleTimeString()

// 定时器修改 obj.foo
setInterval(() => {
  obj.foo = new Date().toLocaleTimeString()
}, 1000)

// 更新视图
function update() {
  app.innerHTML = obj.foo
}

可以看到,每次修改 obj.foo 的时候,都会触发我们定义的 getset ,并调用 update 方法更新了视图,到这里,一个最简单的响应式处理就完成了。

【掌握原理】实现简易的 Vue 响应式

处理深层次的嵌套

一个对象通常情况下不止一个属性,所以当我们要给每个属性添加响应式的时候,就需要遍历这个对象的所有属性,给每个 key 调用 defineReactive 进行处理。

/**
 * @param {*} obj  目标对象
 */
function observe(obj) {
  // 先判断类型, 响应式处理的目标一定要是个对象类型
  if (typeof obj !== 'object' || obj === null) {
    return
  }
  // 遍历 obj, 对 obj 的每个属性进行响应式处理
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}
// 定义对象 obj
const obj = {
  foo: 'foo',
  bar: 'bar',
  friend: {
    name: 'aa'
  }
}

// 访问 obj 的属性 , foo 和 bar 都被劫持到,就不在浏览器演示了。
obj.bar = 'barrrrrrrr' // => 🚀🚀~ set: bar
obj.foo = 'fooooooooo' // => 🚀🚀~ set: foo

// 访问 obj 的属性 obj.friend.name 
obj.friend.name = 'bb' // => 🚀🚀~ get: friend

当我们访问 obj.friend.name 的时候,也只是打印出来 get: friend ,而不是 friend.name , 所以我们还要进行个 递归,把 深层次的属性 同样也做响应式处理。

function defineReactive(obj, key, val) {
  // 递归
  observe(val)
  
  // 继续执行 Object.defineProperty...
  Object.defineProperty(obj, key, {
    ... ...
  })
}

// 再次访问 obj.friend.name
obj.friend.name = 'bb' // => 🚀🚀~ set: name

递归的时机在 defineReactive 这个方法中,如果 value 是对象就进行递归,如果不是对象直接返回,继续执行下面的代码,保证 obj 中嵌套的属性都进行响应式的处理,所以当我们再次访问 obj.friend.name 的时候,就打印出了 set: name

处理直接赋值一个对象

上面已经实现了对深层属性的响应式处理,那么如果我直接给属性赋值一个对象呢?

const obj = {
  friend: {
    name: 'aa'
  }
}
obj.friend = {           // => 🚀🚀~ set: friend
  name: 'bb'
}
obj.friend.name = 'cc'   // => 🚀🚀~ get: friend

这种赋值方式还是只打印出了 get: friend ,并没有劫持到 obj.friend.name ,那怎么办呢?我们只需要在 触发 set 的时候,判断一下 value 的类型,如果它是个对象类型,我们就对他执行 observe 方法。

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    ... ...
    set(newVal) {
      // 只有当新值和旧值不同的时候 才会触发重新赋值操作
      if (newVal !== val) {
        console.log('🚀🚀~ set:', key);
        // 如果 newVal 是个对象类型,再次做响应式处理。
        if (typeof obj === 'object' &amp;&amp; obj !== null) {
          observe(newVal)
        }
        val = newVal
      }
    }
  })
}
// 再次给 obj.friend 赋值一个对象
obj.friend = {
  name: 'bb'
}
// 再次访问 obj.friend.name , 这个时候就成功的劫持到了 name 属性
obj.friend.name = 'cc'  //=> 🚀~ set: name

处理新添加一个属性

上面的例子都是操作 已经存在 的属性,那么如果我们 新添加 一个属性呢?

const obj = {}
obj.age = 18
obj.age = 20

当我们试图修改 obj.age 的时候,什么都没有打印出来,说明并没有对 obj.age 进行响应式处理。这里也非常好理解,因为新增加的属性并没有经过 defineReactive 的处理,所以我们就需要一个方法来手动处理新添加属性这种情况。

/**
 * @param {*} obj  目标对象
 * @param {*} key  目标对象的一个属性
 * @param {*} val  目标对象的一个属性的初始值
 */
function $set(obj, key, val) {
  // vue 中在这进行了很多判断,val 是对象还是数组等等,我们就从简了
  defineReactive(obj, key, val)
}

// 调用 $set 方法给 obj 添加新的属性
$set(obj, 'age', 18)

// 再次访问 obj.age 
obj.age = 20 //=> 🚀🚀~ set: age

新定义的 $set 方法,内部也是把目标属性进行了 defineReactive 处理,这时我们再次更新 obj.age 的时候,就打印出了 set: age , 也就实现了一个响应式的处理。

VUE中的数据响应式

实现简易的Vue

这是 Vue 中最基本的使用方式,创建一个 Vue 的实例,然后就可以在模板中使用 data 中定义的响应式数据了,今天我们就来完成一个简易版的 Vue

<div id='app'>
  <p>{{counter}}</p>
  <p>{{counter}}</p>
  <p>{{counter}}</p>
  <p my-text='counter'></p>
  <p my-html='desc'></p>
  <button @click='add'>点击增加</button>
  <p>{{name}}</p>
  <input type="text" my-model='name'>
</div>

<script>
  const app = new MyVue({
    el: "#app",
    data: {
      counter: 1,
      desc: `<span style='color:red' >一尾流莺</span>`
    },
    methods: {
      add() {
        this.counter++
      }
    }
  })
</script>

原理

【掌握原理】实现简易的 Vue 响应式

设计类型介绍

  • MyVue: 框架构造函数
  • Observer:执行数据响应化(区分数据是对象还是数组)
  • Compile:编译模板,初始化视图,收集依赖(更新函数,创建 watcher
  • Watcher:执行更新函数(更新 dom
  • Dep:管理多个 Watcher 批量更新

流程解析

  • 初始化时通过 Observer 对数据进行响应式处理,在 Observerget 的时候创建一个 Dep 的实例,用来通知更新。
  • 初始化时通过 Compile 进行编译,解析模板语法,找到其中动态绑定的数据,从 data 中获取数据并初始化视图,把模板语法替换成数据。
  • 同时进行一次订阅,创建一个 Watcher ,定义一个更新函数 ,将来数据发生变化时,Watcher 会调用更新函数 把 Watcher 添加到 dep 中 。
  • Watcher 是一对一的负责某个具体的元素,data 中的某个属性在一个视图中可能会出现多次,也就是会创建多个 Watcher,所以一个 Dep 中会管理多个 Watcher
  • Observer 监听到数据发生变化时,Dep 通知所有的 Watcher 进行视图更新。

代码实现 - 第一回合 数据响应式

observe

observe 方法相对于上面,做了一小点的改动,不是直接遍历调用 defineReactive 了,而是创建一个 Observer 类的实例 。

// 遍历obj 对其每个属性进行响应式处理
function observe(obj) {
  // 先判断类型, 响应式处理的目标一定要是个对象类型
  if (typeof obj !== 'object' || obj === null) {
    return
  }
  new Observer(obj)
}

Observer类

Observer 类之前有解释过,它就是用来 做数据响应式 的,在它内部区分了数据是 对象 还是 数组 ,然后执行不同的响应式方案。

// 根据传入value的类型做响应的响应式处理
class Observer {
  constructor(value) {
    this.value = value
    if (Array.isArray(value)) {
      // todo  这个分支是数组的响应式处理方式 不是本文重点 暂时忽略
    } else {
      // 这个分支是对象的响应式处理方式
      this.walk(value)
    }
  }

  // 对象的响应式处理 跟前面讲到过的一样,再封装一层函数而已
  walk(obj) {
    // 遍历 obj, 对 obj 的每个属性进行响应式处理
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])
    })
  }
}

MVVM类(MyVue)

这一回合我们就先在实例初始化的时候,对 data 进行响应式处理,为了能用 this.key 的方式访问this.$data.key,我们需要做一层代理。

class MyVue {
  constructor(options) {
    // 把数据存一下
    this.$options = options
    this.$data = options.data

    // data响应式处理
    observe(this.$data)

    // 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
    proxy(this)
  }
}

proxy 代理也非常容易理解,就是通过 Object.defineProperty 改变一下引用。

/**
 * 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
 * @param {*} vm vue 实例
 */
function proxy(vm) {
  Object.keys(vm.$data).forEach(key => {
    // 通过  Object.defineProperty 方法进行代理 这样访问 this.key 等价于访问 this.$data.key
    Object.defineProperty(vm, key, {
      get() {
        return vm.$data[key]
      },
      set(newValue) {
        vm.$data[key] = newValue
      }
    })
  })
}

代码实现 - 第二回合 模板编译

这一趴要实现下面这个流程,VNode 不是本文的重点,所以先去掉 Vnode 的环节,内容都在注释里啦~

【掌握原理】实现简易的 Vue 响应式
// 解析模板语法
// 1.处理插值表达式{{}}
// 2.处理指令和事件
// 3.以上两者初始化和更新
class Compile {
  /**
   * @param {*} el 宿主元素
   * @param {*} vm vue实例
   */
  constructor(el, vm) {
    this.$vm = vm
    this.$el = document.querySelector(el)

    // 如果元素存在,执行编译
    if (this.$el) {
      this.compile(this.$el)
    }
  }

  // 编译
  compile(el) {
    // 获取 el 的子节点,判断它们的类型做相应的处理
    const childNodes = el.childNodes
    childNodes.forEach(node => {
      // 判断节点的类型 本文以元素和文本为主要内容 不考虑其他类型
      if (node.nodeType === 1) { // 这个分支代表节点的类型是元素
        // 获取到元素上的属性
        const attrs = node.attributes
        // 把 attrs 转换成真实数组
        Array.from(attrs).forEach(attr => {
          // 指令长 my-xxx = 'abc'  这个样子
          // 获取节点属性名
          const attrName = attr.name
          // 获取节点属性值
          const exp = attr.value
          // 判断节点属性是不是一个指令
          if (attrName.startsWith('my-')) {
            // 获取具体的指令类型 也就是 my-xxx 后面的 xxx 部分
            const dir = attrName.substring(3)
            // 如果this[xxx]指令存在  执行这个指令
            this[dir] &amp;&amp; this[dir](node, exp)
          }
        })
      } else if (this.isInter(node)) { // 这个分支代表节点的类型是文本 并且是个插值语法{{}}
        // 文本的初始化
        this.compileText(node)
      }
      // 递归遍历 dom 树
      if (node.childNodes) {
        this.compile(node)
      }
    })
  }

  // 编译文本
  compileText(node) {
    // 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}}
    // this.$vm[RegExp.$1] 等价于 this.$vm[key]
    // 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化
    node.textContent = this.$vm[RegExp.$1]
  }

  // my-text 指令对应的方法
  text(node, exp) {
    // 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key'
    // 把 this.$vm[key] 赋值给文本 即可
    node.textContent = this.$vm[exp]
  }

  // my-html 指令对应的方法
  html(node, exp) {
    // 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key'
    // 把 this.$vm[key] 赋值给innerHTML 即可
    node.innerHTML = this.$vm[exp]
  }

  // 是否是插值表达式{{}}
  isInter(node) {
    return node.nodeType === 3 &amp;&amp; /\{\{(.*)\}\}/.test(node.textContent)
  }
}

代码实现 - 第三回合 收集依赖

视图中会用到的 data 中的属性 key 的地方,都可以被称为一个 依赖 ,同一个 key 可能会出现多次,每次出现都会创建一个 Watcher 进行维护,这些 Watcher 需要收集起来统一管理,这个过程叫做 收集依赖

同一个 key 创建的多个 Watcher 需要一个 Dep 来管理,需要更新时由 Dep 统一进行通知。

【掌握原理】实现简易的 Vue 响应式

上面这段代码中,name1 用到了两次, 创建了两个 WatcherDep1 收集了这两个 Watchername2 用到了一次, 创建了一个 WatcherDep2 收集了这一个 Watcher

【掌握原理】实现简易的 Vue 响应式

收集依赖的思路

  • defineReactive 时为每一个 key 创建一个 Dep 实例
  • 初始化视图时,读取某个 key,例如 name1,创建一个 Watcher1
  • 由于触发 name1getter 方法,便将 Watcher1 添加到 name1 对应的 Dep
  • name1 发生更新时,会触发 setter,便可通过对应的 Dep 通知其管理的所有 Watcher 进行视图的更新

Watcher类

收集依赖的过程,在 Watcher 实例创建的时候,首先把实例赋值给 Dep.target,手动读一下 data.key 的值 ,触发 defineReactive 中的 get ,把当前的 Watcher 实例添加到 Dep 中进行管理,然后再把Dep.target 赋值为 null

// 监听器:负责依赖的更新
class Watcher {
  /**
   * @param {*} vm vue 实例
   * @param {*} key Watcher实例对应的 data.key
   * @param {*} cb 更新函数
   */
  constructor(vm, key, updateFn) {
    this.vm = vm
    this.key = key
    this.updateFn = updateFn

    // 触发依赖收集 把当前 Watcher 赋值给 Dep 的静态属性 target
    Dep.target = this
    // 故意读一下 data.key 的值 为了触发 defineReactive 中的 get
    this.vm[this.key]
    // 收集依赖以后 再置为null
    Dep.target = null
  }

  // 更新方法 未来被 Dep 调用
  update() {
    // 执行实际的更新操作
    this.updateFn.call(this.vm, this.vm[this.key])
  }
}

Dep类

addDep 方法把 Watchers 收集起来 放在 deps 中进行管理,notify 方法通知 deps 中的所有 Watchers 进行视图的更新。

class Dep {
  constructor() {
    this.deps = [] // 存放 Watchers
  }
  // 收集 Watchers
  addDep(dep) {
    this.deps.push(dep)
  }

  // 通知所有的 Watchers 进行更新 这里的 dep 指的就是收集起来的 Watcher
  notify() {
    this.deps.forEach(dep => dep.update())
  }
}

升级Compile

在第二回合中,我们的 Compile 类只实现了视图的初始化,所以在第三回合中要把它升级一下,支持视图的更新。

Watcher 实例就是在初始化后创建的,用来监听更新。

class Compile {
  ... ... // 省略号的地方都没有发生改变
    // 下面是发生改变的代码
  /**
   * 根据指令的类型操作 dom 节点
   * @param {*} node dom节点
   * @param {*} exp 表达式 this.$vm[key]
   * @param {*} dir 指令
   */
  update(node, exp, dir) {
    // 1.初始化 获取到指令对应的实操函数
    const fn = this[dir + 'Updater']
    //  如果函数存在就执行
    fn &amp;&amp; fn(node, this.$vm[exp])
    // 2.更新 再次调用指令对应的实操函数 值由外面传入
    new Watcher(this.$vm, exp, function(val) {
      fn &amp;&amp; fn(node, val)
    })

  }

  // 编译文本 {{xxx}}
  compileText(node) {
    // 可以通过 RegExp.$1 来获取到 插值表达式中间的内容 {{key}}
    // this.$vm[RegExp.$1] 等价于 this.$vm[key]
    // 然后把这个 this.$vm[key] 的值 赋值给文本 就完成了 文本的初始化
    this.update(node, RegExp.$1, 'text')
  }

  // my-text 指令
  text(node, exp) {
    this.update(node, exp, 'text')
  }

  // my-text 指令对应的实操
  textUpdater(node, value) {
    // 这个指令用来修改节点的文本,这个指令长这样子 my-text = 'key'
    // 把 this.$vm[key] 赋值给文本 即可
    node.textContent = value
  }

  // my-html 指令
  html(node, exp) {
    this.update(node, exp, 'html')
  }

  // my-html 指令对应的实操
  htmlUpdater(node, value) {
    // 这个指令用来修改节点的文本,这个指令长这样子 my-html = 'key'
    // 把 this.$vm[key] 赋值给innerHTML 即可
    node.innerHTML = value
  }

  // 是否是插值表达式{{}}
  isInter(node) {
    return node.nodeType === 3 &amp;&amp; /\{\{(.*)\}\}/.test(node.textContent)
  }

}

Watcher和Dep建立关联

首先在 defineReactive 中创建 Dep 实例,与 data.key 是一一对应的关系,然后再 get 中 调用 dep.addDep 进行依赖的收集,Dep.target 就是一个 Watcher。在 set 中 调用 dep.notify() 通知所有的 Watchers 更新视图。

function defineReactive(obj, key, val) {
  ... ... 
  // 创建 Dep 实例 , 与 key 一一对应
  const dep = new Dep()

  // 通过该方法拦截数据
  Object.defineProperty(obj, key, {
    // 读取数据的时候会走这里
    get() {
      console.log('🚀🚀~ get:', key);
      // 依赖收集 Dep.target 就是 一个Watcher
      Dep.target &amp;&amp; dep.addDep(Dep.target)

      return val
    },
    // 更新数据的时候会走这里
    set(newVal) {
      // 只有当新值和旧值不同的时候 才会触发重新赋值操作
      if (newVal !== val) {
        console.log('🚀🚀~ set:', key);
        // 如果 newVal 是个对象类型,再次做响应式处理。
        if (typeof obj === 'object' &amp;&amp; obj !== null) {
          observe(newVal)
        }
        val = newVal
        
        // 通知更新
        dep.notify()
      }
    }
  })
}

代码实现 - 第四回合 事件和双向绑定

事件绑定

事件绑定也很好理解,首先判断节点的属性是不是以 @ 开头,然后拿到事件的类型,也就是例子中的 click, 再根据函数名找到 methods 中定义的函数体,最后添加事件监听就行了。

class Compile {
  ... ... // 省略号的地方都没有发生改变
  compile(el) {
      // 判断节点属性是不是一个事件
      if (this.isEvent(attrName)) {
        // @click="onClick"
        const dir = attrName.substring(1) // click
        // 事件监听
        this.eventHandler(node, exp, dir)
      }
  }
  ... ... 
  // 判断节点是不是一个事件 也就是以@开头
  isEvent(dir) {
    return dir.indexOf("@") === 0
  }
  eventHandler(node, exp, dir) {
    // 根据函数名字在配置项中获取函数体
    const fn = this.$vm.$options.methods &amp;&amp; this.$vm.$options.methods[exp]
    // 添加事件监听
    node.addEventListener(dir, fn.bind(this.$vm))
  }
  ... ... 
}

双向绑定

my-model 其实也是一个指令,走的也是指令相关的处理逻辑,所以我们只需要添加一个 model 指令和对应的 modelUpdater 处理函数就行了。

my-model 双向绑定其实就是 事件绑定 和修改 value 的一个语法糖,本文以 input 为例,其它的表单元素绑定的事件会有不同,但是道理是一样的。

class Compile {

  // my-model指令 my-model='xxx'
  model(node, exp) {
    // update 方法只完成赋值和更新
    this.update(node, exp, 'model')
    // 事件监听
    node.addEventListener('input', e => {
      // 将新的值赋值给 data.key 即可
      this.$vm[exp] = e.target.value
    })
  }

  modelUpdater(node, value) {
    // 给表单元素赋值
    node.value = value
  }

}

现在也可以更新一下模板编译的流程图啦~

【掌握原理】实现简易的 Vue 响应式

后语

【掌握原理】实现简易的 Vue 响应式

到这里一个简易版的 Vue 数据响应式就完成了,整套流程从头到尾都是自己手写的,还怕不懂原理么?

本文的完整示例代码地址如下 👉👉 完整代码

参考资料

开课吧全栈架构师课程

回复

我来回复
  • 暂无回复内容