前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >手写 Vue (二):响应式

手写 Vue (二):响应式

作者头像
我是一条小青蛇
发布2020-12-09 10:14:35
6570
发布2020-12-09 10:14:35
举报
文章被收录于专栏:青笔原创青笔原创青笔原创

1. 响应式的本质

提到 Vue 的响应式,通常指的是视图跟随数据的改变而更新。开发上带来的便利是,在需要更新视图呈现时,只需修改视图渲染所需要的数据即可,而不用手动操作DOM。从实现来说,可以分为两个部分:

  • 监听数据改变
  • 更新视图

我们很熟悉如何监听鼠标的点击,键盘的输入等用户事件,但是很少直接去监听一个数据改变的事件。虽然,不存在数据改变这个事件,但是监听数据改变是可以做到的,并且从程序设计角度来说,和给事件绑定一个回调函数没有本质的不同。

为了比较监听普通事件和监听数据改变的区别,我们先使用事件的方式,来实现“响应式”视图更新。

下面的代码中,我们定义了数据变量data和视图更新函数updateupdate函数在更新视图时,读取了datatext属性作为视图节点的文本内容。然后监听一个input元素的input事件,事件的回调函数中,将用户输入的值替换data.text的当前值,然后调用update函数,通知视图进行更新。

var data = { text: 'hello' } function update() { document.getElementById('app').textContent = data.text } update() var textElm = document.getElementById('text') textElm.value = data.text textElm.addEventListener('input', function() { data.text = this.value update() })

<input id='text' />
<div id='app'></div>
<script>
  /* 定义渲染数据和视图更新函数 */
  var data = {
    text: 'hello'
  }
  function update() {
    document.getElementById('app').textContent = data.text
  }
  update()
  /* 绑定 input 事件,在修改数据后更新视图 */
  var textElm = document.getElementById('text')
  textElm.value = data.text
  textElm.addEventListener('input', function() {
    data.text = this.value
    update()
  })
</script>

借助input事件,我们间接实现了“响应式”,但它只是起到一个纽带的作用,不能直接对数据的改变作出响应。

2. 监听数据改变

2.1 Object.defineProperty

Object.defineProperty(obj, prop, descriptor) 可以给对象添加或者修改已有属性。函数接受三个参数:

  • obj: 要定义属性的对象
  • prop: 要定义或修改的属性的名称,可以StringSymbol类型
  • descriptor: 要定义或修改的属性描述符,必须是Object类型

这里重点需要了解的是属性描述符对象 descriptordescriptor 支持以下字段:

  • configurable: Boolean,为true时,才能改变属性描述符,以及删除属性
  • enumerable: Boolean,为true时,可以通过for ... inObject.keys 方法枚举
  • value: 该属性对应的值。可以是任何有效的 JavaScript 值
  • writable: Boolean,为true时,属性值,也就是 value 才能被赋值运算符改变
  • get: 属性的 getter 函数,当访问该属性时,会调用此函数
  • set: 属性的 setter 函数,当属性值被修改时,会调用此函数

其中 valuewritable 只能出现在数据描述符中;而getset只能出现在存取描述符中。一个属性描述符descriptor只能是其中之一,因此当定义了 valuewritable ,就不能再定义 getset,否则报错 Cannot both specify accessors and a value or writable attribute。反之亦然。

由于,我们需要在对象属性改变时获得通知,我需要使用存取描述符来定义对象属性,即定义set来响应属性值的修改,定义get来响应属性的访问。

以上文的data为例,我们希望在通过data.text = xxx的方式改变对象的属性值时,更新视图,所以要重新定义属性text的描述符,在set函数中调用视图更新函数update。这里还需要定义get,因为,我不但需要对属性值更改时作出响应,同时在update函数中,我们还需要读取data.text的值,而如果不定义get,获取的值就为undefined

var data = {
  text: 'hello'
}

var text = data.text

Object.defineProperty(data, 'text', {
  get: function() {
    return text
  },
  set: function(newValue) {
    if (text !== newValue) {
      text = newValue
      update()
    }
  }
})

