vue3发布挺久了,目前看在实际业务中使用问题不大,不过对于一些跑得好好的2.x老项目而言,很难直接升到3.x,在不升级 3.x的情况下还想使用 composition-api的话,有两种方式,一是使用 @vue/composition-api,二是直接升级 vue2.7,v2.7版本内置了 @vue/composition-api,所以不用手动引入了
本人参与的一个实际业务项目,引入了 vue2.7,但是在使用 ref等响应式 api的时候发现效果与预期不符合,例如下述基于 vue2.7 的代码
<template><div><p>{{ list[0] }}-{{ data }}</p><button @click="add">Add</button></div>
</template>
<script>
import { ref } from "vue"
export default {setup() {const list = ref([0])const data = ref(0)return {data,list,add() {list.value[0] = list.value[0] + 1data.value = data.value + 1conosle.log(list.value, data.value)}}}
}
</script>
当点击 Add按钮的时候,期望 list[0] 和 data 的值自增 1,且页面上展示的值也能同时更新,然而点击之后,控制台打印出来的 list.value、data.value都没啥问题,但页面上展示的值只有 data 更新了,list[0]却一直固定为初始值,展示出来的值依旧是之前的值,除非我将 list.value指向一个新的地址,页面才能正常更新
add() {list.value[0] = list.value[0] + 1// 重新指定引用地址list.value = list.value.slice()
}
鄙人不才,之前一直以为只要2.x项目引入了 @vue/composition-api,哪怕底层不一样,用法和表现应该是和 vue3.x差不多才对,但是碰到这个问题后我才意识到二者还是有差别的,因为我的这种写法是比较常见的,作为一个成熟的框架,vue3.x应该不会存在这种问题,然后试了下 vue3.x,相同的写法,确实如我所料,不需要重新指定 list.value的引用地址,页面也能正常更新
已经很长时间没看过源码了,正好借着这个问题简单看下,从源码上搞清楚区别
vue 2.7 的实现
ref 不起作用的原因
源码基于
v2.7.13
问题是由 ref引起的,那么就从它看起
// src/v3/reactivity/ref.ts
export function ref<T extends object>(value: T
): [T] extends [Ref] ? T : Ref<UnwrapRef<T>>
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) {return createRef(value, false)
}
ref 的类型声明有三个,第一个的意思是如果传入了一个 Ref 类型的参数,则返回这个参数的类型;第二个的意思是传入一个任意类型的参数,返回一个包装了此任意类型的 Ref类型,第三个的意思是可以不传入任何参数,这个时候 ts无法自动推导类型,但你可以传入一个类型来告诉 ts 这个值的类型是什么
// src/v3/reactivity/ref.ts
function createRef(rawValue: unknown, shallow: boolean) {if (isRef(rawValue)) {return rawValue}const ref: any = {}def(ref, RefFlag, true)def(ref, ReactiveFlags.IS_SHALLOW, shallow)def(ref,'dep',defineReactive(ref, 'value', rawValue, null, shallow, isServerRendering()))return ref
}
createRef中首先判断传入值是不是已经是一个 Ref类型的值了,如果是,则不做处理,直接将这个值返回 否则的话,连续调用三次 def 来处理传入的值
// src/core/util/lang.ts
export function def(obj: Object, key: string, val: any, enumerable?: boolean) {Object.defineProperty(obj, key, {value: val,enumerable: !!enumerable,writable: true,configurable: true})
}
def调用了 Object.defineProperty 来给对象的属性设置值
第三次调用时,又调用 defineReactive对 rawValue进行了预处理,在 ref上挂了一个 value属性
// src/core/observer/index.ts
export function defineReactive( obj: object,key: string,val?: any,customSetter?: Function | null,shallow?: boolean,mock?: boolean ) {const dep = new Dep()// ...// cater for pre-defined getter/settersconst getter = property && property.getconst setter = property && property.set// ...let childOb = !shallow && observe(val, false, mock)Object.defineProperty(obj, key, {enumerable: true,configurable: true,get: function reactiveGetter() {// ...},set: function reactiveSetter(newVal) {// ...}})return dep
}
defineReactive 又给 ref的 value属性定义了 getter、setter,当写入或读取 ref.value 的时候,就会触发这里定义的 getter、setter
get: function reactiveGetter() {const value = getter ? getter.call(obj) : valif (Dep.target) {if (__DEV__) {// ...} else {dep.depend()}// ...}return isRef(value) && !shallow ? value.value : value
}
get方法里的 Dep 是 vue2.x依赖收集的核心,我曾经也写过文章分析这块的实现,在本文这不是重点,就不多说了,get方法的最后有一个对当前获取的数据是否是 ref的判断,如果是,则返回value.value,这就是需要通过 ref.value来获取到我们设置在 ref里真实数据值的原因
设置值的时候肯定是触发 set 方法
// src/core/observer/index.ts
set: function reactiveSetter(newVal) {const value = getter ? getter.call(obj) : valif (!hasChanged(value, newVal)) {return}// ...if (setter) {setter.call(obj, newVal)} else if (getter) {// #7981: for accessor properties without setterreturn} else if (!shallow && isRef(value) && !isRef(newVal)) {value.value = newValreturn} else {val = newVal}childOb = !shallow && observe(newVal, false, mock)if (__DEV__) {// ...} else {dep.notify()}
}
这是派发更新的那一套,最后的 dep.notify() 就用于重新渲染,但直到看到这里我也没看到哪里有对数组的子项设置值时的处理了,Object.defineProperty 只是设置 了ref.value 时的处理,ref.value[0]上并没有 set方法,所以设置 list.value[0] 值的时候,在数据层面确实可以设置成功,但并不会触发页面更新
不使用 composition-api的时候,如果 props或者data是一个数组,改变数组子项的时候,是可以触发页面更新的,这是因为 vue 专门对这种情况做了遍历处理,保证了数组子项也是被 Object.defineProperty处理过的,例如,对于 data来说,在初始化的时候会调用 initData,initData又会调用 observe,在 observe里就有对数组子项的遍历处理
// src/core/observer/index.ts
export class Observer {constructor(public value: any, public shallow = false, public mock = false) {// ...if (isArray(value)) {// ...for (let i = 0, l = arrayKeys.length; i < l; i++) {const key = arrayKeys[i]def(value, key, arrayMethods[key])}if (!shallow) {this.observeArray(value)}} else {// ...}}
}
解决方法
不过,虽然直接设置 ref.value的数组子项无法触发更新,但我们知道,vue2.x 是劫持了数组的 push、splice 等方法的,就是用于数组的更新,所以如果你实在是不想给 ref.value 换个地址又想在修改子项的时候触发页面更新,可以调用这些被劫持的方法来达到目的
// 第一种,修改 .value 地址
list.value[0] = list.value[0] + 1
list.value = list.value.slice()
// 第二种,调用 splice
list.value.splice(0, 1, list.value[0] + 1)
实际上,在对一个对象做响应式处理的时候更建议使用 reactive 而不是 ref,reactive就可以实现在数组子项修改的时候也触发更新,原理跟props、data类似,也是调用了 observe方法进行数组遍历
// src/v3/reactivity/reactive.ts
function reactive(target: object) {makeReactive(target, false)return target
}
function makeReactive(target: any, shallow: boolean) {// ...if (__DEV__) {if (isArray(target)) {warn(`Avoid using Array as root value for ${shallow ? `shallowReactive()` : `reactive()`} as it cannot be tracked in watch() or watchEffect(). Use ${shallow ? `shallowRef()` : `ref()`} instead. This is a Vue-2-only limitation.`)}}// ...const ob = observe(target,shallow,isServerRendering() /* ssr mock reactivity */)// ...
}
一开始我没用 reactive的原因是,如果写成 const list = reactive([0]),vue会报一个 warn,建议用 ref所以我听从建议用了 ref,只是没想到这个建议不是那么靠谱
reactive warn 的原因
看到这个 warn信息我又有点好奇了,为啥数组作为 reactive 的 root value,watch/watchEffect就会失效呢?还是看代码
// src/v3/apiWatch.ts
function doWatch( source: WatchSource | WatchSource[] | WatchEffect | object,cb: WatchCallback | null,{immediate,deep,flush = 'pre',onTrack,onTrigger}: WatchOptions = emptyObject ): WatchStopHandle {// ...if (isRef(source)) {// ...} else if (isReactive(source)) {getter = () => {;(source as any).__ob__.dep.depend()return source}deep = true}// ...
}
watch会调用 doWatch,里面有对传入值类型的判断以调用不同的处理方法,当要 watch的值是 reactive的时候,会调用 depend方法,这是个依赖收集的方法,所以当修改watch的值的时候,会触发派发更新使得视图更新,但这里只是对 root值进行了依赖收集,如果传入的值是一个数组,并没有对数组的子项进行依赖收集,所以改变数组子项的时候,并不会被watch到,也不会触发响应式更新
这里没有主动对数组子项进行响应式包装,但如果数组的子项本来就是已经被响应式包装过的,那还是可以被 watch到的,比如子项已经是 ref类型数据了
setup() {const data1 = ref(0)const list = reactive([data1])watch(list, v => {console.log('list 改变', v)})return {list,add() {list[0].value = list[0].value + 1}}
}
不过这里 watch 响应的直接原因不是因为 list 的子项改变,按照常理,想要watch到数组子项改变,是需要第二个参数的deep: true参数的,这里是因为 data1的改变而触发的响应,所以不需要这个参数也可以
vue 3.x 的实现
源码基于
v3.2.41
// packages/reactivity/src/ref.ts
export function ref(value?: unknown) {return createRef(value, false)
}
function createRef(rawValue: unknown, shallow: boolean) {if (isRef(rawValue)) {return rawValue}return new RefImpl(rawValue, shallow)
}
初始化一个 RefImpl 实例作为 ref实例
// packages/reactivity/src/ref.ts
class RefImpl<T> {constructor(value: T, public readonly __v_isShallow: boolean) {this._rawValue = __v_isShallow ? value : toRaw(value)this._value = __v_isShallow ? value : toReactive(value)}
}
分别调用 toRaw 和 toReactive处理传入 value,并将处理后的结果设置到实例的两个内部属性上, this._rawValue 存储传入的值,用于后续的数值对比等,主要看 this._value,这才是我们想看的值
// packages/reactivity/src/reactive.ts
export const toReactive = <T extends unknown>(value: T): T => isObject(value) ? reactive(value) : value
如果传入的 value 是一个对象,那么调用 reactive方法处理,否则直接返回原值,这里可以看到,当 value 是一个对象的时候,ref 是借助了 reactive的,先看下如果不是一个对象的情况
RefImpl 上有 value 属性get 和 set 方法,先看 get
get value() {trackRefValue(this)return this._value
}
trackRefValue 用于依赖收集,这里不用管,最终是看到 get 是把 this._value 返回了的 再看 set
set value(newVal) {const useDirectValue =this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)newVal = useDirectValue ? newVal : toRaw(newVal)if (hasChanged(newVal, this._rawValue)) {this._rawValue = newValthis._value = useDirectValue ? newVal : toReactive(newVal)triggerRefValue(this, newVal)}
}
设置value的时候,会看下新旧值是否一样,如果一样就不管了,否则会重置 this._value,然后重新依赖收集一下,这里对于 this._value的重新设置同样判断下是不是对象
当给 ref传入一个数组的时候,显然会走 toReactive,也就是会调用 reactive,reactive又调用了 createReactiveObject
// packages/reactivity/src/reactive.ts
export function reactive(target: object) {// ...return createReactiveObject(target,false,mutableHandlers,mutableCollectionHandlers,reactiveMap)
}
function createReactiveObject( target: Target,isReadonly: boolean,baseHandlers: ProxyHandler<any>,collectionHandlers: ProxyHandler<any>,proxyMap: WeakMap<Target, any> ) {// ..const proxy = new Proxy(target,targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers)proxyMap.set(target, proxy)return proxy
}
createReactiveObject 函数体前面一大段都是对合法性等判断不用看,最后是执行了 new Proxy,返回了一个 proxy 实例,也就是对 value进行了代理
看到 proxy就大概明白了,proxy 是可以监听到任意层级子级的 set 和 get的,且不需要额外的逻辑,就和监听其他属性一样,那么除非是故意写不处理数组子项的逻辑,否则默认就是契合这套基于 Proxy的响应式系统的
主要看第二个参数,如果 ref参数是一个正常数组的话,那么第二个参数就是 baseHandlers
// packages/reactivity/src/baseHandlers.ts
export const mutableHandlers: ProxyHandler<object> = {get,set,deleteProperty,has,ownKeys
}
const set = /*#__PURE__*/ createSetter()
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)// don't trigger if target is something up in the prototype chain of originalif (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}
}
最后会调用 trigger,trigger调用 triggerEffects->triggerEffect,触发 effect.run/effect.scheduler,这个方法就会触发组件的视图更新,这些就都是响应式的内容了,完全可以单开系列文章细说,本文就不再扩展了
function triggerEffect( effect: ReactiveEffect,debuggerEventExtraInfo?: DebuggerEventExtraInfo ) {// ...if (effect.scheduler) {effect.scheduler()} else {effect.run()}
}
小结
本文是带着问题去看源码,只是为了弄清楚为什么 ref 跟我想象的运行结果不一致,所以其他不直接相关的代码我都是直接跳过,比如响应式系统,虽然跟ref确实有联系,但因为不是我所关注的,所以到底怎么个响应式法不需要去深入去看,知道有这么回事就行了,避免错综复杂的逻辑越绕越多,只看与我问题相关的逻辑,直到找出我想要的答案
当然了,如果你有时间精力的话,能把整个源码看完并串起来那最好不过了,但那毕竟是个系统性工程,还是得徐徐图之,无法快速得到一些针对性的答案
最后
整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。




有需要的小伙伴,可以点击下方卡片领取,无偿分享
Vue 2.7 中 ref 更新页面问题解析
本文探讨了在 Vue 2.7 中使用 composition-api 的 ref 方法时遇到的页面不更新问题。作者发现直接修改 ref 绑定的数组子项无法触发页面更新,原因是 Vue 2.7 的响应式系统未处理这种情况。解决方案包括使用数组的变异方法或 reactive 替代。同时,文中还提及了 Vue 3.x 中对数组子项响应式的处理方式。
1006

被折叠的 条评论
为什么被折叠?



