在制作业务过程中,经常有vue开发会遇到。当前数据更新了但是视图并没有更新的问题。
这个涉及到了Vue自身对于变化侦测方面的处理,所以给大家讲一下Vue是如何实现的变化侦测。
极简的双向绑定示例
首先从最简单的双向绑定入手:
<!DOCTYPE html>
<html>
<body>
// html
<p>请修改输入字段中的文本,然后在字段外点击以触发 onchange。</p>
<span id="span">我是默认内容</span>
<br />
<input id="input" type="text" name="txt" value="Hello" />
// javascript
<script>
let span = document.getElementById('span')
let input = document.getElementById('input')
input.addEventListener('change', (event) => {
span.innerHTML = event.target.value
})
</script>
</body>
</html>
从上面代码中可以看到,我们是操作DOM来实现的响应式。
但是这样看来,相比现在的Vue响应式 存在着很多很多不方便。比如我们要手动为对应的输入框来绑定change。所以这一步操作能不能自动运行呢?让我们可以操作数据而不去操作DOM就可以更新DOM。
我们在这个阶段情况下,利用数据模型Model在视图中显示是个很简单的事情,而且View对Model的修改也是非常容易的。但反过来,Model修改时,View也对应刷新。
这就不简单了,我们就需要知道Model的哪个数据它修改了,它对应使用的View是谁 在这方面 React和vue提供了两个不同的解决方案,具体可以参考这篇文章。
vue数据响应式的中心思想
我们Vue使用的响应式数据的实现思路,就是在Vue初始化时,将传入的data选项进行遍历,并且对这个data选项内的对象所有的属性使用Object.defineProperty将这些属性转为getter/setter。
而且我们这个Object.defineProperty是es5的属性,还没法使用babel将其转化为更低版本的实现。因为在ES5之前并没有相关的实现。所以无法进行降级处理,也就无法支持IE8以及以下版本的浏览器。
贴一张官方的图 通过这张图可以明显地看到我们这个响应式系统有watcher render data(dep,obseorver)
这时就要引出我们的Object.defineProperty了,它是构成我们Vue变化侦测的重要方法。
Object.defineProperty是什么东西?
Object.defineProperty方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
一般的用法是使用get和set方法来监听读写操作如下。
|
let car = {
brand: 'BMW',
price: 3000
}
let val = 'BMW'
// 使用 Object.defineProperty 监听了car对象的brand属性的读写操作
Object.defineProperty(car,'brand',{
enumerable: true, // 是否可枚举 Object.keys(car)
configurable: true,// 是否可删除 delete car.brand
get(){
console.log('brand属性被读取了')
return val;
},
set(newVal){
console.log('brand属性被修改了')
val = newVal;
}
})
将Vue示例上的data变为响应式
|
// 源码位置:src/core/observer/index.js
/**
* Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
* 需要注意的是 如下的大多数代码都是伪代码 只是真实代码中的一部分
*/
export class Obseorver {
constructor (value) {
this.value = value
if (Array.isArray(value)) {
// 当value为数组时的逻辑
// ...
} else {
this.walk(value)
}
}
walk (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive (obj,key,val) {
// 如果只传了obj和key,那么val = obj[key]
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
if(val === newVal){
return
}
console.log(`${key}属性被修改了`);
val = newVal;
}
})
}
在Observer中判断值的类型,将object类型的数据通过walk来转换为get/set形式来侦测变化。最后还在defineReactive中判断,val值的类型来递归子属性。
defineReactive 是真正为数据添加 get 和 set 属性方法的方法,它将 data 中的数据定义一个响应式对象。
并给该对象设置 get 和 set 属性方法,其中 get 方法是对依赖进行收集, set 方法是当数据改变时通知 Watcher 派发更新。
vue是怎么知道谁利用了响应式数据?依赖收集
依赖收集的原理是:当视图被渲染时,会触发渲染中所使用到的数据的 get 属性方法,通过 get 方法进行依赖收集。
思考一下,我们之所以要将数据变为可观测数据,其目的是在当数据发生变化时,可以通知那些使用数据的地方。
举个例子:
|
<template>
<h1>{{ name }}</h1>
</tempalte>
该模板中使用了name这条数据,所以当name发生变化时候,要向这个使用的地方发送通知。
那么依赖什么时候需要收集和更新? 可观测数据被获取的时候收集依赖,也就是 在getter中收集依赖。可观测数据被更改的时候更新依赖,也就是在setter中更新依赖 。
那么我们知道了何时去收集依赖和更新依赖,但是我们要将依赖收集到哪里去呢?
最好的做法是为每个数据创建一个依赖管理器,把这个数据的所有依赖都管理起来。
假设 ,每个key都有一个数组 用来存储当前key的依赖 ,并且假设依赖是个函数,并保存在window.target上。
|
let uid = 0
export default class Dep {
constructor() {
this.id = uid++
this.subs = []
}
// 添加
addSub(sub) {
this.subs.push(sub)
}
// 删除依赖
removeSub(sub) {
this.remove(this.subs, sub)
}
// 添加一个依赖
depend() {
console.log('收集依赖');
if(window.target) {
console.log('depend',window.target);
this.addSub(window.target)
// dep会记录 数据发生变化时需要通知哪些watcher watcher也记录了自己会被哪些dep通知
}
}
// 通知更新所有依赖
notify() {
console.log('notify','更新依赖');
const subs = this.subs.slice()
for(let i = 0,len= subs.length;i<len;i++) {
subs[i].update()
}
}
// 删除
remove(arr, item) {
// 把watcher从sub中删除调 当数据发生变化时 就不会通知这个已被删除的watcher unwatch原理
if (arr.length) {
const index = arr.indexOf(item)
if(index > -1) {
return arr.splice(index,1)
}
}
}
}
这里我们新增加了一个类叫做Dep,用来管理依赖。
然后我们将defineReactive稍稍改造一下。
|
function defineReactive (obj,key,val) {
// 如果只传了obj和key,那么val = obj[key]
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
let dep = new Dep(); // 修改的地方
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
console.log(`${key}属性被读取了`);
// 添加依赖
dep.depend(); // 修改的地方
return val;
},
set(newVal){
if(val === newVal){
return
}
console.log(`${key}属性被修改了`);
val = newVal;
dep.notify(); // 修改的地方
}
})
}
在set被触发的时候,我们调用dep中的notify方法来更新依赖。
在get被触发的时候,我们调用dep中的depend方法来收集依赖。
那到底什么是依赖?
在上面说了这么多依赖(window.target),那么这个依赖到底是什么呢?
收集谁 换句话来说就是 当数据发生改变的时候 通知谁。
但是因为,我们用到这个数据的地方有很多,而且类型不一致,比如我可能是在template中使用,也可能在watch中使用。所以需要抽象出一个能集中处理这些情况的类,当数据变化的时候,我们只需要通知它,让他内部再通知其他的地方。所以vue 给他起了个好听的名字 “watcher”。 所以我们要收集watcher!
什么是watcher呢?
对于watcher的定义:watcher是一个中间角色,用来数据变化时候通知它,它再去通知其他。
关于watcher有一个经典使用方式
|
vm.$watch(data.a.b.c,function (newVal,oldVal) {
// 做点什么
})
可以想一下,我们该如何实现这个功能?是不是只需要将data.a.b.c属性添加到dep中就行了。
当data.a.b.c的值发生变化的时候,通知watcher,watcher执行参数中的回调函数。
下面来看一下 watcher的实现
|
import { parsePath } from '../../../util/lang'
export default class Watcher {
constructor(vm, expOrFn, cb, options) {
this.vm = vm
this.getter = parsePath(expOrFn) // 执行this.getter() 就可以读取data.a.b.c的内容
this.cb = cb
this.value = this.get()
}
get() {
// 把watcher实例自身赋给了全局的一个唯一对象window.target上
window.target = this
// 读取data.a.b.c的值 触发getter 获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter 把this 指向到vm
let value = this.getter.call(this.vm, this.vm)
window.target = undefined
return value
}
update() {
console.log('update', '更新依赖')
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
// parsePath的代码
/**
* 解析简单路径
*/
export const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
/**
* 解析简单路径
* 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
* 例如:
* data = {a:{b:{c:2}}}
* parsePath('a.b.c')(data) // 2
*/
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path){
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
watcher逻辑如下。
- 当实例化Watcher类的时候,会先执行构造函数。
- 在构造函数中调用了this.get()实例方法。
在get()方法中,通过window.target = this 把实例赋值给了全局的一个唯一对象 window.target上, 然后通过
let value = this.getter.call(this.vm, this.vm)
获取一下被依赖的数据 此步目的是为了触发数据的getter,所以我们触发了收集依赖。
- 在dep.depend() 中 取到挂载到window.target的值 也就是 watcher 实例 并存入依赖数组中,最后在get方法中将window.target释放掉
- 当数据变化的时候 会触发数据的setter,因为在setter中调用了dep.notify(),我们在dep.notify()中 遍历了所有依赖,并执行依赖的update()方法,也就是Watcher类中的update()实例方法,在update()方法中我们调用了数据变化的更新回调函数,从而更新视图。
vue是如何将模板与响应式数据绑定起来的
在日常开发过程中,我们把写在template标签内的类原生html内容称之为模板。
Vue将template内的内容进行编译,把属于Vue能力的东西进行处理。比如变量,指令等,生成渲染函数Render。
Render会将模板内容生成为对应的VNode,而VNode经过一系列优化处理后,最后根据VNode创建真实的DOM节点并插入到视图中,最终完成视图的渲染。
在这个阶段具体流程如下
- 模板解析阶段:将一堆模板字符串用正则表达式解析成抽象语法树AST
- 优化阶段: 遍历AST.找出其中的静态节点并打上标记
- 代码生成阶段:将AST转为渲染函数。
vue在当前情况下存在的问题
虽然上面一系列的操作 通过Object.defineProperty方法实现了对object数据的可观测,但是这个方法目前只是能观测到取值和设置值的操作 ,当对object中添加一堆新的key/value时 或删除一堆已有的Key/value时,他是无法观测到的。
导致我们新添加的key/value后,无法通知依赖,从而也就导致对应的视图无法进行响应式更新。
总结
所谓的变化侦测就是侦测数据的变化。当数据变化时候能够侦测并通知。
data通过observer转换成了getter/setter的形式来追踪其变化
- 当外界通过Watcher读取数据时,会触发getter将Watcher添加到依赖中
- 当数据发生了变化时,会触发setter,从而向Dep中的依赖(即Watcher)发送通知
- Watcher接收到通知后 会向外界发送通知 变化通知到外界后可能会触发视图更新,也可能会触发用户的某个回调函数.
Vue正确操作数据的方法。
更改数据触发视图更新需要满足条件:
1、数据是被vue劫持的; 例如 vm.name
2、视图中有用到对应的数据;
{{name}}
|
// 引用数据类型新增加属性 操作方法 注:vm是vue实例
// 方法一. 此方法是替换了obj的内存指向,所以更改了obj的内存指向整个会重新劫持。
vm.obj = {
...vm.obj,
b:1000
}
// 方法二.此方法是官方提供的一个API 用以将新增的属性变为响应式数据
vm.$set(obj,b,1000)
|
// 数组 增删改查
// 对于数组的增删改查 应当避免使用索引和长度来操作数组 因为Vue内部只对数组的七个方法进行了劫持
// 方法一. 此方法同样是替换了array的内存指向,索引更改了array的内存指向整个会重新劫持。
vm.array = [...vm.array,[1,2,3]]
// 方法二. 利用Vue劫持的数组操作方法来进行操作 能触发视图更新的方法有:push pop unshift shift reverse splice sort
// 关于数组劫持 源码位置在 https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js 在这里就可以看到vue所劫持的方法
vm.array.push(100);
vm.array.splice(2,1,30);
|
// 其内部还有一个强制更新视图的方法 迫使Vue.js实例重新渲染。注意它仅仅影响实例本身以及插入插槽内容的子组件,而不是所有子组件。
vm.$forceUpdate()
// 源码内部实现如下 其实内部只是执行实例watcher的update方法,就可以使Vue.js实例重新渲染。
Vue.prototype.$forceUpdate = function () {
var vm = this;
if (vm._watcher) {
vm._watcher.update();
}
};
需要注意的是,如果你目前场景需要使用$forceUpdate()了,那么毫无疑问你的代码绝对有问题。