这样定义后,我们便可以直接修改data.text值更新视图了。读者可以将以下完整代码,保存到一个 html 文件中,然后在浏览器控制台中通过data.text = 'world'赋值的方式,查看视图的变化。

<div id='app'></div>
<script>
  /* 定义渲染数据和视图更新函数 */
  var data = {
    text: 'hello'
  }
  function update() {
    document.getElementById('app').textContent = data.text
  }
  update()
  /* 使用 Object.defineProperty 实现响应式视图更新 */
  var text = data.text
  Object.defineProperty(data, 'text', {
    get: function() {
      return text
    },
    set: function(newValue) {
      if (text !== newValue) {
        text = newValue
        update()
      }
    }
  })
</script>

这里只是针对data的属性text定义响应式。为了代码更加通用,以用于任意对象,可以编写一个函数defineReactive(obj, key, update)(函数名参考了 Vue2 的定义,读者可以在 Vue2 源码中搜索该函数)。

function defineReactive(obj, key, update) {
  var value = obj[key]
  Object.defineProperty(obj, key, {
    get: function() {
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        value = newValue
        update()
      }
    }
  })
  return obj
}

于是上面的代码可以改写成:

var data = {
  text: 'hello'
}
function update() {
  document.getElementById('app').textContent = data.text
}
update()

defineReactive(data, 'text', update)

2.2 Proxy

响应对象属性改变,除了Object.definProperty外,浏览器还支持另一个全局的构造函数Proxy,用于自定义对象的基本操作,如:属性查找,赋值,枚举,函数调用等。相比而言,前者只能自定义对象属性的访问和赋值。

Proxy的使用方法如下:

const proxy = new Proxy(target, handler)
  • target: 需要代理的目标对象。可以是任何类型的对象,包括原生数组,函数,甚至另一个代理
  • handler: 以函数作为属性的对象。属性中的函数分别定义了在对 proxy 实例执行各种操作的自定义行为

handelr 对象支持的方法(通常被称为traps,中文翻译为陷阱,可以理解为钩子或者执行某项操作的回调函数)有:

  • get: 读取属性值时调用
  • set: 对属性赋值时调用
  • has: 使用in操作符时调用
  • deleteProperty: 使用delete操作符时调用
  • ownKeys: 使用Object.getOwnPropertyNames方法和Object.getOwnPropertySymbols方法时调用
  • apply: 函数调用操作时调用
  • construct: 使用new操作符时调用
  • defineProperty: 使用Object.defineProperty方法时调用
  • getOwnPropertyDescriptor: 使用Object.getOwnPropertyDescriptor方法时调用
  • getPrototypeOf: 使用Object.getPrototypeOf方法时调用
  • setPrototypeOf: 使用Object.setPrototypeOf方法时调用
  • isExtensible: 使用Object.isExtensible方法时调用
  • preventExtensions: 使用Object.preventExtensions方法时调用

可以看到Proxy对对象自定义行为的控制比Object.defineProperty更加全面。这里,我们重点关注和后者相同部分,即getset。虽然名称都是getset,但方法的传参不同。Object.defineProperty是针对对象的某个属性定义getset,而Proxy是针对整个对象。并且通过Proxy构造函数返回的是一个proxy实例,而不是原对象。因此,Proxy中的getset参数比Object.defineProperty的多了两个参数:

  • obj: 要代理的目标对象,即 target
  • key: 代理对象访问或设置的属性

以前文的data对象为例,定义getset方法如下:

const dataProxy = new Proxy(data, {
  get(obj, key) {
    return obj[key]
  },
  set(obj, key, newValue) {
    obj[key] = newValue
    // 表示成功
    return true
  }
})

这里和Object.defineProperty还有最大不同的是,前者响应式在新返回的代理对象生效,而对原对象属性尽心访问和修改是不会触发setget回调的。因此,如果使用Proxy重写前文的响应式视图更新,需要在读取和设置对象属性时使用dataProxy,完整代码如下:

