diff --git a/packages/runtime-core/__tests__/hooks.spec.ts b/packages/runtime-core/__tests__/hooks.spec.ts index 84e44719..29d1073b 100644 --- a/packages/runtime-core/__tests__/hooks.spec.ts +++ b/packages/runtime-core/__tests__/hooks.spec.ts @@ -24,32 +24,6 @@ describe('hooks', () => { expect(serialize(counter.$el)).toBe(`
1
`) }) - it('useEffect', async () => { - let effect = -1 - - const Counter = withHooks(() => { - const [count, setCount] = useState(0) - useEffect(() => { - effect = count - }) - return h( - 'div', - { - onClick: () => { - setCount(count + 1) - } - }, - count - ) - }) - - const counter = renderIntsance(Counter) - expect(effect).toBe(0) - triggerEvent(counter.$el, 'click') - await nextTick() - expect(effect).toBe(1) - }) - it('should be usable inside class', async () => { class Counter extends Component { render() { @@ -105,6 +79,32 @@ describe('hooks', () => { expect(serialize(counter.$el)).toBe(`
1
`) }) + it('useEffect', async () => { + let effect = -1 + + const Counter = withHooks(() => { + const [count, setCount] = useState(0) + useEffect(() => { + effect = count + }) + return h( + 'div', + { + onClick: () => { + setCount(count + 1) + } + }, + count + ) + }) + + const counter = renderIntsance(Counter) + expect(effect).toBe(0) + triggerEvent(counter.$el, 'click') + await nextTick() + expect(effect).toBe(1) + }) + it('useEffect with empty keys', async () => { // TODO }) @@ -112,4 +112,20 @@ describe('hooks', () => { it('useEffect with keys', async () => { // TODO }) + + it('useData', () => { + // TODO + }) + + it('useMounted/useUnmounted/useUpdated', () => { + // TODO + }) + + it('useWatch', () => { + // TODO + }) + + it('useComputed', () => { + // TODO + }) }) diff --git a/packages/runtime-core/src/experimental/hooks.ts b/packages/runtime-core/src/experimental/hooks.ts index 241adfe1..e2d67be4 100644 --- a/packages/runtime-core/src/experimental/hooks.ts +++ b/packages/runtime-core/src/experimental/hooks.ts @@ -1,7 +1,8 @@ import { ComponentInstance, FunctionalComponent, Component } from '../component' -import { mergeLifecycleHooks, Data } from '../componentOptions' +import { mergeLifecycleHooks, Data, WatchOptions } from '../componentOptions' import { VNode, Slots } from '../vdom' -import { observable } from '@vue/observer' +import { observable, computed, stop, ComputedGetter } from '@vue/observer' +import { setupWatcher } from '../componentWatch' type RawEffect = () => (() => void) | void @@ -15,9 +16,12 @@ type EffectRecord = { deps: any[] | void } +type Ref = { current: T } + type HookState = { state: any - effects: EffectRecord[] + effects: Record + refs: Record> } let currentInstance: ComponentInstance | null = null @@ -36,26 +40,37 @@ export function unsetCurrentInstance() { currentInstance = null } -function getHookStateForInstance(instance: ComponentInstance): HookState { - let hookState = hooksStateMap.get(instance) +function ensureCurrentInstance() { + if (!currentInstance) { + throw new Error( + `invalid hooks call` + + (__DEV__ + ? `. Hooks can only be called in one of the following: ` + + `render(), hooks(), or withHooks().` + : ``) + ) + } +} + +function getCurrentHookState(): HookState { + ensureCurrentInstance() + let hookState = hooksStateMap.get(currentInstance as ComponentInstance) if (!hookState) { hookState = { state: observable({}), - effects: [] + effects: {}, + refs: {} } - hooksStateMap.set(instance, hookState) + hooksStateMap.set(currentInstance as ComponentInstance, hookState) } return hookState } +// React compatible hooks ------------------------------------------------------ + export function useState(initial: T): [T, (newValue: T) => void] { - if (!currentInstance) { - throw new Error( - `useState must be called in a function passed to withHooks.` - ) - } const id = ++callIndex - const { state } = getHookStateForInstance(currentInstance) + const { state } = getCurrentHookState() const set = (newValue: any) => { state[id] = newValue } @@ -66,11 +81,6 @@ export function useState(initial: T): [T, (newValue: T) => void] { } export function useEffect(rawEffect: Effect, deps?: any[]) { - if (!currentInstance) { - throw new Error( - `useEffect must be called in a function passed to withHooks.` - ) - } const id = ++callIndex if (isMounting) { const cleanup: Effect = () => { @@ -88,26 +98,40 @@ export function useEffect(rawEffect: Effect, deps?: any[]) { } } effect.current = rawEffect - getHookStateForInstance(currentInstance).effects[id] = { + getCurrentHookState().effects[id] = { effect, cleanup, deps } - injectEffect(currentInstance, 'mounted', effect) - injectEffect(currentInstance, 'unmounted', cleanup) - injectEffect(currentInstance, 'updated', effect) + injectEffect(currentInstance as ComponentInstance, 'mounted', effect) + injectEffect(currentInstance as ComponentInstance, 'unmounted', cleanup) + if (!deps || deps.length !== 0) { + injectEffect(currentInstance as ComponentInstance, 'updated', effect) + } } else { - const record = getHookStateForInstance(currentInstance).effects[id] + const record = getCurrentHookState().effects[id] const { effect, cleanup, deps: prevDeps = [] } = record record.deps = deps - if (!deps || deps.some((d, i) => d !== prevDeps[i])) { + if (!deps || hasDepsChanged(deps, prevDeps)) { cleanup() effect.current = rawEffect } } } +function hasDepsChanged(deps: any[], prevDeps: any[]): boolean { + if (deps.length !== prevDeps.length) { + return true + } + for (let i = 0; i < deps.length; i++) { + if (deps[i] !== prevDeps[i]) { + return true + } + } + return false +} + function injectEffect( instance: ComponentInstance, key: string, @@ -119,6 +143,64 @@ function injectEffect( : effect } +export function useRef(initial?: T): Ref { + const id = ++callIndex + const { refs } = getCurrentHookState() + return isMounting ? (refs[id] = { current: initial }) : refs[id] +} + +// Vue API hooks --------------------------------------------------------------- + +export function useData(initial: T): T { + const id = ++callIndex + const { state } = getCurrentHookState() + if (isMounting) { + state[id] = initial + } + return state[id] +} + +export function useMounted(fn: () => void) { + useEffect(fn, []) +} + +export function useUnmounted(fn: () => void) { + useEffect(() => fn, []) +} + +export function useUpdated(fn: () => void, deps?: any[]) { + const isMount = useRef(true) + useEffect(() => { + if (isMount.current) { + isMount.current = false + } else { + return fn() + } + }, deps) +} + +export function useWatch( + getter: () => T, + cb: (val: T, oldVal: T) => void, + options?: WatchOptions +) { + ensureCurrentInstance() + if (isMounting) { + setupWatcher(currentInstance as ComponentInstance, getter, cb, options) + } +} + +export function useComputed(getter: () => T): T { + const computedRef = useRef() + useUnmounted(() => { + stop((computedRef.current as ComputedGetter).runner) + }) + if (isMounting) { + computedRef.current = computed(getter) + } + return (computedRef.current as ComputedGetter)() +} + export function withHooks(render: FunctionalComponent): new () => Component { return class ComponentWithHooks extends Component { static displayName = render.name diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index be6d1fc6..f038318d 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -21,7 +21,18 @@ export { EventEmitter } from './optional/eventEmitter' export { memoize } from './optional/memoize' // Experimental APIs -export { withHooks, useState, useEffect } from './experimental/hooks' +export { + withHooks, + useState, + useEffect, + useRef, + useData, + useWatch, + useComputed, + useMounted, + useUnmounted, + useUpdated +} from './experimental/hooks' // flags & types export { ComponentType, ComponentClass, FunctionalComponent } from './component'