# 第一篇 变化侦测
Fork vuejs/vue - github (opens new window),切换到 2.7.x 之前的最后一个版本,v2.6.14,因为 2.7 开始增加了一些和 vue3 类似的 API, 而且将 js 改成了 ts,增加了代码量,看 vue2 源码 2.6 的版本比较合适
对应 src/core/observer - vue (opens new window) (/əbˈzɜːvə(r)/) 目录
核心是 Watcher (/ˈwɒtʃə(r)/ 观察者) 和 Dep(Dependence 依赖)
# 2. Object 的变化侦测
# 2.1 什么是变化侦测
简单来讲,就是监听数据的变化,进行页面更新。
Vue.js 会通过状态(data 数据)生成 DOM,并显示到页面,这个过程叫渲染。
为了方便理解后面会将 状态 简单的描述为 data 数据。
在运行时,应用内部不断发生变化,需要不停的重新渲染,怎么判断是那些 data 发生了变化呢?
变化侦测就是用来解决这个问题的,它分为两种类型:
- push Vue 中使用的方式,当 data 发生变化,vue 会立即知道,并知道哪些数据变了,可以进行更细粒度的更新。
- pull React 和 Angular 中变化侦测使用的方式,data 变化时,它不知道具体哪个 data 变了,会进行一个暴力比对来找出那些 DOM 节点需要重新渲染。Angular 中对应脏检查流程,React 中使用的是虚拟 DOM
但是粒度越细,每个状态所绑定的依赖越多,内存开销越大。因此 Vue 2.0 版本引入了虚拟 DOM,将粒度调整为中等粒度。
一个 data 绑定的依赖不再是具体的 DOM 节点,而是一个组件。状态变化后会通知组件,组件内部进行虚拟 DOM 比对,大大降低依赖数量,减少内存消耗。
# 2.2 如何追踪变化 defineReactive
Vue2 使用 Object.defineProperty 来监测对象的变化。
下面的 defineReactive 是对 Object.defineProperty 的封装
- 每当从 data 的 key 中读数据时,get 函数会触发
- 每当往 data 的 key 中设置数据时,set 会触发
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
enumerable: true, // /ɪˈnjuːm(ə)rəb(ə)l/
configurable: true,
get: function () {
return val
},
set: function (newVal) {
if (val === newVal) {
return
}
val = newVal
}
})
}
# 2.3如何收集依赖
什么是依赖?依赖就是在 template/watch 中用到 data 的地方。这些地方依赖数据进行视图更新。当 data 变更,要通知这些依赖进行更新。一个依赖就是一个使用到 data 的地方。每个 data 属性都会有一个 dep 属性用于存放它的依赖(用到它的地方)。当 data 变更时,遍历 dep 存放的依赖,通知用到它的地方更新。
收集依赖就是先收集哪些地方用到了 data 数据。方便后面数据更新后,再通知这些依赖进行更新。
一般在 template、watch、computed 中使用 data,数据变化后需要及时通知更新。methods 不用通知。
比如下面的例子中,先把用到数据 name 的地方收集起来,然后等数据发生变化时,把之前的依赖循环触发一遍即可。
<template>
<h1>{{ name }}</h1>
</template>
# 2.4 依赖收集在哪里(Dep)
创建一个 Dep 类(Dependence),用来存放依赖。
依赖可以是 template 里面的数据,也可能是 computed、watch。可以把依赖抽象成一个 Watcher 类。
一个 watcher 实例就是一个依赖,保存在 window.target 上(实际源码中是 Dep.target)
export default class Dep {
constructor() {
this.subs = [] // this.subscribes = [] subscribes 订阅
}
// 添加一个订阅,即依赖实例
addSub(sub) { this.subs.push(sub) }
// 移除一个依赖,remove 是移除一个数据中的一个元素
removeSub(sub) { remote(this.subs, sub) }
// 添加依赖 const dep = new Dep(); dep.depend()
depend() {
window.target && this.addSub(window.target)
}
// setter 后通知,遍历所有依赖,触发依赖更新
notify() {
const subs = this.subs.slice()
for (let i = 0, len = subs.length; i < 1; i++) {
subs[i].update()
}
}
}
# 2.5(6) 把依赖抽象为 Watcher 类
Watcher 是对 template/watch/computed 中依赖的一个抽象,以下面的 watch 为例,来看 Watcher 实现
vm.$watch('a.b,c', function(newVal, oldVal) {
// a.b.c 变化后,执行一些操作
})
export default class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.getter = parsePath(expOrFn) // parsePath('a.b.c'),可以理解为获取 data.a.b.c 的值
this.cb = cb // function(newVal, oldVal) { // a.b.c 变化后,执行一些操作 }
this.value = this.get()
}
// this.value = this.get()
get() {
// 下面三句话,不好理解,需要结合 Dep 类,Observer 类,parsePath 的实现来理解
// 后面将 Observer 介绍完后,再来看这里的代码就好理解了
window.target = this
let value = this.getter.call(this.vm, this.vm)
window.target = undefined
return value
}
update() {
const oldVal = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldVal)
}
}
其中 parsePath 大致实现如下
const bailRE = /[^\w.$]/ // 解析简单路径
// \w 是 [0-9a-zA-Z_] 的简写,表示数字、字母、下划线
// . 匹配除 \n 外的任意一个字符,表示匹配非数字字母下划线的字符,即非法字符
export function parsePath(path) {
if (bailRE.test(path)) {
return
}
const segments = path.split('.') // 例如 ['a', 'b', 'c']
// 返回一个函数,即上面 this.getter 函数,
// parsePath('a.b.c')(this.vm),这里的 obj 就是组件实例 vm,即 this.a.b.c
return function (obj) {
for (let i = 0, len = segments.length; i < len; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
# 2.7 变化侦测 defineReactive 封装 Observer 类
export class Observer {
constructor(value) {
this.value = value
if (!Array.isArray(value)) {
this.walk(value) // 如果是非数组,即对象
}
}
// walk 将对象的每一个属性都转换成 getter/setter 形式来侦测变化
// 只有在数据类型为 Object 时调用
walk(obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
}
function defineReactive(data, key, val) {
// 如果对象的属性也是对象,递归 Observer
if (typeof val === 'obj') {
new Observer(val)
}
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true, // /ɪˈnjuːm(ə)rəb(ə)l/
configurable: true,
get: function () {
dep.depend() // 将依赖存到 Dep 中
return val
},
set: function (newVal) {
if (val === newVal) {
return
}
val = newVal
dep.notify() // 通过 Dep 通知依赖更新
}
})
}
这里重新来看 Watcher 的 get 方法,理解为什么要这么写
- this.getter.call(this.vm) 执行的是 parsePath(expOrFn)(this.vm),vm 可以理解为组件实例
- this.vm.a.b.c 相当于读取组件中的 data.a.b.c 数据,一般所有的 object 数据都会通过 walk 函数,转换为 getter/setter 形式。读取数据会触发 Observer 中 defineReactive 中的 get 方法。
- get 方法中,会调用 dep.depend() 收集依赖,存到 Dep 中,而依赖是一个 watcher 实例,放在 window.target 上
- 这也是,为什么在 this.getter.call(this.vm) 之前需要设置 window.target = this 的原因。正好把当前依赖实例赋值到了 window.target 上,在 dep.depend() 内部执行 this.addSub(window.target),将依赖存放到 Dep 中的 this.subs
- watch('a.b.c', cb) 这个依赖收集完成后,将 window.target 再置空,方便收集下一个依赖
- 另外,Observer denfineReactive 中,setter 方法触发,会执行 depend.notify(),会遍历 this.subs 中存放的 watcher 依赖实例执行 update 方法。即 下面 watcher update 中的 cb 回调函数
export default class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.getter = parsePath(expOrFn) // parsePath('a.b.c'),可以理解为获取 data.a.b.c 的值
this.cb = cb // function(newVal, oldVal) { // a.b.c 变化后,执行一些操作 }
this.value = this.get()
}
// this.value = this.get()
get() {
// 下面三句话,不好理解,需要结合 Dep 类,Observer 类,parsePath 的实现来理解
window.target = this
let value = this.getter.call(this.vm, this.vm)
window.target = undefined
return value
}
update() {
const oldVal = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldVal)
}
}
# Observer, Dep, Watcher 的调用时机
变化侦测,核心是 Observer、Dep、Watcher 这三个类
- Observer 将 data 数据转换为 getter/setter,拦截数据的读和写,get 读数据时,收集依赖存放到 Dep,set 时通知 Dep 中存放的依赖进行更新
- Dep 专门用于存放收集到的依赖,还可以通知依赖进行更新
- Watcher 是对依赖的抽象,一个依赖对应一个 watcher 实例,每一个 template、computed、watch、指令 中使用的 data 数据,都是一个依赖
调用时机问题
- 首先应该是遍历 data 数据,调用 Observer 类的 defineReactive 对每一个属性进行 getter/setter 拦截,初始化一个存放依赖的 Dep 实例,get 方法触发时添加依赖(dep.depend()),set 方法时通知依赖更新(dep.notify())
- template 模板编译、处理声明的 watch、computed 时,对于每一个 data 相关属性,都调用 Watcher,初始化一个 watcher 实例,在实例初始化的 constructor 方法时,通过读取一次对应的 data 值,触发对应 getter 方法,将当前依赖的 watcher 实例通过 dep.depend() 存放到 dep 中
- 另外 this.data.xxx 更新时,触发 setter 方法,会 dep.notify,通知 watcher 实例进行 update,update 会调用 cb(newVal, oldVal) 进行更新。
SFC 单文件组件
<template>
<div>{{name}}</div>
</template>
<script>
export default {
data () {
return {
a: { b: { c: 1} },
name: 'test'
}
},
watch: {
'a.b.c': function (val, oldVal) {
// 做些什么
}
},
computed: {
composeVal() {
return this.a.b.c + this.name
}
}
}
</script>
纯 html
<html>
<div id="app">
<div>{{name}}</div>
</div>
<script>
let app = new Vue({
el: '#app',
data () {
return {
a: { b: { c: 1} },
name: 'test'
}
},
watch: {
'a.b.c': function (val, oldVal) {
// 做些什么
}
},
computed: {
composeVal() {
return this.a.b.c + this.name
}
}
})
</script>
</html>
# 2.8 关于 object 的问题
前面介绍了变化侦测的原理,在遍历 data 数据调用 Observer 将数据转换为 getter/setter,来追踪变化,但如果在后面动态添加了 data 属性,会导致追踪不到这个属性的变化,来看一个例子
const app = new Vue({
el: '#app',
data() {
return {
obj: {
age: 18
}
}
}
methods: {
action() {
this.obj.name = 'Brook'
},
del() {
delete this.obj.age
}
}
})
上面的例子中使用方法,添加一个新的属性,或者删除原有的属性,Vue 无法监测到这个变化,也不会向依赖发送更新通知。
对于这种情况,需要使用 vm.$set 与 vm.$delete 来添加或删除响应式依赖,后面第 4 章会讲到
# 2.9 总结
- 变化侦测就是侦测数据的变化,数据变化时,能侦测到并发送通知
- Object 可以通过 Object.defineProperty 将属性转换为 getter/setter 形式来追踪变化,读取时触发 get,修改时触发 set
- 在 get 中收集依赖,在 set 中,通知依赖进行更新
- 收集依赖需要一个存放的地方,为此创建了 Dep 类型,用来添加/删除依赖,向依赖发送消息、
- 将依赖抽象为一个 watcher 实例
# Object 变化侦测 demo
考虑到后面的数组变化侦测更加复杂,不能仅靠理论代码来揣摩代码运行逻辑。这里通过一个 demo 来将 Object 变化侦测涉及到的逻辑都串起来,能够跑通逻辑,方便后面理解。
目录结构如下,完整代码地址:vue2/my-vue/observer - vue2-implement (opens new window)
my-vue
├── index # 入口
├── observer # 观察者,依赖监听
│ ├── dep.js # 依赖存放
│ ├── observer.js # 转换为 setter/getter 收集依赖/通知更新
│ ├── watcher.js # 依赖实例
测试代码
// my-vue/index.js
import { Observer } from "./observer/observer.js";
import { Watcher } from "./observer/watcher.js";
class MyVue {
constructor(options) {
this.data = options.data();
// 1、遍历 data,调用 Observer 转换为 getter/setter, 拦截处理
const observerData = new Observer(this.data);
console.log("observerData", observerData);
// 2、收集依赖(模板、watch哪些地方用到了 data 数据,每一个地方就是一个依赖实例
// > 2.1 template 处理,省略 complier 部分,模板里面有使用 name, a 两个变量
const domUpdate = (name) => {
return (val, oldVal) => {
console.log(
`${name} 数据变化,新值: ${val}, 旧值: ${oldVal} 假装执行dom更新`
);
};
};
new Watcher(this.data, "name", domUpdate("name"));
new Watcher(this.data, "a", domUpdate("a"));
// > 2.2 watch 处理
Object.keys(options.watch).forEach((prop) => {
let val = options.watch[prop];
// new Watcher(this.data, 'a.b.c', options.watch['a.b.c'].handler)
new Watcher(this.data, prop, val?.handler || val);
});
}
}
// observer 功能测试
const app = new MyVue({
template: "<div>{{ name }}</div><div>{{ a }}</div>",
data() {
return {
a: { b: { c: 1 } },
name: "123",
};
},
watch: {
"a.b.c": {
handler(val, oldVal) {
console.log("监听到 a.b.c 改动", val, oldVal);
},
},
},
});
console.log("app", app);
// 修改数据,看是否侦测到数据变更
app.data.a.b.c = 123;
app.data.name = "test";
app.data.a = { b: { c: 777 } };
运行结果
PS E:\clone\vue2-implement> node .\my-vue\index.js
observerData Observer { value: { a: [Getter/Setter], name: [Getter/Setter] } }
app MyVue { data: { a: [Getter/Setter], name: [Getter/Setter] } }
监听到 a.b.c 改动 123 1
name 数据变化,新值: test, 旧值: 123 假装执行dom更新
a 数据变化,新值: [object Object], 旧值: [object Object] 假装执行dom更新
监听到 a.b.c 改动 777 123
监听到 a.b.c 改动 777 777
PS E:\clone\vue2-implement>
- 其中 node xx.js 时不支持 import,在 package.json 里面加了
"type": "module"
才能正常运行。 - 知道了为什么 vue 源码使用的是 Dep.target 而不是书中的 window.target,因为 Node 并不支持 window,而是 globalThis
- node 运行的缺点在于不能在 console 里查看对象的完整内容,待将 demo 改造为 html 页面
这里有一个问题,修改 a 时,a.b.c 的 watch 回调触发了两次,卡了几天,一度看不下去,因为 watch 的行为明显有问题,加了很多 log,发现问题所在 出现重复收集依赖的情况,因为只要读取值了就会触发依赖收集(比如,通知更新时也读取了最新的值)
对比源码后,发现 Dep depend 收集依赖这里精简了一个去重的逻辑,我 xxx,想骂人。。。。我是一个比较严谨的人,有问题我会卡住,不会继续。浪费不少时间。
假设我在之前的基础上,再增加一行
// 修改数据,看是否侦测到数据变更
app.data.a.b.c = 123;
app.data.name = "test";
app.data.a = { b: { c: 777 } };
app.data.a = { b: { c: 9 } };
会看到最后一行,触发了 6 次更新,上一行是 3 次。。。。。。
app MyVue { data: { a: [Getter/Setter], name: [Getter/Setter] } }
监听到 a.b.c 改动 123 1
name 数据变化,新值: "test", 旧值: "123" 执行dom更新
a 数据变化,新值: {"b":{"c":777}}, 旧值: {"b":{"c":123}} 执行dom更新
监听到 a.b.c 改动 777 123
监听到 a.b.c 改动 777 777
a 数据变化,新值: {"b":{"c":9}}, 旧值: {"b":{"c":777}} 执行dom更新
监听到 a.b.c 改动 9 777
监听到 a.b.c 改动 9 9
a 数据变化,新值: {"b":{"c":9}}, 旧值: {"b":{"c":9}} 执行dom更新
监听到 a.b.c 改动 9 9
监听到 a.b.c 改动 9 9
# 重复依赖收集 bug 解决
修改 dep.js 和 watcher.js 增加去重逻辑
- dep.js 增加 dep 的 id,Dep depend 收集依赖前,通过 id 判断是否重复,重复就不添加
- watcher get return val 前 cleanupDeps
逻辑有点巧妙,需要仔细看
// dep.js
const remove = (arr, item) => {
let index = arr.indexOf(item);
arr.splice(index, 1);
};
let uid = 0;
export class Dep {
constructor() {
this.id = uid++;
this.subs = []; // this.subscribes = [] subscribes 订阅
}
// 添加一个订阅,即依赖实例
addSub(sub) {
this.subs.push(sub);
}
// 移除一个依赖,remove 是移除一个数据中的一个元素
removeSub(sub) {
remove(this.subs, sub);
}
// 添加依赖 const dep = new Dep(); dep.depend()
depend() {
if (Dep.target) {
// 去重
let curWatcher = Dep.target;
if (!curWatcher.newDepIds.has(this.id)) {
curWatcher.newDepIds.add(this.id);
curWatcher.newDeps.push(this);
if (!curWatcher.depIds.has(this.id)) {
this.addSub(curWatcher);
}
}
}
}
// setter 后通知,遍历所有依赖,触发依赖更新
notify() {
const subs = this.subs.slice();
for (let i = 0, len = subs.length; i < len; i++) {
subs[i].update();
}
}
}
watcher.js
let uid = 0;
export class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm;
this.getter = parsePath(expOrFn); // parsePath('a.b.c'),可以理解为获取 data.a.b.c 的值
this.cb = cb; // function(newVal, oldVal) { // a.b.c 变化后,执行一些操作 }
// 去重标记
this.id = ++uid;
this.deps = [];
this.depIds = new Set();
this.newDeps = [];
this.newDepIds = new Set();
this.idInfo = {
id: Math.floor(Math.random() * 999) + 1, // 1 - 1000
expOrFn,
};
this.value = this.get();
}
// this.value = this.get()
get() {
// 下面三句话,不好理解,需要结合 Dep 类,Observer 类,parsePath 的实现来理解
// 后面将 Observer 介绍完后,再来看这里的代码就好理解了
Dep.target = this;
let value = this.getter.call(this.vm, this.vm);
Dep.target = undefined;
this.cleanupDeps();
return value;
}
update() {
const oldVal = this.value;
this.value = this.get();
this.cb.call(this.vm, this.value, oldVal);
}
/**
* Clean up for dependency collection.
*/
cleanupDeps() {
let i = this.deps.length;
while (i--) {
const dep = this.deps[i];
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this);
}
}
let tmp = this.depIds;
this.depIds = this.newDepIds;
this.newDepIds = tmp;
this.newDepIds.clear();
tmp = this.deps;
this.deps = this.newDeps;
this.newDeps = tmp;
this.newDeps.length = 0;
}
}
再次运行
// 修改数据,看是否侦测到数据变更
console.log("执行 app.data.a.b.c = 123;");
app.data.a.b.c = 123;
console.log("执行 app.data.name = 'test';");
app.data.name = "test";
console.log("执行 app.data.a = { b: { c: 777 } };");
app.data.a = { b: { c: 777 } };
console.log("执行 app.data.a = { b: { c: 9 } };");
app.data.a = { b: { c: 9 } };
结果如下,不会重复触发,完整 demo 地址 observer - my-vue - vue2-implement (opens new window)
开始收集依赖 Observer {value: {…}}
app MyVue {data: {…}}
执行 app.data.a.b.c = 123;
监听到 a.b.c 改动 123 1
执行 app.data.name = 'test';
name 数据变化,新值: "test", 旧值: "123" 执行dom更新
执行 app.data.a = { b: { c: 777 } };
a 数据变化,新值: {"b":{"c":777}}, 旧值: {"b":{"c":123}} 执行dom更新
监听到 a.b.c 改动 777 123
执行 app.data.a = { b: { c: 9 } };
a 数据变化,新值: {"b":{"c":9}}, 旧值: {"b":{"c":777}} 执行dom更新
监听到 a.b.c 改动 9 777
# 运行 log 图解
运行逻辑如下图:
# 3. Array 的变化侦测
Object 可以通过 getter/setter 方法侦测到数据变化,对应数组来说却行不通。
因为数组可以通过 Array 原型上的方法来改变数组内容,比如 this.list.push(1),向数组中增加元素 1,并不会触发 getter/setter。
仅修改 observer.js、添加 array 特有拦截方法,dep.js、watcher.js 不用变
# 3.1 如何追踪变化
数组操作一般是调用 Array.prototype 上的原型方法(比如 push/pop等),重新这些方法,加拦截处理即可追踪数组的变化。
# 3.2 拦截器
Array 原型中可以改变数组自身内容的方法有七个:push/pop/shift/unshift/splice/sort/reverse
自定义 arrayMethods,继承 Array.prototype,并重写可以修改数组自身内容的 7 种方法
// array.js
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args);
// 自定义逻辑,触发依赖、通知更新
return result;
});
});
其中
export function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true,
});
}
# 3.3(4) 使用拦截器覆盖 Array 原型
直接修改 Array.prototype,会污染 Array.prototype, 这里仅拦截指定 data 中数组的原型方法
value.__proto__ = arrayMethods
export class Observer {
constructor (value) {
this.value = value
if (Array.isArray(value)) {
value.__proto__ = arrayMethods // 覆盖拦截 value 原型方法
} else {
this.walk(value) // 如果是对象
}
}
}
这里有一个问题,当不支持 __proto__
时,只能将 arrayMethods 的方法遍历处理一个个 copy 到 value 上
// can we use __proto__?
const hasProto = '__proto__' in {}
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
class Observer {
constructor (value) {
this.value = value
if (Array.isArray(value)) {
if (hasProto) {
value.__proto__ = arrayMethods
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
} else {
this.walk(value)
}
}
}
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
# 3.567 如何收集数组依赖、存在哪?
Array 在 getter 中收集依赖,在拦截器中触发依赖
class Observer {
constructor(value) {
this.value = value;
// 新增 dep,有两个好处,1、避免重复侦测,2、方便数组拦截方法获取实例
this.dep = new Dep()
if (Array.isArray(value)) {
// xxx
} else {
this.walk(value); // 如果是非数组,即对象
}
}
}
function defineReactive(data, key, val) {
// val 为数组、或对象时,递归,childOb 为当前 Observer 实例
let childOb = observe(val)
// 这个 dep 是 defineReactive,childOb.dep 是 Observer 实例上的
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend(); // 将依赖存到 Dep 中
// 数组依赖,或 val 为对象
if (childOb) {
childOb.dep.depend()
}
return val;
},
set: function (newVal) {
if (val === newVal) {
return;
}
val = newVal;
// 下面这行书中没有,需要加,防止 app.data.list = [] 后,丢失 __ob__ 依赖等
childOb = observe(newVal);
dep.notify(); // 通过 Dep 通知依赖更新
},
});
}
/**
* Attempt to create an observer instance for a value,
* 尝试为 value 创建一个 Observer 实例
* returns the new observer if successfully observed,
* 如果创建成功,直接返回新创建的 Observer 实例
* or the existing observer if the value already has one.
* 如果已经存在一个 Observer 实例,则直接返回
*/
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value)) {
return
}
let ob
// 这里 __ob__ 参见下一节内容,表示已经侦测过该值
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else {
ob = new Observer(value)
}
return ob
}
# 3.89 数组拦截器中获取 dep,通知依赖更新
上面还漏掉了去重、方便数组拦截器获取 dep 的逻辑,这样就和 observer 函数中的逻辑对应上了
class Observer {
constructor(value) {
this.value = value;
// 新增 dep,有两个好处,1、避免重复侦测,2、方便数组拦截方法获取实例
this.dep = new Dep()
def(value, '__ob__', this) // 为每个侦测的数据加上 __ob__ 属性
if (Array.isArray(value)) {
// xxx
} else {
this.walk(value); // 如果是非数组,即对象
}
}
}
数组拦截方法中使用 __ob__
拿到 dep,发送通知
// array.js
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
// notify change 触发依赖、通知更新
ob.dep.notify();
return result;
});
});
# 3.1011 侦测数组中元素、新增元素的变化
class Observer {
constructor (value) {
this.value = value
if (Array.isArray(value)) {
// 拦截数组 value 原型方法
if (hasProto) {
value.__proto__ = arrayMethods
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
// 侦测数组元素变化
this.observeArray(value)
} else {
this.walk(value)
}
}
// 侦测数组中的每一项
observeArray(items) {
for (let i = 0, len = items.length; i < len; i++) {
observe(items[i])
}
}
}
侦测数组新增元素的变化, 针对 push、unshift、splice 获取 insert 元素,添加响应
// array.js
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__; // observer
let inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted); // 侦测新增元素
// notify change 触发依赖、通知更新
ob.dep.notify();
return result;
});
});
# 3.12 关于 Array 的问题
1、通过数组下标,修改元素,无法拦侦测到数组变化,并不会触发 re-render 或 watch
this.list[0] = 2
2、通过 length 属性设置为 0 清空数组,也无法触发 re-render 和 watch
this.list.length = 0
# 3.13 总结
- 1、Array 追踪变化和 Object 不一样,通过创建拦截器去覆盖数组原型的方式来追踪变化
- 2、为了不污染全局 Array.prototype,在 Observer 中只针对需要侦测变化的数组使用
__proto__
来覆盖原型方法,在 ES6 之前,有些浏览器并不支持__proto__
,针对这些浏览器遍历原型方法,逐一添加拦截 - 3、Array 收集依赖的方式和 Object 一样,在 getter 中收集。但由于使用依赖的位置不同,数组要在拦截器中向依赖发送消息,不能像 Object 那样保存在 defineReactive 中, 而是把依赖保存到 Observer 实例上。
- 4、在 Observer 中,对每个侦测了变化的数据都增加了
__ob__
, 并把 this (Observer)实例保存在__ob__
上,有两个作用 1、标记数组是否已经做了侦测,只被侦测一次。2、可以很方便的通过数据的__ob__
拿到 Observer 上保存的依赖,当拦截到数组变化时,向依赖发送通知。 - 5、除了侦测数组自身变化外,数组中元素发生变化也要侦测,如果是数组,调用 observeArray 方法将数组中的每一个元素都转换为响应式并侦测变化
- 6、除了侦测已有数据外, 当用户使用 push、unshift、splice 等方法有新增数据时,也需要调用 observeArray,对新增数据进行变化侦测
- 7、ES6 之前,JS 并没有提供元编程的能力,对于数组,一些语法无法追踪到变化,只能拦截原型上的方法,而无法拦截数组特有语法,比如使用 length 清空数组、使用下标修改元素
# 完整代码,运行测试
完整代码参见:index-array | my-vue | vue2-implement (opens new window)
增加测试数据、逻辑
data() {
return {
a: { b: { c: 1 } },
name: "123",
list: [{text: 'abc'}, {text: 'def'}] // 新增
};
},
watch: {
list: {
handler(val, oldVal) {
console.log("监听到 list 改动", val, oldVal);
},
},
},
// ...
console.log("执行 app.data.list = []");
app.data.list = []
console.log(app.data.list)
console.log("执行 app.data.list.push({text: 'cde'})");
app.data.list.push({text: 'cde'})
app.data.list.unshift({text: 'hij'})
app.data.list.splice(0, 1, {text: 'bnm'}) // 删除第一个元素,新增一个
执行记录
执行 app.data.list = []
监听到 list 改动 [__ob__: Observer] (2) [{…}, {…}, __ob__: Observer]
[__ob__: Observer]
执行 app.data.list.push({text: 'cde'})
拦截方法 push [{…}, __ob__: Observer]
监听到 list 改动 [{…}, __ob__: Observer] [{…}, __ob__: Observer]
拦截方法 unshift (2) [{…}, {…}, __ob__: Observer]
监听到 list 改动 (2) [{…}, {…}, __ob__: Observer] (2) [{…}, {…}, __ob__: Observer]
拦截方法 splice (2) [{…}, {…}, __ob__: Observer]
监听到 list 改动 (2) [{…}, {…}, __ob__: Observer] (2) [{…}, {…}, __ob__: Observer]
# 4. 变化侦测相关 API 实现原理
# 4.1 vm.$watch
介绍 vm.$watch 内部实现之前,先简单回顾它的用法
vm.$watch(expOrFn, callback, [options])
- 参数:
- {string | Function} expOrFn
- {Function | Object} callback
- {Object} [options]
- {boolean} deep
- {boolean} immediate
- 返回值: {Function} unwatch
- 用法:观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。
Note: when mutating (rather than replacing) an Object or an Array, the old value will be the same as new value because they reference the same Object/Array. Vue doesn’t keep a copy of the pre-mutate value.
注意:当修改(非替换)一个对象或数组,旧值和新值一样,因为他们使用同一个 Object/Array 的引用。vue 不会保持未变更前的值副本。
例如
let unwatch = vm.$watch('a.b.c', function (val , oldVal) {
// 做点什么
})
// 使用 unwatch 可以取消观察
unwatch()
简单介绍下 options 参数 immediate 和 deep
- deep:为了发现对象内部值得变化,可以再选项参数中加这个参数
vm.$watch('someObj', function (val , oldVal) {
// 做点什么
}, {
deep: true
})
// 回调函数也会被触发,如果不加 deep,仅 vm.someObj = xx 才会触发回调
vm.someObj.someVal = 123
- imediate,如果为 true,会先立即执行一次 watch 回调,而不是一定要等到值变更后才执行
vm.$watch('a', callback, {
immediate: true
})
// 会立即以 a 当前的触发一次 callback
$watch 内部原理
$watch 其实是对 Watcher 的一种封装,下面是 $watch 以及 immediate、unwatch 功能的实现
// Vue 组件里面调用 this.$watch 触发
Vue.prototype.$watch = function (expOrFn, cb, options) {
const vm = this
options = options || {}
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
cb.call(vm, watcher.value)
}
return function unWatchFn() {
watcher.teardown()
}
}
new Watcher() 可以收集依赖是因为不管是表达式,还是函数,都会触发对应数据的 getter,触发依赖收集
如果 watch 的是函数,调用函数时,this.a, this.b 都会触发对应属性的 getter,a 属性的 dep 实例会将当前 watcher 实例(Dep.target)存放到 dep.subs 中,b 属性的 dep 实例也会将当前 watcher 实例存放到自己的 dep.subs。当 a 或 b 的值更新的时候,循环对应 dep.subs 时,会触发这里 watcher 实例的 update,执行回调函数。computed 计算属性的依赖收集的逻辑和这里类似。
vm.$watch(
function () {
// 表达式 `this.a + this.b` 每次得出一个不同的结果时
// 处理函数都会被调用。
// 这就像监听一个未被计定义的算属性
return this.a + this.b
},
function (newVal, oldVal) {
// 做点什么
}
)
watcher 相关新增代码如下。需要说明的是,书里面在第 4 章加了 dep id,以及在 watcher 中收集 dep id 去重的逻辑。这里不重复介绍,因为在第 2 章的时候,发现 watcher 重复收集依赖时,通过查看源码,已经补齐了这段逻辑。
new Watcher() {
constructor(vm, expOrFn, cb, options) {
this.vm = vm;
if (typeof expOrFn === "function") {
// 直接执行函数,函数执行会触发里面数据的 getter, 收集对应依赖
this.getter = expOrFn;
} else {
this.getter = parsePath(expOrFn); // parsePath('a.b.c'),可以理解为获取 data.a.b.c 的值
}
if (options) {
this.deep = !!options.deep;
} else {
this.deep = false;
}
// ...
}
// this.value = this.get()
get() {
// 下面三句话,不好理解,需要结合 Dep 类,Observer 类,parsePath 的实现来理解
// 后面将 Observer 介绍完后,再来看这里的代码就好理解了
// console.log(`---get准备开始收集依赖, ${JSON.stringify(this.idInfo)}`);
Dep.target = this;
let value = this.getter.call(this.vm, this.vm);
// 触发递归触发对象内部元素的 getter
if (this.deep) {
// 注意这里并不是递归添加 observer,因为一开始 Observer 时,所有 data 就递归拦截 ok 了。
// 这里是去递归触发子属性/元素的 getter,将当前 watcher 实例(Dep.target)添加到子属性的
// dep.subs 中,当深层次属性变更触发 setter,通知 dep.subs 中的 watcher 实例更新,就会触
// 发当前 watcher 的 update,执行当前 watcher 的 callback 回到函数
traverse(value);
}
Dep.target = undefined;
// console.log("---收集依赖完成,清理依赖");
this.cleanupDeps();
return value;
}
// ...
/**
* Remove self from all dependencies' subscriber list.
* 从所有关联的 dep.subs 列表中删除自己
*/
teardown() {
let i = this.deps.length;
while (i--) {
this.deps[i].removeSub(this);
}
}
}
traverse.js 这里用到了 Set 去重,防止重复触发 getter,另外涉及 Object.freeze 对象可以减少响应,提升性能的知识点,再深层次收集依赖时,如果对象是 Object.isFrozen 冻结的,直接 return,不收集对应依赖
const seenObjects = new Set();
/**
* 递归遍历一个对象 /ri'kəsivli/ /rɪˈkɜːsɪv/
* Recursively traverse an object to evoke all converted
* getters, so that every nested property inside the object
* is collected as a "deep" dependency.
*/
export function traverse(val) {
// /trəˈvɜːs/
_traverse(val, seenObjects);
seenObjects.clear();
}
function _traverse(val, seen) {
let i, keys;
const isA = Array.isArray(val);
if (
(!isA && !isObject(val)) ||
Object.isFrozen(val)
// || val instanceof VNode
) {
return;
}
if (val.__ob__) {
const depId = val.__ob__.dep.id;
if (seen.has(depId)) {
return;
}
seen.add(depId);
}
if (isA) {
i = val.length;
while (i--) _traverse(val[i], seen);
} else {
keys = Object.keys(val);
i = keys.length;
while (i--) _traverse(val[keys[i]], seen);
}
}
function isObject(obj) {
return obj !== null && typeof obj === "object";
}
# 实际运行测试
完整 demo 参见:$watch my-vue - vue2 (opens new window)
class MyVue {
constructor(options) {
this.data = options.data();
// 1、遍历 data,调用 Observer 转换为 getter/setter, 拦截处理
console.log("开始收集依赖");
const observerData = new Observer(this.data);
// 2、收集依赖(模板、watch哪些地方用到了 data 数据,每一个地方就是一个依赖实例
// > 2.1 template 处理,省略 complier 部分,模板里面有使用 name, a 两个变量
const domUpdate = (name) => {
return (val, oldVal) => {
console.log(
`${name} 数据变化,新值: ${JSON.stringify(val)}, 旧值: ${JSON.stringify(oldVal)} 执行dom更新`
);
};
};
new Watcher(this.data, "name", domUpdate("name"));
}
}
// $watch 实现
MyVue.prototype.$watch = function (expOrFn, cb, options) {
const vm = this
options = options || {}
// 由于这里没有做映射,vm 还拿不到 data,这里修改下
const watcher = new Watcher(vm.data, expOrFn, cb, options)
if (options.immediate) {
cb.call(vm, watcher.value)
}
return function unWatchFn() {
watcher.teardown()
}
}
// observer 功能测试
const app = new MyVue({
template: "<div>{{ name }}</div><div>{{ a }}</div>",
data() {
return {
a: { b: { c: 1 } },
b: { a: { c: 1 } },
name: "123",
list: [{text: 'abc'}, {text: 'def'}]
};
},
watch: {
"a.b.c": {
handler(val, oldVal) {
console.log("监听到 a.b.c 改动", val, oldVal);
},
},
list: {
handler(val, oldVal) {
console.log("监听到 list 改动", val, oldVal);
},
},
},
});
app.$watch('a', (val, oldVal) => {
console.log(`【普通】a 发生了变更`, val, oldVal)
}, {
immediate: true
})
let unWatchB = app.$watch('b', (val, oldVal) => {
console.log(`【深层次】b 发生了变更`, val, oldVal)
}, {
deep: true
})
console.log("app", app);
// 修改数据,看是否侦测到数据变更
console.log("执行 app.data.a.b.c = 123;");
app.data.a.b.c = 123; // 不会出发 a watch
console.log("执行 app.data.b.a.c = 777;");
app.data.b.a.c = 777; // 会出发 b deep watch
unWatchB()
app.data.b.a.c = 666; // 不会出发 b,因为已销毁
运行效果如下
开始收集依赖
【普通】a 发生了变更 {__ob__: Observer} undefined
app MyVue {data: {…}}
执行 app.data.a.b.c = 123;
监听到 a.b.c 改动 123 1
执行 app.data.b.a.c = 777;
【深层次】b 发生了变更 {__ob__: Observer} {__ob__: Observer}
# 4.2 vm.$set
前面提到过,有两种场景 vue 是无法监测到数据变更的
1、data 数据中的对象,如果新增一个属性,这个属性默认 vue2 无法进行 getter/setter 拦截的,会丢失响应。
- data 下新增属性无法拦截,是因为最开始遍历 data 中的属性进行 getter/setter 拦截时,这个属性还不存在,因此拦截不了
- data 中某个对象新增一个属性时,也无法拦截,因为 defineProperty set 操作仅当值变更时触发。假设
a.b.c = 1
,那么a.b 的值为 { c: 1 }
,如果新增一个属性a.b.d = 2
,实际上是a.b 的值(对象)中加一个 d 属性值
,但 a.b 这个值(对象)的地址并没有变化,不会触发 set。仅当a.b = { d: 2 }
这种操作,新对象地址不一样时,会触发 set 函数
2、数组中通过下标的方式 arr[0] = 1
修改值,也是无法追踪的,因为 vue2 中数组变化侦测实现方法是对 Array.prototype 上 push/pop 等可以修改数组本身函数的拦截。通过下标修改 vue2 无法感知。
下面是一个测试 demo
// 完整测试demo,参见:https://github.com/zuoxiaobai/vue2-implement/blob/vue2/my-vue/index-array.html
data() {
return {
a: { b: { c: 1 } },
name: "123",
list: [{text: 'abc'}, {text: 'def'}]
};
},
// 下面的改动都不会被侦测到
// 测试
app.data.a.b.d = 123 // 新增一个属性
app.data.a.b.d = 789 // 再修改该属性,看是否是响应式的
// data 层级新增属性测试
app.data.test = 'test'
app.data.list[0] = 1 // 通过下标修改数组
针对这种需要动态给 data 对象添加属性或者修改数组某个元素的场景,vue2 专门提供了 vm.$set 方法
vm.$set 基本用法
vm.$set( target, propertyName/index, value )
参数:
- {Object | Array} target
- {string | number} propertyName/index
- {any} value
返回值为设置的 value
用法:
This is the alias /ˈeɪliəs/ of the global Vue.set. 它是 Vue.set 的别名。
Adds a property to a reactive object, ensuring the new property is also reactive, so triggers view updates. This must be used to add new properties to reactive objects, as Vue cannot detect normal property additions (e.g. this.myObject.newProperty = 'hi').
为一个响应式的对象添加一个属性,确保新属性也是响应式的,能正常的触发视图的更新。这必须用于向响应式对象添加新属性,因为Vue无法检测正常的属性添加 (e.g. this.myObject.newProperty = 'hi').
具体实现如下
import { set } from '目录/observer.js'
Vue.prototype.$set = set
observer.js 里面添加对应逻辑
// set 方法依赖 observer 里面的方法,因此实现放到 observer.js 中
export function set(target, key, val) {
// 如果是数组,通过 splice 来添加/触发响应
if (Array.isArray(target) && isValidArrayIndex(key)) {
// 如果设置的下标超了,延长数组长度
target.length = Math.max(target.length, key);
// 通过 splice 修改或设置值,触发 target 的 splice 方法拦截,通知更新,并把新增元素做响应式处理
target.splice(key, 1, val);
return val;
}
// 如果这个值已经存在了,直接设置值,返回
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val;
}
// 判断对象是否是 vue 响应式数据
const ob = target.__ob__;
// 这里省略一段逻辑,用于限制 target 不能是 Vue.js 实例或 Vue.js 实例的根数据对象
// ...
// 如果不是 vue 响应式数据,直接设置值后返回。
if (!ob) {
target[key] = val;
return val;
}
// 如果 target 是响应式数据,将新增的属性进行 getter/setter 拦截
defineReactive(ob.value, key, val);
// target 变更,通知依赖更新
ob.dep.notify();
return val;
}
# 4.3 vm.$delete
vm.$delete 和 vm.$set 一样,也是为了解决变化侦测缺陷。实现和 $set 类似
import { del } from '目录/observer.js'
Vue.prototype.$delete = del
observer.js
export function del(target, key) {
// 如果是数组,直接用 splice 删除
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.splice(key, 1);
return;
}
// 如果是对象
const ob = target.__ob__;
// 如果对象上不存在这个属性,返回
if (!hasOwn(target, key)) {
return;
}
// 删除这个属性
delete target[key];
// 非 vue 响应式数据,return
if (!ob) {
return;
}
// 如果是 vue 响应式数据,通知依赖更新
ob.dep.notify();
}
# 运行测试
$set/$delete 运行测试
完整 demo 参见:$watch/$set/$delete my-vue - vue2 (opens new window)
import { Observer, del, set } from "./observer-new-watch/observer.js";
MyVue.prototype.$set = set;
MyVue.prototype.$delete = del;
console.log('=========================================')
app.$set(app.data.a.b, 'd', 123) // 新增一个属性
app.$watch('a.b.d', (val, oldVal) => {
console.log(`【普通】a.b.d 发生了变更`, val, oldVal)
})
app.$set(app.data.a.b, 'd', 789)
// data 层级新增属性测试
app.$set(app.data, 'test', 'test')
app.$watch('test', (val, oldVal) => {
console.log(`test 发生了变更`, val, oldVal)
})
app.data.test = '456'
app.$set(app.data.list, 0, 1) // 通过$set下标修改数组
console.log('开始删除')
app.$delete(app.data.a.b, 'd')
app.$delete(app.data.list, 0)
执行记录
=========================================
监听到 a.b.c 改动 123 123
监听到 a.b 改动 {__ob__: Observer} {__ob__: Observer}
---set {__ob__: Observer} d 123 789
【普通】a.b.d 发生了变更 789 123
---set {…} test test 456
test 发生了变更 456 test
splice (2) [1, {…}, __ob__: Observer]
监听到 list 改动 (2) [1, {…}, __ob__: Observer] (2) [1, {…}, __ob__: Observer]
开始删除
监听到 a.b.c 改动 123 123
监听到 a.b 改动 {__ob__: Observer}c: 123__ob__: Observer {value: {…}, dep: Dep}get c: ƒ ()set c: ƒ (newVal)[[Prototype]]: Object {__ob__: Observer}
【普通】a.b.d 发生了变更 undefined 789
拦截方法 splice [{…}, __ob__: Observer]
监听到 list 改动 [{…}, __ob__: Observer]0: {__ob__: Observer}text: "def"__ob__: Observer {value: {…}, dep: Dep}get text: ƒ ()set text: ƒ (newVal)[[Prototype]]: Objectlength: 1__ob__: Observer {value: Array(1), dep: Dep}[[Prototype]]: Array [{…}, __ob__: Observer]
# 4.4 总结
这一章主要介绍了变化侦测相关 API 的内部实现原理。
- vm.$watch,watch 实现是对 Watcher 的一层封装
- 它返回一个 unwatch 函数,用于移除观察,移除主要是通过收集到的 deps,逐一将当前 watcher 实例从对应 dep 的 subs 中移除。watcher 实例对应的 deps 数组在收集依赖时添加。
- immediate 参数实现是在 new Watcher() 后,判断该参数是否为 true,如果为 true,使用当前值,调用一次 callback
- deep 的实现并不是递归将对象子属性都变为响应式。因为在
new Observer(data)
时,都已经拦截 ok 了。核心在于触发深层次元素的 getter,让深层次元素都将当前 watcher 实例收集到它自己的 dep.subs 中,等深层次元素的值变更后,会遍历它的 dep.subs,逐一通知里面的 watcher 实例进行 update。这样就实现了深层次的观察了。具体实现主要放在 watcher 的 get 函数Dep.target = this
和Dep.target = undefined
之间,这个时候,Dep.target 还是当前 watcher 实例,如果 deep 为 true,(在 traverse 方法中)递归去触发深层次元素的 getter,并用 seen 去重,如果是冻结对象,直接跳过收集。另外,watcher 支持函数监听,如果是表达式,和之前一样 getter 是通过 parsePath 去读取 data 值,触发 get。如果是函数,watcher 中读取值的 getter 就是该函数。通过函数可以理解 computed 收集依赖的方法,不是去 parse 解析,而是直接执行函数触发内部 data 的 getter 收集依赖。
- vm.$set,$set 的实现逻辑大致是:如果是 array 直接使用 splice 处理。如果是对象,判断是否是响应式,如果不是响应式设置值就结束,如果是响应式,使用 defineReactive 添加新属性的依赖。再调用
target.__ob__.dep.notify()
通知 target 依赖更新. - vm.$delete,和 $set 类似,如果是数组,使用 splice 处理。如果是对象,直接
delete target.key
删除属性,再看是否是响应式,如果是再执行 dep.notify 通知 target 依赖更新