diff --git a/packages/core/__tests__/instantiation.spec.ts b/packages/core/__tests__/instantiation.spec.ts deleted file mode 100644 index de6760340..000000000 --- a/packages/core/__tests__/instantiation.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { it, describe, expect } from 'vitest'; -import { lazyInject, provide, initInstantiation } from '../src/instantiation'; - -interface Warrior { - fight(): string; -} - -interface Weapon { - hit(): string; -} - -@provide(Katana) -class Katana implements Weapon { - public hit() { - return 'cut!'; - } -} - -@provide(Ninja) -class Ninja implements Warrior { - @lazyInject(Katana) - private _katana: Weapon; - - public fight() { - return this._katana.hit(); - } -} - -initInstantiation(); - -describe('', () => { - it('works', () => { - const n = new Ninja(); - expect(n.fight()).toBe('cut!'); - }); -}); diff --git a/packages/core/__tests__/command.spec.ts b/packages/global.d.ts similarity index 100% rename from packages/core/__tests__/command.spec.ts rename to packages/global.d.ts diff --git a/packages/react-renderer/package.json b/packages/react-renderer/package.json index 17ee945e9..31768d499 100644 --- a/packages/react-renderer/package.json +++ b/packages/react-renderer/package.json @@ -30,7 +30,8 @@ "hoist-non-react-statics": "^3.3.2", "use-sync-external-store": "^1.2.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-is": "^18.3.1" }, "devDependencies": { "@testing-library/react": "^14.2.0", @@ -38,7 +39,8 @@ "@types/hoist-non-react-statics": "^3.3.5", "@types/use-sync-external-store": "^0.0.6", "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22" + "@types/react-dom": "^18.2.22", + "@types/react-is": "^18.3.0" }, "peerDependencies": { "react": "^18.2.0", diff --git a/packages/react-renderer/src/api/app.tsx b/packages/react-renderer/src/api/app.tsx index db58b8882..14f775091 100644 --- a/packages/react-renderer/src/api/app.tsx +++ b/packages/react-renderer/src/api/app.tsx @@ -1,42 +1,15 @@ -import { createRenderer, type AppOptions, type IRender } from '@alilc/lowcode-renderer-core'; +import { createRenderer, type AppOptions } from '@alilc/lowcode-renderer-core'; import { type ComponentType } from 'react'; import { type Root, createRoot } from 'react-dom/client'; -import { createRouter, type RouterOptions } from '@alilc/lowcode-renderer-router'; -import AppComponent from '../components/app'; -import { RendererContext } from '../context/render'; -import { createRouterProvider } from '../components/routerView'; -import { rendererExtends } from '../plugin'; +import { ApplicationView, RendererContext, extension } from '../app'; export interface ReactAppOptions extends AppOptions { faultComponent?: ComponentType; } -const defaultRouterOptions: RouterOptions = { - historyMode: 'browser', - baseName: '/', - routes: [], -}; - export const createApp = async (options: ReactAppOptions) => { - const creator = createRenderer(async (context) => { + return createRenderer(async (context) => { const { schema, boostsManager } = context; - const boosts = boostsManager.toExpose(); - - // router - let routerConfig = defaultRouterOptions; - - try { - const routerSchema = schema.get('router'); - if (routerSchema) { - routerConfig = boosts.codeRuntime.resolve(routerSchema); - } - } catch (e) { - console.error(`schema's router config is resolve error: `, e); - } - - const router = createRouter(routerConfig); - - boosts.codeRuntime.getScope().inject('router', router); // set config // if (options.faultComponent) { @@ -44,24 +17,21 @@ export const createApp = async (options: ReactAppOptions) => { // } // extends boosts - boostsManager.extend(rendererExtends); - - const RouterProvider = createRouterProvider(router); + extension.install(boostsManager); let root: Root | undefined; return { - async mount(el) { - if (root) { - return; - } + async mount(containerOrId) { + if (root) return; - root = createRoot(el); + const defaultId = schema.get('config')?.targetRootID ?? 'app'; + const rootElement = normalizeContainer(containerOrId, defaultId); + + root = createRoot(rootElement); root.render( - - - + , ); }, @@ -72,7 +42,23 @@ export const createApp = async (options: ReactAppOptions) => { } }, }; - }); - - return creator(options); + })(options); }; + +function normalizeContainer(container: Element | string | undefined, defaultId: string): Element { + let result: Element | undefined = undefined; + + if (typeof container === 'string') { + const el = document.getElementById(container); + if (el) result = el; + } else if (container instanceof window.Element) { + result = container; + } + + if (!result) { + result = document.createElement('div'); + result.id = defaultId; + } + + return result; +} diff --git a/packages/react-renderer/src/api/component.tsx b/packages/react-renderer/src/api/component.tsx index c7cbccc2d..364e07ed3 100644 --- a/packages/react-renderer/src/api/component.tsx +++ b/packages/react-renderer/src/api/component.tsx @@ -1,7 +1,7 @@ import { createRenderer, type AppOptions } from '@alilc/lowcode-renderer-core'; import { FunctionComponent } from 'react'; -import { type LowCodeComponentProps, createComponentBySchema } from '../runtime'; -import { RendererContext } from '../context/render'; +import { type LowCodeComponentProps, createComponentBySchema } from '../runtime/component'; +import { RendererContext } from '../app/context'; interface Render { toComponent(): FunctionComponent; diff --git a/packages/react-renderer/src/context/render.ts b/packages/react-renderer/src/app/context.ts similarity index 84% rename from packages/react-renderer/src/context/render.ts rename to packages/react-renderer/src/app/context.ts index 59833b581..b987f1a60 100644 --- a/packages/react-renderer/src/context/render.ts +++ b/packages/react-renderer/src/app/context.ts @@ -3,6 +3,6 @@ import { type RenderContext } from '@alilc/lowcode-renderer-core'; export const RendererContext = createContext(undefined!); -RendererContext.displayName = 'RootContext'; +RendererContext.displayName = 'RendererContext'; export const useRenderContext = () => useContext(RendererContext); diff --git a/packages/react-renderer/src/app/extension.ts b/packages/react-renderer/src/app/extension.ts new file mode 100644 index 000000000..2723ff1ff --- /dev/null +++ b/packages/react-renderer/src/app/extension.ts @@ -0,0 +1,51 @@ +import { type Plugin, type IBoostsService } from '@alilc/lowcode-renderer-core'; +import { type ComponentType, type PropsWithChildren } from 'react'; + +export type WrapperComponent = ComponentType>; + +export interface OutletProps { + [key: string]: any; +} + +export type Outlet = ComponentType; + +export interface ReactRendererExtensionApi { + addAppWrapper(appWrapper: WrapperComponent): void; + + setOutlet(outlet: Outlet): void; +} + +class ReactRendererExtension { + private wrappers: WrapperComponent[] = []; + + private outlet: Outlet | null = null; + + getAppWrappers() { + return this.wrappers; + } + + getOutlet() { + return this.outlet; + } + + toExpose(): ReactRendererExtensionApi { + return { + addAppWrapper: (appWrapper) => { + if (appWrapper) this.wrappers.push(appWrapper); + }, + setOutlet: (outletComponent) => { + if (outletComponent) this.outlet = outletComponent; + }, + }; + } + + install(boostsService: IBoostsService) { + boostsService.extend(this.toExpose()); + } +} + +export const extension = new ReactRendererExtension(); + +export function defineRendererPlugin(plugin: Plugin) { + return plugin; +} diff --git a/packages/react-renderer/src/app/index.ts b/packages/react-renderer/src/app/index.ts new file mode 100644 index 000000000..4224c77e5 --- /dev/null +++ b/packages/react-renderer/src/app/index.ts @@ -0,0 +1,3 @@ +export * from './context'; +export * from './extension'; +export * from './view'; diff --git a/packages/react-renderer/src/app/view.tsx b/packages/react-renderer/src/app/view.tsx new file mode 100644 index 000000000..f7ac43f02 --- /dev/null +++ b/packages/react-renderer/src/app/view.tsx @@ -0,0 +1,34 @@ +import { useRenderContext } from './context'; +import { getComponentByName } from '../runtime/component'; +import { extension } from './extension'; + +export function ApplicationView() { + const renderContext = useRenderContext(); + const { schema } = renderContext; + const appWrappers = extension.getAppWrappers(); + const Outlet = extension.getOutlet(); + + if (!Outlet) return null; + + let element = ; + + const layoutConfig = schema.get('config')?.layout; + + if (layoutConfig) { + const componentName = layoutConfig.componentName; + const Layout = getComponentByName(componentName, renderContext); + + if (Layout) { + const layoutProps: any = layoutConfig.props ?? {}; + element = {element}; + } + } + + if (appWrappers.length > 0) { + element = appWrappers.reduce((preElement, CurrentWrapper) => { + return {preElement}; + }, element); + } + + return element; +} diff --git a/packages/react-renderer/src/components/app.tsx b/packages/react-renderer/src/components/app.tsx deleted file mode 100644 index f7565742e..000000000 --- a/packages/react-renderer/src/components/app.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useRenderContext } from '../context/render'; -import { getComponentByName } from '../runtime'; -import Route from './route'; -import { rendererExtends } from '../plugin'; - -export default function App() { - const renderContext = useRenderContext(); - const { schema } = renderContext; - const appWrappers = rendererExtends.getAppWrappers(); - const wrappers = rendererExtends.getRouteWrappers(); - - let element = ; - - if (wrappers.length > 0) { - element = wrappers.reduce((preElement, CurrentWrapper) => { - return {preElement}; - }, element); - } - - const layoutConfig = schema.get('config')?.layout; - - if (layoutConfig) { - const componentName = layoutConfig.componentName as string; - const Layout = getComponentByName(componentName, renderContext); - - if (Layout) { - const layoutProps: any = layoutConfig.props ?? {}; - element = {element}; - } - } - - if (appWrappers.length > 0) { - element = appWrappers.reduce((preElement, CurrentWrapper) => { - return {preElement}; - }, element); - } - - return element; -} diff --git a/packages/react-renderer/src/components/route.tsx b/packages/react-renderer/src/components/route.tsx deleted file mode 100644 index 246f70669..000000000 --- a/packages/react-renderer/src/components/route.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { type Spec } from '@alilc/lowcode-shared'; -import { useRenderContext } from '../context/render'; -import { usePageConfig } from '../context/router'; -import { rendererExtends } from '../plugin'; -import { createComponentBySchema } from '../runtime'; - -export interface OutletProps { - pageConfig: Spec.PageConfig; - - [key: string]: any; -} - -export default function Route(props: any) { - const pageConfig = usePageConfig(); - - if (pageConfig) { - const Outlet = rendererExtends.getOutlet() ?? RouteOutlet; - - return ; - } - - return null; -} - -function RouteOutlet({ pageConfig }: OutletProps) { - const context = useRenderContext(); - const { schema, packageManager } = context; - const { type = 'lowCode', mappingId } = pageConfig; - console.log( - '%c [ pageConfig ]-29', - 'font-size:13px; background:pink; color:#bf2c9f;', - pageConfig, - ); - - if (type === 'lowCode') { - // 在页面渲染时重新获取 componentsMap - // 因为 componentsMap 可能在路由跳转之前懒加载新的页面 schema - const componentsMap = schema.get('componentsMap'); - packageManager.resolveComponentMaps(componentsMap); - - const LowCodeComponent = createComponentBySchema(mappingId, { - displayName: pageConfig?.id, - }); - - return ; - } - - return null; -} diff --git a/packages/react-renderer/src/components/routerView.tsx b/packages/react-renderer/src/components/routerView.tsx deleted file mode 100644 index bb76722f7..000000000 --- a/packages/react-renderer/src/components/routerView.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { type Router } from '@alilc/lowcode-renderer-router'; -import { useState, useLayoutEffect, useMemo, type ReactNode } from 'react'; -import { RouterContext, RouteLocationContext, PageConfigContext } from '../context/router'; -import { useRenderContext } from '../context/render'; - -export const createRouterProvider = (router: Router) => { - return function RouterProvider({ children }: { children?: ReactNode }) { - const { schema } = useRenderContext(); - const [location, setCurrentLocation] = useState(router.getCurrentLocation()); - - useLayoutEffect(() => { - const remove = router.afterRouteChange((to) => setCurrentLocation(to)); - return () => remove(); - }, []); - - const pageConfig = useMemo(() => { - const pages = schema.get('pages') ?? []; - const matched = location.matched[location.matched.length - 1]; - - if (matched) { - const page = pages.find((item) => matched.page === item.mappingId); - return page; - } - - return undefined; - }, [location]); - - return ( - - - {children} - - - ); - }; -}; diff --git a/packages/react-renderer/src/index.ts b/packages/react-renderer/src/index.ts index adf3b2995..c9dea7920 100644 --- a/packages/react-renderer/src/index.ts +++ b/packages/react-renderer/src/index.ts @@ -1,9 +1,8 @@ export * from './api/app'; export * from './api/component'; -export { defineRendererPlugin } from './plugin'; -export * from './context/render'; -export * from './context/router'; +export { useRenderContext, defineRendererPlugin } from './app'; +export * from './router'; export type { Spec, ProCodeComponent, LowCodeComponent } from '@alilc/lowcode-shared'; export type { PackageLoader, CodeScope, Plugin } from '@alilc/lowcode-renderer-core'; -export type { RendererExtends } from './plugin'; +export type { RendererExtends } from './app/extension'; diff --git a/packages/react-renderer/src/plugin.ts b/packages/react-renderer/src/plugin.ts deleted file mode 100644 index c0f14bfee..000000000 --- a/packages/react-renderer/src/plugin.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Plugin } from '@alilc/lowcode-renderer-core'; -import { type ComponentType, type PropsWithChildren } from 'react'; -import { type OutletProps } from './components/route'; - -export type WrapperComponent = ComponentType>; - -export type Outlet = ComponentType; - -export interface RendererExtends { - addAppWrapper(appWrapper: WrapperComponent): void; - getAppWrappers(): WrapperComponent[]; - - addRouteWrapper(wrapper: WrapperComponent): void; - getRouteWrappers(): WrapperComponent[]; - - setOutlet(outlet: Outlet): void; - getOutlet(): Outlet | null; -} - -const appWrappers: WrapperComponent[] = []; -const wrappers: WrapperComponent[] = []; - -let outlet: Outlet | null = null; - -export const rendererExtends: RendererExtends = { - addAppWrapper(appWrapper) { - if (appWrapper) appWrappers.push(appWrapper); - }, - getAppWrappers() { - return appWrappers; - }, - - addRouteWrapper(wrapper) { - if (wrapper) wrappers.push(wrapper); - }, - getRouteWrappers() { - return wrappers; - }, - - setOutlet(outletComponent) { - if (outletComponent) outlet = outletComponent; - }, - getOutlet() { - return outlet; - }, -}; - -export function defineRendererPlugin(plugin: Plugin) { - return plugin; -} diff --git a/packages/react-renderer/src/context/router.ts b/packages/react-renderer/src/router/context.ts similarity index 67% rename from packages/react-renderer/src/context/router.ts rename to packages/react-renderer/src/router/context.ts index d96402a28..c6f6fa6ce 100644 --- a/packages/react-renderer/src/context/router.ts +++ b/packages/react-renderer/src/router/context.ts @@ -1,5 +1,4 @@ import { type Router, type RouteLocationNormalized } from '@alilc/lowcode-renderer-router'; -import { type Spec } from '@alilc/lowcode-shared'; import { createContext, useContext } from 'react'; export const RouterContext = createContext(undefined!); @@ -13,9 +12,3 @@ export const RouteLocationContext = createContext(undef RouteLocationContext.displayName = 'RouteLocationContext'; export const useRouteLocation = () => useContext(RouteLocationContext); - -export const PageConfigContext = createContext(undefined); - -PageConfigContext.displayName = 'PageConfigContext'; - -export const usePageConfig = () => useContext(PageConfigContext); diff --git a/packages/react-renderer/src/router/index.ts b/packages/react-renderer/src/router/index.ts new file mode 100644 index 000000000..2964948b4 --- /dev/null +++ b/packages/react-renderer/src/router/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './plugin'; diff --git a/packages/react-renderer/src/router/plugin.ts b/packages/react-renderer/src/router/plugin.ts new file mode 100644 index 000000000..0bd155822 --- /dev/null +++ b/packages/react-renderer/src/router/plugin.ts @@ -0,0 +1,42 @@ +import { defineRendererPlugin } from '../app/extension'; +import { LifecyclePhase } from '@alilc/lowcode-renderer-core'; +import { createRouter, type RouterOptions } from '@alilc/lowcode-renderer-router'; +import { createRouterView } from './routerView'; +import { RouteOutlet } from './route'; + +const defaultRouterOptions: RouterOptions = { + historyMode: 'browser', + baseName: '/', + routes: [], +}; + +export const routerPlugin = defineRendererPlugin({ + name: 'rendererRouter', + async setup(context) { + const { whenLifeCylePhaseChange, schema, boosts } = context; + + let routerConfig = defaultRouterOptions; + + try { + const routerSchema = schema.get('router'); + if (routerSchema) { + routerConfig = boosts.codeRuntime.resolve(routerSchema); + } + } catch (e) { + console.error(`schema's router config is resolve error: `, e); + } + + const router = createRouter(routerConfig); + + boosts.codeRuntime.getScope().set('router', router); + + const RouterView = createRouterView(router); + + boosts.addAppWrapper(RouterView); + boosts.setOutlet(RouteOutlet); + + whenLifeCylePhaseChange(LifecyclePhase.Ready).then(() => { + return router.isReady(); + }); + }, +}); diff --git a/packages/react-renderer/src/router/route.tsx b/packages/react-renderer/src/router/route.tsx new file mode 100644 index 000000000..a7c93d27e --- /dev/null +++ b/packages/react-renderer/src/router/route.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { useRenderContext } from '../app/context'; +import { OutletProps } from '../app/extension'; +import { useRouteLocation } from './context'; +import { createComponentBySchema } from '../runtime/component'; + +export function RouteOutlet(props: OutletProps) { + const context = useRenderContext(); + const location = useRouteLocation(); + const { schema, packageManager } = context; + + const pageConfig = useMemo(() => { + const pages = schema.get('pages') ?? []; + const matched = location.matched[location.matched.length - 1]; + + if (matched) { + const page = pages.find((item) => matched.page === item.mappingId); + return page; + } + + return undefined; + }, [location]); + + if (pageConfig?.type === 'lowCode') { + // 在页面渲染时重新获取 componentsMap + // 因为 componentsMap 可能在路由跳转之前懒加载新的页面 schema + const componentsMap = schema.get('componentsMap'); + packageManager.resolveComponentMaps(componentsMap); + + const LowCodeComponent = createComponentBySchema(pageConfig.mappingId, { + displayName: pageConfig.id, + modelOptions: { + metadata: pageConfig, + }, + }); + + return ; + } + + return null; +} diff --git a/packages/react-renderer/src/router/routerView.tsx b/packages/react-renderer/src/router/routerView.tsx new file mode 100644 index 000000000..cf9013df8 --- /dev/null +++ b/packages/react-renderer/src/router/routerView.tsx @@ -0,0 +1,20 @@ +import { type Router } from '@alilc/lowcode-renderer-router'; +import { useState, useEffect, type ReactNode } from 'react'; +import { RouterContext, RouteLocationContext } from './context'; + +export const createRouterView = (router: Router) => { + return function RouterView({ children }: { children?: ReactNode }) { + const [location, setCurrentLocation] = useState(router.getCurrentLocation()); + + useEffect(() => { + const remove = router.afterRouteChange((to) => setCurrentLocation(to)); + return () => remove(); + }, []); + + return ( + + {children} + + ); + }; +}; diff --git a/packages/react-renderer/src/runtime/component.tsx b/packages/react-renderer/src/runtime/component.tsx new file mode 100644 index 000000000..8df82c91a --- /dev/null +++ b/packages/react-renderer/src/runtime/component.tsx @@ -0,0 +1,164 @@ +import { invariant, isLowCodeComponentPackage, type Spec } from '@alilc/lowcode-shared'; +import { forwardRef, useRef, useEffect } from 'react'; +import { isValidElementType } from 'react-is'; +import { useRenderContext } from '../app/context'; +import { reactiveStateFactory } from './reactiveState'; +import { dataSourceCreator } from './dataSource'; +import { type ReactComponent, type ReactWidget, createElementByWidget } from './render'; +import { ModelContextProvider } from './context'; +import { appendExternalStyle } from '../utils/element'; + +import type { + RenderContext, + IComponentTreeModel, + ComponentTreeModelOptions, +} from '@alilc/lowcode-renderer-core'; +import type { ReactInstance, CSSProperties, ForwardedRef } from 'react'; + +export interface ComponentOptions { + displayName?: string; + modelOptions?: ComponentTreeModelOptions; + + widgetCreated?(widget: ReactWidget): void; + componentRefAttached?(widget: ReactWidget, instance: ReactInstance): void; +} + +export interface LowCodeComponentProps { + id?: string; + /** CSS 类名 */ + className?: string; + /** style */ + style?: CSSProperties; + + [key: string]: any; +} + +const lowCodeComponentsCache = new Map(); + +export function getComponentByName( + name: string, + { packageManager, boostsManager }: RenderContext, +): ReactComponent { + const componentsRecord = packageManager.getComponentsNameRecord(); + // read cache first + const result = lowCodeComponentsCache.get(name) || componentsRecord[name]; + + if (isLowCodeComponentPackage(result)) { + const { schema, ...metadata } = result; + const { componentsMap, componentsTree, utils, i18n } = schema; + + if (componentsMap.length > 0) { + packageManager.resolveComponentMaps(componentsMap); + } + + const boosts = boostsManager.toExpose(); + + utils?.forEach((util) => boosts.util.add(util)); + + if (i18n) { + Object.keys(i18n).forEach((locale) => { + boosts.intl.addTranslations(locale, i18n[locale]); + }); + } + + const lowCodeComponent = createComponentBySchema(componentsTree[0], { + displayName: name, + modelOptions: { + id: metadata.id, + metadata, + }, + }); + + lowCodeComponentsCache.set(name, lowCodeComponent); + + return lowCodeComponent; + } + + invariant(isValidElementType(result), `${name} must be a React Component`); + + return result; +} + +export function createComponentBySchema( + schema: string | Spec.ComponentTreeRoot, + { displayName = '__LowCodeComponent__', modelOptions }: ComponentOptions = {}, +) { + const LowCodeComponent = forwardRef(function ( + props: LowCodeComponentProps, + ref: ForwardedRef, + ) { + const renderContext = useRenderContext(); + const { componentTreeModel } = renderContext; + + const modelRef = useRef>(); + + if (!modelRef.current) { + if (typeof schema === 'string') { + modelRef.current = componentTreeModel.createById(schema, modelOptions); + } else { + modelRef.current = componentTreeModel.create(schema, modelOptions); + } + } + + const model = modelRef.current!; + console.log('%c [ model ]-103', 'font-size:13px; background:pink; color:#bf2c9f;', model); + + const isConstructed = useRef(false); + const isMounted = useRef(false); + + if (!isConstructed.current) { + model.initialize({ + defaultProps: props, + stateCreator: reactiveStateFactory, + dataSourceCreator, + }); + + model.triggerLifeCycle('constructor'); + isConstructed.current = true; + + const cssText = model.getCssText(); + if (cssText) { + appendExternalStyle(cssText, { id: model.id }); + } + } + + useEffect(() => { + const scopeValue = model.codeScope.value; + + // init dataSource + scopeValue.reloadDataSource?.(); + + // trigger lifeCycles + // componentDidMount?.(); + model.triggerLifeCycle('componentDidMount'); + + // 当 state 改变之后调用 + // const unwatch = watch(scopeValue.state, (_, oldVal) => { + // if (isMounted.current) { + // model.triggerLifeCycle('componentDidUpdate', props, oldVal); + // } + // }); + + isMounted.current = true; + + return () => { + // componentWillUnmount?.(); + model.triggerLifeCycle('componentWillUnmount'); + // unwatch(); + isMounted.current = false; + }; + }, []); + + return ( + +
+ {model.widgets.map((w) => createElementByWidget(w, model.codeScope))} +
+
+ ); + }); + + LowCodeComponent.displayName = displayName; + + return LowCodeComponent; +} diff --git a/packages/react-renderer/src/runtime/context.ts b/packages/react-renderer/src/runtime/context.ts new file mode 100644 index 000000000..32a303d4b --- /dev/null +++ b/packages/react-renderer/src/runtime/context.ts @@ -0,0 +1,11 @@ +import { IComponentTreeModel } from '@alilc/lowcode-renderer-core'; +import { createContext, useContext, type ReactInstance } from 'react'; +import { type ReactComponent } from './render'; + +export const ModelContext = createContext>( + undefined!, +); + +export const useModel = () => useContext(ModelContext); + +export const ModelContextProvider = ModelContext.Provider; diff --git a/packages/react-renderer/src/runtime/hooks/useReactiveStore.tsx b/packages/react-renderer/src/runtime/hooks/useReactiveStore.tsx new file mode 100644 index 000000000..0176694b3 --- /dev/null +++ b/packages/react-renderer/src/runtime/hooks/useReactiveStore.tsx @@ -0,0 +1,137 @@ +import { mapValue } from '@alilc/lowcode-renderer-core'; +import { + type AnyFunction, + type PlainObject, + isJSExpression, + computed, + watch, + invariant, +} from '@alilc/lowcode-shared'; +import { useRef } from 'react'; +import { produce } from 'immer'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +interface ReactiveOptions { + target: PlainObject; + getter?: (obj: any) => any; + valueGetter?: (expr: any) => any; + filter?: (obj: any) => boolean; +} + +export interface ReactiveStore { + value: Snapshot | null; + onStateChange: AnyFunction | null; + subscribe: (onStoreChange: () => void) => () => void; + getSnapshot: () => Snapshot | null; +} + +function createReactiveStore( + options: ReactiveOptions, +): ReactiveStore { + const { target, getter, filter = isJSExpression, valueGetter } = options; + + invariant( + getter || valueGetter, + 'Either the processor option or the valueGetter option must be provided.', + ); + + let isFlushing = false; + let isFlushPending = false; + + const cleanups: Array<() => void> = []; + const waitPathToSetValueMap = new Map(); + + const store: ReactiveStore = { + value: null, + onStateChange: null, + subscribe(callback: () => void) { + store.onStateChange = callback; + + return () => { + store.onStateChange = null; + + cleanups.forEach((c) => c()); + cleanups.length = 0; + }; + }, + getSnapshot() { + return store.value; + }, + }; + + if (getter) { + const computedValue = computed(() => getter(target)); + + cleanups.push( + watch( + computedValue, + (newValue) => { + Promise.resolve().then(() => { + store.value = newValue; + store.onStateChange?.(); + }); + }, + { immediate: true }, + ), + ); + } else if (valueGetter) { + const initValue = mapValue(target, filter, (node: any, paths) => { + const computedValue = computed(() => valueGetter(node)); + const unwatch = watch(computedValue, (newValue) => { + waitPathToSetValueMap.set(paths, newValue); + + if (!isFlushPending && !isFlushing) { + isFlushPending = true; + Promise.resolve().then(updateStoreValue); + } + }); + + cleanups.push(unwatch); + updateStoreValue(); + + return computedValue.value; + }); + + store.value = initValue; + } + + function updateStoreValue() { + isFlushPending = false; + isFlushing = true; + + if (waitPathToSetValueMap.size > 0) { + store.value = produce(store.value, (draft: any) => { + waitPathToSetValueMap.forEach((value, paths) => { + if (paths.length === 1) { + draft[paths[0]] = value; + } else if (paths.length > 1) { + let target = draft; + let i = 0; + for (; i < paths.length - 1; i++) { + target = draft[paths[i]]; + } + target[paths[i]] = value; + } + waitPathToSetValueMap.delete(paths); + }); + }); + } + + store.onStateChange?.(); + isFlushing = false; + } + + return store; +} + +export function useReactiveStore(options: ReactiveOptions) { + const storeRef = useRef(); + + if (!storeRef.current) { + storeRef.current = createReactiveStore(options); + } + + const store = storeRef.current; + + return useSyncExternalStore(store.subscribe, store.getSnapshot) as any; +} diff --git a/packages/react-renderer/src/runtime/index.ts b/packages/react-renderer/src/runtime/index.ts new file mode 100644 index 000000000..618f76e52 --- /dev/null +++ b/packages/react-renderer/src/runtime/index.ts @@ -0,0 +1,2 @@ +export * from './component'; +export * from './render'; diff --git a/packages/react-renderer/src/runtime/index.tsx b/packages/react-renderer/src/runtime/index.tsx deleted file mode 100644 index 26d756f87..000000000 --- a/packages/react-renderer/src/runtime/index.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import { processValue, someValue } from '@alilc/lowcode-renderer-core'; -import { - isJSExpression, - isJSFunction, - isJSSlot, - invariant, - isLowCodeComponentPackage, - isJSI18nNode, -} from '@alilc/lowcode-shared'; -import { forwardRef, useRef, useEffect, createElement, memo } from 'react'; -import { appendExternalStyle } from '../../../../playground/renderer/src/plugin/remote/element'; -import { reactive } from '../utils/reactive'; -import { useRenderContext } from '../context/render'; -import { reactiveStateCreator } from './reactiveState'; -import { dataSourceCreator } from './dataSource'; -import { normalizeComponentNode, type NormalizedComponentNode } from '../utils/node'; - -import type { PlainObject, Spec } from '@alilc/lowcode-shared'; -import type { - IWidget, - RenderContext, - ICodeScope, - IComponentTreeModel, -} from '@alilc/lowcode-renderer-core'; -import type { - ComponentType, - ReactInstance, - CSSProperties, - ForwardedRef, - ReactElement, -} from 'react'; - -export type ReactComponent = ComponentType; -export type ReactWidget = IWidget; - -export interface ComponentOptions { - displayName?: string; - - widgetCreated?(widget: ReactWidget): void; - componentRefAttached?(widget: ReactWidget, instance: ReactInstance): void; -} - -export interface LowCodeComponentProps { - id?: string; - /** CSS 类名 */ - className?: string; - /** style */ - style?: CSSProperties; - - [key: string]: any; -} - -const lowCodeComponentsCache = new Map(); - -export function getComponentByName( - name: string, - { packageManager, boostsManager }: RenderContext, -): ReactComponent { - const componentsRecord = packageManager.getComponentsNameRecord(); - // read cache first - const result = lowCodeComponentsCache.get(name) || componentsRecord[name]; - - invariant(result, `${name} component not found in componentsRecord`); - - if (isLowCodeComponentPackage(result)) { - const { componentsMap, componentsTree, utils, i18n } = result.schema; - - if (componentsMap.length > 0) { - packageManager.resolveComponentMaps(componentsMap); - } - - const boosts = boostsManager.toExpose(); - - utils?.forEach((util) => boosts.util.add(util)); - - if (i18n) { - Object.keys(i18n).forEach((locale) => { - boosts.intl.addTranslations(locale, i18n[locale]); - }); - } - - const lowCodeComponent = createComponentBySchema(componentsTree[0], { - displayName: name, - }); - - lowCodeComponentsCache.set(name, lowCodeComponent); - - return lowCodeComponent; - } - - return result; -} - -export function createComponentBySchema( - schema: string | Spec.ComponentTreeRoot, - { displayName = '__LowCodeComponent__', componentRefAttached }: ComponentOptions = {}, -) { - const LowCodeComponent = forwardRef(function ( - props: LowCodeComponentProps, - ref: ForwardedRef, - ) { - const renderContext = useRenderContext(); - const { componentTreeModel } = renderContext; - - const modelRef = useRef>(); - - if (!modelRef.current) { - if (typeof schema === 'string') { - modelRef.current = componentTreeModel.createById(schema, { - stateCreator: reactiveStateCreator, - dataSourceCreator, - }); - } else { - modelRef.current = componentTreeModel.create(schema, { - stateCreator: reactiveStateCreator, - dataSourceCreator, - }); - } - } - - const model = modelRef.current; - console.log('%c [ model ]-123', 'font-size:13px; background:pink; color:#bf2c9f;', model); - - const isConstructed = useRef(false); - const isMounted = useRef(false); - - if (!isConstructed.current) { - model.triggerLifeCycle('constructor'); - isConstructed.current = true; - } - - useEffect(() => { - const scopeValue = model.codeScope.value; - - // init dataSource - scopeValue.reloadDataSource?.(); - - let styleEl: HTMLElement | undefined; - const cssText = model.getCssText(); - if (cssText) { - appendExternalStyle(cssText).then((el) => { - styleEl = el; - }); - } - - // trigger lifeCycles - // componentDidMount?.(); - model.triggerLifeCycle('componentDidMount'); - - // 当 state 改变之后调用 - // const unwatch = watch(scopeValue.state, (_, oldVal) => { - // if (isMounted.current) { - // model.triggerLifeCycle('componentDidUpdate', props, oldVal); - // } - // }); - - isMounted.current = true; - - return () => { - // componentWillUnmount?.(); - model.triggerLifeCycle('componentWillUnmount'); - styleEl?.parentNode?.removeChild(styleEl); - // unwatch(); - isMounted.current = false; - }; - }, []); - - const elements = model.widgets.map((widget) => { - return createElementByWidget(widget, model.codeScope, renderContext, componentRefAttached); - }); - - return ( -
- {elements} -
- ); - }); - - LowCodeComponent.displayName = displayName; - - return memo(LowCodeComponent); -} - -function Text(props: { text: string }) { - return <>{props.text}; -} - -Text.displayName = 'Text'; - -function createElementByWidget( - widget: IWidget, - codeScope: ICodeScope, - renderContext: RenderContext, - componentRefAttached?: ComponentOptions['componentRefAttached'], -) { - return widget.build((ctx) => { - const { key, node, model, children } = ctx; - const boosts = renderContext.boostsManager.toExpose(); - - if (typeof node === 'string') { - return createElement(Text, { key, text: node }); - } - - if (isJSExpression(node)) { - return createElement( - reactive(Text, { - target: { text: node }, - valueGetter(expr) { - return model.codeRuntime.resolve(expr, codeScope); - }, - }), - { key }, - ); - } - - if (isJSI18nNode(node)) { - return createElement( - reactive(Text, { - target: { text: node }, - predicate: isJSI18nNode, - valueGetter: (node: Spec.JSI18n) => { - return boosts.intl.t({ - key: node.key, - params: node.params ? model.codeRuntime.resolve(node.params, codeScope) : undefined, - }); - }, - }), - { key }, - ); - } - - function createElementWithProps( - node: NormalizedComponentNode, - codeScope: ICodeScope, - key: string, - ): ReactElement { - const { ref, ...componentProps } = node.props; - const Component = getComponentByName(node.componentName, renderContext); - - const attachRef = (ins: ReactInstance | null) => { - if (ins) { - if (ref) model.setComponentRef(ref as string, ins); - componentRefAttached?.(widget, ins); - } else { - if (ref) model.removeComponentRef(ref); - } - }; - - // 先将 jsslot, jsFunction 对象转换 - let finalProps = processValue( - componentProps, - (node) => isJSFunction(node) || isJSSlot(node), - (node: Spec.JSSlot | Spec.JSFunction) => { - if (isJSSlot(node)) { - const slot = node as Spec.JSSlot; - - if (slot.value) { - const widgets = model.buildWidgets( - Array.isArray(node.value) ? node.value : [node.value], - ); - - if (slot.params?.length) { - return (...args: any[]) => { - const params = slot.params!.reduce((prev, cur, idx) => { - return (prev[cur] = args[idx]); - }, {} as PlainObject); - - return widgets.map((n) => - createElementByWidget( - n, - codeScope.createChild(params), - renderContext, - componentRefAttached, - ), - ); - }; - } else { - return widgets.map((n) => - createElementByWidget(n, codeScope, renderContext, componentRefAttached), - ); - } - } - } else if (isJSFunction(node)) { - return model.codeRuntime.resolve(node, codeScope); - } - - return null; - }, - ); - - finalProps = processValue( - finalProps, - (value) => { - return value.type === 'JSSlot' && !value.value; - }, - (node) => { - console.log('%c [ node ]-303', 'font-size:13px; background:pink; color:#bf2c9f;', node); - return null; - }, - ); - - const childElements = children?.map((child) => - createElementByWidget(child, codeScope, renderContext, componentRefAttached), - ); - - if (someValue(finalProps, isJSExpression)) { - const PropsWrapper = (props: PlainObject) => - createElement( - Component, - { - ...props, - key, - ref: attachRef, - }, - childElements, - ); - - PropsWrapper.displayName = 'PropsWrapper'; - - return createElement( - reactive(PropsWrapper, { - target: finalProps, - valueGetter: (node) => model.codeRuntime.resolve(node, codeScope), - }), - { key }, - ); - } else { - return createElement( - Component, - { - ...finalProps, - key, - ref: attachRef, - }, - childElements, - ); - } - } - - const normalizedNode = normalizeComponentNode(node); - const { condition, loop, loopArgs } = normalizedNode; - - // condition为 Falsy 的情况下 不渲染 - if (!condition) return null; - // loop 为数组且为空的情况下 不渲染 - if (Array.isArray(loop) && loop.length === 0) return null; - - let element: ReactElement | ReactElement[] | null = null; - - if (loop) { - const genLoopElements = (loopData: any[]) => { - return loopData.map((item, idx) => { - const loopArgsItem = loopArgs[0] ?? 'item'; - const loopArgsIndex = loopArgs[1] ?? 'index'; - - return createElementWithProps( - normalizedNode, - codeScope.createChild({ - [loopArgsItem]: item, - [loopArgsIndex]: idx, - }), - `loop-${key}-${idx}`, - ); - }); - }; - - if (isJSExpression(loop)) { - function Loop(props: { loop: boolean }) { - if (!Array.isArray(props.loop)) { - return null; - } - return <>{genLoopElements(props.loop)}; - } - Loop.displayName = 'Loop'; - - const ReactivedLoop = reactive(Loop, { - target: { loop }, - valueGetter: (expr) => model.codeRuntime.resolve(expr, codeScope), - }); - - element = createElement(ReactivedLoop, { key }); - } else { - element = genLoopElements(loop as any[]); - } - } - - if (isJSExpression(condition)) { - function Condition(props: any) { - if (props.condition) { - return element; - } - return null; - } - Condition.displayName = 'Condition'; - - const ReactivedCondition = reactive(Condition, { - target: { condition }, - valueGetter: (expr) => model.codeRuntime.resolve(expr, codeScope), - }); - - element = createElement(ReactivedCondition, { - key, - }); - } - - if (!element) { - element = createElementWithProps(normalizedNode, codeScope, key); - } - - return element; - }); -} diff --git a/packages/react-renderer/src/runtime/reactiveState.ts b/packages/react-renderer/src/runtime/reactiveState.ts index 6f4d4b5ac..b833c5160 100644 --- a/packages/react-renderer/src/runtime/reactiveState.ts +++ b/packages/react-renderer/src/runtime/reactiveState.ts @@ -1,7 +1,7 @@ import { signal, type PlainObject, type Spec } from '@alilc/lowcode-shared'; import { isPlainObject } from 'lodash-es'; -export function reactiveStateCreator(initState: PlainObject): Spec.InstanceStateApi { +export function reactiveStateFactory(initState: PlainObject): Spec.InstanceStateApi { const proxyState = signal(initState); return { @@ -13,10 +13,9 @@ export function reactiveStateCreator(initState: PlainObject): Spec.InstanceState throw Error('newState mush be a object'); } - proxyState.value = { - ...proxyState.value, - ...newState, - }; + Object.keys(newState as PlainObject).forEach((key) => { + proxyState.value[key] = (newState as PlainObject)[key]; + }); }, }; } diff --git a/packages/react-renderer/src/runtime/render.tsx b/packages/react-renderer/src/runtime/render.tsx new file mode 100644 index 000000000..416b0ef08 --- /dev/null +++ b/packages/react-renderer/src/runtime/render.tsx @@ -0,0 +1,222 @@ +import { + type IWidget, + type ICodeScope, + type NormalizedComponentNode, + mapValue, +} from '@alilc/lowcode-renderer-core'; +import { + type PlainObject, + isJSExpression, + isJSI18nNode, + isJSFunction, + isJSSlot, + type Spec, +} from '@alilc/lowcode-shared'; +import { type ComponentType, type ReactInstance, useMemo, createElement } from 'react'; +import { useRenderContext } from '../app/context'; +import { useReactiveStore } from './hooks/useReactiveStore'; +import { useModel } from './context'; +import { getComponentByName } from './component'; + +export type ReactComponent = ComponentType; +export type ReactWidget = IWidget; + +interface WidgetRendererProps { + widget: ReactWidget; + codeScope: ICodeScope; + + [key: string]: any; +} + +export function createElementByWidget( + widget: IWidget, + codeScope: ICodeScope, +) { + const { key, node } = widget; + + if (typeof node === 'string') { + return node; + } + + if (isJSExpression(node)) { + return ; + } + + if (isJSI18nNode(node)) { + return ; + } + + const { condition, loop } = widget.node as NormalizedComponentNode; + + // condition为 Falsy 的情况下 不渲染 + if (!condition) return null; + // loop 为数组且为空的情况下 不渲染 + if (Array.isArray(loop) && loop.length === 0) return null; + + if (isJSExpression(loop)) { + return ; + } + + return ; +} + +export function WidgetComponent(props: WidgetRendererProps) { + const { widget, codeScope, ...otherProps } = props; + const componentNode = widget.node as NormalizedComponentNode; + const { ref, ...componentProps } = componentNode.props; + + const renderContext = useRenderContext(); + + const Component = useMemo( + () => getComponentByName(componentNode.componentName, renderContext), + [widget], + ); + + const state = useReactiveStore({ + target: { + condition: componentNode.condition, + props: preprocessProps(componentProps, widget, codeScope), + }, + valueGetter(expr) { + return widget.model.codeRuntime.resolve(expr, { scope: codeScope }); + }, + }); + + const attachRef = (ins: ReactInstance | null) => { + if (ins) { + if (ref) widget.model.setComponentRef(ref as string, ins); + } else { + if (ref) widget.model.removeComponentRef(ref); + } + }; + + if (!state.condition) { + return null; + } + + return createElement( + Component, + { + ...otherProps, + ...state.props, + key: widget.key, + ref: attachRef, + }, + widget.children?.map((item) => createElementByWidget(item, codeScope)) ?? [], + ); +} + +function preprocessProps(props: PlainObject, widget: ReactWidget, codeScope: ICodeScope) { + // 先将 jsslot, jsFunction 对象转换 + const finalProps = mapValue( + props, + (node) => isJSFunction(node) || isJSSlot(node), + (node: Spec.JSSlot | Spec.JSFunction) => { + if (isJSSlot(node)) { + const slot = node as Spec.JSSlot; + + if (slot.value) { + const widgets = widget.model.buildWidgets( + Array.isArray(node.value) ? node.value : [node.value], + ); + + if (slot.params?.length) { + return (...args: any[]) => { + const params = slot.params!.reduce((prev, cur, idx) => { + return (prev[cur] = args[idx]); + }, {} as PlainObject); + + return widgets.map((n) => createElementByWidget(n, codeScope.createChild(params))); + }; + } else { + return widgets.map((n) => createElementByWidget(n, codeScope)); + } + } + } else if (isJSFunction(node)) { + return widget.model.codeRuntime.resolve(node, { scope: codeScope }); + } + + return null; + }, + ); + + if (process.env.NODE_ENV === 'development') { + // development 模式下 把 widget 的内容作为 prop ,便于排查问题 + finalProps.widget = widget; + } + + return finalProps; +} + +function Text(props: { expr: Spec.JSExpression; codeScope: ICodeScope }) { + const model = useModel(); + const text: string = useReactiveStore({ + target: props.expr, + getter: (obj) => { + return model.codeRuntime.resolve(obj, { scope: props.codeScope }); + }, + }); + + return text; +} + +Text.displayName = 'Text'; + +function I18nText(props: { i18n: Spec.JSI18n; codeScope: ICodeScope }) { + const model = useModel(); + const text: string = useReactiveStore({ + target: props.i18n, + getter: (obj) => { + return model.codeRuntime.resolve(obj, { scope: props.codeScope }); + }, + }); + + return text; +} + +I18nText.displayName = 'I18nText'; + +function LoopWidgetRenderer({ + loop, + widget, + codeScope, + + ...otherProps +}: { + loop: Spec.JSExpression; + widget: ReactWidget; + codeScope: ICodeScope; + + [key: string]: any; +}) { + const { condition, loopArgs } = widget.node as NormalizedComponentNode; + const state = useReactiveStore({ + target: { + loop, + condition, + }, + valueGetter(expr) { + return widget.model.codeRuntime.resolve(expr, { scope: codeScope }); + }, + }); + + if (state.condition && Array.isArray(state.loop) && state.loop.length > 0) { + return state.loop.map((item: any, idx: number) => { + const childScope = codeScope.createChild({ + [loopArgs[0]]: item, + [loopArgs[1]]: idx, + }); + + return ( + + ); + }); + } + + return null; +} diff --git a/packages/react-renderer/src/utils/element.ts b/packages/react-renderer/src/utils/element.ts new file mode 100644 index 000000000..103bf871c --- /dev/null +++ b/packages/react-renderer/src/utils/element.ts @@ -0,0 +1,106 @@ +export const addLeadingSlash = (path: string): string => { + return path.charAt(0) === '/' ? path : `/${path}`; +}; + +export interface ExternalElementOptions { + id?: string; + root?: HTMLElement; +} + +export async function appendExternalScript( + url: string, + { id, root = document.body }: ExternalElementOptions = {}, +): Promise { + if (id) { + const el = document.getElementById(id); + if (el) return el; + } + + return new Promise((resolve, reject) => { + const scriptElement = document.createElement('script'); + + // scriptElement.type = moduleType === 'module' ? 'module' : 'text/javascript'; + /** + * `async=false` is required to make sure all js resources execute sequentially. + */ + scriptElement.async = false; + scriptElement.crossOrigin = 'anonymous'; + scriptElement.src = url; + if (id) scriptElement.id = id; + + scriptElement.addEventListener( + 'load', + () => { + resolve(scriptElement); + }, + false, + ); + scriptElement.addEventListener('error', (error) => { + if (root.contains(scriptElement)) { + root.removeChild(scriptElement); + } + reject(error); + }); + + root.appendChild(scriptElement); + }); +} + +export async function appendExternalCss( + url: string, + { id, root = document.head }: ExternalElementOptions = {}, +): Promise { + if (id) { + const el = document.getElementById(id); + if (el) return el; + } + + return new Promise((resolve, reject) => { + const el: HTMLLinkElement = document.createElement('link'); + el.rel = 'stylesheet'; + el.href = url; + if (id) el.id = id; + + el.addEventListener( + 'load', + () => { + resolve(el); + }, + false, + ); + el.addEventListener('error', (error) => { + reject(error); + }); + + root.appendChild(el); + }); +} + +export async function appendExternalStyle( + cssText: string, + { id, root = document.head }: ExternalElementOptions = {}, +): Promise { + if (id) { + const el = document.getElementById(id); + if (el) return el; + } + + return new Promise((resolve, reject) => { + const el: HTMLStyleElement = document.createElement('style'); + el.innerText = cssText; + if (id) el.id = id; + + el.addEventListener( + 'load', + () => { + resolve(el); + }, + false, + ); + el.addEventListener('error', (error) => { + reject(error); + }); + + root.appendChild(el); + }); +} diff --git a/packages/react-renderer/src/utils/node.ts b/packages/react-renderer/src/utils/node.ts deleted file mode 100644 index e455e8bdc..000000000 --- a/packages/react-renderer/src/utils/node.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Spec } from '@alilc/lowcode-shared'; - -export interface NormalizedComponentNode extends Spec.ComponentNode { - loopArgs: [string, string]; - props: Spec.ComponentNodeProps; -} - -export function normalizeComponentNode(node: Spec.ComponentNode): NormalizedComponentNode { - return { - ...node, - loopArgs: node.loopArgs ?? ['item', 'index'], - props: node.props ?? {}, - condition: node.condition || node.condition === false ? node.condition : true, - }; -} diff --git a/packages/react-renderer/src/utils/reactive.tsx b/packages/react-renderer/src/utils/reactive.tsx deleted file mode 100644 index 26ca030d0..000000000 --- a/packages/react-renderer/src/utils/reactive.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { processValue } from '@alilc/lowcode-renderer-core'; -import { - type AnyFunction, - type PlainObject, - isJSExpression, - computed, - watch, -} from '@alilc/lowcode-shared'; -import { type ComponentType, memo, forwardRef, type PropsWithChildren, createElement } from 'react'; -import { produce } from 'immer'; -import hoistNonReactStatics from 'hoist-non-react-statics'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; - -export interface ReactiveStore { - value: Snapshot; - onStateChange: AnyFunction | null; - subscribe: (onStoreChange: () => void) => () => void; - getSnapshot: () => Snapshot; -} - -function createReactiveStore( - target: Record, - predicate: (obj: any) => boolean, - valueGetter: (expr: any) => any, -): ReactiveStore { - let isFlushing = false; - let isFlushPending = false; - - const cleanups: Array<() => void> = []; - const waitPathToSetValueMap = new Map(); - - const initValue = processValue(target, predicate, (node: any, paths) => { - const computedValue = computed(() => valueGetter(node)); - const unwatch = watch(computedValue, (newValue) => { - waitPathToSetValueMap.set(paths, newValue); - - if (!isFlushPending && !isFlushing) { - isFlushPending = true; - Promise.resolve().then(genValue); - } - }); - - cleanups.push(unwatch); - - return computedValue.value; - }); - - const genValue = () => { - isFlushPending = false; - isFlushing = true; - - if (waitPathToSetValueMap.size > 0) { - store.value = produce(store.value, (draft: any) => { - waitPathToSetValueMap.forEach((value, paths) => { - if (paths.length === 1) { - draft[paths[0]] = value; - } else if (paths.length > 1) { - let target = draft; - let i = 0; - for (; i < paths.length - 1; i++) { - target = draft[paths[i]]; - } - target[paths[i]] = value; - } - }); - }); - } - - waitPathToSetValueMap.clear(); - store.onStateChange?.(); - isFlushing = false; - }; - - const store: ReactiveStore = { - value: initValue, - onStateChange: null, - subscribe(callback: () => void) { - store.onStateChange = callback; - - return () => { - store.onStateChange = null; - - cleanups.forEach((c) => c()); - cleanups.length = 0; - }; - }, - getSnapshot() { - return store.value; - }, - }; - - return store; -} - -interface ReactiveOptions { - target: PlainObject; - valueGetter: (expr: any) => any; - predicate?: (obj: any) => boolean; - forwardRef?: boolean; -} - -export function reactive( - WrappedComponent: ComponentType, - { - target, - valueGetter, - predicate = isJSExpression, - forwardRef: forwardRefOption = true, - }: ReactiveOptions, -): ComponentType> { - const store = createReactiveStore(target, predicate, valueGetter); - - function WrapperComponent(props: any, ref: any) { - const actualProps = useSyncExternalStore(store.subscribe, store.getSnapshot); - - return createElement(WrappedComponent, { - ...props, - ...actualProps, - ref, - }); - } - - const componentName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; - const displayName = `Reactive(${componentName})`; - - const _Reactived = forwardRefOption ? forwardRef(WrapperComponent) : WrapperComponent; - const Reactived = memo(_Reactived) as unknown as ComponentType>; - - Reactived.displayName = WrappedComponent.displayName = displayName; - - return hoistNonReactStatics(Reactived, WrappedComponent); -} diff --git a/packages/renderer-core/__tests__/api/app.spec.ts b/packages/renderer-core/__tests__/api/app.spec.ts deleted file mode 100644 index fb746fd31..000000000 --- a/packages/renderer-core/__tests__/api/app.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createAppFunction } from '../../src/api/app'; -import { definePlugin } from '../../src/plugin'; - -describe('createAppFunction', () => { - it('should require a function argument that returns an render object.', () => { - expect(() => createAppFunction(undefined as any)).rejects.toThrowError(); - }); - - it('should return a function', () => { - const createApp = createAppFunction(async () => { - return { - appBase: { - mount(el) {}, - unmount() {}, - }, - }; - }); - - expect({ createApp }).toEqual({ createApp: expect.any(Function) }); - }); - - it('should construct app object', () => { - expect('').toBe(''); - }); - - it('should plugin inited when app created', async () => { - const plugin = definePlugin({ - name: 'test', - setup() {}, - }); - const spy = vi.spyOn(plugin, 'setup'); - - const createApp = createAppFunction(async () => { - return { - appBase: { - mount(el) {}, - unmount() {}, - }, - }; - }); - - await createApp({ - schema: {}, - plugins: [plugin], - }); - - expect(spy).toHaveBeenCalled(); - }); -}); diff --git a/packages/renderer-core/__tests__/parts/code-runtime/codeScope.spec.ts b/packages/renderer-core/__tests__/parts/code-runtime/codeScope.spec.ts index 3c56eeda9..5811c47b0 100644 --- a/packages/renderer-core/__tests__/parts/code-runtime/codeScope.spec.ts +++ b/packages/renderer-core/__tests__/parts/code-runtime/codeScope.spec.ts @@ -1,46 +1,55 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { ICodeScope, CodeScope } from '../../../src/parts/code-runtime'; +import { describe, it, expect } from 'vitest'; +import { CodeScope } from '../../../src/parts/code-runtime'; -describe('codeScope', () => { - let scope: ICodeScope; - - beforeAll(() => { - scope = new CodeScope({}); +describe('CodeScope', () => { + it('should return initial values', () => { + const initValue = { a: 1, b: 2 }; + const scope = new CodeScope(initValue); + expect(scope.value.a).toBe(1); + expect(scope.value.b).toBe(2); }); - it('should inject a new value', () => { - scope.inject('username', 'Alice'); - expect(scope.value).toEqual({ username: 'Alice' }); + it('inject should add new values', () => { + const scope = new CodeScope({}); + scope.set('c', 3); + expect(scope.value.c).toBe(3); }); - it('should not overwrite an existing value without force', () => { - scope.inject('username', 'Bob'); - expect(scope.value).toEqual({ username: 'Alice' }); + it('inject should not overwrite existing values without force', () => { + const initValue = { a: 1 }; + const scope = new CodeScope(initValue); + scope.set('a', 2); + expect(scope.value.a).toBe(1); + scope.set('a', 3, true); + expect(scope.value.a).toBe(3); }); - it('should overwrite an existing value with force', () => { - scope.inject('username', 'Bob', true); - expect(scope.value).toEqual({ username: 'Bob' }); + it('setValue should merge values by default', () => { + const initValue = { a: 1 }; + const scope = new CodeScope(initValue); + scope.setValue({ b: 2 }); + expect(scope.value.a).toBe(1); + expect(scope.value.b).toBe(2); }); - it('should set new value without replacing existing values', () => { - scope.setValue({ age: 25 }); - expect(scope.value).toEqual({ username: 'Bob', age: 25 }); + it('setValue should replace values when replace is true', () => { + const initValue = { a: 1 }; + const scope = new CodeScope(initValue); + scope.setValue({ b: 2 }, true); + expect(scope.value.a).toBeUndefined(); + expect(scope.value.b).toBe(2); }); - it('should set new value and replace all existing values', () => { - scope.setValue({ loggedIn: true }, true); - expect(scope.value).toEqual({ loggedIn: true }); - }); + it('should create child scopes and respect scope hierarchy', () => { + const parentValue = { a: 1, b: 2 }; + const childValue = { b: 3, c: 4 }; - it('should create a child scope with initial values', () => { - const childScope = scope.createChild({ sessionId: 'abc123' }); - expect(childScope.value).toEqual({ loggedIn: true, sessionId: 'abc123' }); - }); + const parentScope = new CodeScope(parentValue); + const childScope = parentScope.createChild(childValue); - it('should set new values in the child scope without affecting the parent scope', () => { - const childScope = scope.createChild({ theme: 'dark' }); - expect(childScope.value).toEqual({ loggedIn: true, sessionId: 'abc123', theme: 'dark' }); - expect(scope.value).toEqual({ loggedIn: true }); + expect(childScope.value.a).toBe(1); // Inherits from parent scope + expect(childScope.value.b).toBe(3); // Overridden by child scope + expect(childScope.value.c).toBe(4); // Unique to child scope + expect(parentScope.value.c).toBeUndefined(); // Parent scope should not have child's properties }); }); diff --git a/packages/renderer-core/__tests__/utils/hook.spec.ts b/packages/renderer-core/__tests__/utils/hook.spec.ts deleted file mode 100644 index 53e9bcd07..000000000 --- a/packages/renderer-core/__tests__/utils/hook.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createEvent, createHookStore, type HookStore } from '../../src/utils/hook'; - -describe('event', () => { - it('event\'s listener ops', () => { - const event = createEvent(); - const fn = () => {}; - event.add(fn); - - expect(event.list().includes(fn)).toBeTruthy(); - - event.remove(fn); - - expect(event.list().includes(fn)).toBeFalsy(); - - event.add(fn); - - expect(event.list().includes(fn)).toBeTruthy(); - - event.clear(); - - expect(event.list().includes(fn)).toBeFalsy(); - }); -}); - -describe('hooks', () => { - let hookStore: HookStore; - - beforeEach(() => { - hookStore = createHookStore(); - }); - - it('should register hook successfully', () => { - const fn = () => {}; - hookStore.hook('test', fn); - - expect(hookStore.getHooks('test')).toContain(fn); - }); - - it('should ignore empty hook', () => { - hookStore.hook('', () => {}); - hookStore.hook(undefined as any, () => {}); - - expect(hookStore.getHooks('')).toBeUndefined(); - expect(hookStore.getHooks(undefined as any)).toBeUndefined(); - }); - - it('should ignore not function hook', () => { - hookStore.hook('test', 1 as any); - hookStore.hook('test', undefined as any); - - expect(hookStore.getHooks('test')).toBeUndefined(); - }); - - it('should call registered hook', () => { - const spy = vi.fn(); - - hookStore.hook('test', spy); - hookStore.call('test'); - - expect(spy).toHaveBeenCalled(); - }); - - it('callAsync: should sequential call registered async hook', async () => { - let count = 0; - const counts: number[] = []; - const fn = async () => { - counts.push(count++); - }; - - hookStore.hook('test', fn); - hookStore.hook('test', fn); - - await hookStore.callAsync('test'); - - expect(counts).toEqual([0, 1]); - }); - - it('callParallel: should parallel call registered async hook', async () => { - let count = 0; - - const sleep = (delay: number) => { - return new Promise((resolve) => { - setTimeout(resolve, delay); - }); - }; - - hookStore.hook('test', () => { - count++; - }); - hookStore.hook('test', async () => { - await sleep(500); - count++; - }); - hookStore.hook('test', async () => { - await sleep(1000); - expect(count).toBe(2); - }); - - await hookStore.callParallel('test'); - }); - - it('should throw hook error', async () => { - const error = new Error('Hook Error'); - hookStore.hook('test', () => { - throw error; - }); - expect(() => hookStore.call('test')).toThrow(error); - }); - - it('should return a self-removal function', async () => { - const spy = vi.fn(); - const remove = hookStore.hook('test', spy); - - hookStore.call('test'); - - expect(spy).toBeCalledTimes(1); - - remove(); - - hookStore.call('test'); - - expect(spy).toBeCalledTimes(1); - }); - - it('should clear removed hooks', () => { - const result: number[] = []; - - const fn1 = () => result.push(1); - const fn2 = () => result.push(2); - - hookStore.hook('test', fn1); - hookStore.hook('test', fn2); - hookStore.call('test'); - - expect(result).toHaveLength(2); - expect(result).toEqual([1, 2]); - - hookStore.remove('test', fn1); - hookStore.call('test'); - - expect(result).toHaveLength(3); - expect(result).toEqual([1, 2, 2]); - - hookStore.remove('test'); - hookStore.call('test'); - - expect(result).toHaveLength(3); - expect(result).toEqual([1, 2, 2]); - }); - - it('should clear ops works', () => { - hookStore.hook('test1', () => {}); - hookStore.hook('test2', () => {}); - - expect(hookStore.getHooks('test1')).toHaveLength(1); - expect(hookStore.getHooks('test2')).toHaveLength(1); - - hookStore.clear('test1'); - - expect(hookStore.getHooks('test1')).toBeUndefined(); - expect(hookStore.getHooks('test2')).toHaveLength(1); - - hookStore.clear(); - - expect(hookStore.getHooks('test1')).toBeUndefined(); - expect(hookStore.getHooks('test2')).toBeUndefined(); - }); -}); diff --git a/packages/renderer-core/__tests__/utils/node.spec.ts b/packages/renderer-core/__tests__/utils/node.spec.ts new file mode 100644 index 000000000..1120ef177 --- /dev/null +++ b/packages/renderer-core/__tests__/utils/node.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { walk } from '../../src/utils/node'; + +describe('sync walker', () => { + it('down', () => { + const ast = { + hasMask: true, + visible: false, + footer: false, + cancelProps: { + text: false, + type: 'normal', + }, + confirmState: '确定', + confirmStyle: 'primary', + footerActions: 'cancel,ok', + className: 'dialog_lkz6xvcv', + confirmText: { + en_US: 'Confirm', + use: '', + zh_CN: '确定', + type: 'JSExpression', + value: `({"en_US":"OK","key":"i18n-xgse6q6a","type":"i18n","zh_CN":"确定"})[this.utils.getLocale()]`, + key: 'i18n-xgse6q6a', + extType: 'i18n', + }, + autoFocus: true, + title: { + mock: { + en_US: 'Dialog Title', + use: '', + zh_CN: 'Dialog标题', + type: 'JSExpression', + value: `({"en_US":"Dialog Title","key":"i18n-0m3kaceq","type":"i18n","zh_CN":"Dialog标题"})[this.utils.getLocale()]`, + key: 'i18n-0m3kaceq', + extType: 'i18n', + }, + type: 'JSExpression', + value: 'state.dialogInfo && state.dialogInfo.title', + }, + closeable: [ + 'esc', + 'mask', + { + type: 'JSExpression', + value: '1', + }, + ], + cancelText: { + en_US: 'Cancel', + use: '', + zh_CN: '取消', + type: 'JSExpression', + value: `({"en_US":"Cancel","key":"i18n-wtq23279","type":"i18n","zh_CN":"取消"})[this.utils.getLocale()]`, + key: 'i18n-wtq23279', + extType: 'i18n', + }, + width: '800px', + footerAlign: 'right', + popupOutDialog: true, + __style__: ':root {}', + fieldId: 'dialog_case', + height: '500px', + }; + + const newAst = walk(ast, { + enter(node, parent, key, index) { + if (node.type === 'JSExpression') { + this.replace({ + type: '1', + value: '2', + }); + } + }, + }); + + console.log(newAst, Object.is(newAst, ast)); + }); +}); diff --git a/packages/renderer-core/__tests__/utils/value.spec.ts b/packages/renderer-core/__tests__/utils/value.spec.ts new file mode 100644 index 000000000..6766ce0ef --- /dev/null +++ b/packages/renderer-core/__tests__/utils/value.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { someValue, mapValue } from '../../src/utils/value'; + +describe('someValue', () => { + it('should return false for non-plain objects or empty objects', () => { + expect(someValue([], (val) => val > 10)).toBe(false); + expect(someValue({}, (val) => val > 10)).toBe(false); + expect(someValue(null, (val) => val > 10)).toBe(false); + expect(someValue(undefined, (val) => val > 10)).toBe(false); + }); + + it('should return true if the predicate matches object value', () => { + const obj = { a: 5, b: { c: 15 } }; + expect(someValue(obj, (val) => val.c > 10)).toBe(true); + }); + + it('should return true if the predicate matches nested array element', () => { + const obj = { a: [1, 2, { d: 14 }] }; + expect(someValue(obj, (val) => val.d > 10)).toBe(true); + }); + + it('should return false if the predicate does not match any value', () => { + const obj = { a: 5, b: { c: 9 } }; + expect(someValue(obj, (val) => val.c > 10)).toBe(false); + }); + + it('should handle primitives in object values', () => { + const obj = { a: 1, b: 'string', c: true }; + const strPredicate = (val: any) => typeof val.b === 'string'; + expect(someValue(obj, strPredicate)).toBe(true); + + const boolPredicate = (val: any) => typeof val.c === 'boolean'; + expect(someValue(obj, boolPredicate)).toBe(true); + }); + + it('should handle deep nesting with mixed arrays and objects', () => { + const complexObj = { a: { b: [1, 2, { c: 3 }, [{ d: 4 }]] } }; + expect(someValue(complexObj, (val) => val.d === 4)).toBe(true); + }); + + it('should handle functions and undefined values', () => { + const objWithFunc = { a: () => {}, b: undefined }; + const funcPredicate = (val: any) => typeof val.a === 'function'; + expect(someValue(objWithFunc, funcPredicate)).toBe(true); + + const undefinedPredicate = (val: any) => val.b === undefined; + expect(someValue(objWithFunc, undefinedPredicate)).toBe(true); + }); +}); + +describe('mapValue', () => { + const predicate = (obj: any) => obj && obj.process; + const processor = (obj: any, paths: any[]) => ({ ...obj, processed: true, paths }); + + it('should not process object if it does not match the predicate', () => { + const obj = { a: 3, b: { c: 4 } }; + expect(mapValue(obj, predicate, processor)).toEqual(obj); + }); + + it('should process object that matches the predicate', () => { + const obj = { a: { process: true } }; + expect(mapValue(obj, predicate, processor)).toEqual({ + a: { process: true, processed: true, paths: ['a'] }, + }); + }); + + it('should handle nested objects and arrays with various types of predicates', () => { + const complexObj = { + a: { key: 'value' }, + b: [{ key: 'value' }, undefined, null, 0, false], + c: () => {}, + }; + const truthyPredicate = (obj: any) => 'key' in obj && obj.key === 'value'; + const falsePredicate = (obj: any) => false; + + expect(mapValue(complexObj, truthyPredicate, processor)).toEqual({ + a: { key: 'value', processed: true, paths: ['a'] }, + b: [{ key: 'value', processed: true, paths: ['b', 0] }, undefined, null, 0, false], + c: complexObj.c, + }); + + expect(mapValue(complexObj, falsePredicate, processor)).toEqual(complexObj); + }); + + it('should process nested object and arrays that match the predicate', () => { + const nestedObj = { + a: { key: 'value', nested: { key: 'value' } }, + }; + const predicate = (obj: any) => 'key' in obj; + + const result = mapValue(nestedObj, predicate, processor); + + expect(result).toEqual({ + a: { key: 'value', processed: true, paths: ['a'], nested: { key: 'value' } }, + }); + expect(result.a.nested).not.toHaveProperty('processed'); + }); +}); diff --git a/packages/renderer-core/src/apiCreate.ts b/packages/renderer-core/src/apiCreate.ts deleted file mode 100644 index 1003fffc8..000000000 --- a/packages/renderer-core/src/apiCreate.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { invariant, InstantiationService } from '@alilc/lowcode-shared'; -import { RendererMain } from './main'; -import { type IRender, type RenderAdapter } from './parts/extension'; -import type { RendererApplication, AppOptions } from './types'; - -/** - * 创建 createRenderer 的辅助函数 - * @param schema - * @param options - * @returns - */ -export function createRenderer( - renderAdapter: RenderAdapter, -): (options: AppOptions) => Promise> { - invariant(typeof renderAdapter === 'function', 'The first parameter must be a function.'); - - const instantiationService = new InstantiationService({ defaultScope: 'Singleton' }); - instantiationService.bootstrapModules(); - - const rendererMain = instantiationService.createInstance(RendererMain); - - return async (options) => { - await rendererMain.initialize(options); - - return rendererMain.startup(renderAdapter); - }; -} diff --git a/packages/renderer-core/src/index.ts b/packages/renderer-core/src/index.ts index cd7e5403c..0c52e214e 100644 --- a/packages/renderer-core/src/index.ts +++ b/packages/renderer-core/src/index.ts @@ -1,20 +1,23 @@ /* --------------- api -------------------- */ -export * from './apiCreate'; -export { definePackageLoader } from './parts/package'; -export { Widget } from './parts/widget'; +export { createRenderer } from './main'; +export { definePackageLoader } from './services/package'; +export { LifecyclePhase } from './services/lifeCycleService'; +export { Widget } from './services/widget'; +export * from './utils/node'; export * from './utils/value'; /* --------------- types ---------------- */ export type * from './types'; export type { Plugin, - IRender, + IRenderObject, PluginContext, RenderAdapter, RenderContext, -} from './parts/extension'; -export type * from './parts/code-runtime'; -export type * from './parts/component-tree-model'; -export type * from './parts/package'; -export type * from './parts/schema'; -export type * from './parts/widget'; +} from './services/extension'; +export type * from './services/code-runtime'; +export type * from './services/model'; +export type * from './services/package'; +export type * from './services/schema'; +export type * from './services/widget'; +export type * from './services/extension'; diff --git a/packages/renderer-core/src/main.ts b/packages/renderer-core/src/main.ts index 541e61d21..59e454669 100644 --- a/packages/renderer-core/src/main.ts +++ b/packages/renderer-core/src/main.ts @@ -1,91 +1,111 @@ -import { Injectable } from '@alilc/lowcode-shared'; -import { ICodeRuntimeService } from './parts/code-runtime'; -import { IExtensionHostService, type RenderAdapter } from './parts/extension'; -import { IPackageManagementService } from './parts/package'; -import { IRuntimeUtilService } from './parts/runtimeUtil'; -import { IRuntimeIntlService } from './parts/runtimeIntl'; -import { ISchemaService } from './parts/schema'; - +import { Injectable, invariant, InstantiationService } from '@alilc/lowcode-shared'; +import { ICodeRuntimeService } from './services/code-runtime'; +import { + IBoostsService, + IExtensionHostService, + type RenderAdapter, + type IRenderObject, +} from './services/extension'; +import { IPackageManagementService } from './services/package'; +import { ISchemaService } from './services/schema'; +import { ILifeCycleService, LifecyclePhase } from './services/lifeCycleService'; +import { IComponentTreeModelService } from './services/model'; import type { AppOptions, RendererApplication } from './types'; @Injectable() -export class RendererMain { +export class RendererMain { private mode: 'development' | 'production' = 'production'; private initOptions: AppOptions; + private renderObject: RenderObject; + + private adapter: RenderAdapter; + constructor( @ICodeRuntimeService private codeRuntimeService: ICodeRuntimeService, @IPackageManagementService private packageManagementService: IPackageManagementService, - @IRuntimeUtilService private runtimeUtilService: IRuntimeUtilService, - @IRuntimeIntlService private runtimeIntlService: IRuntimeIntlService, @ISchemaService private schemaService: ISchemaService, @IExtensionHostService private extensionHostService: IExtensionHostService, - ) {} + @IComponentTreeModelService private componentTreeModelService: IComponentTreeModelService, + @IBoostsService private boostsService: IBoostsService, + @ILifeCycleService private lifeCycleService: ILifeCycleService, + ) { + this.lifeCycleService.when(LifecyclePhase.OptionsResolved).finally(async () => { + const renderContext = { + schema: this.schemaService, + packageManager: this.packageManagementService, + boostsManager: this.boostsService, + componentTreeModel: this.componentTreeModelService, + lifeCycle: this.lifeCycleService, + }; - async initialize(options: AppOptions) { - const { schema, mode } = options; + this.renderObject = await this.adapter(renderContext); + + await this.packageManagementService.loadPackages(this.initOptions.packages ?? []); + + this.lifeCycleService.phase = LifecyclePhase.Ready; + }); + } + + async main(options: AppOptions, adapter: RenderAdapter) { + const { schema, mode, plugins = [] } = options; if (mode) this.mode = mode; this.initOptions = { ...options }; + this.adapter = adapter; // valid schema this.schemaService.initialize(schema); - this.codeRuntimeService.initialize(options); + this.codeRuntimeService.initialize(options.codeRuntime ?? {}); - // init intl - const finalLocale = options.locale ?? navigator.language; - const i18nTranslations = this.schemaService.get('i18n') ?? {}; + this.extensionHostService.registerPlugin(plugins); - this.runtimeIntlService.initialize(finalLocale, i18nTranslations); + this.lifeCycleService.phase = LifecyclePhase.OptionsResolved; } - async startup(adapter: RenderAdapter): Promise> { - const render = await this.extensionHostService.runRender(adapter); + async getApp(): Promise> { + await this.lifeCycleService.when(LifecyclePhase.Ready); // construct application - const app = Object.freeze>({ + return Object.freeze>({ + // develop use + __options: this.initOptions, + mode: this.mode, schema: this.schemaService, packageManager: this.packageManagementService, - ...render, + ...this.renderObject, use: (plugin) => { - return this.extensionHostService.registerPlugin(plugin); + this.extensionHostService.registerPlugin(plugin); + return this.extensionHostService.doSetupPlugin(plugin); }, }); - - // setup plugins - this.extensionHostService.initialize(app); - await this.extensionHostService.registerPlugin(this.initOptions.plugins ?? []); - - // load packages - await this.packageManagementService.loadPackages(this.initOptions.packages ?? []); - - // resolve component maps - const componentsMaps = this.schemaService.get('componentsMap'); - this.packageManagementService.resolveComponentMaps(componentsMaps); - - this.initGlobalScope(); - - return app; - } - - private initGlobalScope() { - // init runtime uitls - const utils = this.schemaService.get('utils') ?? []; - for (const util of utils) { - this.runtimeUtilService.add(util); - } - - const constants = this.schemaService.get('constants') ?? {}; - - const globalScope = this.codeRuntimeService.getScope(); - globalScope.setValue({ - constants, - utils: this.runtimeUtilService.toExpose(), - ...this.runtimeIntlService.toExpose(), - }); } } + +/** + * 创建 createRenderer 的辅助函数 + * @param schema + * @param options + * @returns + */ +export function createRenderer( + renderAdapter: RenderAdapter, +): (options: AppOptions) => Promise> { + invariant(typeof renderAdapter === 'function', 'The first parameter must be a function.'); + + const instantiationService = new InstantiationService({ defaultScope: 'Singleton' }); + instantiationService.bootstrapModules(); + + const rendererMain = instantiationService.createInstance( + RendererMain, + ) as RendererMain; + + return async (options) => { + await rendererMain.main(options, renderAdapter); + return rendererMain.getApp(); + }; +} diff --git a/packages/renderer-core/src/parts/code-runtime/codeRuntimeService.ts b/packages/renderer-core/src/parts/code-runtime/codeRuntimeService.ts deleted file mode 100644 index 300433492..000000000 --- a/packages/renderer-core/src/parts/code-runtime/codeRuntimeService.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - type PlainObject, - type Spec, - isJSFunction, - isJSExpression, - createCallback, - EventDisposable, - createDecorator, - Provide, -} from '@alilc/lowcode-shared'; -import { type ICodeScope, CodeScope } from './codeScope'; -import { processValue } from '../../utils/value'; - -type BeforeResolveCb = ( - code: Spec.JSExpression | Spec.JSFunction, -) => Spec.JSExpression | Spec.JSFunction; - -export interface ICodeRuntimeService { - initialize({ evalCodeFunction }: CodeRuntimeInitializeOptions): void; - - getScope(): ICodeScope; - - run(code: string, scope?: ICodeScope): R | undefined; - - resolve(value: PlainObject, scope?: ICodeScope): any; - - beforeResolve(fn: BeforeResolveCb): EventDisposable; - - createChildScope(value: PlainObject): ICodeScope; -} - -export const ICodeRuntimeService = createDecorator('codeRuntimeService'); - -export type EvalCodeFunction = (code: string, scope: any) => any; - -export interface CodeRuntimeInitializeOptions { - evalCodeFunction?: EvalCodeFunction; -} - -@Provide(ICodeRuntimeService) -export class CodeRuntimeService implements ICodeRuntimeService { - private codeScope: ICodeScope = new CodeScope({}); - - private callbacks = createCallback(); - - private evalCodeFunction: EvalCodeFunction = evaluate; - - initialize({ evalCodeFunction }: CodeRuntimeInitializeOptions) { - if (evalCodeFunction) this.evalCodeFunction = evalCodeFunction; - } - - getScope() { - return this.codeScope; - } - - run(code: string, scope: ICodeScope = this.codeScope): R | undefined { - if (!code) return undefined; - - try { - let result = this.evalCodeFunction(code, scope.value); - - if (typeof result === 'function') { - result = result.bind(scope.value); - } - - return result as R; - } catch (err) { - // todo replace logger - console.error('%c eval error', code, scope.value, err); - return undefined; - } - } - - resolve(value: PlainObject, scope: ICodeScope = this.codeScope) { - return processValue( - value, - (data) => { - return isJSExpression(data) || isJSFunction(data); - }, - (node: Spec.JSExpression | Spec.JSFunction) => { - const cbs = this.callbacks.list(); - const finalNode = cbs.reduce((node, cb) => cb(node), node); - - const v = this.run(finalNode.value, scope); - - if (typeof v === 'undefined' && finalNode.mock) { - return this.resolve(finalNode.mock, scope); - } - return v; - }, - ); - } - - beforeResolve(fn: BeforeResolveCb): EventDisposable { - return this.callbacks.add(fn); - } - - createChildScope(value: PlainObject): ICodeScope { - return this.codeScope.createChild(value); - } -} - -function evaluate(code: string, scope: any) { - return new Function('scope', `"use strict";return (function(){return (${code})}).bind(scope)();`)( - scope, - ); -} diff --git a/packages/renderer-core/src/parts/code-runtime/codeScope.ts b/packages/renderer-core/src/parts/code-runtime/codeScope.ts deleted file mode 100644 index 1e9a24c8c..000000000 --- a/packages/renderer-core/src/parts/code-runtime/codeScope.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { type PlainObject } from '@alilc/lowcode-shared'; - -export interface ICodeScope { - readonly value: PlainObject; - - inject(name: string, value: any, force?: boolean): void; - setValue(value: PlainObject, replace?: boolean): void; - createChild(initValue: PlainObject): ICodeScope; -} - -/** - * 双链表实现父域值的获取 - */ -interface IScopeNode { - prev?: IScopeNode; - current: PlainObject; - next?: IScopeNode; -} - -export class CodeScope implements ICodeScope { - __node: IScopeNode; - - private proxyValue: PlainObject; - - constructor(initValue: PlainObject) { - this.__node = { - current: initValue, - }; - - this.proxyValue = new Proxy(Object.create(null) as PlainObject, { - set(target, p, newValue, receiver) { - return Reflect.set(target, p, newValue, receiver); - }, - get: (target, p, receiver) => { - let valueTarget: IScopeNode | undefined = this.__node; - - while (valueTarget) { - if (Reflect.has(valueTarget.current, p)) { - return Reflect.get(valueTarget.current, p, receiver); - } - valueTarget = valueTarget.prev; - } - - return Reflect.get(target, p, receiver); - }, - has: (target, p) => { - let valueTarget: IScopeNode | undefined = this.__node; - - while (valueTarget) { - if (Reflect.has(valueTarget.current, p)) { - return true; - } - valueTarget = valueTarget.prev; - } - - return Reflect.has(target, p); - }, - }); - } - - get value() { - return this.proxyValue; - } - - inject(name: string, value: any, force = false): void { - if (this.__node.current[name] && !force) { - return; - } - this.__node.current[name] = value; - } - - setValue(value: PlainObject, replace = false) { - if (replace) { - this.__node.current = { ...value }; - } else { - this.__node.current = Object.assign({}, this.__node.current, value); - } - } - - createChild(initValue: PlainObject): ICodeScope { - const subScope = new CodeScope(initValue); - subScope.__node.prev = this.__node; - - return subScope as ICodeScope; - } -} diff --git a/packages/renderer-core/src/parts/component-tree-model/index.ts b/packages/renderer-core/src/parts/component-tree-model/index.ts deleted file mode 100644 index 4d5f98bc0..000000000 --- a/packages/renderer-core/src/parts/component-tree-model/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './treeModel'; -export * from './treeModelService'; diff --git a/packages/renderer-core/src/parts/extension/plugin.ts b/packages/renderer-core/src/parts/extension/plugin.ts deleted file mode 100644 index 1a5d6fac2..000000000 --- a/packages/renderer-core/src/parts/extension/plugin.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { EventEmitter, KeyValueStore } from '@alilc/lowcode-shared'; -import { type RendererApplication } from '../../types'; -import { IBoosts } from './boosts'; - -export interface PluginContext { - eventEmitter: EventEmitter; - globalState: KeyValueStore; - - boosts: IBoosts; -} - -export interface Plugin { - /** - * 插件的 name 作为唯一标识,并不可重复。 - */ - name: string; - setup(app: RendererApplication, context: PluginContext): void | Promise; - destory?(): void | Promise; - dependsOn?: string[]; -} diff --git a/packages/renderer-core/src/parts/runtimeIntl.ts b/packages/renderer-core/src/parts/runtimeIntl.ts deleted file mode 100644 index 825fea548..000000000 --- a/packages/renderer-core/src/parts/runtimeIntl.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - createDecorator, - Provide, - Intl, - type Spec, - type Locale, - type LocaleTranslationsRecord, - type Translations, -} from '@alilc/lowcode-shared'; - -export interface MessageDescriptor { - key: string; - params?: Record; - fallback?: string; -} - -export interface IRuntimeIntlService { - initialize(locale: Locale, messages: LocaleTranslationsRecord): void; - - t(descriptor: MessageDescriptor): string; - - setLocale(locale: Locale): void; - - getLocale(): Locale; - - addTranslations(locale: Locale, translations: Translations): void; - - toExpose(): Spec.IntlApi; -} - -export const IRuntimeIntlService = createDecorator('IRuntimeIntlService'); - -@Provide(IRuntimeIntlService) -export class RuntimeIntlService implements IRuntimeIntlService { - private intl: Intl; - - private _expose: any; - - initialize(locale: Locale, messages: LocaleTranslationsRecord) { - this.intl = new Intl(locale, messages); - } - - t(descriptor: MessageDescriptor): string { - const formatter = this.intl.getFormatter(); - - return formatter.$t( - { - id: descriptor.key, - defaultMessage: descriptor.fallback, - }, - descriptor.params, - ); - } - - setLocale(locale: string): void { - this.intl.setLocale(locale); - } - - getLocale(): string { - return this.intl.getLocale(); - } - - addTranslations(locale: Locale, translations: Translations) { - this.intl.addTranslations(locale, translations); - } - - toExpose(): Spec.IntlApi { - if (!this._expose) { - this._expose = Object.freeze({ - i18n: (key, params) => { - return this.t({ key, params }); - }, - getLocale: () => { - return this.getLocale(); - }, - setLocale: (locale) => { - this.setLocale(locale); - }, - }); - } - - return this._expose; - } -} diff --git a/packages/renderer-core/src/parts/widget/widget.ts b/packages/renderer-core/src/parts/widget/widget.ts deleted file mode 100644 index bdbf99ab3..000000000 --- a/packages/renderer-core/src/parts/widget/widget.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type Spec, uniqueId, EventDisposable, createCallback } from '@alilc/lowcode-shared'; -import { clone } from 'lodash-es'; -import { IComponentTreeModel } from '../component-tree-model'; - -export interface WidgetBuildContext { - key: string; - - node: Spec.NodeType; - - model: IComponentTreeModel; - - children?: IWidget[]; -} - -export interface IWidget { - readonly key: string; - - readonly node: Spec.NodeType; - - children?: IWidget[]; - - beforeBuild(beforeGuard: (node: T) => T): EventDisposable; - - build( - builder: (context: WidgetBuildContext) => Element, - ): Element; -} - -export class Widget - implements IWidget -{ - private beforeGuardCallbacks = createCallback(); - - public __raw: Spec.NodeType; - - public node: Spec.NodeType; - - public key: string; - - public children?: IWidget[] | undefined; - - constructor( - node: Spec.NodeType, - private model: IComponentTreeModel, - ) { - this.node = clone(node); - this.__raw = node; - this.key = (node as Spec.ComponentNode)?.id ?? uniqueId(); - } - - beforeBuild(beforeGuard: (node: T) => T): EventDisposable { - return this.beforeGuardCallbacks.add(beforeGuard); - } - - build( - builder: (context: WidgetBuildContext) => Element, - ): Element { - const beforeGuards = this.beforeGuardCallbacks.list(); - const finalNode = beforeGuards.reduce((prev, cb) => cb(prev), this.node); - - return builder({ - key: this.key, - node: finalNode, - model: this.model, - children: this.children, - }); - } -} diff --git a/packages/renderer-core/src/services/code-runtime/codeRuntimeService.ts b/packages/renderer-core/src/services/code-runtime/codeRuntimeService.ts new file mode 100644 index 000000000..29791f73c --- /dev/null +++ b/packages/renderer-core/src/services/code-runtime/codeRuntimeService.ts @@ -0,0 +1,129 @@ +import { + type PlainObject, + type Spec, + type EventDisposable, + createDecorator, + Provide, + isJSExpression, + isJSFunction, +} from '@alilc/lowcode-shared'; +import { type ICodeScope, CodeScope } from './codeScope'; +import { evaluate } from '../../utils/evaluate'; +import { isNode } from '../../utils/node'; +import { mapValue } from '../../utils/value'; + +export interface ResolveOptions { + scope?: ICodeScope; +} + +export type NodeResolverHandler = (node: Spec.JSNode) => Spec.JSNode | false | undefined; + +export interface ICodeRuntimeService { + initialize(options: CodeRuntimeInitializeOptions): void; + + getScope(): ICodeScope; + + run(code: string, scope?: ICodeScope): R | undefined; + + resolve(value: PlainObject, options?: ResolveOptions): any; + + onResolve(handler: NodeResolverHandler): EventDisposable; + + createChildScope(value: PlainObject): ICodeScope; +} + +export const ICodeRuntimeService = createDecorator('codeRuntimeService'); + +export interface CodeRuntimeInitializeOptions { + evalCodeFunction?: (code: string, scope: any) => any; +} + +@Provide(ICodeRuntimeService) +export class CodeRuntimeService implements ICodeRuntimeService { + private codeScope: ICodeScope = new CodeScope({}); + + private evalCodeFunction = evaluate; + + private onResolveHandlers: NodeResolverHandler[] = []; + + initialize(options: CodeRuntimeInitializeOptions) { + if (options.evalCodeFunction) this.evalCodeFunction = options.evalCodeFunction; + } + + getScope() { + return this.codeScope; + } + + run(code: string, scope: ICodeScope = this.codeScope): R | undefined { + if (!code) return undefined; + + try { + let result = this.evalCodeFunction(code, scope.value); + + if (typeof result === 'function') { + result = result.bind(scope.value); + } + + return result as R; + } catch (err) { + // todo replace logger + console.error('eval error', code, scope.value, err); + return undefined; + } + } + + resolve(data: PlainObject, options: ResolveOptions = {}): any { + const handlers = this.onResolveHandlers; + + if (handlers.length > 0) { + data = mapValue(data, isNode, (node: Spec.JSNode) => { + let newNode: Spec.JSNode | false | undefined = node; + + for (const handler of handlers) { + newNode = handler(newNode as Spec.JSNode); + if (newNode === false || typeof newNode === 'undefined') { + break; + } + } + + return newNode; + }); + } + + return mapValue( + data, + (data) => { + return isJSExpression(data) || isJSFunction(data); + }, + (node: Spec.JSExpression | Spec.JSFunction) => { + return this.resolveExprOrFunction(node, options); + }, + ); + } + + private resolveExprOrFunction( + node: Spec.JSExpression | Spec.JSFunction, + options: ResolveOptions, + ) { + const v = this.run(node.value, options.scope || this.codeScope); + + if (typeof v === 'undefined' && node.mock) { + return this.resolve(node.mock, options); + } + return v; + } + + /** + * 顺序执行 handler + */ + onResolve(handler: NodeResolverHandler): EventDisposable { + this.onResolveHandlers.push(handler); + return () => { + this.onResolveHandlers = this.onResolveHandlers.filter((h) => h !== handler); + }; + } + + createChildScope(value: PlainObject): ICodeScope { + return this.codeScope.createChild(value); + } +} diff --git a/packages/renderer-core/src/services/code-runtime/codeScope.ts b/packages/renderer-core/src/services/code-runtime/codeScope.ts new file mode 100644 index 000000000..c156e74cd --- /dev/null +++ b/packages/renderer-core/src/services/code-runtime/codeScope.ts @@ -0,0 +1,99 @@ +import { type PlainObject } from '@alilc/lowcode-shared'; +import { trustedGlobals } from '../../utils/globals-es2015'; + +/* + * variables who are impossible to be overwritten need to be escaped from proxy scope for performance reasons + * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/unscopables + */ +const unscopables = trustedGlobals.reduce((acc, key) => ({ ...acc, [key]: true }), { + __proto__: null, +}); + +export interface ICodeScope { + readonly value: PlainObject; + set(name: string, value: any): void; + setValue(value: PlainObject, replace?: boolean): void; + createChild(initValue: PlainObject): ICodeScope; +} + +/** + * 双链表实现父域值的获取 + */ +interface IScopeNode { + parent?: IScopeNode; + current: PlainObject; +} + +export class CodeScope implements ICodeScope { + __node: IScopeNode; + + private proxyValue: PlainObject; + + constructor(initValue: PlainObject) { + this.__node = { + current: initValue, + }; + + this.proxyValue = this.createProxy(); + } + + get value() { + return this.proxyValue; + } + + set(name: string, value: any): void { + this.__node.current[name] = value; + } + + setValue(value: PlainObject, replace = false) { + if (replace) { + this.__node.current = { ...value }; + } else { + this.__node.current = Object.assign({}, this.__node.current, value); + } + } + + createChild(initValue: PlainObject): ICodeScope { + const childScope = new CodeScope(initValue); + childScope.__node.parent = this.__node; + + return childScope; + } + + private createProxy(): PlainObject { + return new Proxy(Object.create(null) as PlainObject, { + set: (target, p, newValue) => { + this.set(p as string, newValue, true); + return true; + }, + get: (_, p) => this.findValue(p) ?? undefined, + has: (_, p) => this.hasProperty(p), + }); + } + + private findValue(prop: PropertyKey) { + if (prop === Symbol.unscopables) return unscopables; + + let node: IScopeNode | undefined = this.__node; + while (node) { + if (Object.hasOwnProperty.call(node.current, prop)) { + return node.current[prop as string]; + } + node = node.parent; + } + } + + private hasProperty(prop: PropertyKey): boolean { + if (prop in unscopables) return true; + + let node: IScopeNode | undefined = this.__node; + while (node) { + if (prop in node.current) { + return true; + } + node = node.parent; + } + + return false; + } +} diff --git a/packages/renderer-core/src/parts/code-runtime/index.ts b/packages/renderer-core/src/services/code-runtime/index.ts similarity index 100% rename from packages/renderer-core/src/parts/code-runtime/index.ts rename to packages/renderer-core/src/services/code-runtime/index.ts diff --git a/packages/renderer-core/src/parts/extension/boosts.ts b/packages/renderer-core/src/services/extension/boosts.ts similarity index 95% rename from packages/renderer-core/src/parts/extension/boosts.ts rename to packages/renderer-core/src/services/extension/boosts.ts index e3707104c..25065aaee 100644 --- a/packages/renderer-core/src/parts/extension/boosts.ts +++ b/packages/renderer-core/src/services/extension/boosts.ts @@ -1,8 +1,8 @@ import { createDecorator, Provide, type PlainObject } from '@alilc/lowcode-shared'; import { isObject } from 'lodash-es'; import { ICodeRuntimeService } from '../code-runtime'; -import { IRuntimeUtilService } from '../runtimeUtil'; -import { IRuntimeIntlService } from '../runtimeIntl'; +import { IRuntimeUtilService } from '../runtimeUtilService'; +import { IRuntimeIntlService } from '../runtimeIntlService'; export type IBoosts = IBoostsApi & Extends; diff --git a/packages/renderer-core/src/parts/extension/extensionHostService.ts b/packages/renderer-core/src/services/extension/extensionHostService.ts similarity index 64% rename from packages/renderer-core/src/parts/extension/extensionHostService.ts rename to packages/renderer-core/src/services/extension/extensionHostService.ts index 8d17f61e3..2a2388e22 100644 --- a/packages/renderer-core/src/parts/extension/extensionHostService.ts +++ b/packages/renderer-core/src/services/extension/extensionHostService.ts @@ -1,33 +1,23 @@ -import { - invariant, - createDecorator, - Provide, - EventEmitter, - KeyValueStore, -} from '@alilc/lowcode-shared'; -import { type Plugin } from './plugin'; +import { createDecorator, Provide, EventEmitter, KeyValueStore } from '@alilc/lowcode-shared'; +import { type Plugin, type PluginContext } from './plugin'; import { IBoostsService } from './boosts'; import { IPackageManagementService } from '../package'; import { ISchemaService } from '../schema'; import { type RenderAdapter } from './render'; -import { IComponentTreeModelService } from '../component-tree-model'; -import type { RendererApplication } from '../../types'; +import { IComponentTreeModelService } from '../model'; +import { ILifeCycleService, LifecyclePhase } from '../lifeCycleService'; interface IPluginRuntime extends Plugin { status: 'setup' | 'ready'; } export interface IExtensionHostService { - initialize(app: RendererApplication): void; + registerPlugin(plugin: Plugin | Plugin[]): void; - /* ========= plugin ============= */ - registerPlugin(plugin: Plugin | Plugin[]): Promise; + doSetupPlugin(plugin: Plugin): Promise; getPlugin(name: string): Plugin | undefined; - /* =========== render =============== */ - runRender(adapter: RenderAdapter): Promise; - dispose(): Promise; } @@ -38,24 +28,37 @@ export const IExtensionHostService = export class ExtensionHostService implements IExtensionHostService { private pluginRuntimes: IPluginRuntime[] = []; - private app: RendererApplication; + private eventEmitter: EventEmitter; - private eventEmitter = new EventEmitter(); - - private globalState = new KeyValueStore(); + private pluginSetupContext: PluginContext; constructor( @IPackageManagementService private packageManagementService: IPackageManagementService, @IBoostsService private boostsService: IBoostsService, @ISchemaService private schemaService: ISchemaService, - @IComponentTreeModelService private componentTreeModelService: IComponentTreeModelService, - ) {} + @ILifeCycleService private lifeCycleService: ILifeCycleService, + ) { + this.eventEmitter = new EventEmitter('ExtensionHost'); + this.pluginSetupContext = { + eventEmitter: this.eventEmitter, + globalState: new KeyValueStore(), + boosts: this.boostsService.toExpose(), + schema: this.schemaService, + packageManager: this.packageManagementService, - initialize(app: RendererApplication) { - this.app = app; + whenLifeCylePhaseChange: (phase) => { + return this.lifeCycleService.when(phase); + }, + }; + + this.lifeCycleService.when(LifecyclePhase.OptionsResolved).then(async () => { + for (const plugin of this.pluginRuntimes) { + await this.doSetupPlugin(plugin); + } + }); } - async registerPlugin(plugins: Plugin | Plugin[]) { + registerPlugin(plugins: Plugin | Plugin[]) { plugins = Array.isArray(plugins) ? plugins : [plugins]; for (const plugin of plugins) { @@ -64,39 +67,18 @@ export class ExtensionHostService implements IExtensionHostService { continue; } - await this.doSetupPlugin(plugin); + this.pluginRuntimes.push({ + ...plugin, + status: 'ready', + }); } } - getPlugin(name: string): Plugin | undefined { - return this.pluginRuntimes.find((item) => item.name === name); - } - - async runRender(adapter: RenderAdapter): Promise { - invariant(adapter, 'render adapter not settled', 'ExtensionHostService'); - - return adapter({ - schema: this.schemaService, - packageManager: this.packageManagementService, - boostsManager: this.boostsService, - componentTreeModel: this.componentTreeModelService, - }); - } - - async dispose(): Promise { - for (const plugin of this.pluginRuntimes) { - await plugin.destory?.(); - } - } - - private async doSetupPlugin(plugin: Plugin) { + async doSetupPlugin(plugin: Plugin) { const pluginRuntime = plugin as IPluginRuntime; if (!this.pluginRuntimes.some((item) => item.name !== pluginRuntime.name)) { - this.pluginRuntimes.push({ - ...pluginRuntime, - status: 'ready', - }); + return; } const isSetup = (name: string) => { @@ -108,11 +90,7 @@ export class ExtensionHostService implements IExtensionHostService { return; } - await pluginRuntime.setup(this.app, { - eventEmitter: this.eventEmitter, - globalState: this.globalState, - boosts: this.boostsService.toExpose(), - }); + await pluginRuntime.setup(this.pluginSetupContext); pluginRuntime.status = 'setup'; // 遍历未安装的插件 寻找 dependsOn 的插件已安装完的插件进行安装 @@ -122,4 +100,14 @@ export class ExtensionHostService implements IExtensionHostService { await this.doSetupPlugin(readyPlugin); } } + + getPlugin(name: string): Plugin | undefined { + return this.pluginRuntimes.find((item) => item.name === name); + } + + async dispose(): Promise { + for (const plugin of this.pluginRuntimes) { + await plugin.destory?.(); + } + } } diff --git a/packages/renderer-core/src/parts/extension/index.ts b/packages/renderer-core/src/services/extension/index.ts similarity index 100% rename from packages/renderer-core/src/parts/extension/index.ts rename to packages/renderer-core/src/services/extension/index.ts diff --git a/packages/renderer-core/src/services/extension/plugin.ts b/packages/renderer-core/src/services/extension/plugin.ts new file mode 100644 index 000000000..42a693a24 --- /dev/null +++ b/packages/renderer-core/src/services/extension/plugin.ts @@ -0,0 +1,27 @@ +import { type EventEmitter, type IStore, type PlainObject } from '@alilc/lowcode-shared'; +import { type IBoosts } from './boosts'; +import { LifecyclePhase } from '../lifeCycleService'; +import { type ISchemaService } from '../schema'; +import { type IPackageManagementService } from '../package'; + +export interface PluginContext { + eventEmitter: EventEmitter; + globalState: IStore; + boosts: IBoosts; + schema: ISchemaService; + packageManager: IPackageManagementService; + /** + * 生命周期变更事件 + */ + whenLifeCylePhaseChange(phase: LifecyclePhase): Promise; +} + +export interface Plugin { + /** + * 插件的 name 作为唯一标识,并不可重复。 + */ + name: string; + setup(context: PluginContext): void | Promise; + destory?(): void | Promise; + dependsOn?: string[]; +} diff --git a/packages/renderer-core/src/parts/extension/render.ts b/packages/renderer-core/src/services/extension/render.ts similarity index 67% rename from packages/renderer-core/src/parts/extension/render.ts rename to packages/renderer-core/src/services/extension/render.ts index d9c8b9a28..9a9522f3e 100644 --- a/packages/renderer-core/src/parts/extension/render.ts +++ b/packages/renderer-core/src/services/extension/render.ts @@ -1,10 +1,11 @@ import { IPackageManagementService } from '../package'; import { IBoostsService } from './boosts'; import { ISchemaService } from '../schema'; -import { IComponentTreeModelService } from '../component-tree-model'; +import { IComponentTreeModelService } from '../model'; +import { ILifeCycleService } from '../lifeCycleService'; -export interface IRender { - mount: (el: HTMLElement) => void | Promise; +export interface IRenderObject { + mount: (containerOrId?: string | HTMLElement) => void | Promise; unmount: () => void | Promise; } @@ -16,6 +17,8 @@ export interface RenderContext { readonly boostsManager: IBoostsService; readonly componentTreeModel: IComponentTreeModelService; + + readonly lifeCycle: ILifeCycleService; } export interface RenderAdapter { diff --git a/packages/renderer-core/src/services/lifeCycleService.ts b/packages/renderer-core/src/services/lifeCycleService.ts new file mode 100644 index 000000000..52e2f546f --- /dev/null +++ b/packages/renderer-core/src/services/lifeCycleService.ts @@ -0,0 +1,75 @@ +import { Provide, createDecorator, Barrier } from '@alilc/lowcode-shared'; + +export const enum LifecyclePhase { + Starting = 1, + + OptionsResolved = 2, + + Ready = 3, + + BeforeMount = 4, + + Mounted = 5, + + BeforeUnmount = 6, +} + +export interface ILifeCycleService { + /** + * A flag indicating in what phase of the lifecycle we currently are. + */ + phase: LifecyclePhase; + + /** + * Returns a promise that resolves when a certain lifecycle phase + * has started. + */ + when(phase: LifecyclePhase): Promise; +} + +export const ILifeCycleService = createDecorator('lifeCycleService'); + +@Provide(ILifeCycleService) +export class LifeCycleService implements ILifeCycleService { + private readonly phaseWhen = new Map(); + + private _phase = LifecyclePhase.Starting; + + get phase(): LifecyclePhase { + return this._phase; + } + + set phase(value: LifecyclePhase) { + if (value < this.phase) { + throw new Error('Lifecycle cannot go backwards'); + } + + if (this._phase === value) { + return; + } + + // this.logService.trace(`lifecycle: phase changed (value: ${value})`); + + this._phase = value; + + const barrier = this.phaseWhen.get(this._phase); + if (barrier) { + barrier.open(); + this.phaseWhen.delete(this._phase); + } + } + + async when(phase: LifecyclePhase): Promise { + if (phase <= this._phase) { + return; + } + + let barrier = this.phaseWhen.get(phase); + if (!barrier) { + barrier = new Barrier(); + this.phaseWhen.set(phase, barrier); + } + + await barrier.wait(); + } +} diff --git a/packages/renderer-core/src/parts/component-tree-model/treeModel.ts b/packages/renderer-core/src/services/model/componentTreeModel.ts similarity index 65% rename from packages/renderer-core/src/parts/component-tree-model/treeModel.ts rename to packages/renderer-core/src/services/model/componentTreeModel.ts index 853d52ca6..4d4d68439 100644 --- a/packages/renderer-core/src/parts/component-tree-model/treeModel.ts +++ b/packages/renderer-core/src/services/model/componentTreeModel.ts @@ -1,16 +1,39 @@ -import { type Spec, type PlainObject, isComponentNode, invariant } from '@alilc/lowcode-shared'; +import { + type Spec, + type PlainObject, + isComponentNode, + invariant, + uniqueId, +} from '@alilc/lowcode-shared'; import { type ICodeScope, type ICodeRuntimeService } from '../code-runtime'; import { IWidget, Widget } from '../widget'; +export interface NormalizedComponentNode extends Spec.ComponentNode { + loopArgs: [string, string]; + props: Spec.ComponentNodeProps; +} + +export interface InitializeModelOptions { + defaultProps?: PlainObject | undefined; + + stateCreator: ModelScopeStateCreator; + dataSourceCreator: ModelScopeDataSourceCreator; +} + /** * 根据低代码搭建协议的容器组件描述生成的容器模型 */ export interface IComponentTreeModel { + readonly id: string; + readonly codeScope: ICodeScope; readonly codeRuntime: ICodeRuntimeService; readonly widgets: IWidget[]; + + initialize(options: InitializeModelOptions): void; + /** * 获取协议中的 css 内容 */ @@ -37,11 +60,6 @@ export interface IComponentTreeModel { export type ModelScopeStateCreator = (initalState: PlainObject) => Spec.InstanceStateApi; export type ModelScopeDataSourceCreator = (...args: any[]) => Spec.InstanceDataSourceApi; -export interface ComponentTreeModelOptions { - stateCreator: ModelScopeStateCreator; - dataSourceCreator: ModelScopeDataSourceCreator; -} - const defaultDataSourceSchema: Spec.ComponentDataSource = { list: [], dataHandler: { @@ -50,44 +68,60 @@ const defaultDataSourceSchema: Spec.ComponentDataSource = { }, }; +export interface ComponentTreeModelOptions { + id?: string; + metadata?: PlainObject; +} + export class ComponentTreeModel implements IComponentTreeModel { private instanceMap = new Map(); + public id: string; + public codeScope: ICodeScope; public widgets: IWidget[] = []; + public metadata: PlainObject = {}; + constructor( public componentsTree: Spec.ComponentTree, public codeRuntime: ICodeRuntimeService, - options: ComponentTreeModelOptions, + options?: ComponentTreeModelOptions, ) { invariant(componentsTree, 'componentsTree must to provide', 'ComponentTreeModel'); - this.initModelScope(options.stateCreator, options.dataSourceCreator); + this.id = options?.id ?? `model_${uniqueId()}`; + if (options?.metadata) { + this.metadata = options.metadata; + } if (componentsTree.children) { this.widgets = this.buildWidgets(componentsTree.children); } } - private initModelScope( - stateCreator: ModelScopeStateCreator, - dataSourceCreator: ModelScopeDataSourceCreator, - ) { + initialize({ defaultProps, stateCreator, dataSourceCreator }: InitializeModelOptions) { const { state = {}, + defaultProps: defaultSchemaProps, props = {}, dataSource = defaultDataSourceSchema, methods = {}, } = this.componentsTree; - this.codeScope = this.codeRuntime.createChildScope({}); + this.codeScope = this.codeRuntime.createChildScope({ + props: { + ...props, + ...defaultSchemaProps, + ...defaultProps, + }, + }); - const initalState = this.codeRuntime.resolve(state, this.codeScope); - const initalProps = this.codeRuntime.resolve(props, this.codeScope); + const initalState = this.codeRuntime.resolve(state, { scope: this.codeScope }); + const initalProps = this.codeRuntime.resolve(props, { scope: this.codeScope }); const stateApi = stateCreator(initalState); const dataSourceApi = dataSourceCreator(dataSource, stateApi); @@ -95,7 +129,7 @@ export class ComponentTreeModel this.codeScope.setValue( Object.assign( { - props: initalProps, + props: { ...defaultProps, ...initalProps }, $: (ref: string) => { const insArr = this.instanceMap.get(ref); if (!insArr) return undefined; @@ -111,9 +145,9 @@ export class ComponentTreeModel ); for (const [key, fn] of Object.entries(methods)) { - const customMethod = this.codeRuntime.resolve(fn, this.codeScope); + const customMethod = this.codeRuntime.resolve(fn, { scope: this.codeScope }); if (typeof customMethod === 'function') { - this.codeScope.inject(key, customMethod); + this.codeScope.set(key, customMethod); } } } @@ -133,7 +167,7 @@ export class ComponentTreeModel const lifeCycleSchema = this.componentsTree.lifeCycles[lifeCycleName]; - const lifeCycleFn = this.codeRuntime.resolve(lifeCycleSchema, this.codeScope); + const lifeCycleFn = this.codeRuntime.resolve(lifeCycleSchema, { scope: this.codeScope }); if (typeof lifeCycleFn === 'function') { lifeCycleFn.apply(this.codeScope.value, args); } @@ -162,13 +196,31 @@ export class ComponentTreeModel buildWidgets(nodes: Spec.NodeType[]): IWidget[] { return nodes.map((node) => { - const widget = new Widget(node, this); + if (isComponentNode(node)) { + const normalized = normalizeComponentNode(node); + const widget = new Widget(normalized, this); - if (isComponentNode(node) && node.children?.length) { - widget.children = this.buildWidgets(node.children); + if (normalized.children?.length) { + widget.children = this.buildWidgets(normalized.children); + } + + return widget; + } else { + return new Widget(node, this); } - - return widget; }); } } + +export function normalizeComponentNode(node: Spec.ComponentNode): NormalizedComponentNode { + const [loopArgsOne, loopArgsTwo] = node.loopArgs ?? []; + const { children, ...props } = node.props ?? {}; + + return { + ...node, + loopArgs: [loopArgsOne || 'item', loopArgsTwo || 'index'], + props, + condition: node.condition || node.condition === false ? node.condition : true, + children: node.children ?? children, + }; +} diff --git a/packages/renderer-core/src/parts/component-tree-model/treeModelService.ts b/packages/renderer-core/src/services/model/componentTreeModelService.ts similarity index 88% rename from packages/renderer-core/src/parts/component-tree-model/treeModelService.ts rename to packages/renderer-core/src/services/model/componentTreeModelService.ts index 953ef1b25..bd61900af 100644 --- a/packages/renderer-core/src/parts/component-tree-model/treeModelService.ts +++ b/packages/renderer-core/src/services/model/componentTreeModelService.ts @@ -4,18 +4,18 @@ import { type IComponentTreeModel, ComponentTreeModel, type ComponentTreeModelOptions, -} from './treeModel'; +} from './componentTreeModel'; import { ISchemaService } from '../schema'; export interface IComponentTreeModelService { create( componentsTree: Spec.ComponentTree, - options: ComponentTreeModelOptions, + options?: ComponentTreeModelOptions, ): IComponentTreeModel; createById( id: string, - options: ComponentTreeModelOptions, + options?: ComponentTreeModelOptions, ): IComponentTreeModel; } @@ -32,14 +32,14 @@ export class ComponentTreeModelService implements IComponentTreeModelService { create( componentsTree: Spec.ComponentTree, - options: ComponentTreeModelOptions, + options?: ComponentTreeModelOptions, ): IComponentTreeModel { return new ComponentTreeModel(componentsTree, this.codeRuntimeService, options); } createById( id: string, - options: ComponentTreeModelOptions, + options?: ComponentTreeModelOptions, ): IComponentTreeModel { const componentsTrees = this.schemaService.get('componentsTree'); const componentsTree = componentsTrees.find((item) => item.id === id); diff --git a/packages/renderer-core/src/services/model/index.ts b/packages/renderer-core/src/services/model/index.ts new file mode 100644 index 000000000..d4c01d45a --- /dev/null +++ b/packages/renderer-core/src/services/model/index.ts @@ -0,0 +1,2 @@ +export * from './componentTreeModel'; +export * from './componentTreeModelService'; diff --git a/packages/renderer-core/src/parts/package/index.ts b/packages/renderer-core/src/services/package/index.ts similarity index 100% rename from packages/renderer-core/src/parts/package/index.ts rename to packages/renderer-core/src/services/package/index.ts diff --git a/packages/renderer-core/src/parts/package/loader.ts b/packages/renderer-core/src/services/package/loader.ts similarity index 100% rename from packages/renderer-core/src/parts/package/loader.ts rename to packages/renderer-core/src/services/package/loader.ts diff --git a/packages/renderer-core/src/parts/package/managementService.ts b/packages/renderer-core/src/services/package/managementService.ts similarity index 92% rename from packages/renderer-core/src/parts/package/managementService.ts rename to packages/renderer-core/src/services/package/managementService.ts index 6b59f0938..e96af6189 100644 --- a/packages/renderer-core/src/parts/package/managementService.ts +++ b/packages/renderer-core/src/services/package/managementService.ts @@ -9,6 +9,8 @@ import { } from '@alilc/lowcode-shared'; import { get as lodashGet } from 'lodash-es'; import { PackageLoader } from './loader'; +import { ISchemaService } from '../schema'; +import { ILifeCycleService, LifecyclePhase } from '../lifeCycleService'; export interface NormalizedPackage { id: string; @@ -62,6 +64,16 @@ export class PackageManagementService implements IPackageManagementService { private packageLoaders: PackageLoader[] = []; + constructor( + @ISchemaService private schemaService: ISchemaService, + @ILifeCycleService private lifeCycleService: ILifeCycleService, + ) { + this.lifeCycleService.when(LifecyclePhase.Ready).then(() => { + const componentsMaps = this.schemaService.get('componentsMap'); + this.resolveComponentMaps(componentsMaps); + }); + } + async loadPackages(packages: Spec.Package[]) { for (const item of packages) { // low code component not need load @@ -99,7 +111,7 @@ export class PackageManagementService implements IPackageManagementService { if (map.devMode === 'lowCode') { const packageInfo = this.lowCodeComponentPackages.get((map as LowCodeComponent).id); - if (packageInfo) { + if (map.componentName && packageInfo) { this.componentsRecord[map.componentName] = packageInfo; } } else { @@ -123,7 +135,7 @@ export class PackageManagementService implements IPackageManagementService { } const recordName = map.componentName ?? map.exportName; - this.componentsRecord[recordName] = result; + if (recordName && result) this.componentsRecord[recordName] = result; } } } diff --git a/packages/renderer-core/src/services/runtimeIntlService.ts b/packages/renderer-core/src/services/runtimeIntlService.ts new file mode 100644 index 000000000..df4bf7f2b --- /dev/null +++ b/packages/renderer-core/src/services/runtimeIntlService.ts @@ -0,0 +1,102 @@ +import { + createDecorator, + Provide, + Intl, + type Spec, + type Locale, + type Translations, +} from '@alilc/lowcode-shared'; +import { ILifeCycleService, LifecyclePhase } from './lifeCycleService'; +import { ICodeRuntimeService } from './code-runtime'; +import { ISchemaService } from './schema'; + +export interface MessageDescriptor { + key: string; + params?: Record; + fallback?: string; +} + +export interface IRuntimeIntlService { + locale: string; + + t(descriptor: MessageDescriptor): string; + + setLocale(locale: Locale): void; + + getLocale(): Locale; + + addTranslations(locale: Locale, translations: Translations): void; +} + +export const IRuntimeIntlService = createDecorator('IRuntimeIntlService'); + +@Provide(IRuntimeIntlService) +export class RuntimeIntlService implements IRuntimeIntlService { + private intl: Intl = new Intl(); + + public locale: string = navigator.language; + + constructor( + @ILifeCycleService private lifeCycleService: ILifeCycleService, + @ICodeRuntimeService private codeRuntimeService: ICodeRuntimeService, + @ISchemaService private schemaService: ISchemaService, + ) { + this.lifeCycleService.when(LifecyclePhase.OptionsResolved).then(() => { + const config = this.schemaService.get('config'); + const i18nTranslations = this.schemaService.get('i18n'); + + if (config?.defaultLocale) { + this.setLocale(config.defaultLocale); + } + if (i18nTranslations) { + Object.keys(i18nTranslations).forEach((key) => { + this.addTranslations(key, i18nTranslations[key]); + }); + } + }); + + this.lifeCycleService.when(LifecyclePhase.Ready).then(() => { + this.toExpose(); + }); + } + + t(descriptor: MessageDescriptor): string { + const formatter = this.intl.getFormatter(); + + return formatter.$t( + { + id: descriptor.key, + defaultMessage: descriptor.fallback, + }, + descriptor.params, + ); + } + + setLocale(locale: string): void { + this.intl.setLocale(locale); + } + + getLocale(): string { + return this.intl.getLocale(); + } + + addTranslations(locale: Locale, translations: Translations) { + this.intl.addTranslations(locale, translations); + } + + private toExpose(): void { + const exposed: Spec.IntlApi = { + i18n: (key, params) => { + return this.t({ key, params }); + }, + getLocale: () => { + return this.getLocale(); + }, + setLocale: (locale) => { + this.setLocale(locale); + }, + }; + + this.codeRuntimeService.getScope().setValue(exposed); + } +} diff --git a/packages/renderer-core/src/parts/runtimeUtil.ts b/packages/renderer-core/src/services/runtimeUtilService.ts similarity index 69% rename from packages/renderer-core/src/parts/runtimeUtil.ts rename to packages/renderer-core/src/services/runtimeUtilService.ts index 7785efb59..daca3bcc8 100644 --- a/packages/renderer-core/src/parts/runtimeUtil.ts +++ b/packages/renderer-core/src/services/runtimeUtilService.ts @@ -1,14 +1,14 @@ import { type AnyFunction, type Spec, createDecorator, Provide } from '@alilc/lowcode-shared'; import { IPackageManagementService } from './package'; import { ICodeRuntimeService } from './code-runtime'; +import { ILifeCycleService, LifecyclePhase } from './lifeCycleService'; +import { ISchemaService } from './schema'; export interface IRuntimeUtilService { add(utilItem: Spec.Util): void; add(name: string, fn: AnyFunction): void; remove(name: string): void; - - toExpose(): Spec.UtilsApi; } export const IRuntimeUtilService = createDecorator('rendererUtilService'); @@ -17,12 +17,21 @@ export const IRuntimeUtilService = createDecorator('rendere export class RuntimeUtilService implements IRuntimeUtilService { private utilsMap: Map = new Map(); - private _expose: any; - constructor( @ICodeRuntimeService private codeRuntimeService: ICodeRuntimeService, @IPackageManagementService private packageManagementService: IPackageManagementService, - ) {} + @ILifeCycleService private lifeCycleService: ILifeCycleService, + @ISchemaService private schemaService: ISchemaService, + ) { + this.lifeCycleService.when(LifecyclePhase.Ready).then(() => { + const utils = this.schemaService.get('utils') ?? []; + for (const util of utils) { + this.add(util); + } + + this.toExpose(); + }); + } add(utilItem: Spec.Util): void; add(name: string, fn: AnyFunction): void; @@ -41,22 +50,20 @@ export class RuntimeUtilService implements IRuntimeUtilService { this.utilsMap.delete(name); } - toExpose(): Spec.UtilsApi { - if (!this._expose) { - this._expose = new Proxy(Object.create(null), { - get: (_, p: string) => { - return this.utilsMap.get(p); - }, - set() { - return false; - }, - has: (_, p: string) => { - return this.utilsMap.has(p); - }, - }); - } + private toExpose(): void { + const exposed = new Proxy(Object.create(null), { + get: (_, p: string) => { + return this.utilsMap.get(p); + }, + set() { + return false; + }, + has: (_, p: string) => { + return this.utilsMap.has(p); + }, + }); - return this._expose; + this.codeRuntimeService.getScope().set('utils', exposed); } private parseUtil(utilItem: Spec.Util) { diff --git a/packages/renderer-core/src/parts/schema/index.ts b/packages/renderer-core/src/services/schema/index.ts similarity index 100% rename from packages/renderer-core/src/parts/schema/index.ts rename to packages/renderer-core/src/services/schema/index.ts diff --git a/packages/renderer-core/src/parts/schema/schemaService.ts b/packages/renderer-core/src/services/schema/schemaService.ts similarity index 64% rename from packages/renderer-core/src/parts/schema/schemaService.ts rename to packages/renderer-core/src/services/schema/schemaService.ts index 41c262382..ee2dd5784 100644 --- a/packages/renderer-core/src/parts/schema/schemaService.ts +++ b/packages/renderer-core/src/services/schema/schemaService.ts @@ -2,11 +2,13 @@ import { type Spec, createDecorator, Provide, + type IStore, KeyValueStore, - type EventDisposable, } from '@alilc/lowcode-shared'; import { isObject } from 'lodash-es'; import { schemaValidation } from './validation'; +import { ILifeCycleService, LifecyclePhase } from '../lifeCycleService'; +import { ICodeRuntimeService } from '../code-runtime'; export interface NormalizedSchema extends Spec.Project {} @@ -18,22 +20,26 @@ export interface ISchemaService { get(key: K): NormalizedSchema[K]; set(key: K, value: NormalizedSchema[K]): void; - - onValueChange( - key: K, - listener: (value: NormalizedSchema[K]) => void, - ): EventDisposable; } export const ISchemaService = createDecorator('schemaService'); @Provide(ISchemaService) export class SchemaService implements ISchemaService { - private store: KeyValueStore; + private store: IStore = new KeyValueStore< + NormalizedSchema, + NormalizedSchemaKey + >({ + setterValidation: schemaValidation, + }); - constructor() { - this.store = new KeyValueStore(new Map(), { - setterValidation: schemaValidation, + constructor( + @ILifeCycleService private lifeCycleService: ILifeCycleService, + @ICodeRuntimeService private codeRuntimeService: ICodeRuntimeService, + ) { + this.lifeCycleService.when(LifecyclePhase.Ready).then(() => { + const constants = this.get('constants') ?? {}; + this.codeRuntimeService.getScope().set('constants', constants); }); } @@ -55,11 +61,4 @@ export class SchemaService implements ISchemaService { get(key: K): NormalizedSchema[K] { return this.store.get(key) as NormalizedSchema[K]; } - - onValueChange( - key: K, - listener: (value: NormalizedSchema[K]) => void, - ): EventDisposable { - return this.store.onValueChange(key, listener); - } } diff --git a/packages/renderer-core/src/parts/schema/validation.ts b/packages/renderer-core/src/services/schema/validation.ts similarity index 100% rename from packages/renderer-core/src/parts/schema/validation.ts rename to packages/renderer-core/src/services/schema/validation.ts diff --git a/packages/renderer-core/src/parts/widget/index.ts b/packages/renderer-core/src/services/widget/index.ts similarity index 100% rename from packages/renderer-core/src/parts/widget/index.ts rename to packages/renderer-core/src/services/widget/index.ts diff --git a/packages/renderer-core/src/services/widget/widget.ts b/packages/renderer-core/src/services/widget/widget.ts new file mode 100644 index 000000000..825165e00 --- /dev/null +++ b/packages/renderer-core/src/services/widget/widget.ts @@ -0,0 +1,34 @@ +import { type Spec, uniqueId } from '@alilc/lowcode-shared'; +import { clone } from 'lodash-es'; +import { IComponentTreeModel } from '../model'; + +export interface IWidget { + readonly key: string; + + readonly node: Spec.NodeType; + + model: IComponentTreeModel; + + children?: IWidget[]; +} + +export class Widget + implements IWidget +{ + public __raw: Spec.NodeType; + + public node: Spec.NodeType; + + public key: string; + + public children?: IWidget[] | undefined; + + constructor( + node: Spec.NodeType, + public model: IComponentTreeModel, + ) { + this.node = clone(node); + this.__raw = node; + this.key = (node as Spec.ComponentNode)?.id ?? uniqueId(); + } +} diff --git a/packages/renderer-core/src/types.ts b/packages/renderer-core/src/types.ts index d1f304f2f..eac89badb 100644 --- a/packages/renderer-core/src/types.ts +++ b/packages/renderer-core/src/types.ts @@ -1,26 +1,23 @@ import { type Spec } from '@alilc/lowcode-shared'; -import { type Plugin } from './parts/extension'; -import { type ISchemaService } from './parts/schema'; -import { type IPackageManagementService } from './parts/package'; -import { type IExtensionHostService } from './parts/extension'; -import { type EvalCodeFunction } from './parts/code-runtime'; +import { type Plugin } from './services/extension'; +import { type ISchemaService } from './services/schema'; +import { type IPackageManagementService } from './services/package'; +import { type CodeRuntimeInitializeOptions } from './services/code-runtime'; export interface AppOptions { schema: Spec.Project; packages?: Spec.Package[]; plugins?: Plugin[]; - /** - * 应用语言,默认值为浏览器当前语言 navigator.language - */ - locale?: string; - /** * 运行模式 */ mode?: 'development' | 'production'; - evalCodeFunction?: EvalCodeFunction; + /** + * code runtime 设置选项 + */ + codeRuntime?: CodeRuntimeInitializeOptions; } export type RendererApplication = { @@ -30,5 +27,5 @@ export type RendererApplication = { readonly packageManager: IPackageManagementService; - use: IExtensionHostService['registerPlugin']; + use(plugin: Plugin): Promise; } & Render; diff --git a/packages/renderer-core/src/utils/evaluate.ts b/packages/renderer-core/src/utils/evaluate.ts new file mode 100644 index 000000000..4be366110 --- /dev/null +++ b/packages/renderer-core/src/utils/evaluate.ts @@ -0,0 +1,5 @@ +export function evaluate(code: string, scope: any) { + return new Function('scope', `"use strict";return (function(){return (${code})}).bind(scope)();`)( + scope, + ); +} diff --git a/packages/renderer-core/src/utils/globals-es2015.ts b/packages/renderer-core/src/utils/globals-es2015.ts new file mode 100644 index 000000000..86c8e0414 --- /dev/null +++ b/packages/renderer-core/src/utils/globals-es2015.ts @@ -0,0 +1,75 @@ +// generated from https://github.com/sindresorhus/globals/blob/main/globals.json es2015 part +export const globals = [ + 'Array', + 'ArrayBuffer', + 'Boolean', + 'constructor', + 'DataView', + 'Date', + 'decodeURI', + 'decodeURIComponent', + 'encodeURI', + 'encodeURIComponent', + 'Error', + 'escape', + 'eval', + 'EvalError', + 'Float32Array', + 'Float64Array', + 'Function', + 'hasOwnProperty', + 'Infinity', + 'Int16Array', + 'Int32Array', + 'Int8Array', + 'isFinite', + 'isNaN', + 'isPrototypeOf', + 'JSON', + 'Map', + 'Math', + 'NaN', + 'Number', + 'Object', + 'parseFloat', + 'parseInt', + 'Promise', + 'propertyIsEnumerable', + 'Proxy', + 'RangeError', + 'ReferenceError', + 'Reflect', + 'RegExp', + 'Set', + 'String', + 'Symbol', + 'SyntaxError', + 'toLocaleString', + 'toString', + 'TypeError', + 'Uint16Array', + 'Uint32Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'undefined', + 'unescape', + 'URIError', + 'valueOf', + 'WeakMap', + 'WeakSet', +]; + +const spiedGlobals = [ + 'window', + 'self', + 'globalThis', + 'top', + 'parent', + 'hasOwnProperty', + 'document', + 'eval', +]; +export const trustedGlobals = [ + ...globals.filter((item) => spiedGlobals.includes(item)), + 'requestAnimationFrame', +]; diff --git a/packages/renderer-core/src/utils/node.ts b/packages/renderer-core/src/utils/node.ts new file mode 100644 index 000000000..d77ce2433 --- /dev/null +++ b/packages/renderer-core/src/utils/node.ts @@ -0,0 +1,191 @@ +/** + * TypeScript-ization estree-walker + * fork from: https://github.com/Rich-Harris/estree-walker + */ + +import { type PlainObject, type Spec } from '@alilc/lowcode-shared'; + +type Node = Spec.JSNode; + +interface WalkerContext { + skip: () => void; + remove: () => void; + replace: (node: Node) => void; +} + +class WalkerBase { + should_skip: boolean = false; + + should_remove: boolean = false; + + replacement: Node | null = null; + + context: WalkerContext; + + constructor() { + this.context = { + skip: () => (this.should_skip = true), + remove: () => (this.should_remove = true), + replace: (node) => (this.replacement = node), + }; + } + + replace( + parent: Node | null, + prop: keyof Node | null | undefined, + index: number | null | undefined, + node: Node, + ) { + if (parent && prop) { + if (index != null) { + parent[prop][index] = node; + } else { + parent[prop] = node; + } + } + } + + remove( + parent: Node | null | undefined, + prop: keyof Node | null | undefined, + index: number | null | undefined, + ) { + if (parent && prop) { + if (index !== null && index !== undefined) { + parent[prop].splice(index, 1); + } else { + delete parent[prop]; + } + } + } +} + +export type SyncWalkerHandler = ( + this: WalkerContext, + node: Node, + parent: Node | null, + key: PropertyKey | undefined, + index: number | undefined, +) => void; + +export class SyncWalker extends WalkerBase { + enter: SyncWalkerHandler | undefined; + + leave: SyncWalkerHandler | undefined; + + constructor(enter?: SyncWalkerHandler | undefined, leave?: SyncWalkerHandler | undefined) { + super(); + + this.enter = enter; + this.leave = leave; + } + + visit( + node: Node, + parent: Node | null, + prop?: keyof Node | undefined, + index?: number | undefined, + ): Node | null { + if (node) { + if (this.enter) { + const _should_skip = this.should_skip; + const _should_remove = this.should_remove; + const _replacement = this.replacement; + this.should_skip = false; + this.should_remove = false; + this.replacement = null; + + this.enter.call(this.context, node, parent, prop, index); + + if (this.replacement) { + node = this.replacement; + this.replace(parent, prop, index, node); + } + + if (this.should_remove) { + this.remove(parent, prop, index); + } + + const skipped = this.should_skip; + const removed = this.should_remove; + + this.should_skip = _should_skip; + this.should_remove = _should_remove; + this.replacement = _replacement; + + if (skipped) return node; + if (removed) return null; + } + + let key: keyof Node; + + for (key in node) { + const value = node[key] as unknown; + + if (value && typeof value === 'object') { + if (Array.isArray(value)) { + const nodes = value as unknown[]; + for (let i = 0; i < nodes.length; i += 1) { + const item = nodes[i]; + if (isNode(item)) { + if (!this.visit(item, node, key, i)) { + // removed + i--; + } + } + } + } else if (isNode(value)) { + this.visit(value, node, key, undefined); + } + } + } + + if (this.leave) { + const _replacement = this.replacement; + const _should_remove = this.should_remove; + this.replacement = null; + this.should_remove = false; + + this.leave.call(this.context, node, parent, prop, index); + + if (this.replacement) { + node = this.replacement; + this.replace(parent, prop, index, node); + } + + if (this.should_remove) { + this.remove(parent, prop, index); + } + + const removed = this.should_remove; + + this.replacement = _replacement; + this.should_remove = _should_remove; + + if (removed) return null; + } + } + + return node; + } +} + +/** + * Ducktype a node. + * + * @param {unknown} value + * @returns {value is Node} + */ +export function isNode(value: unknown): value is Node { + return ( + value !== null && typeof value === 'object' && 'type' in value && typeof value.type === 'string' + ); +} + +export function walk( + ast: PlainObject, + { enter, leave }: { enter?: SyncWalkerHandler; leave?: SyncWalkerHandler } = {}, +) { + const instance = new SyncWalker(enter, leave); + return instance.visit(ast as any, null); +} diff --git a/packages/renderer-core/src/utils/value.ts b/packages/renderer-core/src/utils/value.ts index aafcba6a0..7a10ffc27 100644 --- a/packages/renderer-core/src/utils/value.ts +++ b/packages/renderer-core/src/utils/value.ts @@ -1,40 +1,46 @@ +import { type PlainObject } from '@alilc/lowcode-shared'; import { isPlainObject, isEmpty } from 'lodash-es'; -export function someValue(obj: any, predicate: (data: any) => boolean) { - if (!isPlainObject(obj) || isEmpty(obj)) return false; - if (predicate(obj)) return true; - - for (const val of Object.values(obj)) { - if (someValue(val, predicate)) return true; +export function someValue( + obj: PlainObject | PlainObject[], + filter: (data: PlainObject) => boolean, +): boolean { + if (Array.isArray(obj)) { + return obj.some((item) => someValue(item, filter)); } - return false; + if (!isPlainObject(obj) || isEmpty(obj)) return false; + if (filter(obj)) return true; + + return Object.values(obj).some((val) => someValue(val, filter)); } -export function processValue( - obj: any, - predicate: (obj: any) => boolean, - processor: (node: any, paths: Array) => any, +export function mapValue( + obj: PlainObject, + filter: (obj: PlainObject) => boolean, + callback: (node: any, paths: Array) => any, ): any { - const innerProcess = (target: any, paths: Array): any => { + if (!someValue(obj, filter)) return obj; + + const mapping = (target: any, paths: Array): any => { if (Array.isArray(target)) { - return target.map((item, idx) => innerProcess(item, [...paths, idx])); + return target.map((item, idx) => mapping(item, [...paths, idx])); } if (!isPlainObject(target) || isEmpty(target)) return target; - if (!someValue(target, predicate)) return target; - if (predicate(target)) { - return processor(target, paths); - } else { - const result = {} as any; - for (const [key, value] of Object.entries(target)) { - result[key] = innerProcess(value, [...paths, key]); - } - - return result; + if (filter(target)) { + return callback(target, paths); } + + const result: PlainObject = {}; + + for (const [key, value] of Object.entries(target)) { + result[key] = mapping(value, [...paths, key]); + } + + return result; }; - return innerProcess(obj, []); + return mapping(obj, []); } diff --git a/packages/renderer-router/src/router.ts b/packages/renderer-router/src/router.ts index bde98fe8c..d682f91fb 100644 --- a/packages/renderer-router/src/router.ts +++ b/packages/renderer-router/src/router.ts @@ -42,6 +42,8 @@ export interface Router extends Spec.RouterApi { beforeRouteLeave: (fn: NavigationGuard) => () => void; afterRouteChange: (fn: NavigationHookAfter) => () => void; + + isReady(): Promise; } const START_LOCATION: RouteLocationNormalized = { @@ -68,6 +70,7 @@ export function createRouter(options: RouterOptions): Router { const beforeGuards = createCallback(); const afterGuards = createCallback(); + const readyHandlers = createCallback(); let currentLocation: RouteLocationNormalized = START_LOCATION; let pendingLocation = currentLocation; @@ -203,7 +206,9 @@ export function createRouter(options: RouterOptions): Router { } return navigateTriggerBeforeGuards(toLocation, from) - .catch(() => {}) + .catch((error) => { + return markAsReady(error); + }) .then(() => { finalizeNavigation(toLocation, from, true, replace, data); @@ -293,7 +298,7 @@ export function createRouter(options: RouterOptions): Router { } currentLocation = toLocation; - // markAsReady(); + markAsReady(); } let removeHistoryListener: undefined | null | (() => void); @@ -337,6 +342,32 @@ export function createRouter(options: RouterOptions): Router { }); } + let ready: boolean; + + /** + * Mark the router as ready, resolving the promised returned by isReady(). Can + * only be called once, otherwise does nothing. + * @param err - optional error + */ + function markAsReady(err?: E): E | void { + if (!ready) { + // still not ready if an error happened + ready = !err; + setupListeners(); + readyHandlers.list().forEach(([resolve, reject]) => (err ? reject(err) : resolve())); + readyHandlers.clear(); + } + + return err; + } + + function isReady(): Promise { + if (ready && currentLocation !== START_LOCATION) return Promise.resolve(); + return new Promise((resolve, reject) => { + readyHandlers.add([resolve, reject]); + }); + } + // init setupListeners(); if (currentLocation === START_LOCATION) { @@ -370,5 +401,7 @@ export function createRouter(options: RouterOptions): Router { beforeRouteLeave: beforeGuards.add, afterRouteChange: afterGuards.add, + + isReady, }; } diff --git a/packages/shared/src/abilities/instantiation/index.ts b/packages/shared/src/abilities/instantiation/index.ts index 50196be9c..c4693ed39 100644 --- a/packages/shared/src/abilities/instantiation/index.ts +++ b/packages/shared/src/abilities/instantiation/index.ts @@ -15,7 +15,7 @@ export type Constructor = new (...args: any[]) => T; export function createDecorator(serviceId: string): ServiceIdentifier { const id = ( function (target: Constructor, targetKey: string, indexOrPropertyDescriptor: any): any { - return inject(serviceId)(target, targetKey, indexOrPropertyDescriptor); + return set(serviceId)(target, targetKey, indexOrPropertyDescriptor); } ); id.toString = () => serviceId; diff --git a/packages/shared/src/abilities/intl.ts b/packages/shared/src/abilities/intl.ts index 27a205342..836ea6377 100644 --- a/packages/shared/src/abilities/intl.ts +++ b/packages/shared/src/abilities/intl.ts @@ -14,7 +14,7 @@ export class Intl { private currentMessage: ComputedSignal; private intlShape: IntlFormatter; - constructor(defaultLocale?: string, messages: LocaleTranslationsRecord = {}) { + constructor(defaultLocale: string = navigator.language, messages: LocaleTranslationsRecord = {}) { if (defaultLocale) { defaultLocale = nomarlizeLocale(defaultLocale); } else { diff --git a/packages/shared/src/abilities/storage.ts b/packages/shared/src/abilities/storage.ts index 80ea3522c..14b21496a 100644 --- a/packages/shared/src/abilities/storage.ts +++ b/packages/shared/src/abilities/storage.ts @@ -1,4 +1,3 @@ -import { invariant } from '../utils'; import { PlainObject } from '../types'; /** @@ -20,23 +19,12 @@ export interface IStore { /** * 统一存储接口 */ -export class KeyValueStore { +export class KeyValueStore implements IStore { + private readonly store = new Map(); + private setterValidation: ((key: K, value: O[K]) => boolean | string) | undefined; - private waits = new Map< - K, - { - once?: boolean; - resolve: (data: any) => void; - }[] - >(); - - constructor( - private readonly store: IStore = new Map(), - options?: { - setterValidation?: (key: K, value: O[K]) => boolean | string; - }, - ) { + constructor(options?: { setterValidation?: (key: K, value: O[K]) => boolean | string }) { if (options?.setterValidation) { this.setterValidation = options.setterValidation; } @@ -45,8 +33,8 @@ export class KeyValueStore { get(key: K, defaultValue: O[K]): O[K]; get(key: K, defaultValue?: O[K] | undefined): O[K] | undefined; get(key: K, defaultValue?: O[K]): O[K] | undefined { - const value = this.store.get(key, defaultValue); - return value; + const value = this.store.get(key); + return value ?? defaultValue; } set(key: K, value: O[K]): void { @@ -60,7 +48,6 @@ export class KeyValueStore { } this.store.set(key, value); - this.dispatchValue(key); } delete(key: K): void { @@ -74,77 +61,4 @@ export class KeyValueStore { get size(): number { return this.store.size; } - - /** - * 获取指定 key 的值,若此时还未赋值,则等待,若已有值,则直接返回值 - * 注:此函数返回 Promise 实例,只会执行(fullfill)一次 - * @param key - * @returns - */ - waitForValue(key: K) { - const val = this.get(key); - if (val !== undefined) { - return Promise.resolve(val); - } - return new Promise((resolve) => { - this.addWaiter(key, resolve, true); - }); - } - - /** - * 获取指定 key 的值,函数回调模式,若多次被赋值,回调会被多次调用 - * @param key - * @param fn - * @returns - */ - onValueChange(key: T, fn: (value: O[T]) => void): () => void { - const val = this.get(key); - if (val !== undefined) { - // @ts-expect-error: val is not undefined - fn(val); - } - this.addWaiter(key, fn as any); - return () => { - this.removeWaiter(key, fn as any); - }; - } - - private dispatchValue(key: K): void { - const waits = this.waits.get(key); - if (!waits) return; - - for (let i = waits.length - 1; i >= 0; i--) { - const waiter = waits[i]; - waiter.resolve(this.get(key)!); - if (waiter.once) { - waits.splice(i, 1); // Remove the waiter if it only waits once - } - } - - if (waits.length === 0) { - this.waits.delete(key); // No more waiters for the key - } - } - - private addWaiter(key: K, resolve: (value: O[K]) => void, once?: boolean) { - if (this.waits.has(key)) { - this.waits.get(key)!.push({ resolve, once }); - } else { - this.waits.set(key, [{ resolve, once }]); - } - } - - private removeWaiter(key: K, resolve: (value: O[K]) => void) { - const waits = this.waits.get(key); - if (!waits) return; - - this.waits.set( - key, - waits.filter((waiter) => waiter.resolve !== resolve), - ); - - if (this.waits.get(key)!.length === 0) { - this.waits.delete(key); - } - } } diff --git a/packages/shared/src/signals.ts b/packages/shared/src/signals.ts index a87082fdb..e5e0acd98 100644 --- a/packages/shared/src/signals.ts +++ b/packages/shared/src/signals.ts @@ -9,19 +9,31 @@ import { ref, computed, ReactiveEffect, + shallowRef, + type ShallowRef, type ComputedRef, type Ref, getCurrentScope, isRef, isReactive, isShallow, - EffectScheduler, + readonly, + type EffectScheduler, } from '@vue/reactivity'; import { noop, isObject, isPlainObject, isSet, isMap, isFunction } from 'lodash-es'; import { isPromise } from './utils'; -export { ref as signal, computed, watchEffect as effect, watch as reaction, isRef as isSignal }; -export type { Ref as Signal, ComputedRef as ComputedSignal }; +export { + ref as signal, + shallowRef as shallowSignal, + computed, + watchEffect as effect, + watch as reaction, + isRef as isSignal, + isShallow as isShallowSignal, + readonly, +}; +export type { Ref as Signal, ComputedRef as ComputedSignal, ShallowRef as ShallowSignal }; export type WatchSource = Ref | ComputedRef | (() => T); export type WatchEffect = (onCleanup: OnCleanup) => void; diff --git a/packages/shared/src/types/specs/lowcode-spec.ts b/packages/shared/src/types/specs/lowcode-spec.ts index b24650677..b66f30b35 100644 --- a/packages/shared/src/types/specs/lowcode-spec.ts +++ b/packages/shared/src/types/specs/lowcode-spec.ts @@ -40,7 +40,7 @@ export interface Project { /** * 当前应用配置信息 */ - config?: Record; + config?: ProjectConfig; /** * 当前应用元数据信息 */ @@ -60,6 +60,32 @@ export interface Project { pages?: PageConfig[]; } +/** + * 当前应用配置信息 + */ +export interface ProjectConfig { + /** + * 默认语言配置,不填则为 navagator.language + */ + defaultLocale?: string; + /** + * 布局组件配置 + */ + layout?: { + componentName: string; + props: Record; + }; + /** + * 默认挂载 dom 节点 + */ + targetRootID?: string; + + // todo + theme?: any; + + [key: string]: any; +} + /** * https://lowcode-engine.cn/site/docs/specs/lowcode-spec#22-%E7%BB%84%E4%BB%B6%E6%98%A0%E5%B0%84%E5%85%B3%E7%B3%BBa * 协议中用于描述 componentName 到公域组件映射关系的规范。 @@ -259,7 +285,11 @@ export interface ComponentNode { */ componentName: string; /** - * 组件属性对象 + * 默认 props + */ + defaultProps?: JSONObject; + /** + * 组件 props 对象 */ props?: ComponentNodeProps; /** @@ -415,11 +445,16 @@ export interface JSONObject { [key: string]: JSONValue | JSONObject | JSONObject[]; } +export interface JSNode { + type: string; + [key: string]: any; +} + /** * 节点类型(A) * 通常用于描述组件的某一个属性为 Node 或 Function-Return-Node 的场景。 */ -export interface JSSlot { +export interface JSSlot extends JSNode { type: 'JSSlot'; value: ComponentNode | ComponentNode[]; params?: string[]; @@ -430,7 +465,7 @@ export interface JSSlot { /** * 事件函数类型(A) */ -export interface JSFunction { +export interface JSFunction extends JSNode { type: 'JSFunction'; value: string; @@ -440,7 +475,7 @@ export interface JSFunction { /** * 变量类型(A) */ -export interface JSExpression { +export interface JSExpression extends JSNode { type: 'JSExpression'; value: string; @@ -450,7 +485,7 @@ export interface JSExpression { /** * 国际化多语言类型(AA) */ -export interface JSI18n { +export interface JSI18n extends JSNode { type: 'i18n'; /** * i18n 结构中字段的 key 标识符 @@ -462,6 +497,4 @@ export interface JSI18n { params?: Record; } -export type JSNode = JSSlot | JSExpression | JSExpression | JSI18n; - export type NodeType = string | JSExpression | JSI18n | ComponentNode; diff --git a/packages/shared/src/utils/async.ts b/packages/shared/src/utils/async.ts new file mode 100644 index 000000000..e5235feed --- /dev/null +++ b/packages/shared/src/utils/async.ts @@ -0,0 +1,46 @@ +/** + * A barrier that is initially closed and then becomes opened permanently. + */ +export class Barrier { + private _isOpen: boolean; + private _promise: Promise; + private _completePromise!: (v: boolean) => void; + + constructor() { + this._isOpen = false; + this._promise = new Promise((c) => { + this._completePromise = c; + }); + } + + isOpen(): boolean { + return this._isOpen; + } + + open(): void { + this._isOpen = true; + this._completePromise(true); + } + + wait(): Promise { + return this._promise; + } +} + +/** + * A barrier that is initially closed and then becomes opened permanently after a certain period of + * time or when open is called explicitly + */ +export class AutoOpenBarrier extends Barrier { + private readonly _timeout: any; + + constructor(autoOpenTimeMs: number) { + super(); + this._timeout = setTimeout(() => this.open(), autoOpenTimeMs); + } + + override open(): void { + clearTimeout(this._timeout); + super.open(); + } +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index d0dbb3b6a..19337a60c 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './unique-id'; export * from './type-guards'; export * from './platform'; export * from './callback'; +export * from './async';