专栏首页艺述思维石桥码农:Vue3 与 Vue2 在响应机制的实现上有什么差别?

石桥码农:Vue3 与 Vue2 在响应机制的实现上有什么差别?

文 / 李艺

目录

一、问题:vue2 通过数组索引改变数据不能触发视图更新是怎么回事?
二、分析:在 vue3 不存在这个问题,vue2 与 vue3 的响应机制分别是怎么实现的?
三、实践:现在如何体验 vue3 框架,四种体验方式的归纳
    3.1、在 vue2 项目中复用
    3.2、从 vue-next 源码编译
    3.3、基于项目模板
    3.4、基于插件自动转化
四、总结与思考

一、问题:vue2 通过数组索引改变数据不能触发视图更新是怎么回事?

vue 是目前最流行的前端框架之一,在中国区至少有 70 万的开发者在使用。

vue 开发者可能都遇到过这样一个问题:如果模板中数据绑定的是一个数组,我们在 js 代码里面,直接以索引方式改变数组元素的值,有时候视图并不会按照我们的期许更新。下面,我们创建一个代码示例,再现这个场景:

<template>
  <div>
    <p>{{arr}}</p>
    <button v-for="(item,index) in arr" :key="index" @click="change(item,index)">{{item}}</button>
    <button @click="arr.push(4)">push</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data(){
    return {
      arr:[1,2,3]
    }
  },
  methods:{
    change(item,index){
      this.arr[index]=0
    }
  }
}
</script>

这是一个 vue 组件,在一个独立的文件里,在它的数据对象 data 中有一个数组 arr,被它的模板以 v-for 循环的方式渲染在视图中。当我们单击这些动态渲染的带有数字的按钮时,视图并不会改变。

在上面的 js 代码中,我们明明通过索引改变了数组元素,为什么视图会没有效果呢?

现在我们运行一下,看看这个组件的实际运行效果:

我们看到,在运行效果中,一共有 4 个按钮,前 3 个是通过 v-for 循环动态渲染的,最后一个push按钮用于数据的动态添加。在运行中发现,我们单击前 3 个按钮,按钮文本不会改变,只有单击push按钮时,视图才会更新。

这是为什么?为什么通过数组索引改变元素的值,视图不能及时更新呢?这是不是 vue 框架的一个 bug 呢?

事实上这个问题 vue 团队也是承认的,并且在官方文档上也有提示,我们可以在这个链接:

https://cn.vuejs.org/v2/guide/list.html#数组更新检测

找到这样一段话:

由于 js 的限制,Vue 不能检测以下数组的变动,这些情况包括:

当你利用索引直接设置一个数组项时: vm.items[indexOfItem] = newValue

以及当你修改数组的长度时: vm.items.length = newLength

举个例子:

