响应式的进化
本项目涉及代码:https://github.com/dangjingtao/vue3_reactivity_simple
推荐阅读:observer-util:
https://github.com/nx-js/observer-util
1. defineProperty
的缺点和vue 2的hack 方案
1.1 新属性设置不上
vue 2 的响应式已经很强大了。但是对于对象上新增的属性则有些吃力:
let vm = new Vue({ data() { a: 1 }, watch: { b() { console.log('change !!') } }})// 没反应!
正常来说,被监听的数据在初始化时就已经被全部监听了。后续并不会再次这种时候,不得不通过vm.$set
(全局 Vue.set 的别名。)来处理新增的属性。
语法:this.$set( target, key, value )
。
target:要更改的数据源(可以是对象或者数组);key:要设置的数据名;value :赋值。
文档地址: https://cn.vuejs.org/v2/api/#Vue-set
1.2 数组监听不上
此外对于数组也无法监听原地改动:
let obj = {}Object.defineProperty(obj, 'a', { configurable: true, enumerable: true, get: () => { console.log('get value by defineProperty') return val }, set: (newVal) => { console.log('set value by defineProperty') val = newVal }})obj.a = [] // set value by definePropertyobj.a.push('1') // get value by definePropertyobj.a[0] = 1 // get value by definePropertyobj.a.pop(1) // get value by definePropertyobj.a = [1, 2, 3] // set value by defineProperty
上述案例中,使用push、pop、直接通过索引为数组添加元素时会触发属性a的getter,是因为与这些操作的返回值有关,以push方法为例,使用push方法为数组添加元素时,push方法返回值是添加之后数组的新长度,当访问数组新长度时就会自然而然触发属性a的getter。
vue2 的做法是干脆把数组的原型方法都劫持了,从而达到监听数组的目的:
var arrayProto = Array.prototypevar arrayMethods = Object.create(arrayProto);[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(item){ Object.defineProperty(arrayMethods,item,{ value:function mutator(){ //缓存原生方法,之后调用 console.log('array被访问'); var original = arrayProto[item] var args = Array.from(arguments) original.apply(this,args) // console.log(this); }, })
2. Proxy——新时代的响应式
较Proxy
来说,Object.defineProperty
是ie 8 支持的方法。对比几年前,兼容性的矛盾似乎不再那么突出(vue 3 最低支持ie 11)。所以在新一代的vue演进中,响应式机制的改革被提到了一个非常重要的位置。
在前面的文章中,我们了解过defineProperty和Proxy的用法。
我们写defineProperty的时候,总是会用一层对象循环来遍历对象的属性,一个个调整其中变化:
Object.keys(data).forEach(key => { Object.defineProperty(data, key, { get() { return data[key]; }, set(nick) { // 监听点 data[key] = nick; } })})
而Proxy监听一个对象是这样的:
new Proxy(data, { get(key) { }, set(key, value) { },});
可以看到Proxy的语法非常简洁,根本不需要关心具体的 key,它去拦截的是 「修改 data 上的任意 key」 和 「读取 data 上的任意 key」。所以,不管是已有的 key 还是新增的 key,都逃不过它的魔爪。
Proxy 更加强大的地方还在于 Proxy 除了 get 和 set,还可以拦截更多的操作符。Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。
Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
所以说,前端响应式数据的新世代——Proxy,已经到来了。
3. vue3 中的响应式
可在此处克隆最新的仓库代码:https://github.com/vuejs/vue-next.git,下载下来之后运行dev命令打包:
npm run dev
即可阅读源码。
在vue 3中负责响应式部分的仓库名为 @vue/rectivity,它不涉及 Vue 的其他的任何部分,甚至可以轻松的集成进 React[注]。是非常非常“正交” 的实现方式。
[注] https://juejin.im/post/5e70970af265da576429aada
对于vue3 的effect API,如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect。
在vue3 中,实现数据观察是这样的:
// 定义响应式数据const data = reactive({ count: 1});// 观测变化,类似react中的useEffectconst effection = effect(() => console.log('count changed', data.count));data.count = 2; // 'count changed 2'
如果想要监听单条数据,可以用ref
:
llet count = ref(0);effect(()=>{ console.log(`count被变更为${count}`)});count.value += 1;
React 中手动声明 [data.count] 这个依赖的步骤被 Vue3 内部直接做掉了,在 effect 函数内部读取到 data.count 的时候,它就已经被收集作为依赖了。少了useState,setData,看起来比react更方便了。
因此实现响应式最重要的api是:
•reactive•effect•ref
接下来我们就尝试简单实现之。
3.1 ref:监听单个变量
3.1.1 简化实现
先来实现ref(对单个数据的监听)。
新建一个Proxy.js:
let activeEffect;class Dep{ constructor(){ this.subs = new Set(); } depend(){ // 收集依赖 if(activeEffect){ this.subs.add(activeEffect); } } notify(){ // 数据变化,通知effect执行。 this.subs.forEach(effect=>effect()) }}const ref = (initVal)=>{ const dep = new Dep(); let state = { get value(){ // 收集依赖 dep.depend(); return initVal; }, set value(newVal){ // 修改,通知dep执行有此依赖的effect dep.notify(); return newVal; } } return state;}let state = ref(0);const effect = (fn)=>{ activeEffect = fn; fn();}effect(()=>{ console.log(`state被变更为`,state.value)});state.value = 1;
在上面的代码中,state.value每次被设置,都会打印出变更提示。
3.1.2 源码解读
在源码中找到packages/reactivity/src/refs.ts
,可以看到Ref方法是由createRef完成的。
// 源码 `packages/reactivity/src/refs.ts`export function ref(value?: unknown) { return createRef(value)}// ... // 创建reffunction createRef(rawValue: unknown, shallow = false) { if (isRef(rawValue)) { return rawValue } let value = shallow ? rawValue : convert(rawValue) const r = { __v_isRef: true, get value() { track(r, TrackOpTypes.GET, 'value') return value }, set value(newVal) { if (hasChanged(toRaw(newVal), rawValue)) { rawValue = newVal value = shallow ? newVal : convert(newVal) trigger( r, TriggerOpTypes.SET, 'value', __DEV__ ? { newValue: newVal } : void 0 ) } } } return r}
vue3 源码中,拦截各种取值、赋值操作,依托 track
和 trigger
两个函数进行依赖收集和派发更新。分别对应简化实现中的Dep.depend
和Dep.notify
。
•track 用来在读取时收集依赖。•trigger 用来在更新时触发依赖。
在vue3中,Dep不再是一个class类。而是一个非常大的Map对象。
在Proxy 第二个参数 handler 也就是陷阱操作符[注]中,拦截各种取值、赋值操作,依托 track 和 trigger 两个函数进行依赖收集和派发更新。
•track 用来在读取时收集依赖。•trigger 用来在更新时触发依赖。
[注] 陷阱操作符: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler
3.2 reactive:监听对象
现在我们想模仿vue 3 的Composition API,实现一个这样的功能:
lang="en"> charset="UTF-8"> name="viewport" content="width=device-width, initial-scale=1.0"> Document
id="app">
id="btn">click const root = document.querySelector('#app'); const btn = document.querySelector('#btn'); let obj = reactive({name: 'djtao', age: 18 }); let double = computed(() => obj.age * 2); effect(() => {console.log(`数据变更为`, obj); root.innerHTML = `
Name(监听属性):${obj.name}
Age(监听属性):${obj.age}double(计算属性):${double.value}`; }); btn.addEventListener('click', e => {obj.name = 'dangjingtao'; obj.age += 1; })
要求点击按钮更新监听属性及计算属性。
结合tracker和trigger的角色,接下来就实现上面代码的用法。
3.2.1 监听(响应式核心)
我们用一个cerateBaseHandler来处理生成proxy的handler,然后通过track和trigger分辨收集依赖和派发更新。
// 依赖收集const track = () => {}// 依赖派发const trigger = () => {}// 计算属性const computed = () => {}// 副作用const effect = fn =>{ fn();}const createBaseHandler = (taget) => { return { get(target,key){ track(target,key); return target[key]; // 此处源码用Reflect }, set(target,key,newValue){ const info = { oldValue:data[key], newValue }; data[key] = newValue; trigger(target,key,info); } }}// 对象响应式const reactive = (data) => { const observer = new Proxy(data,createBaseHandler(data)); return observer;}
那么响应式核心就写好了。
3.2.2 track和trigger
我们把所有的依赖放到一个类似栈的数组结构中。
在做之前,应该设想下“依赖”这个对象(不妨命名为targetMap
)的数据结构:首先它可能接收多个reactive的代理对象(命名为target
),而每个taget都对应各自的依赖(depMap
)——提示使用weakMap数据结构。这个depMap
是一个Map对象。可通过属性名拿到该属性名具体要收集的依赖集合dep(这是个Set对象)。当我们拿到effect之后,把它添加到dep中。依赖收集就完成了。

不妨试想收集依赖就是一个找自己位置的游戏,首先根据target在大对象中找到该数据的专属依赖。然后根据属性key,再找到这个数据,这个属性的一系列依赖,最后把副作用添加进去。
targetMap: { { name: 'djtao',age:18 }: depMap } depMap:{ 'name':dep}dep:Set()
在收集依赖的过程中,一律遵循“找不到就创建”的原则。
// 它用来建立 监听对象 -> 依赖 的映射let targetMap = new WeakMap();const effectStack = [];// 依赖收集const track = (target,key) => { // reactive可能有多个,一个可能又有多个key const effect = effectStack[effectStack.length - 1]; if(effect){ // 尝试找到属于该数据的依赖对象(depMap) let depMap = targetMap.get(target); // 如果不存在,则把它定义为一个Map,并添加到targetMap中 if(!depMap){ depMap = new Map(); targetMap.set(target,depMap); } // 找到这个对象的这个属性,获取对这个对象,这个属性对依赖。 let dep = depMap.get(key); // 如果不存在,则初始化一个set。 if(!dep){ dep = new Set(); depMap.set(key,dep); } // 核心逻辑:现在拿到dep了,把副作用添加进去。 dep.add(effect); // 此处在deps也绑定上dep effect.deps.push(dep); }}
trigger就好理解多了,对应的是简化实现一节代码的Dep.notify()
,当数据变化时,拿出依赖,遍历执行。
const trigger = (target, key, info) => { // 简化来说 就是通过 key 找到所有更新函数 依次执行 const depMap = targetMap.get(target); const deps = depMap.get(key) deps.forEach(effect => effect());}
实际上trigger需要进一步细化
3.2.3 computed和effect
接下来就是实现computed和effect副作用。
怎么理解二者的关系?简化来看,computed就是一个特殊的effect。因为它与原对象同时监听同一个或n个属性。需要一个option去配置它当它被标记为computed时,内容为true。
所以在trigger中,还需要更加健壮些:
// 依赖派发const trigger = (target,key,info) => { // 查找对象依赖 const depMap =targetMap.get(target); // 如果没找到副作用即可结束 if(!depMap){ return } // effects 和 computedRunners 用于遍历执行副作用和computed的依赖 const effects = new Set(); const computedRunners = new Set(); if(key){ let deps = depMap.get(key); deps.forEach(effect => { // 计算flag为true时,添加到computedRunners if(effect().computed){ computedRunners.add(effect); }else{ effects.add(effect); } }); computedRunners.forEach(computed=>computed()); effects.forEach(effect=>effect()); }}
这样trigger就相对完整了。
因为是一个特殊的effect,读取computed的value属性的时候,即可执行计算。
// 计算属性:特殊的effect,多了一个配置const computed = (fn) => { const runner = effect(fn,{computed:true,lazy:true}); return { effect:runner, get value(){ return runner(); } }}
再看effect:需要一个createReactiveEffect
方法处理一下:
// 副作用const effect = (fn,options={}) =>{ let e = createReactiveEffect(fn,options); // 惰性:首次不执行,后续更新才执行 if(!options.lazy){ e(); } return e;}// const createReactiveEffect = (fn,options={})=>{ const effect = (...args) =>{ return run(effect,fn,args); } // 单纯为了后续清理,以及缓存 effect.deps = []; effect.computed = options.computed; effect.lazy = options.lazy; return effect;}// 辅助方法,执行前入栈,最后执行完之后出栈// 以此保证最上面一个effect是最新的const run =(effect,fn,args)=>{ if(effectStack.indexOf(effect)===-1){ try { effectStack.push(effect); return fn(...args); } finally { effectStack.pop(); } }}
run方法维护effectStack,并负责执行具体方法。
好了,需求就已经做完了。看看效果:
点击前:

点击后:

历次打印结果:

可以看到,响应式系统中,首先监听到初始值,点击按钮,先监听了name的变化,然后是age的变化。
自此,参照vue3源码的响应式系统完成。
3.3 源码导读
代码在packages/src/reactivity/reactive.ts
export function reactive(target: object) { // if trying to observe a readonly proxy, return the readonly version. if (target && (target as Target)[ReactiveFlags.IS_READONLY]) { return target } return createReactiveObject( target, false, mutableHandlers, mutableCollectionHandlers )}
createReactiveObject
的关键部分:
function createReactiveObject( target: Target, isReadonly: boolean, baseHandlers: ProxyHandler, collectionHandlers: ProxyHandler) { // ... const observed = new Proxy( target, collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers ) // ... return observed}
collectionHandlers对应的是Set,Map等数据类型,baseHandler对应普通对象。
baseHandler中也调用了trigger。

trigger和track的逻辑如下:
export function track(target: object, type: TrackOpTypes, key: unknown) { // ... 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())) } if (!dep.has(activeEffect)) { dep.add(activeEffect) activeEffect.deps.push(dep) // ... }}export function trigger( target: object, type: TriggerOpTypes, key?: unknown, newValue?: unknown, oldValue?: unknown, oldTarget?: Map<unknown, unknown> | Set) { const depsMap = targetMap.get(target) if (!depsMap) { // never been tracked return } const effects = new Set<ReactiveEffect>() const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => { if (effectsToAdd) { effectsToAdd.forEach(effect => { if (effect !== activeEffect || !shouldTrack) { effects.add(effect) } else { // the effect mutated its own dependency during its execution. // this can be caused by operations like foo.value++ // do not trigger or we end in an infinite loop } }) } } if (type === TriggerOpTypes.CLEAR) { // collection being cleared // trigger all effects for target depsMap.forEach(add) } else if (key === 'length' && isArray(target)) { depsMap.forEach((dep, key) => { if (key === 'length' || key >= (newValue as number)) { add(dep) } }) } else { // schedule runs for SET | ADD | DELETE //. ... } const run = (effect: ReactiveEffect) => { if (__DEV__ && effect.options.onTrigger) { effect.options.onTrigger({ effect, target, key, type, newValue, oldValue, oldTarget }) } if (effect.options.scheduler) { effect.options.scheduler(effect) } else { effect() } } effects.forEach(run)}
在computed中,也把它作为一个特殊的effect存在。
