实现简易的 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

<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的时候,都会触发我们定义的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。

<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>

复制代码

原理


设计类型介绍

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的环节,内容都在注释里啦~


// 解析模板语法

// 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统一进行通知。


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


收集依赖的思路

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

  }

}

复制代码

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

最后

如果你觉得此文对你有一丁点帮助,点个赞。

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

完整源码下载地址:http://github.crmeb.net/u/lsq

PHP 学习手册:https://doc.crmeb.com

技术交流论坛:https://q.crmeb.com

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容