学习 vue 源码对于每一个使用它的伙伴都很重要,尤其对于面试八股文是一把 🔑 ,但最重要的还是一种透过表象看本质的态度,提高自我技能和成长的过程 💪
源码会涉及到大量的代码,面对大量的代码要学会任务划分,先搞明白自己熟悉的部分如:响应式,然后可以尝试自己手写一遍加强理解。源码体量太大了,千万不要开始就从头到尾分析,这样很难让自己专注于某个功能,啃源码也会非常痛苦。同时也要学会调试某个具体功能,通过调试堆栈也可以很清晰看到流程是怎么样的,当将拆解的模块搞熟搞透了,再看看整体的流程是怎么样的,将会游刃有余
❝ vue 系列模块都是深入原理知识,会涉及到大量的分析和调试,因此更新周期会拉长,请耐心等待
❞ 阅前认知 首先要意识到响应式的核心设计模式就是观察者模式
(也可以理解为发布订阅),任何无关数据改变都不可能自动让其他的数据也发生变化,除非有鬼。所以当被观察者的数据发生变化时,就会通知他的所有观察者进行相应的更新,而观察者可能也是其他的被观察者,这样一切就有了联动;所以理解响应式就是要理解不同源之间是如何订阅的。
Vue2 分析 众所周知 vue2 是通过 Object.defineProperty [1] 这个 API 实现响应式的,定义如下:
Object .defineProperty(obj, prop, descriptor)
descriptor
:目标属性所拥有的特性,包含以下可选属性: - configurable
:是否允许属性被删除或修改,默认值为 false - enumerable
:是否可被枚举,默认值为 false - value
:属性的值,默认值为 undefined - writable
:是否可被赋值运算符改变其值,默认值为 false - get
:获取函数,默认值为 undefined - set
:设置函数,默认值为 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(); // [!code ++] // 当前正在运行的函数 let activeFn: Function | null = null ; // [!code ++] activeFn = p1; // [!code ++] p1(); // [!code ++] user.name = '小王' ; // [!code ++] activeFn = p2; // [!code ++] p2(); // [!code ++] function defineReactive (obj: Record<string , any >, key: any ) { let value = obj[key] Object .defineProperty(obj, key, { configurable: true , enumerable: true , get () { // 收集当前正在执行的函数 cbs.add(activeFn!); // [!code ++] 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(); // [!code --] const cbs: Map<string , Set<Function >> = new Map(); // [!code ++] function defineReactives (obj: Record<string , any >, key: any ) { let value = obj[key] Object .defineProperty(obj, key, { configurable: true , enumerable: true , get () { cbs.add(activeFn!); // [!code --] if (!cbs.has(key)) { // [!code ++] cbs.set(key, new Set()); // [!code ++] } // [!code ++] const deps = cbs.get(key) // [!code ++] deps?.add(activeFn!); // [!code ++] 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()); // [!code --] const deps = cbs.get(key) // [!code ++] deps?.forEach(dep => dep()) // [!code ++] } }) }
上面将 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响应式模型
Vue响应式模型
具体实现 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(); } } }// [!code ++] // 这里是用来设置当前的watcher,dep和watcher进行联系的桥梁 Dep.target = null ; // [!code ++] 当前的watcher const targetStack = []; // [!code ++] 维护wather栈结构 // [!code ++] // 进栈、设置当前watcher export function pushTarget (target: ?Watcher ) { // [!code ++] targetStack.push(target); // [!code ++] Dep.target = target; // [!code ++] } // [!code ++] // [!code ++] // 出栈、当前watcher为栈最后一个 export function popTarget ( ) { // [!code ++] targetStack.pop(); // [!code ++] Dep.target = targetStack[targetStack.length - 1 ]; // [!code ++] } // [!code ++]
上面我们也说了 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); };
// 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)) { // [!code ++] // [!code ++] // 这里就是改写 array 的原型方法,具体的方法在 arrayMethods中,这里就是替换这个值的原型,而不是Array整体原型 if (hasProto) { // [!code ++] protoAugment(value, arrayMethods); // [!code ++] } else { // [!code ++] copyAugment(value, arrayMethods, arrayKeys); // [!code ++] } // [!code ++] this .observeArray(value); // [!code ++] } // [!code ++] // 上面我们只说了这里,用来处理嵌套对象 this .walk(value); } // [!code ++] // 如果数据中的数据是对象 对每个对象拦截 observeArray(items: Array <any >) { // [!code ++] for (let i = 0 , l = items.length; i < l; i++) { // [!code ++] observe(items[i]); // [!code ++] } // [!code ++] } // [!code ++] }
具体来看数组方法的改造实现:
// 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); // [!code ++] Object .defineProperty(obj, key, { get : function reactiveGetter ( ) { if (Dep.target) { dep.depend(); if (childOb) { // [!code ++] // 这里是关键,当访问某个属性时,如果当前属性时数组,那么就会通过他自己的 // ob中的dep进行依赖收集,你可以认为这里就是为数组准备的 childOb.dep.depend(); // [!code ++] if (Array .isArray(value)) { // [!code ++] dependArray(value); // [!code ++] } } } return value; }, set : function reactiveSetter (newVal ) { // 属性值改变时对新的值重新进行观测,并改变childOb childOb = !shallow && observe(newVal); // [!code ++] }, }); }
可能上面一时半会有点绕,还是一样自己多调试几遍简单的 demo 就会明白的,下面我们再对自己实现的进行改造,让它支持数组:
class Observer { constructor (value: any ) { // 处理数组,改写原型方法 if (Array .isArray(value)) { // [!code ++] Object .setPrototypeOf(value, arrayMethods) // [!code ++] this .observeArray(value); // [!code ++] } else { // [!code ++] this .walk(value); // [!code ++] } // [!code ++] } observeArray(items: Array <any >) { // [!code ++] for (let i = 0 , l = items.length; i < l; i++) { // [!code ++] observe(items[i]); // [!code ++] } // [!code ++] } // [!code ++] // 省略其他... }const arrayProto = Array .prototype // [!code ++] export const arrayMethods = Object .create(arrayProto) // [!code ++] const methodsToPatch = ['push' , 'pop' , 'shift' , 'unshift' , 'splice' , 'sort' ,'reverse' ] // [!code ++] methodsToPatch.forEach(function (method ) { // [!code ++] const original = arrayProto[method as any ]; // [!code ++] Object .defineProperty(arrayMethods, method, { // [!code ++] value(...args: any ) { // [!code ++] const result = original.apply(this , args) // [!code ++] const ob = this .__ob__ // [!code ++] let inserted // [!code ++] switch (method) { // [!code ++] case 'push' : // [!code ++] case 'unshift' : // [!code ++] inserted = args // [!code ++] break // [!code ++] case 'splice' : // [!code ++] inserted = args.slice(2 ) // [!code ++] break // [!code ++] } // [!code ++] if (inserted) ob.observeArray(inserted) // [!code ++] ob.dep.notify() // [!code ++] return result // [!code ++] } // [!code ++] }) // [!code ++] }) // [!code ++] function defineReactive (obj: Record<string , any >, k: string ) { const childOb = observe(val); Object .defineProperty(obj, k, { get () { if (Dep.target) { dep.depend(); if (childOb) { // [!code ++] childOb.dep.depend(); // [!code ++] } // [!code ++] } 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 [2] 和Reflect [3] 代替,后者是天生的元编程性能更好,如果你对这两者 API 还不太熟悉的话,请先阅读 MDN 对应的文档
举个简单例子看下这两个 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: { // [!code ++] hobby: "篮球" // [!code ++] }, // [!code ++] }); effect(() => console .log(`p3:我的名字:${user.name} ,年龄:${user.age} ,爱好:${user.info.hobby} ` )); // [!code ++]
深层嵌套 要解决以上的问题就要深层对对象进行代理,我们的代码使用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" ) { // [!code ++] return reactive(result) // [!code ++] } // [!code ++] return result; }, // 省略... }); }const user = reactive({ name: "小明" , age: 10 , info: { // [!code ++] hobby: "篮球" , // [!code ++] girlFriend: { // [!code ++] name: '鲁班' // [!code ++] } // [!code ++] }, // [!code ++] }); effect(() => console .log(`p3:我的名字:${user.name} ,年龄:${user.age} ,爱好:${user.info.hobby} ,女朋友:${user.info.girlFriend.name} ` )); // [!code ++]
以上改造好了后看下执行结果:
到这里其实还差对数组结构方法的拦截,看如下代码:
const user = reactive({ name: "小明" , age: 10 , friends: ["鲁班" , "钟馗" ] // [!code ++] }); effect(() => console .log(`p4:我的名字:${user.name} ,朋友:${user.friends?.join("、" )} ` )); // [!code ++]
实现数组
那么对于数组的方法又是如何拦截的呢?对数组的修改通常都会获取 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)) { // [!code ++] return depsMap.get("length" )?.forEach((dep ) => dep?.()); // [!code ++] } // [!code ++] 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 ) { // [!code ++] return reactive({ value }); // [!code ++] } // [!code ++] const loading = ref(false ); effect(() => loading.value && console .log("加载中。。。" ))
以上打印结果如下:
实现 toRefs toRefs 主要用来解决对象解构后不再响应式的问题,解决这个也很简单,让解构的属性值获取的还是原来的对象,这样在访问/修改属性时还是触发的原来响应式对象。
我们来简单的实现下这个功能:
class ObjectRef { // [!code ++] constructor ( // [!code ++] private readonly _object: any , // [!code ++] private readonly _key: any , // [!code ++] ) {} // [!code ++] get value() { // [!code ++] const val = this ._object[this ._key] // [!code ++] return val; // [!code ++] } // [!code ++] set value(newVal) { // [!code ++] this ._object[this ._key] = newVal // [!code ++] } // [!code ++] }function toRefs (obj: any ) { // [!code ++] const res = {}; // [!code ++] Object .keys(obj).forEach(k => { // [!code ++] res[k] = new ObjectRef(obj, k) // [!code ++] }) // [!code ++] return res; // [!code ++] } // [!code ++]
使用 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}" }, ] }
点击侧边栏 debugger 图标,然后选择我们设置调试配置名字Launch Chrome
点击启动。
上面启动后会新开一个 chrome,运行我们的 serve 静态服务,然后我们找到/packages/vue/examples/
下的对应的 html 文件。
如下图,我们在reactivity.html
中打了断点,当访问当前页面时就会 debug:
使用 vscode 的好处就是,可以直接在编辑器中调试,不需要看 chrome 的打印结果:
这样借助 vscode 调试对于源码的阅读更加友好和清晰,一定要学会调试技巧在工作中也是很有帮助的。
总结
总之 Vue 的响应式都是通过拦截对象的 setter/getter,进而实现合理依赖收集的过程,当对象改变时便通知以来进行更新。这也是 Vue 核心概念数据驱动视图
。通过将数据和视图进行绑定,当数据发生变化时,视图会自动更新。这种响应式的设计方式使得开发者可以更加方便地管理和更新应用程序的状态,减少了手动操作 DOM 的繁琐工作。
到了这里相信你也知道数据驱动视图的背后原理了。
Reference
[1] Object.defineProperty:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
[2] Proxy:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
[3] Reflect:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect
该文章在 2024/11/26 12:16:38 编辑过