var vm = new Vue({
  data: {
    items: ['a', 'b', 'c']
  }
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的```

在官方文档中还明示,直接通过数组索引改变数组元素或直接修改数组长度,均不能触发视图更新。

那么在实际开发中,如果需要改变数组元素,应该怎么做呢?官方文档也给出了解决方案,可以使用Vue.set方法或使用数组的特定操作方法,例如splice。具体的示例代码是这样的:

// Vue.set
Vue.set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)

既然有问题,那么为什么 vue 框架没有优化解决呢?是不能解决吗?

答案是可以解决的。

现在我们修改一下上面测试代码中的change方法,在改变数组元素后,打印一下数组元素的值:

change(item,index){
  this.arr[index]=0
  // 数据可以改变,但视图不会更新
  console.log(this.arr[index]);
}

运行效果是这样的:

我们看到,当我们单击数字按钮时,即使视图没有更新,数据其实已已经更新了。

vue框架里,有这样一个forceUpdate方法:

vm.$forceUpdate()

将这个方法放在修改数组元素之后调用,其实也可以强制视图更新。也就是说,这个问题vue框架其实是可以解决的,并不是像文档中所说的“因为受js限制”不能解决。

事实上在前面的测试中,我们也发现当单击push按钮时,我们往数组推入了一个新数据项,这个时候所有视图都更新了,包括前面的数字按钮。

那么,为什么push按钮可以触发视图更新?这是因为vue框架虽然没有监听数组索引和数组长度的改变,但是对个别特殊的数组方法进行了钩子改造。当我们调用下面这 7 个数组方法时:

push、pop、shift、unshift、splice、sort、reverse

都会触发视图的更新响应。

关于数组索引更新的这个问题,在社区有人提出过疑问,vue团队也做出过回答:

从截图中可以看到,是因为性能的考虑,解决这个问题并维持响应机制的一致性,会影响用户体验和程序性能,投入产出不成正比,所以没有实现。

问题到这里已经明白了,接下来我们看看在vue3中这个问题是如何解决的。

二、分析:在 vue3 不存在这个问题,vu2 与 vu3 的响应机制分别是怎么实现的?

上面的问题在vue3得到了完美的解决。与前面的示例代码实现同样的功能,作者用vue3再实现一下,代码是这样的:

<template>
  <div>
    <button v-for="(item,index) in arr" :key="index" @click="change(item,index)">{{item}}</button>
    <button @click="arr.push(4)">push</button>
  </div>
</template>

<script>
import {ref} from 'vue'

export default {
  setup(){
    let arr = ref([1,2,3])
    function change(item,index){
      // 这行代码就可以起作用
      arr.value[index]=0
      // 数据变,视图亦变
      window.console.log(arr.value[index]);
    }
    return {arr, change}
  }
}
</script>

这是vue3的代码,setup是它独特的标志。主要代码都是在setup函数内实现,功能和上面示例是一样的,我们看一下运行效果:

从效果来看,当以数组索引改变数据时,不但数据更新了,视图也有更新。

那么问题来了,相同的代码逻辑,在vue2中存在的问题,在vue3中不是问题了,为什么?这也是这篇文章作者想探讨的核心问题。

答案在于vue2vue3的响应机制的实现方式不同,vue2的响应机制是基于Object.defineProperty实现的,而vue3是通过Proxy实现的。

可能有读者会问了,为什么不用vue3的实现方法将vue2优化一下呢,这样vue2不就没有问题了吗?

这个问题作者觉得可能有两个方面原因:第一个,2013 年vue第一个版本开始编写的时候,那个时候还没有Proxy语法可以使用,Proxyes6的语法,es62015 年颁布的;还有另外一个原因,在有了es6 Proxy语法以后,基于旧版本优化牵涉的代码太多,所以vue团队索性重新拉了一个新版本,当然还有其它新特性的考虑,统一都在新版本中一起做了。

那么这两种响应机制的实现方式,具体是怎样的呢,下面我们通过模拟、还有查看源码的方式,来对比它们之间的差别。

首先我们看这段代码:

function reactive(target) {
  for (let key in target) {
    let value = target[key]
    if(typeof value == 'object') {
      reactive(value)
    }else{
      Object.defineProperty(target, key, {
        get() {
          return value
        },
        set(newVal) {
          if (newVal !== value) {
            console.log('value change:', key, newVal)
            value = newVal
          }
        }
      })
    }
  }
}
console.log('init');

let data = {kind:1, arr:[1,2,3]}
reactive(data)
data.arr[2] = 0 //此行还会触发更新
data.arr.push(4)
data.arr[3] = 0 //此行不会触发更新
data.kind = 0

在这段代码中,我们模拟实现了vue2的响应机制。我们期望的是,当data.arr[2]data.arr[3]改变数据时,value change会打印出来。

运行时打印的结果是这样的:

init
value change: 2 0
value change: kind 0

data变量相当于vue数据源,当我们以数组索引的方式改变数据时,关于value change的打印并没有出现。

现在我们看一下Object.defineProperty这个方法。通过查看mdn文档,有这样一个描述:

Object.defineProperty() 
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

语法:
Object.defineProperty(obj, prop, descriptor)

具体的参数说明可以查看这个文档:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

vue2就是通过这样一个方法实现它的响应机制的。

在模拟了vue2的实现后,接下来我们来模拟vue3响应机制的实现,看这段代码:

function reactive2(target) {
 let handler = {
   get(target, key, receiver) {
     let value = Reflect.get(target, key, receiver)
     return typeof value === 'object' ? reactive2(value) : value
   },
   set(target, key, newVal, receiver) {
     console.log("value change2:",key, newVal);
     let res = Reflect.set(target, key, newVal, receiver);
     return res;
   }
 }
 return new Proxy(target, handler);
}
let proxy = reactive2({name: 'yangbo', arr:[1,2,3]})
proxy.name = 'yb';
console.log(proxy.name)
proxy.arr[2] = 0
console.log(proxy.arr[2])
proxy.arr.push(4)
proxy.arr[3] = 0
console.log(proxy.arr[3])

在这段代码中,我们通过Proxy创建了一个数据对象的代理,变量proxy相当于vue组件的数据源对象data

现在我们运行一下,运行时打印的结果是这样的:

init2
value change2: name yb
yb
value change2: 2 0
0
value change2: 3 4
value change2: length 4
value change2: 3 0

从运行效果来看,基于Proxy实现的响应体制,不仅能监听数组索引的修改,对数组长度的变化也有感知。也难怪vue3基于它进行响应机制的重构了,有了这个机制原来vue2对数组方法的钩子改造也不再需要了。

到这里,读者可能会有一个疑问:“这是你自己实现的响应机制,vue框架也是这么实现了吗?”

确认这个问题很容易,查看vue源码就可以了。我们先看vue2的源码。

大家可以在线打开或者下载这个vue仓库:

https://github.com/vuejs/vue

打开这个index文件:

vue/src/core/observer/index.js

大约 135 行有这样的代码:

// Define a reactive property on an Object.
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  ...
) {
  const dep = new Dep()
  ...

  Object.defineProperty(obj, key, {
    ...
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()//在这里收集的依赖,只有初始化阶段Dep.target有值,为真
        ...
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      ...
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify() //在这里实现的视图节点更新
    }
  })
}

可以看到源码与作者的实现方式是类似的,主要是通过Object.defineProperty定义了一个get和一个set。为了方便大家查看,作者把不相关的代码给略去了。在这里值得一提的是,关于dep这个对象,它是一个vue自实现的观察者模式对象,它在初始化阶段收集数据依赖,在数据更新时通过dep.notify()方法通知虚拟DOM节点更新视图。vue2判断是不是初始化阶段,是通过“dep.target”这个属性判断的,只有当初始化时,这个target属性才有值。

查看了vue2的源码,我们再看一下vue3响应机制的实现方式。

打开或下载这个vue-next仓库:

https://github.com/vuejs/vue-next

找到这个reactive文件:

packages/reactivity/src/reactive.ts

大约 108 行有这样一段代码:

function createReactiveObject(
  target: unknown,
  ...
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  ...
  // 在初始化阶段使用collectionHandlers,运行时阶段使用baseHandlers
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  observed = new Proxy(target, handlers)
  ...
  return observed
}

在这段代码中,通过new Proxy实现了响应机制。在初始化阶段,使用collectionHandlers作为handlers的实参;在运行时阶段,使用baseHandlers作为实参。collectionHandlers是用于收集数据依赖的,重点我们看baseHandlers的实现。

再看一下这个baseHandlers文件:

packages/reactivity/src/baseHandlers.ts

大约 140 行有这样一段代码:

export const mutableHandlers: ProxyHandler<object> = {
  get,// from createGetter
  set,// from createSetter
  deleteProperty,
  has,
  ownKeys
}

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: object, key: string | symbol, receiver: object) {
    ...
    const res = Reflect.get(target, key, receiver)
    ...
    return ...res
  }
}

function createSetter(isReadonly = false, shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    ...
    const oldValue = (target as any)[key]
    ...
    const hadKey = hasOwn(target, key)
    const result = Reflect.set(target, key, value, receiver)
    ...
    return result
  }
}

这是上面提到的baseHandlers的实现代码,与作者实现的vue3响应机制的代码具有相似的脉路,都是通过创建一个Proxy对象,代理对实际的数据对象的访问。关于collectionHandlers的实现,大家可以自己查看源码,这里不多做介绍了。

到这里,我们的问题基本上已经讲完了,不知道作者有没有讲明白,大家是不是都清楚了呢。上面涉及的所有示例代码,都可以从这里下载:

https://git.code.tencent.com/shiqiaomarong/vue-go-rapiddev-example/tags/20200206

关于vue2的示例代码在这里查看:

vue-and-go-example/vue-next2/src/components/HelloWorld.vue

vue3的示例代码在这里查看:

vue-and-go-example/hello-vue3-sfc/src/components/HelloWorld.vue

通过源码编译体验vue3框架的示例代码在这里查看:

vue-and-go-example/vue3-from-source/index2.html

三、实践:现在如何体验vue3 框架,四种体验方式归纳

目前vue3还没有正式发布,还处于alpha阶段,读者可能想提前试用vue3,作者整理一下,大概有 4 种体验方式,分享给大家。

3.1、在 vue2 项目中复用

第一个方式,就是直接在vue2项目里,通过安装一个插件,体验vue3的组合API编码风格,这个插件是@vue/composition-api,具体的体验步骤是这样的:

// 1 使用 vue cli 创建普通的 vue2 项目
vue create vue-next2

// 2 安装插件
yarn add @vue/composition-api

// 3 修改 main.js,启用插件:
import VueCompositionApi from "@vue/composition-api";
Vue.use(VueCompositionApi);

// 4 引用并使用
import CompositionApi from './components/CompositionApi.vue'

具体示例可以查看:

vue-and-go-example/vue-next2/src/components/CompositionApi.vue

这种方式使用的还是vue2的框架,并不是vue3的框架。那么能不能直接使用vue3的框架呢,因为目前vue3已经发布alpha4第 4 个版本了,离真正发布越来越近了?

答案是可以的。

3.2、从 vue-next 源码编译

目前通过指令安装,安装的仍然是vue2版本,但我们可以通过源码编译的方式,直接使用vue3框架。这就是第二种方式,步骤大概是这样的:

// 1 下载源码
git clone https://github.com/vuejs/vue-next.git --depth=1

// 2 安装依赖,准备编译
cd vue-next
yarn

// 3 编译源码
yarn build vue -f global

// 4 在html页面中使用
packages/vue/dist/vue.global.js

使用这种方式,有一个html示例代码供参考:

<div id="app">
  <button @click="increment">
    Count is: {{ state.count }}, double is: {{ state.double }}
  </button>
  <counter></counter>
</div>

<script src="vue.global.js"></script>

<script>
  let { reactive, ref, computed } = Vue

  const Counter = {
    template: `
        <div>
            <span>{{ count }}</span>
        </div>
        <div>
            <button @click="increase">add</button>
        </div>
    `,
    setup() {
      const count = ref(0)
      function increase() {
        count.value++;
      }
      return {
        count,
        increase
      }
    }
  }

  Vue.createApp({
    components: {
      Counter
    },
    setup() {
      const state = reactive({
        count: 0,
        double: computed(() => state.count * 2)
      })

      function increment() {
        state.count++
      }

      return {
        state,
        increment
      }
    }
  }).mount('#app')
</script>

在这段代码中,由于没有工程化编译支持,是通过script标签加载vue.global.js文件的。vue3的业务逻辑都集中在setup这个函数里。vue3也支持components组件复用,在上面代码中,Counter就是一个独立的vue组件。

这种源码编译的体验方式比较麻烦,由于国内网络环境的原因,在使用yarn指令安装插件依赖时,可能会出现安装失败。那么有没有更简单的方法体验vue3框架呢?

答案也是有的。

3.3、基于项目模板

第三种方式就是直接使用vue团队提供的预设项目模板:

https://github.com/vuejs/vue-next-webpack-preview

使用步骤很简单:

// 1 下载源码
git clone https://github.com/vuejs/vue-next-webpack-preview.git --depth=1

// 2 安装、运行
yarn
yarn serve

在这个vue3项目模板里,运行vue3代码所需的全部依赖都已经设置好了,基于这个源码直接修改就可以试用。

在前不久,大概 2020 年 1 月 13 日 vue 团队公布了一个插件,名称叫vue-cli-plugin-vue-next。这个插件的可以将我们普通的vue2项目自动转为一个vue3项目,它会自动修改工程配置以满足需要,这就是第 4 种体验方式。

3.4、基于插件自动转化

这个插件是vue-cli-plugin-vue-next,地址在这里:

https://github.com/vuejs/vue-cli-plugin-vue-next

这种方式体验起来也很简单,但是网络必须要好。

先是用vue cli创建一个普通的vue2项目,然后再安装这个插件vue-cli-plugin-vue-next。这个插件会自动修改项目的package.json配置文件、还有部分文件源码。具体步骤是这样的:

// 1 更新工具、创建 vue2 项目
yarn global add @vue/cli
vue create hello-vue3-sfc

// 2 安装插件
cd hello-vue3-sfc
vue add vue-next

// 3 启动、运行项目
yarn & yarn serve

这个插件的 0.0.3 版本有一个 bug,作者写这篇文章时发现了这个问题。在 main.js 文件里的这行代码:

createApp().mount(App, '#app')

应该改为:

createApp(App).mount('#app')

这行错误代码会引发一个莫名奇妙的异常:

error:
Uncaught TypeError: parent.appendChild is not a function

但随后作者发现,这个 Bug 在新版本 0.0.4 中被修复了。如果你安装的是 0.0.3 版本,直接将这行代码修改就可以正常运行。如果是使用淘宝镜像源安装的,不能及时获取新版本,可以尝试切换到官方源:

yarn config set registry https://registry.yarnpkg.com

安装完成以后,可以再切换回来:

yarn config set registry https://registry.npm.taobao.org

这个插件在安装时,会询问“插件会修改代码,要不要继续?”:

There are uncommited changes in the current repository, it's recommended to commit or stash them first.
? Still proceed? Y

在终端中直接敲入Y或Yes就可以完成。那么,这个插件它是如何工作的呢?可以通过查看源码得知,在插件源码的generator目录下,放置的是替换逻辑,指定vue2项目中哪些文件哪些内容被替换为什么内容,感兴趣的读者可以在线查看。

关于这个插件的使用,有一个老外还录制了一个很不错的视频教程,可以通过这个地址查看:

https://www.youtube.com/watch?v=o-jiS563yI8

好啦,以上就是目前提前体验vue3框架的 4 种方式。读者朋友们可以根据自己的情况灵活选择。

四、总结与思考

值得注意的是,以上 4 种方式,无论是哪一种,vue 团队都有明确的警示,现在vue3框架还处于alpha阶段,不是正式版,不建议在真实的生产环境中使用,在体验过程中也可能会遇到各种 Bug,这些都是正常的。

在体验 vue3 框架时,如何知道哪些方法可以使用、以及如何使用呢,在 vue2 中实现的功能在 vue3 中应该如何实现呢,在哪里查看这些说明,有一个文档可以帮助我们:

https://vue-composition-api-rfc.netlify.com/#basic-example

最后我们总结一下,今天这篇文章主要讲了一个问题,就是vue2vue3在响应机制的实现上有哪些差别,还有vue2项目里使用数组更新数据时视图不更新的问题在vue3中是如何完美解决的,并简略对比了两个版本的源码实现。不知道作者有没有讲明白,读者朋友们有什么问题,欢迎在评论区留言探讨。

vue2的实现方式是在数据源对象上通过Object.defineProperty方法递归创建属性实现的,这些属性是属于被创建对象的;而vue3的实现方式,是通过给数据对象创建一个Proxy代理实现的,访问这个数据对象的任何属性都会通过这个代理。在vue3中并没有创建多余的对象属性,无论从代码的优雅程度上,还是从性能上考虑,vue3的方案都更胜一筹。在实际的项目开发中,如果我们也需要 对一个对象的属性施加监听,也可以考虑通过Proxy代理的方式实现。

最后谈一点作者对vue3框架的体验感受吧:首先,vue3的响应机制的实现更加高效了,效率更高了,同时它也解决了vue2中遗留的数组更新问题;其次,在vue3项目中,使用组合风格的 Api 编写业务逻辑更加自由。目前vue3已经发布了alpha4版本,离正式发布已经很接近了。作者很期待新版本的发布。相信新版本发布后,会有更多的开发者使用和喜欢这个框架的。不知道读者朋友们在体验后怎么看,欢迎留言。

参考链接

  • https://juejin.im/post/5dac69bf5188252b51183982 vue2.0 响应式到vue3.0响应式原理
  • https://juejin.im/post/5c8ce1005188257ade024ed6 基于Object.defineProperty之后,vue源码解析依赖收集、依赖触发
  • https://juejin.im/post/5e1c19856fb9a0301d11ae0b vue为什么不能检测数组的变化
  • https://www.vuemastery.com/blog/vue-3-start-using-it-today/ 开始试用 vue 3
  • https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty 关于 Object.defineProperty()

本文分享自微信公众号 - 用故事讲技术(ygsjjs),作者:石桥码农

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-02-06

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 12 手写配置启动一个 vue2 项目

    2019年10月5日,vue 团队发布了 Vue3.0 预览版源码,预计到 2020 年第一季度将发布 3.0 正式版。3.0 包涵了许多激动人心的新特性。

    李艺
  • vue 开发常用工具及配置一

    访问 nodejs.org 下载。这是必不可缺的环境之一。下载最新的 LTS 版本。LTS 代表长期维护,通常比较稳定。

    李艺
  • 23 列表渲染与“就地复用”原则

    除了使用for in,还可以使用for of。以of代替in,在数组遍历与对象遍历中是通用的。

    李艺
  • webpack+vue2.4+bootstrap4创建工程项目

    之所以不想用现成的桌面UI和移动UI是觉得现存的组件库真心丑,所以希望自己采用bootstrap4 能高度定制化, 这个方案暂时只适合桌面端,移动端控件真心还没...

    lilugirl
  • Zynq-7000 ARM端MIO的使用

    Xilinx Zynq-7000 芯片的PS端MIO(multiuse I/O)所在位置如下图红色框所示。MIO(0:15)在bank0上,MIO(1...

    FPGA开源工作室
  • 微软与瑞银达成协议,将银行数据上云

    银行数字化是必然的趋势,但是考虑到数据安全,目前大多数银行数据都选择采用本地服务器,但是随着服务内容和数据的增多,本地的维护成本太高等问题的出现,银行是否上云成...

    镁客网
  • React创建build生产构建,使用Nginx服务器部署及报500错误的解决方法

    今天尝试使用 Nginx 服务器跑 React build 生产构建,结果报错“500 Internal Server Error”。查了些资料,最后解决了,顺...

    德顺
  • eclipse 集成阿里的p3c插件

    IT故事会
  • flink 到底有什么优势值得大家这么热衷

    flink 通过实现了 Google Dataflow 流式计算模型实现了高吞吐、低延迟、高性能兼具实时流式计算框架。

    kk大数据
  • 逆向对抗技术之ring3解除文件句柄,删除文件

    这些问题主要是工作中会遇到.包括后面的逆向对抗技术.有的可能只会提供思路.并且做相应的解决与对抗.

    IBinary

扫码关注云+社区

领取腾讯云代金券