Преглед изворни кода

wip: computed tests passing

Evan You пре 4 година
родитељ
комит
05aaa33a45

+ 1 - 1
src/composition-api/apiWatch.ts

@@ -49,7 +49,7 @@ export interface WatchOptionsBase extends DebuggerOptions {
   flush?: 'pre' | 'post' | 'sync'
 }
 
-interface DebuggerOptions {
+export interface DebuggerOptions {
   onTrack?: (event: DebuggerEvent) => void
   onTrigger?: (event: DebuggerEvent) => void
 }

+ 13 - 3
src/composition-api/index.ts

@@ -26,17 +26,27 @@ export {
   // isProxy,
   // shallowReactive,
   // shallowReadonly,
-  // markRaw,
-  // toRaw,
+  markRaw,
+  toRaw,
   ReactiveFlags,
   // DeepReadonly,
   // ShallowReactive,
   UnwrapNestedRefs
 } from './reactivity/reactive'
 
+export {
+  computed,
+  ComputedRef,
+  WritableComputedRef,
+  WritableComputedOptions,
+  ComputedGetter,
+  ComputedSetter
+} from './reactivity/computed'
+
 export {
   watch,
   watchEffect,
   watchPostEffect,
-  watchSyncEffect
+  watchSyncEffect,
+  DebuggerEvent
 } from './apiWatch'

+ 85 - 3
src/composition-api/reactivity/computed.ts

@@ -1,4 +1,9 @@
+import { isServerRendering, noop, warn } from 'core/util'
 import { Ref } from './ref'
+import Watcher from 'core/observer/watcher'
+import Dep from 'core/observer/dep'
+import { currentInstance } from '../currentInstance'
+import { DebuggerOptions } from '../apiWatch'
 
 declare const ComputedRefSymbol: unique symbol
 
@@ -8,7 +13,7 @@ export interface ComputedRef<T = any> extends WritableComputedRef<T> {
 }
 
 export interface WritableComputedRef<T> extends Ref<T> {
-  // TODO readonly effect: ReactiveEffect<T>
+  readonly effect: { stop(): void }
 }
 
 export type ComputedGetter<T> = (...args: any[]) => T
@@ -19,6 +24,83 @@ export interface WritableComputedOptions<T> {
   set: ComputedSetter<T>
 }
 
-export function computed() {
-  // TODO
+export function computed<T>(
+  getter: ComputedGetter<T>,
+  debugOptions?: DebuggerOptions
+): ComputedRef<T>
+export function computed<T>(
+  options: WritableComputedOptions<T>,
+  debugOptions?: DebuggerOptions
+): WritableComputedRef<T>
+export function computed<T>(
+  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
+  // TODO debug options
+  debugOptions?: DebuggerOptions
+) {
+  let getter: ComputedGetter<T>
+  let setter: ComputedSetter<T>
+
+  const onlyGetter = typeof getterOrOptions === 'function'
+  if (onlyGetter) {
+    getter = getterOrOptions
+    setter = __DEV__
+      ? () => {
+          warn('Write operation failed: computed value is readonly')
+        }
+      : noop
+  } else {
+    getter = getterOrOptions.get
+    setter = getterOrOptions.set
+  }
+
+  return new ComputedRefImpl(
+    getter,
+    setter,
+    onlyGetter,
+    isServerRendering()
+  ) as any
+}
+
+class ComputedRefImpl<T> {
+  public dep?: Dep = undefined
+
+  public readonly __v_isRef = true
+  public readonly effect
+
+  private _watcher: Watcher | null
+
+  constructor(
+    private _getter: ComputedGetter<T>,
+    private readonly _setter: ComputedSetter<T>,
+    public readonly __v_isReadonly: boolean,
+    isSSR: boolean
+  ) {
+    const watcher = (this._watcher = isSSR
+      ? null
+      : new Watcher(currentInstance, _getter, noop, { lazy: true }))
+    this.effect = {
+      stop() {
+        watcher && watcher.teardown()
+      }
+    }
+  }
+
+  get value() {
+    const watcher = this._watcher
+    if (watcher) {
+      if (watcher.dirty) {
+        watcher.evaluate()
+      }
+      if (Dep.target) {
+        watcher.depend()
+      }
+      return watcher.value
+    } else {
+      return this._getter()
+    }
+  }
+
+  set value(newValue: T) {
+    this._setter(newValue)
+  }
 }

+ 4 - 3
src/composition-api/reactivity/effect.ts

@@ -30,12 +30,12 @@ export class ReactiveEffect<T = any> {
 
   constructor(
     public fn: () => T,
-    public scheduler?: EffectScheduler // TODO scope?: EffectScope
+    scheduler?: EffectScheduler // TODO scope?: EffectScope
   ) {
     // TODO recordEffectScope(this, scope)
     // TODO debug options
     this._watcher = new Watcher(currentInstance, fn, noop, {
-      // force cb trigger
+      // always force trigger update if has scheduler
       deep: true,
       sync: !scheduler,
       scheduler
@@ -54,4 +54,5 @@ export class ReactiveEffect<T = any> {
 }
 
 // since we are not exposing this in Vue 2, it's used only for internal testing.
-export const effect = (fn: () => any) => new ReactiveEffect(fn)
+export const effect = (fn: () => any, scheduler?: any) =>
+  new ReactiveEffect(fn, scheduler)

+ 8 - 7
src/composition-api/reactivity/ref.ts

@@ -18,7 +18,7 @@ export interface Ref<T = any> {
   /**
    * @private
    */
-  dep: Dep
+  dep?: Dep
 }
 
 export function isRef<T>(r: Ref<T> | unknown): r is Ref<T>
@@ -58,7 +58,10 @@ function createRef(rawValue: unknown, shallow: boolean) {
 }
 
 export function triggerRef(ref: Ref) {
-  ref.dep.notify()
+  if (__DEV__ && !ref.dep) {
+    warn(`received object is not a triggerable ref.`)
+  }
+  ref.dep && ref.dep.notify()
 }
 
 export function unref<T>(ref: T | Ref<T>): T {
@@ -74,18 +77,16 @@ export type CustomRefFactory<T> = (
 }
 
 class CustomRefImpl<T> {
-  public dep?: Dep = undefined
-
+  public dep = new Dep()
   private readonly _get: ReturnType<CustomRefFactory<T>>['get']
   private readonly _set: ReturnType<CustomRefFactory<T>>['set']
 
   public readonly __v_isRef = true
 
   constructor(factory: CustomRefFactory<T>) {
-    const dep = new Dep()
     const { get, set } = factory(
-      () => dep.depend(),
-      () => dep.notify()
+      () => this.dep.depend(),
+      () => this.dep.notify()
     )
     this._get = get
     this._set = set

+ 7 - 3
src/core/observer/watcher.ts

@@ -6,7 +6,8 @@ import {
   _Set as Set,
   handleError,
   invokeWithErrorHandling,
-  noop
+  noop,
+  bind
 } from '../util/index'
 
 import { traverse } from './traverse'
@@ -70,7 +71,10 @@ export default class Watcher implements DepTarget {
       this.lazy = !!options.lazy
       this.sync = !!options.sync
       this.before = options.before
-      this.scheduler = options.scheduler
+      if ((this.scheduler = options.scheduler)) {
+        // @ts-ignore
+        this.run = bind(this.run, this)
+      }
     } else {
       this.deep = this.user = this.lazy = this.sync = false
     }
@@ -175,7 +179,7 @@ export default class Watcher implements DepTarget {
     } else if (this.sync) {
       this.run()
     } else if (this.scheduler) {
-      this.scheduler()
+      this.scheduler(this.run)
     } else {
       queueWatcher(this)
     }

+ 3 - 0
src/platforms/web/entry-runtime-with-compiler.ts

@@ -4,4 +4,7 @@ import { extend } from 'shared/util'
 
 extend(Vue, vca)
 
+import { effect } from 'vca/reactivity/effect'
+Vue.effect = effect
+
 export default Vue

+ 298 - 0
test/unit/features/composition-api/reactivity/computed.spec.ts

@@ -0,0 +1,298 @@
+import {
+  computed,
+  reactive,
+  ref,
+  isReadonly,
+  // toRaw,
+  WritableComputedRef
+  // DebuggerEvent
+} from 'vca/index'
+import { effect } from 'vca/reactivity/effect'
+import { nextTick } from 'core/util'
+// import { TrackOpTypes, TriggerOpTypes } from 'vca/reactivity/operations'
+
+describe('reactivity/computed', () => {
+  it('should return updated value', () => {
+    const value = reactive({ foo: 1 })
+    const cValue = computed(() => value.foo)
+    expect(cValue.value).toBe(1)
+    value.foo = 2
+    expect(cValue.value).toBe(2)
+  })
+
+  it('should compute lazily', () => {
+    const value = reactive<{ foo?: number }>({ foo: undefined })
+    const getter = vi.fn(() => value.foo)
+    const cValue = computed(getter)
+
+    // lazy
+    expect(getter).not.toHaveBeenCalled()
+
+    expect(cValue.value).toBe(undefined)
+    expect(getter).toHaveBeenCalledTimes(1)
+
+    // should not compute again
+    cValue.value
+    expect(getter).toHaveBeenCalledTimes(1)
+
+    // should not compute until needed
+    value.foo = 1
+    expect(getter).toHaveBeenCalledTimes(1)
+
+    // now it should compute
+    expect(cValue.value).toBe(1)
+    expect(getter).toHaveBeenCalledTimes(2)
+
+    // should not compute again
+    cValue.value
+    expect(getter).toHaveBeenCalledTimes(2)
+  })
+
+  it('should trigger effect', () => {
+    const value = reactive<{ foo?: number }>({ foo: undefined })
+    const cValue = computed(() => value.foo)
+    let dummy
+    effect(() => {
+      dummy = cValue.value
+    })
+    expect(dummy).toBe(undefined)
+    value.foo = 1
+    expect(dummy).toBe(1)
+  })
+
+  it('should work when chained', () => {
+    const value = reactive({ foo: 0 })
+    const c1 = computed(() => value.foo)
+    const c2 = computed(() => c1.value + 1)
+    expect(c2.value).toBe(1)
+    expect(c1.value).toBe(0)
+    value.foo++
+    expect(c2.value).toBe(2)
+    expect(c1.value).toBe(1)
+  })
+
+  it('should trigger effect when chained', () => {
+    const value = reactive({ foo: 0 })
+    const getter1 = vi.fn(() => value.foo)
+    const getter2 = vi.fn(() => {
+      return c1.value + 1
+    })
+    const c1 = computed(getter1)
+    const c2 = computed(getter2)
+
+    let dummy
+    effect(() => {
+      dummy = c2.value
+    })
+    expect(dummy).toBe(1)
+    expect(getter1).toHaveBeenCalledTimes(1)
+    expect(getter2).toHaveBeenCalledTimes(1)
+    value.foo++
+    expect(dummy).toBe(2)
+    // should not result in duplicate calls
+    expect(getter1).toHaveBeenCalledTimes(2)
+    expect(getter2).toHaveBeenCalledTimes(2)
+  })
+
+  it('should trigger effect when chained (mixed invocations)', async () => {
+    const value = reactive({ foo: 0 })
+    const getter1 = vi.fn(() => value.foo)
+    const getter2 = vi.fn(() => {
+      return c1.value + 1
+    })
+    const c1 = computed(getter1)
+    const c2 = computed(getter2)
+
+    let dummy
+    // @discrepancy Vue 2 chained computed doesn't work with sync watchers
+    effect(() => {
+      dummy = c1.value + c2.value
+    }, nextTick)
+    expect(dummy).toBe(1)
+
+    expect(getter1).toHaveBeenCalledTimes(1)
+    expect(getter2).toHaveBeenCalledTimes(1)
+    value.foo++
+
+    await nextTick()
+    expect(dummy).toBe(3)
+    // should not result in duplicate calls
+    expect(getter1).toHaveBeenCalledTimes(2)
+    expect(getter2).toHaveBeenCalledTimes(2)
+  })
+
+  it('should no longer update when stopped', () => {
+    const value = reactive<{ foo?: number }>({ foo: undefined })
+    const cValue = computed(() => value.foo)
+    let dummy
+    effect(() => {
+      dummy = cValue.value
+    })
+    expect(dummy).toBe(undefined)
+    value.foo = 1
+    expect(dummy).toBe(1)
+    cValue.effect.stop()
+    value.foo = 2
+    expect(dummy).toBe(1)
+  })
+
+  it('should support setter', () => {
+    const n = ref(1)
+    const plusOne = computed({
+      get: () => n.value + 1,
+      set: val => {
+        n.value = val - 1
+      }
+    })
+
+    expect(plusOne.value).toBe(2)
+    n.value++
+    expect(plusOne.value).toBe(3)
+
+    plusOne.value = 0
+    expect(n.value).toBe(-1)
+  })
+
+  it('should trigger effect w/ setter', () => {
+    const n = ref(1)
+    const plusOne = computed({
+      get: () => n.value + 1,
+      set: val => {
+        n.value = val - 1
+      }
+    })
+
+    let dummy
+    effect(() => {
+      dummy = n.value
+    })
+    expect(dummy).toBe(1)
+
+    plusOne.value = 0
+    expect(dummy).toBe(-1)
+  })
+
+  // #5720
+  it('should invalidate before non-computed effects', async () => {
+    let plusOneValues: number[] = []
+    const n = ref(0)
+    const plusOne = computed(() => n.value + 1)
+    effect(() => {
+      n.value
+      plusOneValues.push(plusOne.value)
+    }, nextTick)
+    expect(plusOneValues).toMatchObject([1])
+    // access plusOne, causing it to be non-dirty
+    plusOne.value
+    // mutate n
+    n.value++
+    await nextTick()
+    // on the 2nd run, plusOne.value should have already updated.
+    expect(plusOneValues).toMatchObject([1, 2])
+  })
+
+  it('should warn if trying to set a readonly computed', () => {
+    const n = ref(1)
+    const plusOne = computed(() => n.value + 1)
+    ;(plusOne as WritableComputedRef<number>).value++ // Type cast to prevent TS from preventing the error
+
+    expect(
+      'Write operation failed: computed value is readonly'
+    ).toHaveBeenWarnedLast()
+  })
+
+  it('should be readonly', () => {
+    let a = { a: 1 }
+    const x = computed(() => a)
+    expect(isReadonly(x)).toBe(true)
+    expect(isReadonly(x.value)).toBe(false)
+    expect(isReadonly(x.value.a)).toBe(false)
+    const z = computed<typeof a>({
+      get() {
+        return a
+      },
+      set(v) {
+        a = v
+      }
+    })
+    expect(isReadonly(z)).toBe(false)
+    expect(isReadonly(z.value.a)).toBe(false)
+  })
+
+  it('should expose value when stopped', () => {
+    const x = computed(() => 1)
+    x.effect.stop()
+    expect(x.value).toBe(1)
+  })
+
+  // TODO
+  // it('debug: onTrack', () => {
+  //   let events: DebuggerEvent[] = []
+  //   const onTrack = vi.fn((e: DebuggerEvent) => {
+  //     events.push(e)
+  //   })
+  //   const obj = reactive({ foo: 1, bar: 2 })
+  //   const c = computed(() => (obj.foo, 'bar' in obj, Object.keys(obj)), {
+  //     onTrack
+  //   })
+  //   expect(c.value).toEqual(['foo', 'bar'])
+  //   expect(onTrack).toHaveBeenCalledTimes(3)
+  //   expect(events).toEqual([
+  //     {
+  //       effect: c.effect,
+  //       target: toRaw(obj),
+  //       type: TrackOpTypes.GET,
+  //       key: 'foo'
+  //     },
+  //     {
+  //       effect: c.effect,
+  //       target: toRaw(obj),
+  //       type: TrackOpTypes.HAS,
+  //       key: 'bar'
+  //     },
+  //     {
+  //       effect: c.effect,
+  //       target: toRaw(obj),
+  //       type: TrackOpTypes.ITERATE,
+  //       key: ITERATE_KEY
+  //     }
+  //   ])
+  // })
+
+  // TODO
+  // it('debug: onTrigger', () => {
+  //   let events: DebuggerEvent[] = []
+  //   const onTrigger = vi.fn((e: DebuggerEvent) => {
+  //     events.push(e)
+  //   })
+  //   const obj = reactive({ foo: 1 })
+  //   const c = computed(() => obj.foo, { onTrigger })
+
+  //   // computed won't trigger compute until accessed
+  //   c.value
+
+  //   obj.foo++
+  //   expect(c.value).toBe(2)
+  //   expect(onTrigger).toHaveBeenCalledTimes(1)
+  //   expect(events[0]).toEqual({
+  //     effect: c.effect,
+  //     target: toRaw(obj),
+  //     type: TriggerOpTypes.SET,
+  //     key: 'foo',
+  //     oldValue: 1,
+  //     newValue: 2
+  //   })
+
+  //   // @ts-ignore
+  //   delete obj.foo
+  //   expect(c.value).toBeUndefined()
+  //   expect(onTrigger).toHaveBeenCalledTimes(2)
+  //   expect(events[1]).toEqual({
+  //     effect: c.effect,
+  //     target: toRaw(obj),
+  //     type: TriggerOpTypes.DELETE,
+  //     key: 'foo',
+  //     oldValue: 2
+  //   })
+  // })
+})

+ 25 - 20
test/unit/features/composition-api/reactivity/reactive.spec.ts

@@ -1,8 +1,14 @@
-import { ref, isRef } from 'vca/reactivity/ref'
-import { reactive, isReactive, toRaw, markRaw } from 'vca/reactivity/reactive'
-import { effect } from 'vca/reactivity/effect'
+import {
+  ref,
+  isRef,
+  reactive,
+  isReactive,
+  toRaw,
+  markRaw,
+  computed
+} from 'vca/index'
 import { set } from 'core/observer'
-// TODO import { computed } from 'vca/reactivity/computed'
+import { effect } from 'vca/reactivity/effect'
 
 describe('reactivity/reactive', () => {
   test('Object', () => {
@@ -186,22 +192,21 @@ describe('reactivity/reactive', () => {
     expect(isRef(observedObjectRef)).toBe(true)
   })
 
-  // TODO
-  // test('should unwrap computed refs', () => {
-  //   // readonly
-  //   const a = computed(() => 1)
-  //   // writable
-  //   const b = computed({
-  //     get: () => 1,
-  //     set: () => {}
-  //   })
-  //   const obj = reactive({ a, b })
-  //   // check type
-  //   obj.a + 1
-  //   obj.b + 1
-  //   expect(typeof obj.a).toBe(`number`)
-  //   expect(typeof obj.b).toBe(`number`)
-  // })
+  test('should unwrap computed refs', () => {
+    // readonly
+    const a = computed(() => 1)
+    // writable
+    const b = computed({
+      get: () => 1,
+      set: () => {}
+    })
+    const obj = reactive({ a, b })
+    // check type
+    obj.a + 1
+    obj.b + 1
+    expect(typeof obj.a).toBe(`number`)
+    expect(typeof obj.b).toBe(`number`)
+  })
 
   test('should allow setting property from a ref to another ref', () => {
     const foo = ref(0)

+ 5 - 3
test/unit/features/composition-api/reactivity/ref.spec.ts

@@ -7,10 +7,12 @@ import {
   toRef,
   toRefs,
   customRef,
-  Ref
-} from 'vca/reactivity/ref'
+  Ref,
+  isReactive,
+  isShallow,
+  reactive
+} from 'vca/index'
 import { effect } from 'vca/reactivity/effect'
-import { isReactive, isShallow, reactive } from 'vca/reactivity/reactive'
 
 describe('reactivity/ref', () => {
   it('should hold a value', () => {