实现简易的 Vue 响应式

我们首先封装一个响应式处理的方法 defineReactive,通过 defineProperty 这个方法重新定义对象属性的 get 和 set 描述符,来实现对数据的劫持,每次 读取数据 的时候都会触发 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
123
// 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 的时候,都会触发我们定义的 get 和 set ,并调用 update 方法更新了视图,到这里,一个最简单的响应式处理就完成了。

处理深层次的嵌套
一个对象通常情况下不止一个属性,所以当我们要给每个属性添加响应式的时候,就需要遍历这个对象的所有属性,给每个 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' && 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 。

{{counter}}

{{counter}}

{{counter}}

{{name}}

复制代码

原理

实现简易的 Vue 响应式_第1张图片
设计类型介绍
MyVue: 框架构造函数
Observer:执行数据响应化(区分数据是对象还是数组)
Compile:编译模板,初始化视图,收集依赖(更新函数,创建 watcher)
Watcher:执行更新函数(更新 dom )
Dep:管理多个 Watcher 批量更新
流程解析
初始化时通过 Observer 对数据进行响应式处理,在 Observer 的 get 的时候创建一个 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 响应式_第2张图片

// 解析模板语法
// 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] && 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 && /\{\{(.*)\}\}/.test(node.textContent)
  }
}
复制代码

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

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

实现简易的 Vue 响应式_第3张图片

上面这段代码中,name1 用到了两次, 创建了两个 Watcher ,Dep1 收集了这两个 Watcher ,name2 用到了一次, 创建了一个 Watcher , Dep2 收集了这一个 Watcher。

实现简易的 Vue 响应式_第4张图片

收集依赖的思路
defineReactive 时为每一个 key 创建一个 Dep 实例
初始化视图时,读取某个 key,例如 name1,创建一个 Watcher1
由于触发 name1 的 getter 方法,便将 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 && fn(node, this.$vm[exp])
    // 2.更新 再次调用指令对应的实操函数 值由外面传入
    new Watcher(this.$vm, exp, function(val) {
      fn && 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 && /\{\{(.*)\}\}/.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 && dep.addDep(Dep.target)

      return val
    },
    // 更新数据的时候会走这里
    set(newVal) {
      // 只有当新值和旧值不同的时候 才会触发重新赋值操作
      if (newVal !== val) {
        console.log('~ set:', key);
        // 如果 newVal 是个对象类型,再次做响应式处理。
        if (typeof obj === 'object' && 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 && 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 响应式_第5张图片

最后
如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激 !

完整源码下载地址:https://market.cloud.tencent....

PHP学习手册:https://doc.crmeb.com
技术交流论坛:https://q.crmeb.com

你可能感兴趣的:(vue.js)