[vue3]watch api 性能问题分析

1,514 阅读6分钟

背景

今天在一个大数据列表渲染的项目中对数组使用了 watch api, 发现从数据修改到watch到数据修改时间有些长,接近 500ms。

// 需求大概如下
组件接受一个大的数组对象,通过 watch api 对props进行监听,当数据发生修改的时候进行一些操作。  

本文主要是通过对 watch api 遇到的性能问题分析,深入到源码了解 watch 的实现以及简单聊下跟响应式数据的关联,找到性能消耗的原因及优化方案。

备注:本文分析的源码基于@3.2.37

watch

用法很简单,但也支持多种用法(这段挺无聊的,基本就是尽量尝试简单的解释下 watch 的 typescript overload,可以跳过):

一、监听单个对象
// 支持 ref对象,ComputedRef对象,函数对象 
// 回调, 当前值,缓存前值, onCleanup(第二次触发,暂时没想到使用场景)
// option, immediate是否立即回调, deep是否深度遍历
watch(value, (currentVal, preVal) => {}, {immediate: boolean, deep: boolean})
二、监听多个对象
// 数组对象, 支持 ref对象,ComputedRef对象,函数对象 
// cb回调, 当前值[], 缓存前值[]
// option, immediate是否立即回调, deep是否深度遍历
watch([val1, val2], ([currentVal1, currentVal2], [preVal1, preVal2]) => {}, {immediate: boolean, deep: boolean})
三、监听 reactive 对象
// Object, 支持 reactive 对象
// cb回调, 当前值,缓存前值
// option, immediate是否立即回调, deep是否深度遍历
watch(reactiveValue, ([currentVal1, currentVal2], [preVal1, preVal2]) => {},  {immediate: boolean, deep: boolean})
// 四、其实还有一个给 MultiWatchSources 通过 ReadOnly 参数,注释也没解释清楚(somehow [...T] breaks when the type is readonly),暂不深入

从上面可以看出来,其实 watch 只会对 响应式 数据进行监听。但对不同的数据类型有不一样的处理方式;

响应式数据

响应式数据是指 Ref/reactive 对象,其实响应式分两个步骤:依赖收集/触发更新;

// ref 对象
class RefImpl {
  get value() {
    // 依赖收集
    trackRefValue(this)
    return this._value
  }
  set value(newVal) {
    const useDirectValue =
      this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
    newVal = useDirectValue ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = useDirectValue ? newVal : toReactive(newVal)
      // 触发更新
      triggerRefValue(this, newVal)
    }
  }
}

// reactive 对象
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 // collectionHandlers(是 Map, Set 等对象的处理方式),我们主要是看下 baseHandlers(Object, Array) 的处理
  )

  return proxy
}
export const mutableHandlers: ProxyHandler<object> = {
  get, // 实际调用了 createGetter 执行了 track 方法进行依赖收集
  set, // 调用了 createSetter 执行了 trigger 方法触发更新
  deleteProperty,
  has,
  ownKeys
}

// ComputedRef对象 实际上是拓展的 Ref 对象,暂时不深入了解这些拓展属性用法,理解为 Ref 对象即可
export interface ComputedRef<T = any> extends WritableComputedRef<T> {
  readonly value: T
  [ComputedRefSymbol]: true
}
export interface WritableComputedRef<T> extends Ref<T> {
  readonly effect: ReactiveEffect<T>
}

一句话理解:vue3中有两种响应式对象(Ref/Reactive),Ref 通过 getter/setter 进行依赖收集和触发更新,Reactive 通过 Proxy(getter/setter) 进行依赖收集和触发更新。

其他响应式数据

在我们实际开发中我们还会发现有一些其他常用的响应式数据对象, 那这些数据是如何实现响应式的呢? e.g.
1、provide/inject;

export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
    const parentProvides =
      currentInstance.parent && currentInstance.parent.provides
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides)
    }
    // TS doesn't allow symbol as index type
    provides[key as string] = value
}
export function inject(
  key: InjectionKey<any> | string,
  defaultValue?: unknown,
  treatDefaultAsFactory = false
) {
        const provides =
      instance.parent == null
        ? instance.vnode.appContext && instance.vnode.appContext.provides
        : instance.parent.provides
}

通过 instance.parent 读取父组件的 provides 进行inject,其实就是直接获取父组件(祖先组件)的数据。但这里有个值的学习的地方,通过 Object.create(parentProvides) 给当前组件创建 provides 数据,可以创建一个子孙节点的原型链获取祖先节点的数据。其实就需要 provide 的数据都是响应式的,不能注入其他类型的数据。

2、props;

function setFullProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  props: Data,
  attrs: Data
) {
let camelKey
if (options && hasOwn(options, (camelKey = camelize(key)))) { // camelize是转化为驼峰命名的方法
    if (!needCastKeys || !needCastKeys.includes(camelKey)) {
        props[camelKey] = value 
    }
}

props的逻辑比较简单,直接通过父组件的 props 遍历赋值。跟1一样,props传入的数据也需要是响应式的。

3、vuex;

// 跟节点 provide 数据
install (app, injectKey) {
    app.provide(injectKey || storeKey, this)
}
export function resetStoreState (store, state, hot) {
    store._state = reactive({
        data: state
    })
}

vuex其实就是在根节点创建 provide ,通过vue3 的 reactive 来创建响应式的数据;

4、pinia;
pinia其实跟vuex也是一样的(但pinia对vue2和vue3兼容做了很多处理,还有例如 vue-demi 这个插件库的使用,可以使用vue2/vue3两个版本的插件,后续有时间再进一步了解下)。这里不再赘述了。

总结就是不管是插件/传值,都需要通过 Ref/Reactive 来创建响应式数据。

watch api 对不同类型的数据类型的处理方式

在阅读源码之前,大概跑了下断点(node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js),捋了下大概流程:
watch api 其实做的是任务收集,将任务通过通过函数方式(getter)收集到 Scheduler 中延后(等响应式数据发生变化时)执行;
我们来看下 doWatch 方法第一步,通过工厂方法模式 getter 对所有数据类型进行嵌套

  if (isRef(source)) {
    getter = () => source.value
    forceTrigger = isShallow(source)
  } else if (isReactive(source)) {
    getter = () => source
    deep = true
  } else if (isArray(source)) {
    isMultiSource = true
    forceTrigger = source.some(s => isReactive(s) || isShallow(s))
    getter = () =>
      source.map(s => {
        if (isRef(s)) {
          return s.value
        } else if (isReactive(s)) {
          return traverse(s)
        } else if (isFunction(s)) {
          return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
        } else {
          __DEV__ && warnInvalidSource(s)
        }
      })
  } else if (isFunction(source)) {
    if (cb) {
      // getter with cb
      getter = () =>
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else {
      // no cb -> simple effect
      getter = () => {
        if (instance && instance.isUnmounted) {
          return
        }
        if (cleanup) {
          cleanup()
        }
        return callWithAsyncErrorHandling(
          source,
          instance,
          ErrorCodes.WATCH_CALLBACK,
          [onCleanup]
        )
      }
    }
  } else {
    getter = NOOP
    __DEV__ && warnInvalidSource(source)
  }

  if (cb && deep) {
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }

如果是 Ref 对象的话,直接赋值给 getter(后面再看getter的处理), shallow 是 shallowRef 用于控制是否更新的 api,暂时不在这里说明了。
如果是 Reactive 对象,也直接赋值给 getter, 同时默认设置了 deep 为 true;
如果是 数组 对象((Ref | Reactive)[]),进行遍历处理,逻辑跟外层一样;
如果是 函数 对象(() => Ref | Reactive | (Ref | Reactive)[]),则将函数执行结果赋值给 getter;
如果有 cb 且 deep 为 true,则调用 traverse 这个函数:

function traverse(value, seen) {
    seen = seen || new Set();
    if (seen.has(value)) {
        return value;
    }
    seen.add(value);
    if (isRef(value)) {
        traverse(value.value, seen);
    }
    else if (isArray(value)) {
        for (let i = 0; i < value.length; i++) {
            traverse(value[i], seen);
        }
    }
    else if (isSet(value) || isMap(value)) {
        value.forEach((v) => {
            traverse(v, seen);
        });
    }
    else if (isPlainObject(value)) {
        for (const key in value) {
            traverse(value[key], seen);
        }
    }
    return value;
}

函数里对数组对象进行了深度遍历取值,看起来我遇到的性能问题就是在这里了,这里做个测试看下:

// 修改下源码
getter = () => {
    console.log(`开始deep: ${new Date().getTime()}`)
    traverse(baseGetter())
    console.log(`结束deep: ${new Date().getTime()}`)
}
// 测试代码
const data = reactive({
    userList: getMessage(50000) // 获取50000条数据
})
watch(data.userList, () => {
    console.log(`监听到 userList 变化,时间: ${new Date().getTime()}`)
})
function change() {
  setTimeout(() => {
      console.log(`修改数据, ${new Date().getTime()}`)
      data.userList[0].userInfo.name = 'fish'
  }, 5000)
}
change()
// 结果如下
修改数据, 1660727509493
开始deep: 1660727509501
结束deep: 1660727509932
监听到 userList 变化,时间: 1660727509943

这里的遍历占据了从数据修改到watch到结果的95%以上的时间(431/450),因为需要对数组内所有的元素以及元素内的所有响应式数据进行依赖收集,导致了 watch api有较大的性能消耗。

结论

watch api在数据量大的情况下是会有较大的性能消耗的,结合我的情况,我要做的watch的性能优化主要在两个思路:
1、浅层监听; 只监听对数组的修改(),不监听数组元素的修改;
2、不使用watch; 自己在修改的源头处理;