Vue源码分析之响应式原理
本模块都是vue原理系列文章,其中包含响应式原理、计算属性、侦听函数、编译原理、组件创建及渲染、插槽和状态管理等等相关方面,总之对于vue的使用及核心思想这里都会涉及到。目前vue从主流版本vue2升级到vue3了,因此文章中也会比对两个版本之间同一原理实现差别
学习vue源码对于每一个使用它的伙伴都很重要,尤其对于面试八股文是一把 🔑 ,但最重要的还是一种透过表象看本质的态度,提高自我技能和成长的过程 💪
源码会涉及到大量的代码,面对大量的代码要学会任务划分,先搞明白自己熟悉的部分如:响应式,然后可以尝试自己手写一遍加强理解。源码体量太大了,千万不要开始就从头到尾分析,这样很难让自己专注于某个功能,啃源码也会非常痛苦。同时也要学会调试某个具体功能,通过调试堆栈也可以很清晰看到流程是怎么样的,当将拆解的模块搞熟搞透了,再看看整体的流程是怎么样的,将会游刃有余
vue系列模块都是深入原理知识,会涉及到大量的分析和调试,因此更新周期会拉长,请耐心等待
Vue2分析
众所周知vue2是通过 Object.defineProperty 这个API实现响应式的,定义如下:
Object.defineProperty(obj, prop, descriptor)
obj
:要定义属性的对象prop
:要定义或修改的属性的名称descriptor
:目标属性所拥有的特性,包含以下可选属性:configurable
:是否允许属性被删除或修改,默认值为 falseenumerable
:是否可被枚举,默认值为 falsevalue
:属性的值,默认值为 undefinedwritable
:是否可被赋值运算符改变其值,默认值为 falseget
:获取函数,默认值为 undefinedset
:设置函数,默认值为 undefined
该方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,通常设置属性的 getter
或 setter
函数,从而实现对属性的拦截和控制
本次分析的vue2版本为2.6.11
简单实现
通过一个简单的例子看下效果:
const user = {
name: "小明",
age: 10
};
function defineReactive(obj: Record<string, any>, key: any) {
let value = obj[key]
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
console.log('访问属性:', key)
return value;
},
set(newValue) {
console.log('设置属性', key , '值', newValue)
value = newValue;
}
})
}
Object.keys(user).forEach(k => defineReactive(user, k));
以上定义了一个user
对象,并使用defineReactive
方法访问和设置该对象的属性时打印日志,现在我们尝试访问和设置它的属性,以下为实际打印情况:
既然可以拦截到对象属性的值访问和修改,也就可以做一些其他的操作;比如改变属性时执行某个方法:
intro = () => console.log(`我的名字:${user.name},年龄:${user.age}`);
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
console.log('访问属性:', key)
return value;
},
set(newValue) {
value = newValue;
// 设置属性时执行intro方法
intro();
}
})
这样在user的属性改变时,执行intro都可以获取到最新的值。以上有个弊端就是需要将执行方法写死在set里,如果有n个依赖方法要写n个,可以这样简单封装下:
// 省略部分代码
const cbs = [];
// 添加待执行的方法
cbs.push(intro);
// Object.defineProperty
set(newValue) {
value = newValue;
// 现在设置值时,只需要将cbs里的方法执行一遍
cbs.forEach(cb => cb());
}
这样解决了set里写很多执行方法的问题,但再仔细看一看问题又来了,cbs中的执行方法需要手动添加,如果有很多个方法也是需要先写死在里面,如何解决这个问题呢?如果能自动收集需要执行的方法就完美了!你可能有以下几个疑问❓
- 如何自动收集:上面借用了
Object.defineProperty
的set方法执行了cb,同样可以使用get方法,可以在访问属性时对正在执行的方法进行收集 - 收集的对象范围:当然是谁使用了当前对象的属性就收集时,而其他就是没有任何关联性的方法,改变了当前对象什么都不会发生
依赖自动收集与更新
通过上面实现遗留的问题,接着来看看如何解决并实现它们:
const user = {
name: "小明",
age: 10,
};
Object.keys(user).forEach(k => defineReactive(user, k));
const p1 = () => console.log(`【people1】名字:${user.name}`);
const p2 = () => console.log(`【people2】名字:${user.name},年龄:${user.age}`);
const cbs: Set<Function> = new Set();
// 当前正在运行的函数
let activeFn: Function | null = null;
activeFn = p1;
p1();
user.name = '小王';
activeFn = p2;
p2();
function defineReactive(obj: Record<string, any>, key: any) {
let value = obj[key]
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
// 收集当前正在执行的函数
cbs.add(activeFn!);
console.log('get log: 访问属性 ', key, '当前activeFn为 ', activeFn?.name);
return value;
},
set(newValue) {
value = newValue;
console.log('set log: 设置属性 ', key, '当前activeFn为 ', activeFn?.name);
cbs.forEach(cb => cb());
}
})
}
上面定义了两个执行方法p1、p2,其中p1使用了name属性,p2使用name、age属性,执行p1时将activeFn设置为p1,代表当前的执行函数为p1;然后将activeFn设置为p2再执行p2,这样执行两者的过程中会访问到user属性,就会触发get函数,然后将当前的activeFn添加到cbs中,这里使用set作为数据结构去重,最终p1和p2都会添加进去。当修改user的属性值时触发set,执行cbs里的方法也就是p1、p2。上面的执行结果如下图:
从图中可以看出执行主动p1、p2都会打印正确的值,而且修改了name属性后确实会触发cbs执行。细心的同学会发现,当修改age属性值时,所有的回调p1、p2都执行了,按理说p1只用到name属性不会执行,因为他和age没有任何关联;从以上代码分析我们将对象的每个属性的依赖都添加到了同一个cbs中,并访问set时,执行了所有的cbs,所以才会都执行。解决这个问题就需要针对每个属性做区分或者分开存储,下面我们进行简单改造:
const cbs: Set<Function> = new Set();
const cbs: Map<string, Set<Function>> = new Map();
function defineReactives(obj: Record<string, any>, key: any) {
let value = obj[key]
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
cbs.add(activeFn!);
if (!cbs.has(key)) {
cbs.set(key, new Set());
}
const deps = cbs.get(key)
deps?.add(activeFn!);
console.log('get log: 访问属性 ', key, '当前activeFn为 ', activeFn?.name)
return value;
},
set(newValue) {
value = newValue;
console.log('set log: 设置属性 ', key, '当前activeFn为 ', activeFn?.name)
cbs.forEach(cb => cb());
const deps = cbs.get(key)
deps?.forEach(dep => dep())
}
})
}
上面将cbs改成map结构,每个key对应一个set数据结构,针对对象的不同key单独来存储对应的cbs,这样在更新某个属性的值时只会触发对应key的所有cb执行,来看下改造后的执行结果:
从上面执行结果可以看到改造后当修改age时只会执行p2函数,而修改了name属性值后p1、p2都会被执行,这符合我们的预期
以上基本上实现了依赖的自动收集和派发更新,可能有人说还需要手动执行activeFn
的赋值操作,我的回答是必须的,当然在以上的例子中确实每次都要手动赋值,我们并没有实现递归组件创建等过程,这里只是演示一下如何触发依赖收集;不管怎么在依赖收集前都是要主动执行一次,当使用Vue时也是如此:new Vue
,当new时内部会触发一系列操作如:模板编译、依赖收集等等,递归创建组件时也会不断地进行依赖的收集,总之都会有一次主动执行
现在简单用官方的方式实现一下,支持深度嵌套,其主要涉及到observe、Observer、Dep、Watcher等函数和对象:
// 步骤:initData => observe => defineReactive => dep => watcher => update => render
// 渲染函数,把它当成vue的组件render函数
function render(renderFn: Function) {
const watcher = renderFn.watcher || new Watcher(renderFn);
if (!renderFn.watcher) {
renderFn.watcher = watcher;
}
Dep.target = watcher;
renderFn();
Dep.target = null;
}
// 每个组件渲染函数都有一个watcher,watcher中包含了被dep收集的所有dep
class Watcher {
deps: Set<Dep>;
cb: Function;
constructor(cb: Function) {
this.deps = new Set();
this.cb = cb;
}
addDep(dep: Dep) {
this.deps.add(dep)
dep.addSub(this);
}
update () {
this.cb?.();
}
}
// 每个属性都有自己的Dep对象,用来收集watcher,当值变化时通知所有的watcher进行更新
class Dep {
static target: Watcher | null;
subs: Array<Watcher>;
constructor() {
this.subs = [];
}
addSub(sub: Watcher) {
if (this.subs.includes(sub)) return;
this.subs.push(sub);
}
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
notify() {
for (let i = 0, l = this.subs.length; i < l; i++) {
this.subs[i].update();
}
}
}
Dep.target = null
// 拦截对象入口
function observe(value: any) {
if (typeof value !== "object") return;
// 保证只会生成一次
return new Observer(value) || value.__ob__;
}
// 使用Observer对象用来标识当前属性已经被拦截了,也就是有了dep对象
class Observer {
dep: Dep;
constructor(value: any) {
this.dep = new Dep();
const self = this;
Object.defineProperty(value, "__ob__", {
configurable: false,
get() {
return self;
},
});
this.walk(value);
}
// 深度遍历对象
walk(value: any) {
if (Object.prototype.toString.call(value) === "[object Object]") {
Object.keys(value).forEach((k) => {
defineReactive(value, k);
});
}
}
}
// 待拦截的对象,支持深度嵌套
const user = {
name: "小明",
age: 10,
friends: {
total: 3,
}
};
// 响应式核心
function defineReactive(obj: Record<string, any>, k: string) {
let val: any = null;
const getter = Object.getOwnPropertyDescriptor(obj, k)?.get;
const setter = Object.getOwnPropertyDescriptor(obj, k)?.set;
if (!getter) {
val = obj[k];
}
// 每个被observe过的对象都会有一个 闭包 dep对象,并且有一个`__ob__`属性
const dep = new Dep();
observe(val);
Object.defineProperty(obj, k, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
dep.depend();
}
return getter ? getter.call(obj) : val;
},
set(newVal) {
if (!setter) {
val = newVal;
} else {
setter.call(obj, newVal);
}
dep.notify();
},
});
}
// render前先对 user 对象进行拦截
observe(user)
// 模拟每个组件的真实渲染数据
let page1 = () => console.log(`页面1: =====> 我的名字:${user.name},年龄:${user.age}`);
let page2 = () => console.log(`页面2: =====> 年龄:${user.age},我有${user.friends.total}朋友`);
let page3 = () => console.log(`页面3: =====> 我的名字:${user.name},我有${user.friends.total}朋友`);
// 模拟vue的组件递归渲染
render(page1)
user.name = "小李";
render(page2)
user.name = "校长";
render(page3)
以上便是用最最简洁的代码还原了下官方的实现过程,趁热打铁来看看vue对其真正的实现过程
官方实现
以上我们使用Object.defineProperty
简单的实现了响应式的原理,从设计模式看其也是典型发布-订阅
模式,在Vue中主要通过Dep
、Watcher
实现了发布订阅模型,每个对象的属性都有一个对应的Dep,Dep中收集了所有需要派发更新的回调即:Watcher
,在属性更新时Dep会通知相关的Watcher进行更新,Watcher会进行页面的重新渲染,这就是为什么当修改数据时页面也会实时更新的原因
整体流程
这里我先简述一下vue响应式的整体过程,然后再以一张响应式模型图描述整个流程。
首先要明白vue的更新粒度是组件级别,也就是数据更新只会触发当前组件重新渲染,而不是整个应用。这种更新方式可以提高渲染性能,因为只需要对变化的组件进行更新,而不需要重新渲染整个应用
- 初始化:以new Vue为例开始创建根组件,创建组件过程会对相关属性的初始化,比如data、props等等
- 对象响应化:拦截data进行改造,也就是getter、setter进行拦截,并生成对应的dep(完成对属性拦截的相关逻辑)
- 挂载:执行挂载时
$mount
,过程中可能会对模板进行编译 - render:render函数生成vnode,最后patch整个vnode生成dom
其中在render过程中每个组件都会生成对应的watcher(渲染watcher
),组建在访问内部的数据(data、computed等等)时,会触发getter
函数然后通过当前属性的dep收集当前的watcher渲染watcher
,这样就完成了依赖的收集(这里省略了computed、watcher等处理逻辑,下一篇讲),当然组件创建或render是个递归过程,因此targetWatcher
(用来告诉Dep当前需要收集的Watcher)一定指向当前的组件渲染watcher;当改变某个对象的属性值时便会触发setter
,所持有的dep将会通知收集的watcher进行更新,watcher再次执行render更新页面。用一张图来描述这个过程:
具体实现
vue的响应式实现大体涵盖了Observe、Dep、Watcher等方法与对象,这里根据流程给出对应的代码,读者只需了解过程是怎么样的,每段代码的含义知道大概意思即可
- 首先组件的初始化会执行Vue的
_init
实例方法,内部通过initState
进行组件相关属性的设置,其中就包含了对data的拦截:
// src/core/instance/init 57行
Vue.prototype._init = function (options?: Object) {
const vm: Component = this;
// 省略一大堆代码...
initState(vm);
}
// src/core/instance/state 54行
export function initState(vm: Component) {
const opts = vm.$options;
if (opts.data) {
initData(vm);
}
}
// src/core/instance/state 114行
function initData(vm: Component) {
let data = vm.$options.data;
data = vm._data = typeof data === "function" ? getData(data, vm) : data || {};
// 观测观测组件data,也就是进行setter、getter拦截
observe(data, true /* asRootData */);
}
- 通过observe入口为每一个级别对象生成对应的Observer对象,并深度遍历实现对组件data属性的拦截:
// src/core/observer/index 110行
export function observe(value: any, asRootData: ?boolean): Observer | void {
return new Observer(value);
}
// src/core/observer/index 37行
export class Observer {
constructor(value: any) {
this.dep = new Dep();
def(value, "__ob__", this);
this.walk(value);
}
// 深度遍历对象
walk(obj: Object) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
}
// ....
}
// src/core/observer/index 135行
export function defineReactive(
obj: Object,
key: string,
val: any,
) {
const dep = new Dep();
const property = Object.getOwnPropertyDescriptor(obj, key);
// cater for pre-defined getter/setters
const getter = property && property.get;
const setter = property && property.set;
let childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val;
// 收集watcher
if (Dep.target) {
dep.depend();
}
return value;
},
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val;
if (newVal === value || (newVal !== newVal && value !== value)) {
return;
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
// 通知更新
dep.notify();
},
});
}
- 拦截对象的每个属性都会有一个
闭包Dep
,这个闭包Dep很重要,在getter/setter用来和watcher进行关联,来看看Dep的实现(Watcher实现后面看):
// src/core/observer/dep
// Dep的实现很简单,省略了部分代码
export default class Dep {
// 全局唯一激活的 Watcher
static target: ?Watcher;
// 用来存放 存放了当前Dep的 所有watcher
subs: Array<Watcher>;
addSub(sub: Watcher) {
this.subs.push(sub);
}
// 收集依赖
// Dep.Watcher会添加当前的Dep
// 同时Watcher的addDep也会执行Dep的addSub将watcher添加到当前Dep(看Watcher方法)
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
// 派发更新,对象的属性值改变时会通过自己的dep.notify通知所有的watcher执行update
notify() {
for (let i = 0, l = this.subs.length; i < l; i++) {
this.subs[i].update();
}
}
}
// 这里是用来设置当前的watcher,dep和watcher进行联系的桥梁
Dep.target = null; 当前的watcher
const targetStack = []; 维护wather栈结构
// 进栈、设置当前watcher
export function pushTarget(target: ?Watcher) {
targetStack.push(target);
Dep.target = target;
}
// 出栈、当前watcher为栈最后一个
export function popTarget() {
targetStack.pop();
Dep.target = targetStack[targetStack.length - 1];
}
- 上面我们也说了vue响应式主要由dep、watcher等方法完成,Dep这里有了,目前就差一个watcher了,那它又是怎么来的呢?不要忘记上面说过组件在渲染的过程中会执行render函数,每个组件都会生成对应的渲染watcher,而渲染又是从mount开始的:
// 执行了$mount才会真正的进行页面渲染(或者el参数不为空)
new Vue({ render: h => h('div') }).$mount("#app")
来从源码看看$mount
的执行位置:vue针对不同的环境做了一些参数处理,这里我们看浏览器环境下
// $mount => 模板编译(compiler-runtime) => mountComponent => render => createElement(VNode,此过程会获取当前组件数据,触发对象的getter对当前渲染watcher进行收集) => patch
// src/platforms/web/entry-runtime-with-compiler 20行
const mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el);
const options = this.$options;
// 用户没有提供 render 函数时,根据提供的template/el 的innerHTML 模板进行编译,最后生成 render函数
if (!options.render) {
let template = options.template;
if (template) {
if (typeof template === "string") {
if (template.charAt(0) === "#") {
template = idToTemplate(template);
}
} else if (template.nodeType) {
template = template.innerHTML;
} else {
return this;
}
} else if (el) {
template = getOuterHTML(el);
}
if (template) {
// 模板编译 生成 render 函数
const { render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: process.env.NODE_ENV !== "production",
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments,
},
this
);
// 将 render 函数赋值给 当前示例的render属性
options.render = render;
options.staticRenderFns = staticRenderFns;
}
}
// 最后执行mount
return mount.call(this, el, hydrating);
};
// src/platforms/web/runtime/index 37行
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined;
// 执行mount真正执行的函数 mountComponent
return mountComponent(this, el, hydrating);
};
- 揭开组件的渲染watcher真面目:
// src/core/instance/lifecycle.js 141行
export function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el;
let updateComponent;
// 这个函数很重要,当组件的data更新时通知渲染watcher更新,watcher会再次执行当前函数
// 这里只需知道:
// 1. render函数会生成vnode此过程会获取到对应的对象就会触发getter
// 2. update用来将新的vnode渲染成dom
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
// 创建组件的渲染watcher,这里是最外层的watcher
new Watcher(
vm,
updateComponent, // watcher更新时会触发
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
}
上面执行mountComponent
方法时会创建一个watcher对象,并将重要的updateComponent
参数传进去;先来看看updateComponent函数的作用,他通过执行render生成vnode,并将vnode传递给update进行patch页面渲染真实dom,在数据更新时重新渲染也就是重新执行当前的updateComponent方法,现在就来看看组件data和watcher是如何联系起来的。先来分析下watcher的执行过程:
// src/core/observer/watcher
// 部分代码省略...
export default class Watcher {
vm: Component;
cb: Function;
// expOrFn是外面传进来的 updateComponent 也就会重新页面渲染
constructor(vm: Component, expOrFn: string | Function, cb: Function) {
this.vm = vm;
vm._watchers.push(this);
this.cb = cb;
this.active = true;
this.deps = [];
this.newDeps = [];
this.depIds = new Set();
this.newDepIds = new Set();
if (typeof expOrFn === "function") {
// getter 变成了 updateComponent
this.getter = expOrFn;
}
// 执行get方法
this.value = this.get();
}
get() {
// 设置当前watcher
pushTarget(this);
let value;
const vm = this.vm;
// 注意这里 执行 updateComponent 方法
// updateComponent 就会执行 render函数,其在生成vnode的过程中
// 会访问组件实例vm中的数据,此时就会触发对象的getter进行依赖收集
// 收集的就是 Dep.target, 也就是当前的 watcher
value = this.getter.call(vm, vm);
popTarget(); // 执行完后,复原Dep.Watcher
this.cleanupDeps();
return value;
}
// 依赖收集时,dep.depend 会执行当前watcher的addDep 将对象的dep添加进去
// 同时将自己添加到dep中,两者是多对多的关系
// 其实每次添加有个去重的关系,主要是为了避免不必要的渲染,如v-if为false时,页面也没必要渲染,这里不展开讲了
addDep(dep: Dep) {
const id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
}
// 当用户在页面改变值或者其他方法改变某个数据的值时,就会触发对象的setter
// dep.notify会执行watcher的update方法
// 这里只看 queueWatcher,其也就是vue的 异步批量更新 方法,最终会执行watcher的 run方法
update() {
// 省略...
queueWatcher(this);
}
// queueWatcher 中会执行此方法,这里只要知道会执行 get 方法即可
// 而get会执行 updateComponent 也就是触发render、patch重新渲染页面
run() {
// 省略部分代码...
const value = this.get();
}
}
到这里也就讲完了vue的响应式过程,可能对源码不熟悉的可能就有点懵。如果你不熟悉的话我推荐你断点调试一个最简单的vue组件,然后将上面的代码每个位置打上断点,自己过几遍就应该明白了
数组实现
Vue通过用Object.defineProperty
实现了对对象的的拦截,但对于值为数组类型Array
或者添加新属性时,此方法都无法监听到值的改变。我们接着上面自己实现的响应式举个例子:
// 改变friends 结构,添加 intro 新方法
const user = {
name: "小明",
friends: ["小红", "小李"]
};
const intro = () => console.log(`我的名字:${user.name},我的朋友:${user.friends?.join("、")}`);
render(intro);
// 当分别改变 name 和 friends 的值时,只有name改变才会重新执行 intro
user.name = '鲁班七号';
user.friends.push('小卫');
上面我们验证了确实无法对数组修改进行拦截,那么Vue是如何实现对数组拦截的呢❓答案就是改写数组的原型方法,改写后就可以实现拦截了,再来看下Observer对象的过程:
// src/core/observer/index
export class Observer {
constructor(value: any) {
this.value = value;
this.dep = new Dep();
// 处理数组
if (Array.isArray(value)) {
// 这里就是改写 array 的原型方法,具体的方法在 arrayMethods中,这里就是替换这个值的原型,而不是Array整体原型
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
}
// 上面我们只说了这里,用来处理嵌套对象
this.walk(value);
}
// 如果数据中的数据是对象 对每个对象拦截
observeArray(items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
}
}
具体来看数组方法的改造实现:
// src/core/observer/array
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// 待改写的方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort','reverse']
methodsToPatch.forEach(function (method) {
// 原始方法
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// 通过原始方法拿到结果
const result = original.apply(this, args)
// 获取当前数组的dep
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是自己的ob,在getter中就是childOb
ob.dep.notify()
return result
})
})
这里总结下上面方法改写,也就是当你执行数组的方法时如:arr.push
时,内部通过ob.dep.notify
手动通知更新,这样重新渲染页面就会看到新的数据了。你可能对当前dep是哪个dep,数组的dep又是如何收集到watcher的?如下:ob.dep.notify
的dep是[1, 2, 3]
自己的dep,而不是defineProperty
中的那个闭包dep
user = {
friends: [1, 2, 3]
}
// 数组改写方法中通过this拿到ob,__ob__就是Observer对象
const ob = this.__ob__
export class Observer {
constructor(value: any) {
this.value = value;
this.dep = new Dep();
// 这里每个值都会生成自己的ob,也可以通过 value.__ob__获取到
def(value, "__ob__", this);
}
}
那在何时进行依赖收集的呢❓
export function defineReactive(obj: Object, key: string) {
// 省略....
// 这里对值继续进行oberve,当是数组时在Observer中就会对数组进行改写
let childOb = !shallow && observe(val);
Object.defineProperty(obj, key, {
get: function reactiveGetter() {
if (Dep.target) {
dep.depend();
if (childOb) {
// 这里是关键,当访问某个属性时,如果当前属性时数组,那么就会通过他自己的
// ob中的dep进行依赖收集,你可以认为这里就是为数组准备的
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value;
},
set: function reactiveSetter(newVal) {
// 属性值改变时对新的值重新进行观测,并改变childOb
childOb = !shallow && observe(newVal);
},
});
}
可能上面一时半会有点绕,还是一样自己多调试几遍简单的demo就会明白的,下面我们再对自己实现的进行改造,让它支持数组:
class Observer {
constructor(value: any) {
// 处理数组,改写原型方法
if (Array.isArray(value)) {
Object.setPrototypeOf(value, arrayMethods)
this.observeArray(value);
} else {
this.walk(value);
}
}
observeArray(items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
}
// 省略其他...
}
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort','reverse']
methodsToPatch.forEach(function (method) {
const original = arrayProto[method as any];
Object.defineProperty(arrayMethods, method, {
value(...args: any) {
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
}
})
})
function defineReactive(obj: Record<string, any>, k: string) {
const childOb = observe(val);
Object.defineProperty(obj, k, {
get() {
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
}
}
return getter ? getter.call(obj) : val;
}
});
}
修改后再看看结果确实符合预期,当修改friends的值时也会执行对应的回调: 细心的读者应该会发现,使用索引修改值时却没有执行回调,是的!这也是vue的问题,虽然内部拦截了数组的一些方法,但却无法拦截通过索引修改值,这是硬伤也是
Object.defineProperty
的缺陷。为了解决这个问题,vue提供了$set
方法,接下来带着好奇往下看吧
$Set原理
其实$set的实现没有那么神秘,原理非常简单:主动进行依赖收集并触发更新,我们直接看看它的实现过程:
export function set(target: Array<any> | Object, key: any, val: any): any {
// 目标对象是 数组时,那么key就是索引, 判断索引是否合法,最终还是调用了 splice方法
// 所以 this.$set(arr, 2, 'something') 本质还是调用了splice方法,当然这个方法已经改写了
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key);
target.splice(key, 1, val);
return val;
}
// 如果目标不是数组对象
// 若key已经存在了,直接返回啥都不做
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val;
}
const ob = (target as any).__ob__;
// 目标对象没有__ob__证明不是响应式对象,直接返回不做处理
if (!ob) {
target[key] = val;
return val;
}
// 主动将key属性变成响应式,触发依赖收集
defineReactive(ob.value, key, val);
// 通知更新
ob.dep.notify();
return val;
}
仔细看看这段代码相信你已经可以搞明白它是如何运作的了,除了$set
外Vue还提供了$delete
方法当删除某个属性时通知更新视图,其原理和set道理一样,自己看看吧
到这里关于Vue2的响应式过程就结束了,还有一些computed
、watch
的实现没有讲我们放在「计算属性与侦听函数」本篇,相信现在你应该对Vue2的响应式有一定的认识了,当然需要你多动动手多写几个例子,然后多调试一下思路就会很清晰了
Vue3分析
从Vue2到使用Vue3是个无感过程应该很多人也在用了,Vue3最明显的特点就是不再支持IE了,这里最根本原因是内部使用了一些新的语言特性,这些特性只被现代浏览器支持,比如:响应式原理本质是使用了Proxy、Reflect等API,这些API原生支持属性拦截,不需要再兼容大量代码,因此速度更快、性能更好
Vue3的官方仓库全部采用了typescript开发,类型提示更加友好。采用monorepo方式将核心功能拆分独立的包,提供给用户更多的选择权,同时也更有利于tree shaking。这里我们先来看看Vue3的响应式是如何实现的吧👇
本次分析的vue3版本为3.3.0
核心API
在Vue3中响应式的核心原理不再使用Object.property
,而由Proxy和Reflect代替,后者是天生的元编程性能更好,如果你对这两者API还不太熟悉的话,可以先阅读我的「元编程」一文
举个简单例子看下这两个API的强大:
// 源对象
const user = {
name: "小明",
age: 10
}
// 代理对象
const proxyUser = new Proxy(user, {
get(target, key, receiver) {
console.log("访问了:", key);
return Reflect.get(target, key, receiver);
},
set(target: any, key, newValue, receiver) {
console.log(`设置 ${key.toString()} 的值由 ${target[key]} 改为 ${newValue}`);
return Reflect.set(target, key, newValue, receiver);
}
});
从上面的例子可以看到使用
Proxy
可以对对象进行get/set拦截,并且支持对未初始化的属性进行拦截(如上id属性),我们知道新属性对于Object.defineProperty是拦截不到的,而proxy却可以优雅的解决掉这个问题。Reflect方法提供了多个静态方法,用来获取或设置原对象的值,更多关于两者的使用这里就不再多说,接下来我用最简单的代码同样实现上面自己实现的响应式
简单实现
其实响应式万变不离其宗,通过对对象的get/set进行拦截,获取对象属性时对依赖进行收集,修改对象属性时派发更新,收集的目标是全局唯一正在运行的函数。也就是说每次运行一个函数时都会将其设置为正在运行的函数,然后其访问某个属性时,这个属性就会收集当前运行函数,有多少个运行函数就会收集多少个,那么当值被修改时就会通知当前属性的所有依赖进行更新,也就会让这些函数重新执行一遍。下面看下实现过程:
// 当前正在运行的函数
let activeEffect: null | Function;
// 副作用函数用来修改 activeEffect 的值
function effect(fn: Function) {
activeEffect = fn;
// 主动执行一次,函数内部访问对象属性时就会触发依赖收集
fn();
activeEffect = null;
}
// 依赖收集集合
const targetMap = new WeakMap<any, Map<string, Set<Function>>>();
// 收集依赖
function track(target: any, key: string) {
if (activeEffect) {
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
// 将当前的 activeEffect 添加到当前 属性的依赖里
dep?.add(activeEffect);
}
}
// 派发更新
function trigger(target: any, key: string) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
// 获取当前 属性 的所有依赖,执行他们
deps?.forEach(dep => dep?.())
}
// 拦截对象的get/set等等
function reactive(target: Record<string, any>) {
return new Proxy(target, {
get(target, key: string, receiver) {
const result = Reflect.get(target, key, receiver);
// 收集依赖
track(target, key);
return result;
},
set(target, key: string, newValue, receiver) {
const oldValue = target[key];
// 值一样直接返回
if (oldValue === newValue) return true;
const result = Reflect.set(target, key, newValue, receiver);
// 出发当前key依赖更新
trigger(target, key);
return result;
},
});
}
const user = reactive({
name: "小明",
age: 10,
});
// p1
effect(() => console.log(`p1:我的名字:${user.name},年龄:${user.age}`));
// p2
effect(() => console.log(`p2:年龄:${user.age}`));
下图是执行结果,可以看出每个属性改变时都会正确的派发更新
再来看看依赖的结构,从下图可以很明显的看到name
只有一个依赖p1,而age
就有两个依赖p1、p2
以上简单的实现了对对象的拦截,但还存在一个问题那就是没有对深层对象进行拦截,如下代码修改了info.hobby
却不会正确的派发更新:
const user = reactive({
name: "小明",
age: 10,
info: {
hobby: "篮球"
},
});
effect(() => console.log(`p3:我的名字:${user.name},年龄:${user.age},爱好:${user.info.hobby}`));
深层嵌套
要解决以上的问题就要深层对对象进行代理,我们的代码使用new Proxy
仅仅会拦截对象的第一层对象,因此在获取深层属性时,是不会触发拦截的。要解决这个问题很简单,那就是判断当前属性的值是不是原始类型(假如),如果不是就需要对值再进行拦截也就是再执行reactive
方法进行代理,这样就会正常的拦截到深层嵌套的属性了。来看下实现,很简单:
function reactive(target: Record<string, any>): any {
return new Proxy(target, {
get(target, key: string, receiver) {
const result = Reflect.get(target, key, receiver);
// 收集依赖
track(target, key);
// 判断当前的值是不是对象,如果是对象继续代理拦截
// 这样在访问深层对象时,其实访问的代理对象
if (typeof result === "object") {
return reactive(result)
}
return result;
},
// 省略...
});
}
const user = reactive({
name: "小明",
age: 10,
info: {
hobby: "篮球",
girlFriend: {
name: '鲁班'
}
},
});
effect(() => console.log(`p3:我的名字:${user.name},年龄:${user.age},爱好:${user.info.hobby},女朋友:${user.info.girlFriend.name}`));
以上改造好了后看下执行结果:
到这里其实还差对数组结构方法的拦截,看如下代码:
const user = reactive({
name: "小明",
age: 10,
friends: ["鲁班", "钟馗"]
});
effect(() => console.log(`p4:我的名字:${user.name},朋友:${user.friends?.join("、")}`));
实现数组
那么对于数组的方法又是如何拦截的呢?对数组的修改通常都会获取length属性触发track,然后通过索引触发set,因此在使用数组方法触发trigger时判断当前对象是不是数组并且当前key为数字,那就通知更新数组length属性的依赖。来看下简单改造:
function trigger(target: any, key: string) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
// 当key为数字时 通知length属性依赖更新
if (Number(key)) {
return depsMap.get("length")?.forEach((dep) => dep?.());
}
const deps = depsMap.get(key);
deps?.forEach((dep) => dep?.());
}
这样当使用数组的方法修改时就可以正确派发更新了
实现Ref
在reactive的基础上实现ref变得非常简单,由于proxy
只能拦截对象无法对原始类型进行拦截,因此可以将原始对象包装成对象然后再进行拦截即可。在vue3中通常这样使用:
const loading = ref(false);
loading.value = true;
console.log(loading.value);
现在我们来简单实现下ref函数:
function ref(value: string | boolean | number) {
return reactive({ value });
}
const loading = ref(false);
effect(() => loading.value && console.log("加载中。。。"))
以上打印结果如下:
实现toRefs
toRefs主要用来解决对象解构后不再响应式的问题,解决这个也很简单,让解构的属性值获取的还是原来的对象,这样在访问/修改属性时还是触发的原来响应式对象
我们来简单的实现下这个功能:
class ObjectRef {
constructor(
private readonly _object: any,
private readonly _key: any,
) {}
get value() {
const val = this._object[this._key]
return val;
}
set value(newVal) {
this._object[this._key] = newVal
}
}
function toRefs(obj: any) {
const res = {};
Object.keys(obj).forEach(k => {
res[k] = new ObjectRef(obj, k)
})
return res;
}
使用toRefs的功能验证是否结构后不会失去响应式:
const { age } = toRefs(user);
effect(() => console.log(`p1:我的名字:${user.name},年龄:${user.age}`));
// 使用解构的属性,来验证它的响应式
effect(() => console.log(`p2:年龄:${age.value}`));
官方实现
上面我们尝试着使用proxy简单实现了下vue3的响应式原理和一些函数,接下来就来扫一下源码是如何实现的。源码写的很健壮这里大家了解下大概流程即可,对于特殊情况可以单独进行分析
- 实现对象拦截代理,这里我们看最普通的reactive:
// packages/reactivity/src/reactive 82行
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
// 只读对象
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
)
}
// packages/reactivity/src/reactive 248行
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>
) {
// 省略...
// 真正 proxy 拦截的地方
const proxy = new Proxy(target, baseHandlers)
proxyMap.set(target, proxy)
return proxy
}
// 这里看下get和set实现
// packages/reactivity/src/baseHandlers 48行
const get = /*#__PURE__*/ createGetter()
const set = /*#__PURE__*/ createSetter()
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
// 获取使用Reflect获取值
const res = Reflect.get(target, key, receiver)
// 以来收集
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
// 如果属性值为对象 如 {}、[] 等等,深层拦截
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
function createSetter(shallow = false) {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
// 获取结果
const result = Reflect.set(target, key, value, receiver)
// 通知更新
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}
}
- 接着来看依赖收集过程也就是track函数的实现:
// packages/reactivity/src/effect 247行
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 首先判断可以收集、是否有正在运行的函数
if (shouldTrack && activeEffect) {
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
trackEffects(dep)
}
}
export function trackEffects(
dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
let shouldTrack = false
if (effectTrackDepth <= maxMarkerBits) {
if (!newTracked(dep)) {
dep.n |= trackOpBit // set newly tracked
shouldTrack = !wasTracked(dep)
}
} else {
shouldTrack = !dep.has(activeEffect!)
}
// 这里只看这一步即可
dep.add(activeEffect!);
}
- 依赖收集的过程就是收集
activeEffect
它是如何运作的呢?来看effect的实现:
// packages/reactivity/src/effect 180行
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
// 创建
const _effect = new ReactiveEffect(fn)
// 执行
_effect.run()
return runner
}
// ReactiveEffect实现
// packages/reactivity/src/effect 53行
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = []
constructor(
public fn: () => T,
) {}
// 创建effect后执行 run方法
run() {
try {
this.parent = activeEffect
// 绑定为当前实例
activeEffect = this
shouldTrack = true
// 执行传进来的函数
return this.fn()
} finally {
// 执行完后还原
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined
}
}
}
- 依赖收集后那么在修改属性值时会触发set同通过
trigger
用来派发更新:
// packages/reactivity/src/effect 305行
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
) {
const depsMap = targetMap.get(target)
if (!depsMap) return
// 统一放在 deps 中处理
let deps: (Dep | undefined)[] = []
// 数组处理逻辑
if (key === 'length' && isArray(target)) {
const newLength = Number(newValue)
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= newLength) {
deps.push(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0) {
deps.push(depsMap.get(key))
}
// 省略 删除、添加判断
}
const effects: ReactiveEffect[] = []
for (const dep of deps) {
if (dep) {
effects.push(...dep)
}
}
triggerEffects(createDep(effects))
}
export function triggerEffects(
dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
const effects = isArray(dep) ? dep : [...dep]
// 计算属性
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
// 非计算属性
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
}
function triggerEffect(
effect: ReactiveEffect,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// effect是个ReactiveEffect类,执行run函数,触发外部函数执行
effect.run()
}
到这里基本上就关于vue3的响应式基本原理就讲通了,当然只是笼统的梳理下响应式的过程,对于其中特殊的实现这里不再赘述,读者自己尝试打断点分析一下即可。还有一些其它功能如:Ref、toRefs、computed等这些功能其实都很简单,自己尝试看看应该能看懂
响应式对比
1️⃣ vue2响应式原理及缺点:
- 原理:使用 Object.defineProperty 进行响应式数据的劫持和侦听。它会在实例化时递归地将对象的属性转换为 getter 和 setter,从而在属性访问时触发依赖收集和更新;对于新增或删除的属性,需要使用 Vue.set 或 Vue.delete 进行特殊处理,以确保它们也是响应式的
- 缺点:存在一些性能和限制方面的问题,例如无法监听数组索引的变化,需要使用特定的数组方法进行变异操作
2️⃣ vue3响应式原理和优势:
- 原理:基于 ES6 Proxy 的响应式系统,取代了 Object.defineProperty。Proxy 可以捕获对对象的任何属性的访问、赋值和删除操作,并触发相应的更新
- 优势:
- 更好的性能:使用 ES6 Proxy 的响应式系统在某些情况下比 Object.defineProperty 更高效,因为它可以直接捕获属性的操作,无需递归地转换整个对象
- 更好的数组响应:Vue.js 3 的响应式系统可以直接监听数组索引的变化,并通过新的数组方法实现了可响应的变异操作,使得数组的处理更加直观和灵活
- 更全面的响应式追踪:Vue.js 3 的响应式系统可以追踪 Map、Set 等内置对象的变化,提供了更全面的响应式能力
- 更简洁的语法:reactive API 可以更直接地将对象转换为响应式对象,不再需要依赖全局的 Vue 实例
源码调试技巧
本次以vue3为例,打开vue3的项目首先看package.json
中的脚本,我们使用以下脚本:
# 启动项目
pnpm dev
# 启动静态服务
pnpm serve -p 10010
在vscode中添加调试配置,需要注意在跟路径下创建:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
// 这里我们使用启动chrome,访问serve服务的地址
"name": "Launch Chrome",
"request": "launch",
"type": "pwa-chrome",
"url": "http://localhost:10010",
"webRoot": "${workspaceFolder}"
},
]
}
关于vscode的更多调试技巧可以看我的「vscode调试技巧」一文
点击侧边栏debugger图标,然后选择我们设置调试配置名字Launch Chrome
点击启动
上面启动后会新开一个chrome,运行我们的serve静态服务,然后我们找到/packages/vue/examples/
下的对应的html文件
如下图,我们在reactivity.html
中打了断点,当访问当前页面时就会debug:
使用vscode的好处就是,可以直接在编辑器中调试,不需要看chrome的打印结果:
这样借助vscode调试对于源码的阅读更加友好和清晰,一定要学会调试技巧在工作中也是很有帮助的
IOC实现响应式
写作中...
总结
总之Vue的响应式都是通过拦截对象的setter/getter,进而实现合理依赖收集的过程,当对象改变时便通知以来进行更新。这也是Vue核心概念数据驱动视图
。通过将数据和视图进行绑定,当数据发生变化时,视图会自动更新。这种响应式的设计方式使得开发者可以更加方便地管理和更新应用程序的状态,减少了手动操作 DOM 的繁琐工作
到了这里相信你也知道数据驱动视图的背后原理了,接下来我们接着分析「计算属性与侦听函数」

