From 7a60d29a8ca39eb5570b3ff0ced755d872d10448 Mon Sep 17 00:00:00 2001 From: yujin123 <1425816423@qq.com> Date: Wed, 1 Jan 2025 21:02:13 +0800 Subject: [PATCH] refactor(core/renderer): Add DecoElement props type support in JSX --- packages/core/src/api/instance.ts | 13 +++- packages/core/src/decorators/Component.ts | 85 +++++++++++++---------- packages/core/src/decorators/Prop.ts | 2 +- packages/core/src/decorators/Watch.ts | 7 +- packages/core/src/reactive/effect.ts | 4 +- packages/core/src/reactive/observe.ts | 12 ++-- packages/core/src/runtime/lifecycle.ts | 2 +- packages/core/src/types/index.d.ts | 15 ++++ packages/core/src/utils/const.ts | 8 +++ packages/core/src/utils/is.ts | 2 +- packages/renderer/src/is.ts | 4 ++ packages/renderer/src/patch/index.ts | 6 +- packages/renderer/src/vnode.ts | 10 ++- 13 files changed, 114 insertions(+), 56 deletions(-) diff --git a/packages/core/src/api/instance.ts b/packages/core/src/api/instance.ts index 408d1d1..28d70bd 100755 --- a/packages/core/src/api/instance.ts +++ b/packages/core/src/api/instance.ts @@ -2,10 +2,20 @@ import { jsx as h, Fragment } from '@decoco/renderer'; import { nextTickApi } from './global-api'; import { DecoPlugin } from './plugin'; -export class DecoElement extends DecoPlugin { +type DecoElementAttributes = JSX.IntrinsicAttributes & React.HTMLAttributes; + +export class DecoElement extends DecoPlugin { + // jsx static h = h; static Fragment = Fragment; + props: T & DecoElementAttributes = {} as T & DecoElementAttributes; + context: any = {}; + setState: () => void = () => {}; + forceUpdate: () => void = () => {}; + state: any = {}; + refs: any = {}; + // lifecycles componentWillMount() {} componentDidMount() {} shouldComponentUpdate() { @@ -17,6 +27,7 @@ export class DecoElement extends DecoPlugin { adoptedCallback() {} attributeChangedCallback?(name: string, oldValue: any, newValue: any) {} + // hooks $nextTick(this: any, callback: Function) { return nextTickApi.call(this, callback); } diff --git a/packages/core/src/decorators/Component.ts b/packages/core/src/decorators/Component.ts index 0dea987..a55502b 100755 --- a/packages/core/src/decorators/Component.ts +++ b/packages/core/src/decorators/Component.ts @@ -1,34 +1,22 @@ -import { bindEscapePropSet, bindComponentFlag, parseElementAttribute } from '../utils/element'; +import { bindEscapePropSet, bindComponentFlag } from '../utils/element'; import { jsx, render } from '@decoco/renderer'; -import { escapePropSet, observe, StatePool } from '../reactive/observe'; +import { observe, StatePool } from '../reactive/observe'; import { Effect, effectStack } from '../reactive/effect'; import { expToPath } from '../utils/share'; import { forbiddenStateAndPropKey } from '../utils/const'; import { callLifecycle, LifecycleCallback, LifeCycleList } from '../runtime/lifecycle'; import { createJob, queueJob } from '../runtime/scheduler'; -import { doWatch } from './Watch'; +import { doWatch, WatchCallback } from './Watch'; import { DecoPlugin } from '../api/plugin'; -import { isObjectAttribute, isUndefined } from '../utils/is'; +import { isDefined, isObjectAttribute, isString, isUndefined } from '../utils/is'; import { warn } from '../utils/error'; import { EventEmitter } from './Event'; import { applyDiff } from 'deep-diff'; import clone from 'rfdc'; import { DecoratorMetaKeys } from '../enums/decorators'; import { computed } from './Computed'; - -export interface DecoWebComponent { - [K: string | symbol]: any; - readonly uid: number; - - componentWillMountList: LifecycleCallback[]; - componentDidMountList: LifecycleCallback[]; - shouldComponentUpdateList: LifecycleCallback[]; - componentDidUpdateList: LifecycleCallback[]; - connectedCallbackList: LifecycleCallback[]; - disconnectedCallbackList: LifecycleCallback[]; - attributeChangedCallbackList: LifecycleCallback[]; - adoptedCallbackList: LifecycleCallback[]; -} +import { DecoWebComponent } from '../types/index'; +import { DecoElement } from 'src/api/instance'; interface ComponentDecoratorOptions { // tag name @@ -61,7 +49,7 @@ export default function Component(tagOrOptions: string | LegacyComponentOptions, tag = tagOrOptions.tag; } - const observedAttributes = target.prototype.__propKeys || []; + const observedAttributes = target.prototype.props || []; if (customElements.get(tag)) { warn(`custom element ${tag} already exists`); @@ -75,6 +63,10 @@ export default function Component(tagOrOptions: string | LegacyComponentOptions, `Invalid tag name: ${tag}. Tag names must start with a letter and contain only letters, digits, and hyphens.`, ); } + + // displayName is used to create html element in decoco-renderer + target.displayName = tag; + customElements.define(String(tag), getCustomElementWrapper(target, { tag, style, observedAttributes })); }; } @@ -84,10 +76,13 @@ type CustomElementWrapperOptions = { style?: string | StyleSheet; observedAttributes: string[]; }; -function getCustomElementWrapper(target: any, { tag, style, observedAttributes }: CustomElementWrapperOptions): any { +function getCustomElementWrapper( + target: typeof DecoElement, + { tag, style, observedAttributes }: CustomElementWrapperOptions, +): any { return class WebComponent extends target implements DecoWebComponent { uid = ++uid; - + shadowRootLink: ShadowRoot; __updateComponent: () => void; __mounted = false; @@ -105,7 +100,7 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } constructor() { super(); - this.attachShadow({ mode: 'open' }); + this.shadowRootLink = this.attachShadow({ mode: 'open' }); // save shadowRoot for close mode. const componentUpdateEffect = new Effect(__updateComponent.bind(this)); function __updateComponent(this: WebComponent) { @@ -130,6 +125,7 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } } } this.__updateComponent = __updateComponent.bind(this); + this.forceUpdate = this.__updateComponent; bindComponentFlag(this); bindEscapePropSet(this); @@ -226,8 +222,9 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } return; } statePool.initState(this, Array.from(stateKeys)); - Array.from(stateKeys.values()).forEach((name: any) => { - observe(this, name, this[name]); + Array.from(stateKeys.values()).forEach((name) => { + const propertyName = name as keyof DecoElement; + observe(this, propertyName, this[propertyName]); }); } @@ -244,17 +241,21 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } statePool.initState(this, Array.from(propKeys)); - Array.from(propKeys.keys()).forEach((name: any) => { - const attr = this.getAttribute(name); - observe(this, name, this.hasAttribute(name) && !isObjectAttribute(attr) ? attr : this[name], { + Array.from(propKeys.keys()).forEach((name: unknown) => { + if (!isString(name)) { + return; + } + const attr = this.getAttribute(name) as keyof DecoElement; + const propertyName = name as keyof DecoElement; + observe(this, name, this.hasAttribute(name) && !isObjectAttribute(attr) ? attr : this[propertyName], { isProp: true, deep: true, autoDeepReactive: true, }); // prop map to html attribute - if (!this.hasAttribute(name) && this[name] !== undefined && this[name] !== null) { - queueJob(createJob(() => this.setAttribute(name, this[name]))); + if (!this.hasAttribute(name) && this[propertyName] !== undefined && this[propertyName] !== null) { + queueJob(createJob(() => this.setAttribute(name, this[propertyName]?.toString() || ''))); } }); } @@ -293,7 +294,10 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } } for (const item of watchers) { - const { watchKeys, watchMethodName } = item; + const { watchKeys, watchMethodName } = item as { + watchKeys: string[]; + watchMethodName: keyof DecoElement; + }; watchKeys.forEach((watchKey: string) => { const { ctx, property } = expToPath(watchKey, this) || {}; if (!ctx || !property) { @@ -302,6 +306,10 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } } const watchCallback = this[watchMethodName]; + if (!isDefined(watchCallback)) { + warn(`watchCallback ${watchMethodName} is undefined`); + return; + } doWatch(this, watchCallback, ctx, property, statePool, item.options); }); } @@ -315,7 +323,8 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } for (const eventName of events.keys()) { const eventInit = events.get(eventName); const eventEmit = new EventEmitter({ ...eventInit }); - eventEmit.setEventTarget(this as any); + eventEmit.setEventTarget(this); + // @ts-ignore this[eventName] = eventEmit; } } @@ -328,11 +337,11 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } } domUpdate() { - let rootVnode = this.render(); + let rootVnode: any = this.render(); // style if (style instanceof CSSStyleSheet) { - this.shadowRoot.adoptedStyleSheets = [style]; + this.shadowRootLink.adoptedStyleSheets = [style]; } else if (typeof style === 'string') { if (Array.isArray(rootVnode)) { rootVnode.unshift(jsx('style', {}, style)); @@ -341,18 +350,22 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } } } - render(rootVnode, this.shadowRoot); + // TODO: fix type error + render(rootVnode, this.shadowRootLink as unknown as HTMLElement); } initLifecycle() { this.connectedCallbackList.push(super.connectedCallback); this.disconnectedCallbackList.push(super.disconnectedCallback); - this.attributeChangedCallbackList.push(super.attributeChangedCallback); this.adoptedCallbackList.push(super.adoptedCallback); this.componentWillMountList.push(super.componentWillMount); this.componentDidMountList.push(super.componentDidMount); this.shouldComponentUpdateList.push(super.shouldComponentUpdate); this.componentDidUpdateList.push(super.componentDidUpdate); + + if (isDefined(super.attributeChangedCallback)) { + this.attributeChangedCallbackList.push(super.attributeChangedCallback); + } } initStore() { @@ -361,7 +374,7 @@ function getCustomElementWrapper(target: any, { tag, style, observedAttributes } return; } - for (const propName of stores.keys()) { + for (const propName of stores.keys() as (keyof DecoElement)[]) { const { store, getState } = stores.get(propName); const storeState = getState(store.getState()); const obj = clone()(storeState); diff --git a/packages/core/src/decorators/Prop.ts b/packages/core/src/decorators/Prop.ts index 84a87a1..de5513e 100755 --- a/packages/core/src/decorators/Prop.ts +++ b/packages/core/src/decorators/Prop.ts @@ -4,7 +4,7 @@ export default function Prop() { propKeys.add(propertyKey); Reflect.defineMetadata('propKeys', propKeys, target); - Object.defineProperty(target, '__propKeys', { + Object.defineProperty(target, 'props', { writable: true, configurable: false, value: Array.from(propKeys), diff --git a/packages/core/src/decorators/Watch.ts b/packages/core/src/decorators/Watch.ts index 27921f0..db9de01 100755 --- a/packages/core/src/decorators/Watch.ts +++ b/packages/core/src/decorators/Watch.ts @@ -1,8 +1,7 @@ -import { DecoratorMetadata } from '../types'; -import { createJob, queueJob, SchedulerJob } from '../runtime/scheduler'; +import { createJob, queueJob } from '../runtime/scheduler'; import { Effect } from '../reactive/effect'; import { StatePool } from '../reactive/observe'; -import { DecoWebComponent } from './Component'; +import { DecoWebComponent } from '../types/index'; export interface WatchOptions { once?: boolean; @@ -26,7 +25,7 @@ export function doWatch( instance: DecoWebComponent, watchCallback: WatchCallback, propertyCtx: any, - property: string | symbol, + property: string | number | symbol, statePool: StatePool, watchOptions: WatchOptions, ) { diff --git a/packages/core/src/reactive/effect.ts b/packages/core/src/reactive/effect.ts index 91b8a11..aaab92e 100755 --- a/packages/core/src/reactive/effect.ts +++ b/packages/core/src/reactive/effect.ts @@ -1,5 +1,5 @@ import { StatePool } from './observe'; -import { DecoWebComponent } from '../decorators/Component'; +import { DecoWebComponent } from '../types/index'; export type EffectOptions = { value?: any; @@ -34,7 +34,7 @@ export class Effect { return this.effect(...args); } - captureSelf(target: any, name: string | symbol, instance?: any) { + captureSelf(target: any, name: string | number | symbol, instance?: any) { const statePool: StatePool = Reflect.getMetadata('statePool', instance || target); statePool.set(target, name, this); } diff --git a/packages/core/src/reactive/observe.ts b/packages/core/src/reactive/observe.ts index 6c25006..78752cc 100755 --- a/packages/core/src/reactive/observe.ts +++ b/packages/core/src/reactive/observe.ts @@ -10,7 +10,7 @@ const isProxy = Symbol.for('isProxy'); // Define an interface that includes the symbol as a key interface ProxyTarget { [isProxy]?: boolean; - [key: string | symbol]: any; + [key: string | number | symbol]: any; } export function createReactive(targetElement: any, target: unknown, options: ObserverOptions = {}) { @@ -78,7 +78,7 @@ export function escapePropSet(target: any, prop: string, value: any) { export function observe( target: any, - name: string | symbol, + name: string | number | symbol, originValue: any, options: ObserverOptions = { deep: true }, ) { @@ -125,7 +125,7 @@ export function observe( export class StatePool { private isInitState: boolean = false; - private store: WeakMap>> = new WeakMap(); + private store: WeakMap>> = new WeakMap(); constructor() {} @@ -145,7 +145,7 @@ export class StatePool { // this.isInitState = true; } - set(target: object, name: string | symbol, effect?: Effect) { + set(target: object, name: string | number | symbol, effect?: Effect) { if (proxyMap.has(target)) { // target is a proxy target = proxyMap.get(target)!; @@ -168,7 +168,7 @@ export class StatePool { }); } - delete(target: object, name: string | symbol, effect?: Effect) { + delete(target: object, name: string | number | symbol, effect?: Effect) { const depKeyMap = this.store.get(target); if (!depKeyMap) { warn(`${target} has no state ${String(name)}`); @@ -193,7 +193,7 @@ export class StatePool { } } - notify(target: object, name: string | symbol) { + notify(target: object, name: string | number | symbol) { const depKeyMap = this.store.get(target) || this.store.set(target, new Map()).get(target); const deps = depKeyMap!.get(name) || depKeyMap!.set(name, new Set()).get(name); deps?.forEach((effect: Effect) => { diff --git a/packages/core/src/runtime/lifecycle.ts b/packages/core/src/runtime/lifecycle.ts index 1ec7ef7..3a11e6f 100755 --- a/packages/core/src/runtime/lifecycle.ts +++ b/packages/core/src/runtime/lifecycle.ts @@ -31,7 +31,7 @@ export function callLifecycle(target: any, lifecycle: LifeCycleList): CallLifecy for (const lifecycleCallback of lifecycles) { try { const callbackResult = lifecycleCallback.call(target); - if (isPromise(callbackResult)) { + if (lifecycle === LifeCycleList.SHOULD_COMPONENT_UPDATE && isPromise(callbackResult)) { throw new Error(`${lifecycle} callback must be sync`); } result.push(callbackResult); diff --git a/packages/core/src/types/index.d.ts b/packages/core/src/types/index.d.ts index 9622e52..cb0b520 100755 --- a/packages/core/src/types/index.d.ts +++ b/packages/core/src/types/index.d.ts @@ -1,3 +1,4 @@ +import { LifecycleCallback } from '../runtime/lifecycle'; import type { StatePool } from '../reactive/observe'; export type ObserverOptions = { @@ -14,3 +15,17 @@ export type DecoratorMetadata = { statePool: StatePool; [key: string]: any; }; + +export interface DecoWebComponent { + readonly uid: number; + shadowRootLink: ShadowRoot; + + componentWillMountList: LifecycleCallback[]; + componentDidMountList: LifecycleCallback[]; + shouldComponentUpdateList: LifecycleCallback[]; + componentDidUpdateList: LifecycleCallback[]; + connectedCallbackList: LifecycleCallback[]; + disconnectedCallbackList: LifecycleCallback[]; + attributeChangedCallbackList: LifecycleCallback[]; + adoptedCallbackList: LifecycleCallback[]; +} diff --git a/packages/core/src/utils/const.ts b/packages/core/src/utils/const.ts index e14aae7..94417a8 100644 --- a/packages/core/src/utils/const.ts +++ b/packages/core/src/utils/const.ts @@ -15,4 +15,12 @@ export const forbiddenStateAndPropKey = new Set([ 'render', '__isDecocoComponent__', 'escapePropSet', + + // jsx + 'props', + 'context', + 'setState', + 'forceUpdate', + 'state', + 'refs', ]); diff --git a/packages/core/src/utils/is.ts b/packages/core/src/utils/is.ts index 5a7b264..2d60aca 100755 --- a/packages/core/src/utils/is.ts +++ b/packages/core/src/utils/is.ts @@ -10,7 +10,7 @@ export function isObject(value: unknown): value is T return typeof value === 'object' && value !== null; } -export function isPlainObject(value: unknown): value is { [key: string | symbol]: K } { +export function isPlainObject(value: unknown): value is { [key: string | number | symbol]: K } { return getTypeof(value) === 'Object'; } diff --git a/packages/renderer/src/is.ts b/packages/renderer/src/is.ts index 4a338f3..b262d94 100755 --- a/packages/renderer/src/is.ts +++ b/packages/renderer/src/is.ts @@ -34,3 +34,7 @@ export function isNull(value: unknown): value is null { export function isDefined(value: unknown): value is T { return !isUndefined(value) && !isNull(value); } + +export function isFunction(value: unknown): value is T { + return typeof value === 'function'; +} diff --git a/packages/renderer/src/patch/index.ts b/packages/renderer/src/patch/index.ts index 1aa7c0f..b415eee 100755 --- a/packages/renderer/src/patch/index.ts +++ b/packages/renderer/src/patch/index.ts @@ -5,8 +5,8 @@ import { patchProps } from './props'; export const vnodeFlag = Symbol.for('decoco:vnode'); -interface Container extends HTMLElement { - [vnodeFlag]: Vnode | Vnode[]; +interface Container extends Element { + [vnodeFlag]?: Vnode | Vnode[]; } export function render(root: any, container: Container) { @@ -24,7 +24,7 @@ export function render(root: any, container: Container) { } } else { container.innerHTML = ''; - mountVnode(root, container); + mountVnode(root, container as HTMLElement); } container[vnodeFlag] = root; diff --git a/packages/renderer/src/vnode.ts b/packages/renderer/src/vnode.ts index b867d01..2427262 100755 --- a/packages/renderer/src/vnode.ts +++ b/packages/renderer/src/vnode.ts @@ -1,5 +1,5 @@ import { Fragment } from './const'; -import { isObject } from './is'; +import { isFunction, isObject } from './is'; const isVnode = Symbol.for('decoco:isVnode'); @@ -57,6 +57,14 @@ export function jsxElementToVnode(element: JSX.Element | JSX.Element[] | string return vnodeList; } else if (!isObject(element)) { return typeof element === 'string' || typeof element === 'number' ? createTextVnode(String(element)) : []; + } else if (isFunction(element.type)) { + if (!('displayName' in element.type) || typeof element.type.displayName !== 'string') { + console.error( + new Error(`jsxElementToVnode: <${element.type}> is a component, but no displayName property.`), + ); + return createTextVnode(''); // TODO: handle error element type + } + return createElementVnode(element.type.displayName, element.props); } else if ((element as any)[isVnode]) { return element as Vnode; } else if (element.type === Fragment) {