目录
前言
现在的前端面试越来越卷了有没有,面试造火箭,工作打螺丝。面试的时候不深度分析下Vue的响应式原理,就约等于你不会这玩意儿。那今天咱就来分析下Vue2和Vue3的响应式。
一、响应式的基本原理
Vue的数据绑定,将template中添加的变量,与定义的属性关联起来,当属性值发生变化时,使用该属性的页面位置,自动发生变化,而不需要开发者去操作dom。
在这个过程中,响应式的作用是:
1. 将template中的变量与定义的属性关联起来
2. 当属性发生变化时,通知对应的页面/组件重新渲染
要达成上述目标,那就需要做如下工作:
1. 数据劫持
数据劫持的目标是,对于vue组件中定义的属性(vue2中在data中定义的,vue3使用reactive等方法声明的),vue需要对其进行劫持,当其被访问和设置的时候,vue需要实时得知,并做出反应。
我们知道,在vue2中,是使用Object.defineProperty实现,vue3是借助了ES6的Proxy API。
2. 收集依赖
收集依赖的目标是,vue需要记录,某个属性,是否被使用,它的变化,需要引起哪些变化。比如触发computed、watch中对应位置的回调,以及触发某个页面组件的重新渲染。
3. 派发更新
当属性发生变化后,需要根据记录的依赖,一一通知对应的方法做出反应。比如执行computed、watch中对应位置的回调,对应页面组件的重新渲染。
二、观察者模式
在Vue依赖收集和派发更新的过程中,一个属性发生变化,多处处理方法需要做出响应,且必须做出响应。天然适合观察者模式。
观察者模式中有两种角色,观察目标、观察者,简单的实现逻辑如下
// 观察者
class Observer {
constructor(fn) {
this.update = fn;
}
}
// 观察目标
class Subject {
constructor() {
this.observers = [];
}
addObserver(observe) {
this.observers.push(observe);
}
removeObserver(observe) {
const index = this.observers.findIndex(v => v == observe);
if (index >= 0) {
this.observers.splice(index, 1);
}
}
notify(context) {
this.observers.forEach(v => {
v.update(context);
});
}
}
const sub1 = new Subject();
const sub2 = new Subject();
const obs1 = new Observer((context) => {
console.log("obs1监听,收到值变化", context);
})
const obs2 = new Observer((context) => {
console.log("obs2监听,收到值变化", context);
})
sub1.addObserver(obs1);
sub1.addObserver(obs2);
sub2.addObserver(obs2);
sub1.notify("狗子,你变了");
sub2.notify("是的,我变了");
执行结果如下:
观察者有各自的回调函数,观察目标维护一个观察者列表,当其调用notify方法发布通知时,顺次执行观察者列表中所有观察者的回调函数,通知的内容以参数形式传给观察者回调。
可以看到,一个观察目标可以有多个观察者,一个观察者也可以去观察多个观察目标。
放到响应式过程中来说,vue组件的属性是观察目标,视图、计算属性、侦听器,是观察者。
三、Vue2的响应式原理
三个核心 Observer(数据观测者) Watcher(视图更新者) Dep(依赖收集器)
1. 数据劫持(Observer处理)
Vue2的数据劫持,使用Object.defineProperty
Object.defineProperty(data,key,{
enumerable:true,
configurable:true,
get(){
console.log("试图访问"+key+"属性")
return val
},
set(newValue){
console.log("试图修改"+key+"属性")
if(val === newValue) return
val = newValue
}
})
在vue组件初始化时,会对data中的属性进行深度遍历,通过这种方式给每个属性添加getter和setter,当数据被访问或改变时,vue都能第一时间获知。
特别地,对于数组的操作,push、splice、unshift等,都可以给数组添加新的数组项,vue直接重写了这些方法,给新添加的数组项内的属性,也添加响应式。
这种方案存在一些缺陷:
新增属性、删除属性、利用数组下标修改值、对数组.length的修改、Map、WeakMap、Set、WeakSet这些数据结构不支持。因为这些操作,getter和setter监听不到,故而无法做出响应式处理。
2. Vue2中的Watcher 和 Dep
Watcher有三类,渲染watcher,computed的watcher,watch的watcher。在执行过程中,computed的watcher先执行,watch的watcher次之,渲染watcher最后。对应着,先运算值,再根据值的变化进行一轮用户自定义的处理,此时本轮值运算已经结束,有了结果,最后通知render函数将结果渲染到页面上。
一个组件,对应一个渲染watcher,根据用户自定义代码,可以有若干个computed watcher和watch watcher。
一个组件有多个属性,一个computed可能与多个属性相关,所以一个watcher,可能与多个属性相关,也就对应多个dep。
Dep用于收集某个属性对应的watcher,根据上面对watcher的表述可以看出,一个属性可能有多个watcher。
watcher中也会维护哪些属性的变化会触发自身反应,一个watcher可能对应多个dep。
3. 依赖收集
我们来看看渲染watcher依赖收集过程,computed和watch后续再讨论。
渲染watcher的依赖收集发生在什么时候?
function Vue (options) {
// ....
this._init(options)
}
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// *** 省略部分逻辑
// initState中对我们传入对data进行了数据劫持
initState(vm)
// 调用$mount开始挂载
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
我们new Vue时调用了_init方法。
initState进行了数据劫持,此时生命周期处于created后。执行$mount方法。
//$mount调用的实际是mountComponent方法
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 在此处,query方法寻找组件要挂载到的dom节点。
// 如果el是字符串,则用querySelector找,找不到就新建一个div返回。
// 如果el是dom,则直接返回
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
//mountComponent方法
function mountComponent(
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el;
// 在此处,将template转为render函数,vm._render()
callHook(vm, "beforeMount");
let updateComponent;
// ...
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
//
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
hydrating = false;
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true;
callHook(vm, "mounted");
}
return vm;
}
在 beforeMount生命周期前,根据template生成了render函数,然后将render函数包装在updateComponent方法中。
创建组件的渲染watcher,updateComponent方法被作为入参传入。
创建渲染watcher实例后,就到了mounted生命周期,那么依赖收集就应该在实例化渲染watcher的过程中。
接下来看看Watcher的源码
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
// ...
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
}
}
this.value = this.lazy
? undefined
: this.get()
this.deps = []
}
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
addDep(dep) {
this.deps.push(dep);
dep.addSub(this);
}
}
看构造方法,入参有组件实例vm、expOrFn(在渲染watcher实例中实际就是render函数)等。
Watcher实例化时执行构造方法,将render方法expOrFn赋值给this.getter
如果不是懒加载,执行this.get()方法。
get方法中
首先执行pushTarget方法
Dep.target = null
const targetStack = []
function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
function popTarget() {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
全局有一个targetStack的数组,当pushTarget调用时,将当前的watcher实例入栈,并将Dep.target设置为当前watcher实例。
继续看watcher的get方法,执行了这一句
value = this.getter.call(vm, vm)
我们知道,此时this.getter是组件的render函数,这里执行了render函数,用于生成虚拟dom,里面的属性会替换为其真实的值,就需要访问对应的属性值,从而触发属性数据劫持时监听的get方法。依赖收集,就在此处发生。
popTarget的作用是targetStack出栈,将Dep.target设置为栈顶元素对应的watcher
下一步就去数据劫持的defineReactive方法,看get做了什么
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
}
// getter和settter是做什么的?
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
}
})
}
Tips: 代码中的getter和settter是做什么的?
用于记录原生的get和set方法。vue会重写get和set方法,在方法中要先执行原生的get/set逻辑,进行值的获取和设置,再去做vue的特性处理。
defineReactive是给一个属性做数据劫持,首先创建了一个Dep实例。
当触发get时,如果Dep.target(当前激活的Watcher)存在,则执行dep.depend(),这句代码完成了依赖收集。
我们需要看一下Dep的源码
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
// ......
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
notify() {
var subs = this.subs.slice();
//通知所有绑定 Watcher。调用watcher的update()
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
}
}
dep.depend会去找当前Watcher,如果存在,则调用watcher的addDep方法,将自身传入。watcher中收集到关联的依赖项。 在Watcher的addDep中,又调用了dep.addSub,将watcher自身传入,完成了互相收集。
4. 派发更新
派发更新的动作,在数据劫持设置的set方法中执行。
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
}
// cater for pre-defined getter/setters
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,
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
实际上核心就是最后一句,dep.notify()
Dep的源码中可以看到,notify方法会将dep实例中收集的所有的watcher遍历一遍,并执行watcher的update方法。
update方法会依照一定规则去执行对应watcher的回调(渲染watcher对应的就是render函数)。
接下来就是生成新vnode,新旧vnode比较,然后最小化更新到页面。
四、Vue3的响应式原理
Vue3的响应式核心思想和Vue2是一致的,数据劫持+观察者模式。
1. 数据劫持
上面提到,Vue2进行数据劫持的方案存在缺陷。
新增属性、删除属性、利用数组下标修改值、对数组.length的修改、Map、WeakMap、Set、WeakSet这些数据结构不支持。因为这些操作,getter和setter监听不到,故而无法做出响应式处理。
比如delete this.xxx这种写法,无法被get、set监听到。
Vue3出现的背景是主流浏览器已经原生支持了ES6语法,以IE为代表的老浏览器的市场份额越来越低。
所以Vue3采用了ES6 Proxy API对对象进行数据劫持。上面提到的无法被监听的操作,用proxy都有对应的方法可以识别监听到。
Vue3全面抛弃了IE浏览器,微软浏览器必须得Edge浏览器才能支持Vue3。
在数据劫持方面,Vue2和Vue3还有一处显著区别
Vue2的数据劫持是在初始化时,一次性递归遍历了所有的属性,分别进行数据劫持。
Vue3中,初始化时只对第一层的属性进行劫持,当第一层某个属性被访问到时,才会给该属性的下一层进行数据劫持。
好处是提升了性能,定义了但无用的属性不会被添加响应式,初始化数据劫持不需要递归很深。
2. 依赖收集
Vue3使用effect代替了Vue2中的Watcher。
我们还是从组件初始化开始,看一个渲染effect的工作过程。
// 初始化组件
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
) => {
/* 第一步: 创建component 实例 */
const instance: ComponentInternalInstance = (initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
))
/* 第二步 : TODO:初始化 初始化组件,建立proxy , 根据字符窜模版得到 */
setupComponent(instance)
/* 第三步:建立一个渲染effect,执行effect */
setupRenderEffect(
instance, // 组件实例
initialVNode, //vnode
container, // 容器元素
anchor,
parentSuspense,
isSVG,
optimized
)
}
可以看到,初始化组件时,首先创建组件实例,然后使用setupComponent方法,建立proxy,进行数据劫持。接着,调用setupRenderEffect,创建一个渲染effect并执行。
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
/* 创建一个渲染 effect */
instance.update = effect(function componentEffect() {
//... 后面会讲这块源码
},{ scheduler: queueJob })
}
其实质,就是调用effect方法,创建一个effect,并赋值给组件实例的update属性,作为渲染更新视图用。
effect方法接受两个参数,第一个是个方法,里面是effect被触发后需要处理的逻辑。第二个是一些配置。
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
const effect = createReactiveEffect(fn, options)
/* 如果不是懒加载 立即执行 effect函数 */
if (!options.lazy) {
effect()
}
return effect
}
effect方法,最终创建出来的effect,还是一个方法。
如果配置中没有设置lazy,创建effect后,立即执行它一次。
创建effect的实际逻辑,在createReactiveEffect中。
function createReactiveEffect<T = any>(
fn: (...args: any[]) => T, /**回调函数 */
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(...args: unknown[]): unknown {
try {
enableTracking()
effectStack.push(effect) //往effect数组中里放入当前 effect
activeEffect = effect //TODO: effect 赋值给当前的 activeEffect
return fn(...args) //TODO: fn 为effect传进来 componentEffect
} finally {
effectStack.pop() //完成依赖收集后从effect数组删掉这个 effect
resetTracking()
/* 将activeEffect还原到之前的effect */
activeEffect = effectStack[effectStack.length - 1]
}
} as ReactiveEffect
/* 配置一下初始化参数 */
effect.id = uid++
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = [] /* TODO:用于收集相关依赖 */
effect.options = options
return effect
}
该方法定义了一个effect变量,它是一个方法,同时还给它设置了一些额外的参数,其中effect.deps数组就是用于收集相关依赖的。
上一段代码中,effect方法执行时,实际执行了这段代码中effect中的逻辑。
它做的事情也很简单,先将当前effect放入effectStack中,并将activeEffect指向当前effect。
之后,就执行了fn,往上层层溯源,它其实就是setupRenderEffect方法中,effect方法的第一个入参componentEffect。
执行结束后,将当前effect出栈,activeEffect指向effectStack中的栈顶元素。
回顾上面的代码,实际作用是创建了一个渲染effect,没有别的额外工作。那么,依赖收集相关的逻辑,应该就在componentEffect方法中。
function componentEffect() {
if (!instance.isMounted) {
let vnodeHook: VNodeHook | null | undefined
const { el, props } = initialVNode
const { bm, m, a, parent } = instance
/* TODO: 触发instance.render函数,形成树结构 */
const subTree = (instance.subTree = renderComponentRoot(instance))
if (bm) {
//触发 beforeMount声明周期钩子
invokeArrayFns(bm)
}
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
/* 触发声明周期 mounted钩子 */
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
instance.isMounted = true
} else {
// 更新组件逻辑
// ......
}
}
介绍下前提,在setupComponent中,根据template生成了render函数。在componentEffect中,通过renderComponentRoot方法,执行了render函数,此时,会访问页面展示使用到的属性,并触发其get方法,并完成依赖收集。
当这一步完成后,触发beforeMount生命周期回调。然后调用patch方法进行页面渲染,之后触发mounted生命周期回调。
function createGetter(isReadonly = false, shallow = false) {
return function get(target: object, key: string | symbol, receiver: object) {
const res = Reflect.get(target, key, receiver)
/* 浅逻辑 */
if (shallow) {
!isReadonly && track(target, TrackOpTypes.GET, key)
return res
}
/* 数据绑定 */
!isReadonly && track(target, TrackOpTypes.GET, key)
return isObject(res)
? isReadonly
?
/* 只读属性 */
readonly(res)
/* */
: reactive(res)
: res
}
}
get方法由createGetter创建。它接受两个参数,是否只读,是否浅逻辑。
根据代码,可以看出两个信息
1. track方法可以对当前属性进行依赖收集,如果当前属性是对象,也是对对象本身进行依赖收集,而不会递归对其子属性进行处理。
2. vue3初始化时只对第一层属性进行数据劫持,当某个对象属性的get被触发时,且非只读和浅逻辑,则对该对象进行数据劫持。
这样可以优化性能,初次渲染更快,而且有一些定义了但没用的属性值,不会进行深层次的响应式处理
/* target 对象本身 ,key属性值 type 为 'GET' */
export function track(target: object, type: TrackOpTypes, key: unknown) {
/* 当打印或者获取属性的时候 console.log(this.a) 是没有activeEffect的 当前返回值为0 */
let depsMap = targetMap.get(target)
if (!depsMap) {
/* target -map-> depsMap */
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
/* key : dep dep观察者 */
depsMap.set(key, (dep = new Set()))
}
/* 当前activeEffect */
if (!dep.has(activeEffect)) {
/* dep添加 activeEffect */
dep.add(activeEffect)
/* 每个 activeEffect的deps 存放当前的dep */
activeEffect.deps.push(dep)
}
}
再来看看track方法,核心代码是最下面的四句。
/* 当前activeEffect */
if (!dep.has(activeEffect)) {
/* dep添加 activeEffect */
dep.add(activeEffect)
/* 每个 activeEffect的deps 存放当前的dep */
activeEffect.deps.push(dep)
}
dep指的是当前属性的依赖集合(关联的effect集合),effect中也会维护deps,表示当前effect关联的属性集合。
activeEffect指的是当前激活的effect,上面有讲。
track中的其他代码,都是为了找到当前属性的依赖集合,也就是dep。
这里使用两个Map,一个Set来存储响应式对象、属性、依赖集合之间的关系。
targetMap 键名是proxy,被劫持的响应式对象,值是depsMap,当前proxy中所有属性的依赖集合的集合。
depsMap 键名是key,也就是属性名,值是dep,当前属性的依赖集合。
dep是一个Set,里面的值是一个个的依赖(effect),用Set是为了去重。
至此,依赖收集工作就完成了。
3. 派发更新
和vue2一样,当数据发生变化,触发了属性的set方法,set方法中会派发更新。
/* 根据value值的改变,从effect和computer拿出对应的callback ,然后依次执行 */
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
/* 获取depssMap */
const depsMap = targetMap.get(target)
/* 没有经过依赖收集的 ,直接返回 */
if (!depsMap) {
return
}
const effects = new Set<ReactiveEffect>() /* effect钩子队列 */
const computedRunners = new Set<ReactiveEffect>() /* 计算属性队列 */
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || !shouldTrack) {
if (effect.options.computed) { /* 处理computed逻辑 */
computedRunners.add(effect) /* 储存对应的dep */
} else {
effects.add(effect) /* 储存对应的dep */
}
}
})
}
}
add(depsMap.get(key))
const run = (effect: ReactiveEffect) => {
if (effect.options.scheduler) { /* 放进 scheduler 调度*/
effect.options.scheduler(effect)
} else {
effect() /* 不存在调度情况,直接执行effect */
}
}
//TODO: 必须首先运行计算属性的更新,以便计算的getter
//在任何依赖于它们的正常更新effect运行之前,都可能失效。
computedRunners.forEach(run) /* 依次执行computedRunners 回调*/
effects.forEach(run) /* 依次执行 effect 回调( TODO: 里面包括渲染effect )*/
}
定义effects存储涉及的effect,computedRunner存储computed产生的effect。
从targetMap中找到depsMap,再从depsMap中根据key获取当前属性的依赖集合,depsMap.get(key)。
使用add方法对集合中的依赖分类,分别存到effects和computedRunner中。然后分别遍历并执行effect方法。
其中渲染effect方法,会触发页面重新渲染。
4. 拾遗
4.1 Proxy和Reflect
在Proxy get的handler中,首先调用Reflect.get,这个调用是用来做什么的呢?
答案:为了获取到相应处理,原生的处理结果。
我们用Proxy拦截get,是为了在get的基础上,增加收集依赖的操作,但首先要保证其原生的能力,也就是获取到相应属性的值,并return出去。
Proxy和Reflect有一一对应的捕获器,Proxy.get可以捕获get操作,Reflect.get可以获取相应属性的值。其他捕获器也是一样的道理。
4.2 结合生命周期看Vue2的初始化
A、创建vue实例,事件循环,定义生命周期方法
触发beforeCreated,此时数据未初始化,也无法获取到props和data
B、数据劫持、创建inject
触发created,此时已经可以访问到props和data,可以在此进行接口调用等操作
C、调用vm.$mounte()方法
判断是否有template,如果没有,则用el的innerHTML作为template。
将template转为render函数
触发beforeMount
D、创建渲染Watcher,在构造函数中,调用了传入的render函数
创建虚拟dom,在此会访问data中数据,触发其getter,完成依赖收集工作
将虚拟dom渲染为真实页面
创建vm.$el(创建真实dom),并用其代替el,也就是el指定的挂载位置
触发mounted
Vue3和Vue2的生命周期过程大差不差,区别在beforeMount的触发时机。
Vue3是先执行render函数,收集依赖,完成后再触发beforeMount。
Vue2是先触发beforeMount,再实例化渲染watcher,在执行构造函数时执行render函数,收集依赖。
总结
Vue2和Vue3的响应式原理就告一段落啦,欢迎小伙伴们多多交流沟通。