文 / 李艺
目录
一、问题:vue2 通过数组索引改变数据不能触发视图更新是怎么回事?
二、分析:在 vue3 不存在这个问题,vue2 与 vue3 的响应机制分别是怎么实现的?
三、实践:现在如何体验 vue3 框架,四种体验方式的归纳
3.1、在 vue2 项目中复用
3.2、从 vue-next 源码编译
3.3、基于项目模板
3.4、基于插件自动转化
四、总结与思考
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
得到了完美的解决。与前面的示例代码实现同样的功能,作者用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
中不是问题了,为什么?这也是这篇文章作者想探讨的核心问题。
答案在于vue2
和vue3
的响应机制的实现方式不同,vue2
的响应机制是基于Object.defineProperty
实现的,而vue3
是通过Proxy实现的。
可能有读者会问了,为什么不用vue3
的实现方法将vue2
优化一下呢,这样vue2
不就没有问题了吗?
这个问题作者觉得可能有两个方面原因:第一个,2013 年vue
第一个版本开始编写的时候,那个时候还没有Proxy
语法可以使用,Proxy
是es6
的语法,es6
是 2015
年颁布的;还有另外一个原因,在有了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
还没有正式发布,还处于alpha
阶段,读者可能想提前试用vue3
,作者整理一下,大概有 4 种体验方式,分享给大家。
第一个方式,就是直接在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 个版本了,离真正发布越来越近了?
答案是可以的。
目前通过指令安装,安装的仍然是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
框架呢?
答案也是有的。
第三种方式就是直接使用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 种体验方式。
这个插件是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
最后我们总结一下,今天这篇文章主要讲了一个问题,就是vue2
和vue3
在响应机制的实现上有哪些差别,还有vue2
项目里使用数组更新数据时视图不更新的问题在vue3
中是如何完美解决的,并简略对比了两个版本的源码实现。不知道作者有没有讲明白,读者朋友们有什么问题,欢迎在评论区留言探讨。
vue2
的实现方式是在数据源对象上通过Object.defineProperty
方法递归创建属性实现的,这些属性是属于被创建对象的;而vue3
的实现方式,是通过给数据对象创建一个Proxy
代理实现的,访问这个数据对象的任何属性都会通过这个代理。在vue3
中并没有创建多余的对象属性,无论从代码的优雅程度上,还是从性能上考虑,vue3
的方案都更胜一筹。在实际的项目开发中,如果我们也需要 对一个对象的属性施加监听,也可以考虑通过Proxy
代理的方式实现。
最后谈一点作者对vue3
框架的体验感受吧:首先,vue3
的响应机制的实现更加高效了,效率更高了,同时它也解决了vue2
中遗留的数组更新问题;其次,在vue3
项目中,使用组合风格的 Api 编写业务逻辑更加自由。目前vue3
已经发布了alpha4
版本,离正式发布已经很接近了。作者很期待新版本的发布。相信新版本发布后,会有更多的开发者使用和喜欢这个框架的。不知道读者朋友们在体验后怎么看,欢迎留言。