作为一个典型的 MVVM 框架,相信对于 Vue 的基本使用,大家都能熟练使用。但是对于 Vue MVVM 的实现原理,如果不阅读源码,答出来则比较难了。虽然说不阅读源码也能完成日常的工作内容,但是如果去面试时,免不了被问起,如果不能答上来,则好的机会也会错失。除此之外,通过阅读源码我们可以了解到有哪些坑,从而避免跳入。
一、准备工作
要想阅读源码,必不可少的需要进行调试,所以需要clone官方的源码。
git clone https://github.com/vuejs/vue.git
// 安装依赖
npm i
vue 源码是用 ts 写的,而浏览器中并不能运行 ts,所以通常我们都是将 ts 编译为 js。对于 vue 同样如此,我们首先需要对 vue 源码进行编译,然后在项目引入 vue 的 js 文件,但是前面说了,要想阅读源码,必不可少的需要进行调试。必须得建立 js 和 ts 之间的映射,我们才可以找到对应的源码。
在 package.json 中的 dev 命令后添加--sourcemap,然后执行npm run dev即可。
"dev": "rollup -w -c scripts/config.js --environment TARGET:full-dev --sourcemap"
最后创建一个 html 文件并引入 dist 文件夹的 vue.js 便可以进行调试了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 响应式原理</title>
</head>
<body>
<div id="app">{{ name }}</div>
<script src="./vue.js"></script>
<script>
new Vue({
el: '#app',
data: {
name: 'babur',
name: 23,
},
})
</script>
</body>
</html>
src 目录为 vue 原本的代码,我们可以在浏览器中之间打断点。
二、响应式源码
vue 响应式的源码为于 src/core/abserver 位置,我们之间从这里看即可,没必要去关注与本节内容不想干的内容。同时,为了避免过于关注与主流程无关的代码,vue 源码中的部分代码会进行移除。
observe
在 index.ts 有这样一个函数 observe,可以看到这个函数做了这样一件事,如果 value 对象没有绑定 __ob__ 属性,则为当前 value 对象创建一个 Observer 对象,否则之间返回 __ob__ 属性。
export function observe(
value: any,
): Observer | void {
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else {
ob = new Observer(value)
}
return ob
}
看了这段代码,可能有以下几个问题?
- value 对象是什么?
__ob__又是什么?Observer对象的作用是什么?
这里时候就需要我们进行调试了。在 ovserve 的第一行位置打一个断点:
通过调试,可以解答上面的两个问题。
- value 对象就是组件中 data 属性的对象(因为要做数据的响应式,必须将对象绑定在 data 属性才可以)。
__ob__就是我们新建的 Observer 对象。
第三个问题,则需要我们去阅读 Observer 的源码才可以解答。
Observer
export class Observer {
dep: Dep
constructor(public value: any) {
this.value = value
this.dep = new Dep()
value.__ob__ = this;
const keys = Object.keys(value)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
defineReactive(value, key, NO_INIITIAL_VALUE, undefined)
}
}
}
通过阅读上述代码,可以知道 Observer 主要做了两件事。
- 将自己绑定到 value 对象的
__ob__属性。 - 遍历对象的所有属性,并且每个属性都会调用 defineReactive 方法。(根据我们对于 vue 的使用,当视图中依赖的数据发生变化时,页面就会重新渲染。这里所有的属性都调用了 defineReactive 方法,所以可以知道该方法就是数据响应式的原理)
defineReactive
export function defineReactive(
obj: object,
key: string,
val?: any,
) {
const dep = new Dep()
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
const value = val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
}
return value.value;
},
set: function reactiveSetter(newVal) {
childOb = observe(newVal)
dep.notify()
}
})
return dep
}
通过代码,可以看到该函数主要做了两个事
- 为属性设置 getter 和 setter
- 如果属性所对应的 value 是一个对象,则继续之前的流程,一直递归为当前对象创建 Observer 一直到执行到这里。这样做的目的是什么?
深度监听
深度监听
data: {
name: 'babur',
age: 23,
info: {
m: {
n: 1
}
}
}
倘若我们的 data 为如上的数据,按照上面的流程,则 data.__ob__ = Observer1,data.info.__ob__ = Observer2、data.info.m.__ob__ = Observer3 这样便做到了深度监听的效果,对象不管有多深,每一个属性(data.info.m.n)都设置了属性描述符.
setter
set: function reactiveSetter(newVal) {
childOb = observe(newVal)
dep.notify()
}
当我们对 data 对象的每一个已有属性做修改时,都会执行 setter。
这个方法同样是做了两件事:
- 如果新设置的值是一个对象时,则继续为新值创建 Observer
this.name = {
firstName: 'xxxx',
lastName: 'xxx',
info: {
a: {
// 不管有多深,依然可以监听到每个属性
b: ''
}
}
}
- 调用 notify(我们可以猜猜这个函数要做什么?1、数据发生变化时,让页面更新。2、如果有监听器或计算属性,则调用对应的函数)
getter
不管是对页面更新还是执行监听器或者计算属性所绑定的函数,我们必须得确定是哪个组件、哪个监听器才可以。
怎么才能知道呢?我们缺钱时,肯定是要取银行取现金,这样银行才知道你缺钱了。
vue 同样采用这样的理念,就是在组件需要这个数据时,我们才知道是哪个组件对这个数据有依赖,这个时候将这个组件收集便可以了。
get: function reactiveGetter() {
const value = val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
}
return value.value;
},
什么时候会调用 getter ?
- 数据渲染到页面的情况下
- 组件中依赖当前数据进行计算
Dep
在 getter 和 setter 中,我们都用到了 Dep 这个类及其对象,我们可以看看代码。
export default class Dep {
subs: Array<DepTarget>
constructor() {
this.id = uid++
this.subs = []
}
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify() {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
export function pushTarget(target?: DepTarget | null) {
targetStack.push(target)
Dep.target = target
}
可以看出, subs 用来存储依赖,通过 depend 方法将依赖添加到 subs 中,当属性发生变化时,则通过 notify 遍历依赖数组,进行更新。
Dep.target 是一个全局变量,用来记录当前的依赖,当依赖添加到 subs 后,则置空。供下一个依赖所引用。
说了这么多,我们还是没有找到谁才是依赖对象。搜索 pushTarget方法所引用的地方,在 watcher.ts 中调用了该方法,并且 wather 实例作为参数被传入。
所以所有的依赖对象都是 Watcher 实例。
Watcher
数据发生变化时,需要告知所有的依赖即 wather 实例。接下来我们看看 watcher 是被什么时候被推入队列,以及数据发生变化时,wather 做了什么?
constructor(
vm: Component | null,
expOrFn: string | (() => any),
cb: Function,
options?: WatcherOptions | null,
isRenderWatcher?: boolean
) {
// 一系列的属性赋值
this.xxxx = xxx;
this.vm = vm;
// 数据发生变化时的回调函数
this.cb = cb;
// 用于获取对象属性的值的表达式或者函数
this.getter = parsePath(expOrFn);
this.value = this.get();
}
经过一系列的赋值,最后在构造器中调用了 get 方法。
get 源码如下:
get() {
Dep.target = this;
value = this.getter.call(vm, vm)
Dep.target = null;
}
get 函数中将 wather 实例绑定到对象的全局变量 Dep.target 中,然后获取 value 对象的属性。
获取 value 对象的属性会做什么?
会进入到属性描述符的 geeter 中,而我们在 getter 中添加了依赖,这样我们就成功将依赖对象的整个加入过程给理清楚了。
接着我们看一看 update 方法做了什么。
update() {
this.run();
}
run() {
this.cb.call(this.vm, value, oldValue);
}
前面已经说过了,cb 是数据发生变化时,所要执行的方法。这样我们就搞清了整个数据响应式的过程。
接下来当然是验证的流程了。
组件
对应 vue 组件来说,肯定是其 data 属性的依赖对象。当数据发生变化时,肯定要更新视图。
接着我们在 update 位置打一个断点,并修改 data 对象的属性的值。
可以看到立即执行到了 update 方法
可以看到在run方法执行完后,视图发生了变化。
侦听器
在组件中定义如下两个侦听器。
new Vue({
el: '#app',
data: {
name: 'babur',
age: 23,
},
watch: {
name: {
handler(val) {
console.log('name updated, new value is: ', val);
}
},
age(val) {
console.log('age updated, new value is: ', val);
}
}
})
可以看到每个侦听器都会创建一个 watcher 实例。并且在 cb 属性绑定了回调函数。
计算属性
我们知道,当计算属性所依赖的数据发生变化时,对应的计算属性函数则会再次执行。
computed: {
info() {
const info = this.name + this.age;
return info;
}
}
这是因为 vue 同样会为计算属性创建一个 watcher。
通过以上篇章,想必对于数据响应式原理有了深刻的认识。
三、数组响应式源码
对于数组的响应式,根据我们的实践以及官方文档中的说明,让我们别直接通过修改索引去修改数组或者调用指定的7个方法才可以实现响应式。
同样的,为什么这样做还是得通过源码才可以理解。
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__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// 告知依赖数组发生变化,进行视图更新或其他操作
ob.dep.notify()
return result
})
})
上面的代码做了这样几件事:
- 根据 Array 的原型创建一个新的对象
- 重写了 methodsToPatch 中的7个方法
如果数组中新添加的项是对象,则为创建观察者,流程同上。- 告知数组的依赖项,诗句发生变化了。
重写了原型对象,那么是在什么时候把这个原型对象赋值给数组的呢?
四、问题
以上便是数组响应式的原理了,接下来我们看看我们哪些错误的使用方法导致响应式无效。
可以先根据上面所讲解,想一想问题答案,在文章的后面公布答案。
问题一
const vm = new Vue({
el: '#app',
data: {
user: {
name: 'babur',
age: 23,
},
}
})
问题二
const vm = new Vue({
el: '#app',
data: {
user: {
name: 'babur',
age: 23,
},
}
})
问题三
const vm = new Vue({
el: '#app',
data: {
stus: [
{
name: 'a',
age: 10,
},
{
name: 'b',
age: 20
}
]
}
})
问题四
const vm = new Vue({
el: '#app',
data: {
stus: [
{
name: 'a',
age: 10,
},
{
name: 'b',
age: 20
}
]
}
})
答案
问题一:一开始遍历 user 对象的所有属性时,不能为没有的属性定义属性描述符,从而没有办法设置依赖以及进行响应式。
问题二:删除对象的属性没有办法被监听到,从而没有办法进行响应式。
问题三:直接通过索引修改同样没有办法被监听到,从而没有办法进行响应式。
问题四:同二。
五、扩展
在 observer 包中,还有这样的一个文件,就是 scheduler.ts。
这个文件主要做了这样一件事,将 watcher 加入到队列中,然后在下一次 nextTick 的时候调度队列中的所有 watcher。
export function queueWatcher(watcher: Watcher) {
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
为什么要在下一次 nextTick 的时候在执行。
想象一下,如果数据发生了1000次、10000次,每次都去刷新视图或者执行监听器是不是会带来性能消耗呢?并且也没必要每一次都执行,只需要在视图刷新前执行最后一次即可。
如上便是我对vue响应式的理解,如有错误,欢迎指正,讨论。