引言
在Vue中,我们的基本操作像下面这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <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模板。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| <!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); } });
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的关系上。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| 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 }
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 if (newVal === value || (newVal !== newVal && value !== value)) { return } if (process.env.NODE_ENV !== 'production' && customSetter) { customSetter() } 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的数据劫持也存在一定的问题。当数据是一个数组时,我有这样一段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <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
大概就是这样了。