<div id='app'></div>
<script>
  function reactive(target, update) {
    var targetProxy = new Proxy(target, {
      get(obj, key) {
        return obj[key]
      },
      set(obj, key, newValue) {
        obj[key] = newValue
        update()
        // 表示成功
        return true
      }
    })
    return targetProxy
  }

  var data = {
    text: 'hello'
  }
  var dataProxy = reactive(data, update)
  function update() {
    document.getElementById('app').textContent = dataProxy.text
  }
  update()
</script>

如果同样在浏览器控制台修改数据,我们应该使用dataProxy.text = 'xxx' 而不是 data.text = 'xxxx'

3. 基于虚拟DOM的视图更新

在《手写 Vue (一)》中,我们实现了基于虚拟 DOM 的视图挂载。现在结合响应式实现虚拟 DOM 的到真实 DOM 的响应式更新。

完整代码如下:

function Vue(options) {
  var vm = this
  function update () {
    vm.update()
  }
  var data = options.data
  var keys = Object.keys(data)
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    this[key] = data[key]
    defineReactive(this, key, update)
  }
  this.$options = options
}

Vue.prototype.render = function() {
  var render = this.$options.render
  return render.call(this, createVNode)
}

Vue.prototype.update = function() {
  var vnode = this.render()
  this.$el = createElm(vnode, this.$el.parentNode, this.$el)
}

Vue.prototype.$mount = function (id) {
  this.$el = document.querySelector(id)
  this.update()
  return this
}

function createVNode(tag, data, children) {
  var vnode = { tag: tag, data: undefined, children: undefined, text: undefined }
  if (typeof data === 'string') {
    vnode.text = data
  } else {
    vnode.data = data
    if (Array.isArray(children)) {
      vnode.children = children
    } else {
      vnode.children = [ children ]
    }
  }
  return vnode
}

function createElm(vnode, parentElm, refElm) {
  var elm
  // 创建真实DOM节点
  if (vnode.tag) {
    elm = document.createElement(vnode.tag)
  } else if (vnode.text) {
    elm = document.createTextNode(vnode.text)
  }
  // 将真实DOM节点插入到文档中
  if (refElm) {
    parentElm.insertBefore(elm, refElm)
    parentElm.removeChild(refElm)
  } else {
    parentElm.appendChild(elm)
  }

  // 递归创建子节点
  if (Array.isArray(vnode.children)) {
    for (var i = 0, l = vnode.children.length; i < l; i++) {
      var childVNode = vnode.children[i]
      createElm(childVNode, elm)
    }
  } else if (vnode.text) {
    elm.textContent = vnode.text
  }

  return elm
}

function defineReactive(obj, key, update) {
  var value = obj[key]
  Object.defineProperty(obj, key, {
    get: function() {
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        value = newValue
        update()
      }
    }
  })
  return obj
}

将以上代码保存到文件myvue_2.js中,再新建html文件myvue_2.html,替换以下内容:

<div id="app"></div>
<script src="myvue_2.js"></script>
<script>
var vm = new Vue(
  {
    data: {
      text: 'hello world!'
    },
    render(h) {
      return h('div', this.text)
    }
  }
).$mount('#app')
</script>

尝试在浏览器控制台输入:

vm.text = 'anything you like!!!'

如果看到显示内容即时更新为你修改的内容,那么,恭喜你成功做到了和 Vue 一样的响应式视图更新。

小结

我们成功利用set拦截,实现了响应式视图更新,但是还不够完美,因为,我们对data对象中任何属性的赋值都会执行视图更新操作,而不管update是否用到了这个属性。这意味着,如果data有很多个属性,但并非所有属性都会用于视图的渲染,这样我们就会做一些多余的视图更新操作,显然这是没有意义的性能开销。要做到自动根据update中实际使用的到属性,只对用到的属性执行视图更新,就涉及到依赖的搜集。关于依赖搜集的实现,我们在下一篇文章中继续探讨。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-04-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 响应式的本质
  • 2. 监听数据改变
    • 2.1 Object.defineProperty
      • 2.2 Proxy
      • 3. 基于虚拟DOM的视图更新
      • 小结
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档