diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 77fbb515..12af237a 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -172,10 +172,7 @@ export function mergeComponentOptions(to: any, from: any): ComponentOptions { res[key] = value } else { // merge lifecycle hooks - res[key] = function(...args: any[]) { - existing.call(this, ...args) - value.call(this, ...args) - } + res[key] = mergeLifecycleHooks(existing, value) } } else if (isArray(value) && isArray(existing)) { res[key] = existing.concat(value) @@ -188,6 +185,13 @@ export function mergeComponentOptions(to: any, from: any): ComponentOptions { return res } +export function mergeLifecycleHooks(a: Function, b: Function): Function { + return function(...args: any[]) { + a.call(this, ...args) + b.call(this, ...args) + } +} + export function mergeDataFn(a: Function, b: Function): Function { // TODO: backwards compat requires recursive merge, // but maybe we should just warn if we detect clashing keys diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index a42e812d..6fcaa1f7 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -57,7 +57,7 @@ const renderProxyHandlers = { receiver: any ): boolean { if (__DEV__) { - if (isReservedKey(key)) { + if (isReservedKey(key) && key in target) { // TODO warn setting immutable properties return false } diff --git a/packages/runtime-core/src/createRenderer.ts b/packages/runtime-core/src/createRenderer.ts index db06a64c..9a46a273 100644 --- a/packages/runtime-core/src/createRenderer.ts +++ b/packages/runtime-core/src/createRenderer.ts @@ -1155,7 +1155,7 @@ export function createRenderer(options: RendererOptions) { const { $proxy, - $options: { beforeMount, mounted, renderTracked, renderTriggered } + $options: { beforeMount, renderTracked, renderTriggered } } = instance if (beforeMount) { @@ -1194,6 +1194,10 @@ export function createRenderer(options: RendererOptions) { if (vnode.ref) { mountRef(vnode.ref, $proxy) } + + // retrieve mounted value right before calling it so that we get + // to inject effects in first render + const { mounted } = instance.$options if (mounted) { lifecycleHooks.push(() => { mounted.call($proxy) diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 889d0fb4..cfb457e3 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -18,6 +18,7 @@ export { createAsyncComponent } from './optional/asyncComponent' export { KeepAlive } from './optional/keepAlive' export { mixins } from './optional/mixins' export { EventEmitter } from './optional/eventEmitter' +export { withHooks, useState, useEffect } from './optional/hooks' // flags & types export { ComponentType, ComponentClass, FunctionalComponent } from './component' diff --git a/packages/runtime-core/src/optional/hooks.ts b/packages/runtime-core/src/optional/hooks.ts new file mode 100644 index 00000000..50d7f443 --- /dev/null +++ b/packages/runtime-core/src/optional/hooks.ts @@ -0,0 +1,116 @@ +import { ComponentInstance, APIMethods } from '../component' +import { mergeLifecycleHooks, Data } from '../componentOptions' +import { VNode, Slots } from '../vdom' +import { observable } from '@vue/observer' + +type RawEffect = () => (() => void) | void + +type Effect = RawEffect & { + current?: RawEffect | null | void +} + +type EffectRecord = { + effect: Effect + deps: any[] | void +} + +type ComponentInstanceWithHook = ComponentInstance & { + _state: Record + _effects: EffectRecord[] +} + +let currentInstance: ComponentInstanceWithHook | null = null +let isMounting: boolean = false +let callIndex: number = 0 + +export function useState(initial: any) { + if (!currentInstance) { + throw new Error( + `useState must be called in a function passed to withHooks.` + ) + } + const id = ++callIndex + const state = currentInstance._state + const set = (newValue: any) => { + state[id] = newValue + } + if (isMounting) { + set(initial) + } + return [state[id], set] +} + +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 = () => { + const { current } = cleanup + if (current) { + current() + cleanup.current = null + } + } + const effect: Effect = () => { + cleanup() + const { current } = effect + if (current) { + effect.current = current() + } + } + effect.current = rawEffect + + currentInstance._effects[id] = { + effect, + deps + } + + injectEffect(currentInstance, 'mounted', effect) + injectEffect(currentInstance, 'unmounted', cleanup) + if (!deps) { + injectEffect(currentInstance, 'updated', effect) + } + } else { + const { effect, deps: prevDeps = [] } = currentInstance._effects[id] + if (!deps || deps.some((d, i) => d !== prevDeps[i])) { + effect.current = rawEffect + } else { + effect.current = null + } + } +} + +function injectEffect( + instance: ComponentInstanceWithHook, + key: string, + effect: Effect +) { + const existing = instance.$options[key] + ;(instance.$options as any)[key] = existing + ? mergeLifecycleHooks(existing, effect) + : effect +} + +export function withHooks(render: T): T { + return { + displayName: render.name, + created() { + const { _self } = this + _self._state = observable({}) + _self._effects = [] + }, + render(props: Data, slots: Slots, attrs: Data, parentVNode: VNode) { + const { _self } = this + callIndex = 0 + currentInstance = _self + isMounting = !_self._mounted + const ret = render(props, slots, attrs, parentVNode) + currentInstance = null + return ret + } + } as any +}