引言
在Vue中,我们的基本操作像下面这样:
<template>
<div id='app' @click='change'>
<span>{{ msg }}</span>
</div>
</template>
<script>
export default {
data() {
return {
msg: 'hello,world'
}
},
methods: {
change() {
this.msg = 'abcdefg';
}
}
}
</script>
当我们点击div时,会发现span里边的内容由hello,world变成了abcdefg。这就是Vue响应式系统比较直观的展现了。我们只需要改变数据,就能在视图上看到这种改变。
抛开Vue,我们怎么样才能实现这样一个系统呢?即我只需要更新数据,就能直接在视图上看到变更后的数据......
基本思路其实也很简单。下面我们一步步来实现它。首先定义一段html模板。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Static Template</title>
</head>
<body>
<div class="app"></div>
<script>
window.onload = function() {
const oApp = document.querySelector(".app");
const data = {
msg: ""
};
const Event = {
subs: {},
add(name, fn) {
if (this.subs[name]) {
this.subs[name].push(fn);
} else {
this.subs[name] = [fn];
}
},
fire(name, data) {
if (this.subs[name]) {
this.subs[name].forEach(fn => {
fn(data);
});
}
}
};
const watcher = function(dom) {
Event.add("change", function(data) {
dom.textContent = data;
});
};
Object.defineProperty(data, "msg", {
get() {
watcher(oApp);
return this.value;
},
set(newVal) {
this.value = newVal;
Event.fire("change", newVal);
}
});
// test
data.msg = "maxy61";
alert(data.msg);
setTimeout(() => {
data.msg = "maxy61209";
alert(data.msg);
}, 3000);
};
</script>
</body>
</html>

这里,我们实现了修改data.msg,div的内容随之变化的效果。但是和vue的那种令人舒服的调用模式还是天壤之别。不过尽管简单,但是也是Vue能够做到模板响应数据变化的基本原理。
即通过Object.defineProperty来对数据进行拦截,拦截其get和set操作。在get时,收集关于访问该数据的dom模板信息;当数据改变时,调用之前添加的关于dom模板信息的函数。在Vue中,添加的是watcher实例。而和dom相关的watcher实例叫做渲染watcher。
Vue的响应式系统
Vue中的双向绑定的实现依赖于其响应式系统。而响应式系统的实现主要靠数据劫持+发布订阅模式来实现的。
在Vue2.0中,数据劫持主要依靠Object.defineProperty来实现的。而发布订阅模式主要表现在Dep和Watcher的关系上。具体代码如下:
// 来自vue源码中 src/core/observer/index.js中
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
Vue的数据响应过程主要分为三步:1. defineReactive拦截get和set;2. 收集渲染watcher 3. 数据改变时触发渲染watcher,更新dom。
- 在Vue实例初始化时,会对配置项中options的data进行处理。对data进行遍历,然后对其中的每一项都用defineReactive拦截数据的get和set操作,同时为每个数据设置一个依赖收集器dep,来使其变成响应式属性。
- 在初始化的最后,会调用vm.$mount操作。该操作最终会生成一个渲染Watcher,渲染watcher在实例化的时候会调用watcher.get方法,于是Dep.target被设置为当前渲染watcher。之后调用vm._update(vm._render(), hydrate)。在这个过程中会触发对数据的访问,进而触发数据的get,于是当前的watcher就被添加到所访问数据dep的subs里。
- 当html模板所访问的数据发生变化时,数据的依赖收集器dep会调用notify方法,进而调用之前收集到的渲染watcher。渲染watcher最终再次调用vm._update(vm._render(), hydrate)方法,完成对html模板的更新。
主要用到了三个构造函数: Observer, Dep, Watcher
Observer: 一个object对应一个Observer实例,对object下的每一项进行defineReactive(即数据劫持),使其变成响应式属性。
Dep: 负责收集watcher及发布更新,和数据是一对一的关系
Watcher: 负责具体的更新操作
Vue数据劫持的缺陷
然而,Vue2.0的数据劫持也存在一定的问题。当数据是一个数组时,我有这样一段代码:
<template>
<div id="app">
<img width="25%" src="./assets/logo.png">
<p v-for='val in arr' :key='val'>{{val}}</p>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
arr: [1, 2, 3, 4, 5]
}
},
mounted() {
setTimeout(() => {
this.arr[0] = 10;
}, 3000);
}
};
</script>
<style>
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
然而在mounted中的改变并没有任何效果,执行效果详见codesandbox。Vue2.0对于数组的处理,是修改了Array原型上的几个方法,只有对数据的操作调用了对应的方法,才会具有响应式属性的效果。具体的方法有push, pop, shift, unshift, splice, sort, reverse. 当被观察的数组调用了上面这些方法时,才能够触发对应的视图更新。当然,如果数组中有object,当object变化时,视图也能够更新。
鉴于当前数据劫持方案所暴漏出的缺陷,在Vue3.0中,尤大用ES6推出的Proxy来代替Object.defineProperty。Proxy相对于Object.defineProperty,提供了更加全面的数据劫持能力。详见Proxy文档。
- 采用Proxy用作数据劫持后,我们可以对整个对象进行数据拦截,而不用再一个个定义属性的key去做拦截。不过如果对象下的属性是对象的话,需要对深层的数据再次生成Proxy实例进行数据拦截。
- Proxy提供了get, set, apply, has, construct, deleteProperty, defineProperty等更多的数据劫持方法。
- 浏览器原生支持,无需像Object.defineProperty一样在处理数组时通过修改数组原型方法做hack。例如:数组demo
大概就是这样了。