From 7ce883e8f68d0cefaf7c655bcb1fc57cd66e93db Mon Sep 17 00:00:00 2001 From: 1ncounter <1ncounter.100@gmail.com> Date: Wed, 13 Mar 2024 11:25:30 +0800 Subject: [PATCH] feat: init branch files --- lerna.json | 3 +- package.json | 28 +- runtime/react-renderer/package.json | 11 + runtime/react-renderer/src/api/create-app.tsx | 65 +++ .../src/api/create-component.tsx | 498 ++++++++++++++++++ runtime/react-renderer/src/components/app.tsx | 58 ++ .../react-renderer/src/components/outlet.tsx | 47 ++ .../react-renderer/src/components/route.tsx | 29 + .../src/components/router-view.tsx | 42 ++ runtime/react-renderer/src/index.ts | 0 .../react-renderer/src/plugins/intl/index.ts | 57 ++ .../react-renderer/src/plugins/intl/intl.tsx | 35 ++ .../react-renderer/src/plugins/intl/parser.ts | 99 ++++ .../react-renderer/src/plugins/utils/index.ts | 98 ++++ runtime/react-renderer/src/renderer.ts | 60 +++ runtime/react-renderer/src/router.ts | 35 ++ runtime/react-renderer/src/signals.ts | 134 +++++ runtime/react-renderer/src/utils/element.ts | 157 ++++++ runtime/react-renderer/src/utils/reactive.tsx | 138 +++++ runtime/react-renderer/tsconfig.json | 3 + runtime/react-renderer/vitest.config.ts | 0 runtime/renderer-core/package.json | 15 + .../src/api/create-app-function.ts | 106 ++++ .../src/api/create-component-function.ts | 224 ++++++++ runtime/renderer-core/src/boosts.ts | 32 ++ runtime/renderer-core/src/code-runtime.ts | 150 ++++++ runtime/renderer-core/src/error.ts | 14 + runtime/renderer-core/src/index.ts | 0 runtime/renderer-core/src/package.ts | 167 ++++++ runtime/renderer-core/src/plugin.ts | 75 +++ runtime/renderer-core/src/schema.ts | 125 +++++ runtime/renderer-core/src/utils/hook.ts | 134 +++++ runtime/renderer-core/src/utils/value.ts | 40 ++ runtime/renderer-core/src/validate/schema.ts | 11 + runtime/renderer-core/tsconfig.json | 4 + runtime/renderer-core/vitest.config.ts | 0 runtime/router/package.json | 8 + runtime/router/src/guard.ts | 128 +++++ runtime/router/src/history.ts | 467 ++++++++++++++++ runtime/router/src/index.ts | 16 + runtime/router/src/matcher.ts | 256 +++++++++ runtime/router/src/router.ts | 399 ++++++++++++++ runtime/router/src/types.ts | 9 + runtime/router/src/utils/helper.ts | 55 ++ runtime/router/src/utils/path-parser.ts | 53 ++ runtime/router/src/utils/query.ts | 28 + runtime/router/src/utils/record-matcher.ts | 32 ++ runtime/router/src/utils/url.ts | 50 ++ runtime/router/tsconfig.json | 3 + runtime/router/vitest.config.ts | 0 50 files changed, 4181 insertions(+), 17 deletions(-) create mode 100644 runtime/react-renderer/package.json create mode 100644 runtime/react-renderer/src/api/create-app.tsx create mode 100644 runtime/react-renderer/src/api/create-component.tsx create mode 100644 runtime/react-renderer/src/components/app.tsx create mode 100644 runtime/react-renderer/src/components/outlet.tsx create mode 100644 runtime/react-renderer/src/components/route.tsx create mode 100644 runtime/react-renderer/src/components/router-view.tsx create mode 100644 runtime/react-renderer/src/index.ts create mode 100644 runtime/react-renderer/src/plugins/intl/index.ts create mode 100644 runtime/react-renderer/src/plugins/intl/intl.tsx create mode 100644 runtime/react-renderer/src/plugins/intl/parser.ts create mode 100644 runtime/react-renderer/src/plugins/utils/index.ts create mode 100644 runtime/react-renderer/src/renderer.ts create mode 100644 runtime/react-renderer/src/router.ts create mode 100644 runtime/react-renderer/src/signals.ts create mode 100644 runtime/react-renderer/src/utils/element.ts create mode 100644 runtime/react-renderer/src/utils/reactive.tsx create mode 100644 runtime/react-renderer/tsconfig.json create mode 100644 runtime/react-renderer/vitest.config.ts create mode 100644 runtime/renderer-core/package.json create mode 100644 runtime/renderer-core/src/api/create-app-function.ts create mode 100644 runtime/renderer-core/src/api/create-component-function.ts create mode 100644 runtime/renderer-core/src/boosts.ts create mode 100644 runtime/renderer-core/src/code-runtime.ts create mode 100644 runtime/renderer-core/src/error.ts create mode 100644 runtime/renderer-core/src/index.ts create mode 100644 runtime/renderer-core/src/package.ts create mode 100644 runtime/renderer-core/src/plugin.ts create mode 100644 runtime/renderer-core/src/schema.ts create mode 100644 runtime/renderer-core/src/utils/hook.ts create mode 100644 runtime/renderer-core/src/utils/value.ts create mode 100644 runtime/renderer-core/src/validate/schema.ts create mode 100644 runtime/renderer-core/tsconfig.json create mode 100644 runtime/renderer-core/vitest.config.ts create mode 100644 runtime/router/package.json create mode 100644 runtime/router/src/guard.ts create mode 100644 runtime/router/src/history.ts create mode 100644 runtime/router/src/index.ts create mode 100644 runtime/router/src/matcher.ts create mode 100644 runtime/router/src/router.ts create mode 100644 runtime/router/src/types.ts create mode 100644 runtime/router/src/utils/helper.ts create mode 100644 runtime/router/src/utils/path-parser.ts create mode 100644 runtime/router/src/utils/query.ts create mode 100644 runtime/router/src/utils/record-matcher.ts create mode 100644 runtime/router/src/utils/url.ts create mode 100644 runtime/router/tsconfig.json create mode 100644 runtime/router/vitest.config.ts diff --git a/lerna.json b/lerna.json index 7fad993f6..d70acb63f 100644 --- a/lerna.json +++ b/lerna.json @@ -4,7 +4,8 @@ "npmClient": "yarn", "useWorkspaces": true, "packages": [ - "packages/*" + "packages/*", + "runtime/*" ], "command": { "bootstrap": { diff --git a/package.json b/package.json index 213bbc1c4..dae20d21f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "private": true, "workspaces": { "packages": [ - "packages/*" + "packages/*", + "runtime/*" ], "nohoist": [ "**/css-modules-typescript-loader", @@ -44,30 +45,25 @@ } }, "devDependencies": { + "@alilc/build-plugin-lce": "^0.0.5", + "@alilc/lowcode-test-mate": "^1.0.1", + "@types/react-router": "5.1.18", + "babel-jest": "^26.5.2", "del": "^6.1.1", "execa": "^5.1.1", - "f2elint": "^2.0.1", + "f2elint": "^4.2.1", "gulp": "^4.0.2", "husky": "^7.0.4", "lerna": "^4.0.0", - "typescript": "4.6.2", + "typescript": "^5.4.2", "yarn": "^1.22.17", "rimraf": "^3.0.2", - "@types/react-router": "5.1.18", - "@alilc/build-plugin-lce": "^0.0.5", - "babel-jest": "^26.5.2", - "@alilc/lowcode-test-mate": "^1.0.1" + "rollup": "^4.13.0", + "vite": "^5.1.6", + "vitest": "^1.3.1" }, "engines": { - "node": ">=14.17.0 <18" - }, - "tnpm": { - "mode": "yarn", - "lockfile": "enable" - }, - "resolutions": { - "typescript": "4.6.2", - "react-error-overlay": "6.0.9" + "node": ">=14.17.0" }, "repository": "git@github.com:alibaba/lowcode-engine.git" } diff --git a/runtime/react-renderer/package.json b/runtime/react-renderer/package.json new file mode 100644 index 000000000..bc92b6dcd --- /dev/null +++ b/runtime/react-renderer/package.json @@ -0,0 +1,11 @@ +{ + "name": "@alilc/react-renderer", + "version": "2.0.0-beta.0", + "description": "", + "type": "module", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme", + "dependencies": { + "@vue/reactivity": "^3.4.21" + } +} \ No newline at end of file diff --git a/runtime/react-renderer/src/api/create-app.tsx b/runtime/react-renderer/src/api/create-app.tsx new file mode 100644 index 000000000..97f5ac7b7 --- /dev/null +++ b/runtime/react-renderer/src/api/create-app.tsx @@ -0,0 +1,65 @@ +import { + type App, + type RenderBase, + createAppFunction, + type AppOptionsBase, +} from '@alilc/runtime-core'; +import { type DataSourceCreator } from '@alilc/runtime-shared'; +import { type ComponentType } from 'react'; +import { type Root, createRoot } from 'react-dom/client'; +import { createRenderer } from '../renderer'; +import AppComponent from '../components/app'; +import { intlPlugin } from '../plugins/intl'; +import { globalUtilsPlugin } from '../plugins/utils'; +import { initRouter } from '../router'; + +export interface AppOptions extends AppOptionsBase { + dataSourceCreator: DataSourceCreator; + faultComponent?: ComponentType; +} + +export interface ReactRender extends RenderBase {} + +export type ReactApp = App; + +export const createApp = createAppFunction( + async (context, options) => { + const renderer = createRenderer(); + const appContext = { ...context, renderer }; + + initRouter(appContext); + + options.plugins ??= []; + options.plugins!.unshift(globalUtilsPlugin, intlPlugin); + + // set config + if (options.faultComponent) { + context.config.set('faultComponent', options.faultComponent); + } + context.config.set('dataSourceCreator', options.dataSourceCreator); + + let root: Root | undefined; + + const reactRender: ReactRender = { + async mount(el) { + if (root) { + return; + } + + root = createRoot(el); + root.render(); + }, + unmount() { + if (root) { + root.unmount(); + root = undefined; + } + }, + }; + + return { + renderBase: reactRender, + renderer, + }; + } +); diff --git a/runtime/react-renderer/src/api/create-component.tsx b/runtime/react-renderer/src/api/create-component.tsx new file mode 100644 index 000000000..f1350c97a --- /dev/null +++ b/runtime/react-renderer/src/api/create-component.tsx @@ -0,0 +1,498 @@ +import { + type StateContext, + type ComponentOptionsBase, + createComponentFunction, + type ComponentTreeNode, + type ComponentNode, + someValue, + type ContainerInstance, + processValue, + createNode, + type CodeRuntime, + createCodeRuntime, +} from '@alilc/runtime-core'; +import { + type AnyObject, + type Package, + type JSSlot, + type JSFunction, + isPlainObject, + isJsExpression, + isJsSlot, + isLowCodeComponentPackage, + isJsFunction, + type JSExpression, +} from '@alilc/runtime-shared'; +import { + useEffect, + type ComponentType, + type ReactNode, + type ElementType, + forwardRef, + ForwardedRef, + useMemo, + createElement, + type CSSProperties, + useRef, +} from 'react'; +import { createSignal, watch } from '../signals'; +import { appendExternalStyle } from '../helper/element'; +import { reactive } from '../helper/reactive'; + +function reactiveStateCreator(initState: AnyObject): StateContext { + const proxyState = createSignal(initState); + + return { + get state() { + return proxyState.value; + }, + setState(newState) { + if (!isPlainObject(newState)) { + throw Error('newState mush be a object'); + } + for (const key of Object.keys(newState)) { + proxyState.value[key] = newState[key]; + } + }, + }; +} + +/** + * 作为组件树节点在转换为 reactNode 的过程中的中间介质对象 + * 提供对外拓展、修改的能力 + */ +export interface ConvertedTreeNode { + type: ComponentTreeNode['type']; + /** 节点对应的值 */ + raw: ComponentTreeNode; + /** 转换时所在的上下文 */ + context: { + codeRuntime: CodeRuntime; + [key: string]: any; + }; + /** 用于渲染的组件,只存在于树节点为组件节点的情况 */ + rawComponent?: ComponentType | undefined; + + /** 获取节点对应的 reactNode,被用于渲染 */ + getReactNode(): ReactNode; + /** 设置节点对应的 reactNode */ + setReactNode(element: ReactNode): void; +} + +export interface CreateComponentOptions> + extends ComponentOptionsBase { + displayName?: string; + + beforeNodeCreateComponent?(convertedNode: ConvertedTreeNode): void; + nodeCreatedComponent?(convertedNode: ConvertedTreeNode): void; + nodeComponentRefAttached?(node: ComponentNode, instance: ElementType): void; + componentDidMount?(): void; + componentWillUnmount?(): void; +} + +export interface LowCodeComponentProps { + id?: string; + /** CSS 类名 */ + className?: string; + /** style */ + style?: CSSProperties; + + [key: string]: any; +} + +export const createComponent = createComponentFunction< + ComponentType, + CreateComponentOptions +>({ + stateCreator: reactiveStateCreator, + componentCreator: ({ codeRuntime, createInstance }, componentOptions) => { + const { + displayName = '__LowCodeComponent__', + componentsTree, + componentsRecord, + + beforeNodeCreateComponent, + nodeCreatedComponent, + nodeComponentRefAttached, + componentDidMount, + componentWillUnmount, + + ...extraOptions + } = componentOptions; + + const lowCodeComponentCache = new Map>(); + + function getComponentByName( + componentName: string, + componentsRecord: Record | Package> + ) { + const Component = componentsRecord[componentName]; + if (!Component) { + return undefined; + } + + if (isLowCodeComponentPackage(Component)) { + if (lowCodeComponentCache.has(componentName)) { + return lowCodeComponentCache.get(componentName); + } + + const componentsTree = Component.schema as any; + const LowCodeComponent = createComponent({ + ...extraOptions, + displayName: componentsTree.componentName, + componentsRecord, + componentsTree, + }); + + lowCodeComponentCache.set(componentName, LowCodeComponent); + + return LowCodeComponent; + } + + return Component; + } + + function createConvertedTreeNode( + rawNode: ComponentTreeNode, + codeRuntime: CodeRuntime + ): ConvertedTreeNode { + let elementValue: ReactNode = null; + + const node: ConvertedTreeNode = { + type: rawNode.type, + raw: rawNode, + context: { codeRuntime }, + + getReactNode() { + return elementValue; + }, + setReactNode(element) { + elementValue = element; + }, + }; + + if (rawNode.type === 'component') { + node.rawComponent = getComponentByName( + rawNode.data.componentName, + componentsRecord + ); + } + + return node; + } + + function createReactElement( + node: ComponentTreeNode, + codeRuntime: CodeRuntime, + instance: ContainerInstance, + componentsRecord: Record | Package> + ) { + const convertedNode = createConvertedTreeNode(node, codeRuntime); + + beforeNodeCreateComponent?.(convertedNode); + + if (!convertedNode.getReactNode()) { + if (convertedNode.type === 'string') { + convertedNode.setReactNode(convertedNode.raw.data as string); + } else if (convertedNode.type === 'expression') { + const rawValue = convertedNode.raw.data as JSExpression; + + function Text(props: any) { + return props.text; + } + Text.displayName = 'Text'; + + const ReactivedText = reactive(Text, { + target: { + text: rawValue, + }, + valueGetter: node => codeRuntime.parseExprOrFn(node), + }); + + convertedNode.setReactNode(); + } else if (convertedNode.type === 'component') { + const createReactElementByNode = () => { + const Component = convertedNode.rawComponent; + if (!Component) return null; + + const rawNode = convertedNode.raw as ComponentNode; + const { + id, + componentName, + condition = true, + loop, + loopArgs = ['item', 'index'], + props: nodeProps = {}, + } = rawNode.data; + + // condition为 Falsy 的情况下 不渲染 + if (!condition) return null; + // loop 为数组且为空的情况下 不渲染 + if (Array.isArray(loop) && loop.length === 0) return null; + + function createElementWithProps( + Component: ComponentType, + props: AnyObject, + codeRuntime: CodeRuntime, + key: string, + children: ReactNode[] = [] + ) { + const { ref, ...componentProps } = props; + + const refFunction = (ins: any) => { + if (ins) { + if (ref) instance.setRefInstance(ref as string, ins); + nodeComponentRefAttached?.(rawNode, ins); + } + }; + + // 先将 jsslot, jsFunction 对象转换 + const finalProps = processValue( + componentProps, + node => isJsSlot(node) || isJsFunction(node), + (node: JSSlot | JSFunction) => { + if (isJsSlot(node)) { + if (node.value) { + const nodes = ( + Array.isArray(node.value) ? node.value : [node.value] + ).map(n => createNode(n, undefined)); + + if (node.params?.length) { + return (...args: any[]) => { + const params = node.params!.reduce( + (prev, cur, idx) => { + return (prev[cur] = args[idx]); + }, + {} as AnyObject + ); + const subCodeScope = codeRuntime + .getScope() + .createSubScope(params); + const subCodeRuntime = + createCodeRuntime(subCodeScope); + + return nodes.map(n => + createReactElement( + n, + subCodeRuntime, + instance, + componentsRecord + ) + ); + }; + } else { + return nodes.map(n => + createReactElement( + n, + codeRuntime, + instance, + componentsRecord + ) + ); + } + } + } else if (isJsFunction(node)) { + return codeRuntime.parseExprOrFn(node); + } + + return null; + } + ); + + if (someValue(finalProps, isJsExpression)) { + function Props(props: any) { + return createElement( + Component, + { + ...props, + key, + ref: refFunction, + }, + children + ); + } + Props.displayName = 'Props'; + + const Reactived = reactive(Props, { + target: finalProps, + valueGetter: node => codeRuntime.parseExprOrFn(node), + }); + + return ; + } else { + return createElement( + Component, + { + ...finalProps, + key, + ref: refFunction, + }, + children + ); + } + } + + const currentComponentKey = id || componentName; + + let element: any = createElementWithProps( + Component, + nodeProps, + codeRuntime, + currentComponentKey, + rawNode.children?.map(n => + createReactElement(n, codeRuntime, instance, componentsRecord) + ) + ); + + if (loop) { + const genLoopElements = (loopData: any[]) => { + return loopData.map((item, idx) => { + const loopArgsItem = loopArgs[0] ?? 'item'; + const loopArgsIndex = loopArgs[1] ?? 'index'; + const subCodeScope = codeRuntime.getScope().createSubScope({ + [loopArgsItem]: item, + [loopArgsIndex]: idx, + }); + const subCodeRuntime = createCodeRuntime(subCodeScope); + + return createElementWithProps( + Component, + nodeProps, + subCodeRuntime, + `loop-${currentComponentKey}-${idx}`, + rawNode.children?.map(n => + createReactElement( + n, + subCodeRuntime, + instance, + componentsRecord + ) + ) + ); + }); + }; + + if (isJsExpression(loop)) { + function Loop(props: any) { + if (!Array.isArray(props.loop)) { + return null; + } + + return genLoopElements(props.loop); + } + Loop.displayName = 'Loop'; + + const ReactivedLoop = reactive(Loop, { + target: { + loop, + }, + valueGetter: expr => codeRuntime.parseExprOrFn(expr), + }); + + element = createElement(ReactivedLoop, { + key: currentComponentKey, + }); + } 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 => codeRuntime.parseExprOrFn(expr), + }); + + return createElement(ReactivedCondition, { + key: currentComponentKey, + }); + } + + return element; + }; + + convertedNode.setReactNode(createReactElementByNode()); + } + } + + nodeCreatedComponent?.(convertedNode); + + const finalElement = convertedNode.getReactNode(); + // if finalElement is null, todo.. + return finalElement; + } + + const LowCodeComponent = forwardRef(function ( + props: LowCodeComponentProps, + ref: ForwardedRef + ) { + const { id, className, style, ...extraProps } = props; + const isMounted = useRef(false); + + const instance = useMemo(() => { + return createInstance(componentsTree, extraProps); + }, []); + + useEffect(() => { + let styleEl: HTMLElement | undefined; + const scopeValue = instance.codeScope.value; + + // init dataSource + scopeValue.reloadDataSource(); + + if (instance.cssText) { + appendExternalStyle(instance.cssText).then(el => { + styleEl = el; + }); + } + + // trigger lifeCycles + componentDidMount?.(); + instance.triggerLifeCycle('componentDidMount'); + + // 当 state 改变之后调用 + const unwatch = watch(scopeValue.state, (_, oldVal) => { + if (isMounted.current) { + instance.triggerLifeCycle('componentDidUpdate', props, oldVal); + } + }); + + isMounted.current = true; + + return () => { + styleEl?.parentNode?.removeChild(styleEl); + + componentWillUnmount?.(); + instance.triggerLifeCycle('componentWillUnmount'); + unwatch(); + + isMounted.current = false; + }; + }, [instance]); + + return ( +
+ {instance + .getComponentTreeNodes() + .map(n => + createReactElement(n, codeRuntime, instance, componentsRecord) + )} +
+ ); + }); + + LowCodeComponent.displayName = displayName; + + return LowCodeComponent; + }, +}); diff --git a/runtime/react-renderer/src/components/app.tsx b/runtime/react-renderer/src/components/app.tsx new file mode 100644 index 000000000..cd89a7a00 --- /dev/null +++ b/runtime/react-renderer/src/components/app.tsx @@ -0,0 +1,58 @@ +import { AppContext, type AppContextObject } from '../context/app'; +import { createComponent } from '../api/createComponent'; +import Route from './route'; + +export default function App({ context }: { context: AppContextObject }) { + const { schema, config, renderer, packageManager, appScope } = context; + const appWrappers = renderer.getAppWrappers(); + const wrappers = renderer.getRouteWrappers(); + + function getLayoutComponent() { + const layoutName = schema.getByPath('config.layout.componentName'); + + if (layoutName) { + const Component: any = packageManager.getComponent(layoutName); + + if (Component?.devMode === 'lowCode') { + const componentsMap = schema.getComponentsMaps(); + const componentsRecord = + packageManager.getComponentsNameRecord(componentsMap); + + const Layout = createComponent({ + componentsTree: Component.schema, + componentsRecord, + + dataSourceCreator: config.get('dataSourceCreator'), + supCodeScope: appScope, + }); + + return Layout; + } + + return Component; + } + } + + const Layout = getLayoutComponent(); + + let element = ; + + if (wrappers.length > 0) { + element = wrappers.reduce((preElement, CurrentWrapper) => { + return {preElement}; + }, element); + } + + if (Layout) { + const layoutProps = schema.getByPath('config.layout.props') ?? {}; + element = {element}; + } + + if (appWrappers.length > 0) { + element = appWrappers.reduce((preElement, CurrentWrapper) => { + return {preElement}; + }, element); + } + + return {element}; +} diff --git a/runtime/react-renderer/src/components/outlet.tsx b/runtime/react-renderer/src/components/outlet.tsx new file mode 100644 index 000000000..4d91eaa6b --- /dev/null +++ b/runtime/react-renderer/src/components/outlet.tsx @@ -0,0 +1,47 @@ +import type { PageSchema, PageContainerSchema } from '@alilc/runtime-shared'; +import { useAppContext } from '../context/app'; +import { createComponent } from '../api/createComponent'; +import { PAGE_EVENTS } from '../events'; + +export interface OutletProps { + pageSchema: PageSchema; + componentsTree?: PageContainerSchema | undefined; + + [key: string]: any; +} + +export default function Outlet({ pageSchema, componentsTree }: OutletProps) { + const { schema, config, packageManager, appScope, boosts } = useAppContext(); + const { type = 'lowCode' } = pageSchema; + + if (type === 'lowCode' && componentsTree) { + const componentsMap = schema.getComponentsMaps(); + const componentsRecord = + packageManager.getComponentsNameRecord(componentsMap); + + const LowCodeComponent = createComponent({ + supCodeScope: appScope, + dataSourceCreator: config.get('dataSourceCreator'), + componentsTree, + componentsRecord, + + beforeNodeCreateComponent(node) { + boosts.hooks.call(PAGE_EVENTS.COMPONENT_BEFORE_NODE_CREATE, node); + }, + nodeCreatedComponent(result) { + boosts.hooks.call(PAGE_EVENTS.COMPONENT_NODE_CREATED, result); + }, + nodeComponentRefAttached(node, instance) { + boosts.hooks.call( + PAGE_EVENTS.COMPONENT_NODE_REF_ATTACHED, + node, + instance + ); + }, + }); + + return ; + } + + return null; +} diff --git a/runtime/react-renderer/src/components/route.tsx b/runtime/react-renderer/src/components/route.tsx new file mode 100644 index 000000000..150d61257 --- /dev/null +++ b/runtime/react-renderer/src/components/route.tsx @@ -0,0 +1,29 @@ +import { usePageSchema } from '../context/router'; +import { useAppContext } from '../context/app'; + +export default function Route(props: any) { + const { schema, renderer } = useAppContext(); + const pageSchema = usePageSchema(); + const Outlet = renderer.getOutlet(); + + if (Outlet && pageSchema) { + let componentsTree; + const { type = 'lowCode', treeId } = pageSchema; + + if (type === 'lowCode') { + componentsTree = schema + .getComponentsTrees() + .find(item => item.id === treeId); + } + + return ( + + ); + } + + return null; +} diff --git a/runtime/react-renderer/src/components/router-view.tsx b/runtime/react-renderer/src/components/router-view.tsx new file mode 100644 index 000000000..9f0ebfe64 --- /dev/null +++ b/runtime/react-renderer/src/components/router-view.tsx @@ -0,0 +1,42 @@ +import { type Router } from '@alilc/runtime-router'; +import { useState, useLayoutEffect, useMemo, type ReactNode } from 'react'; +import { + RouterContext, + RouteLocationContext, + PageSchemaContext, +} from '../context/router'; +import { useAppContext } from '../context/app'; + +export const createRouterProvider = (router: Router) => { + return function RouterProvider({ children }: { children?: ReactNode }) { + const { schema } = useAppContext(); + const [location, setCurrentLocation] = useState(router.getCurrentRoute()); + + useLayoutEffect(() => { + const remove = router.afterRouteChange(to => setCurrentLocation(to)); + return () => remove(); + }, []); + + const pageSchema = useMemo(() => { + const pages = schema.getPages(); + const matched = location.matched[location.matched.length - 1]; + + if (matched) { + const page = pages.find(item => matched.page === item.id); + return page; + } + + return undefined; + }, [location]); + + return ( + + + + {children} + + + + ); + }; +}; diff --git a/runtime/react-renderer/src/index.ts b/runtime/react-renderer/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/runtime/react-renderer/src/plugins/intl/index.ts b/runtime/react-renderer/src/plugins/intl/index.ts new file mode 100644 index 000000000..3e2191778 --- /dev/null +++ b/runtime/react-renderer/src/plugins/intl/index.ts @@ -0,0 +1,57 @@ +import { type ReactNode, createElement } from 'react'; +import { someValue } from '@alilc/runtime-core'; +import { isJsExpression } from '@alilc/runtime-shared'; +import { definePlugin } from '../../renderer'; +import { PAGE_EVENTS } from '../../events'; +import { reactive } from '../../helper/reactive'; +import { createIntl } from './intl'; + +export { createIntl }; + +declare module '@alilc/runtime-core' { + interface AppBoosts { + intl: ReturnType; + } +} + +export const intlPlugin = definePlugin({ + name: 'intl', + setup({ schema, appScope, boosts }) { + const i18nMessages = schema.getByKey('i18n') ?? {}; + const defaultLocale = schema.getByPath('config.defaultLocale') ?? 'zh-CN'; + const intl = createIntl(i18nMessages, defaultLocale); + + appScope.setValue(intl); + boosts.add('intl', intl); + + boosts.hooks.hook(PAGE_EVENTS.COMPONENT_BEFORE_NODE_CREATE, node => { + if (node.type === 'i18n') { + const { key, params } = node.raw.data; + + let element: ReactNode; + + if (someValue(params, isJsExpression)) { + function IntlText(props: any) { + return intl.i18n(key, props.params); + } + IntlText.displayName = 'IntlText'; + + const Reactived = reactive(IntlText, { + target: { + params, + }, + valueGetter(expr) { + return node.context.codeRuntime.parseExprOrFn(expr); + }, + }); + + element = createElement(Reactived, { key }); + } else { + element = intl.i18n(key, params ?? {}); + } + + node.setReactNode(element); + } + }); + }, +}); diff --git a/runtime/react-renderer/src/plugins/intl/intl.tsx b/runtime/react-renderer/src/plugins/intl/intl.tsx new file mode 100644 index 000000000..8a5a300ba --- /dev/null +++ b/runtime/react-renderer/src/plugins/intl/intl.tsx @@ -0,0 +1,35 @@ +import { parse, compile } from './parser'; +import { createSignal, computed } from '../../signals'; + +export function createIntl( + messages: Record>, + defaultLocale: string +) { + const allMessages = createSignal(messages); + const currentLocale = createSignal(defaultLocale); + const currentMessages = computed( + () => allMessages.value[currentLocale.value] + ); + + return { + i18n(key: string, params: Record) { + const message = currentMessages.value[key]; + const result = compile(parse(message), params).join(''); + + return result; + }, + getLocale() { + return currentLocale.value; + }, + setLocale(locale: string) { + currentLocale.value = locale; + }, + + addMessages(locale: string, messages: Record) { + allMessages.value[locale] = { + ...allMessages.value[locale], + ...messages, + }; + }, + }; +} diff --git a/runtime/react-renderer/src/plugins/intl/parser.ts b/runtime/react-renderer/src/plugins/intl/parser.ts new file mode 100644 index 000000000..7f20da264 --- /dev/null +++ b/runtime/react-renderer/src/plugins/intl/parser.ts @@ -0,0 +1,99 @@ +import { isObject } from '@alilc/runtime-shared'; + +const RE_TOKEN_LIST_VALUE: RegExp = /^(?:\d)+/; +const RE_TOKEN_NAMED_VALUE: RegExp = /^(?:\w)+/; + +type Token = { + type: 'text' | 'named' | 'list' | 'unknown'; + value: string; +}; + +export function parse(format: string): Array { + const tokens: Array = []; + let position: number = 0; + + let text: string = ''; + while (position < format.length) { + let char: string = format[position++]; + if (char === '{') { + if (text) { + tokens.push({ type: 'text', value: text }); + } + + text = ''; + let sub: string = ''; + char = format[position++]; + while (char !== undefined && char !== '}') { + sub += char; + char = format[position++]; + } + const isClosed = char === '}'; + + const type = RE_TOKEN_LIST_VALUE.test(sub) + ? 'list' + : isClosed && RE_TOKEN_NAMED_VALUE.test(sub) + ? 'named' + : 'unknown'; + tokens.push({ value: sub, type }); + } else if (char === '%') { + // when found rails i18n syntax, skip text capture + if (format[position] !== '{') { + text += char; + } + } else { + text += char; + } + } + + text && tokens.push({ type: 'text', value: text }); + + return tokens; +} + +export function compile( + tokens: Token[], + values: Record | any[] = {} +): string[] { + const compiled: string[] = []; + let index: number = 0; + + const mode: string = Array.isArray(values) + ? 'list' + : isObject(values) + ? 'named' + : 'unknown'; + if (mode === 'unknown') { + return compiled; + } + + while (index < tokens.length) { + const token: Token = tokens[index]; + switch (token.type) { + case 'text': + compiled.push(token.value); + break; + case 'list': + compiled.push((values as any[])[parseInt(token.value, 10)]); + break; + case 'named': + if (mode === 'named') { + compiled.push((values as Record)[token.value]); + } else { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `Type of token '${token.type}' and format of value '${mode}' don't match!` + ); + } + } + break; + case 'unknown': + if (process.env.NODE_ENV !== 'production') { + console.warn(`Detect 'unknown' type of token!`); + } + break; + } + index++; + } + + return compiled; +} diff --git a/runtime/react-renderer/src/plugins/utils/index.ts b/runtime/react-renderer/src/plugins/utils/index.ts new file mode 100644 index 000000000..e514a0041 --- /dev/null +++ b/runtime/react-renderer/src/plugins/utils/index.ts @@ -0,0 +1,98 @@ +import { createCodeRuntime, type PackageManager } from '@alilc/runtime-core'; +import { + type UtilItem, + type InternalUtils, + type ExternalUtils, + type AnyFunction, +} from '@alilc/runtime-shared'; +import { definePlugin } from '../../renderer'; + +declare module '@alilc/runtime-core' { + interface AppBoosts { + globalUtils: GlobalUtils; + } +} + +export interface GlobalUtils { + getUtil(name: string): AnyFunction; + + addUtil(utilItem: UtilItem): void; + addUtil(name: string, fn: AnyFunction): void; + + addUtils(utils: Record): void; +} + +export const globalUtilsPlugin = definePlugin({ + name: 'globalUtils', + setup({ schema, appScope, packageManager, boosts }) { + const utils = schema.getByKey('utils') ?? []; + const globalUtils = createGlobalUtils(packageManager); + + const utilsProxy = new Proxy(Object.create(null), { + get(_, p: string) { + return globalUtils.getUtil(p); + }, + set() { + return false; + }, + has(_, p: string) { + return Boolean(globalUtils.getUtil(p)); + }, + }); + + utils.forEach(globalUtils.addUtil); + + appScope.inject('utils', utilsProxy); + boosts.add('globalUtils', globalUtils); + }, +}); + +function createGlobalUtils(packageManager: PackageManager) { + const codeRuntime = createCodeRuntime(); + const utilsMap: Record = {}; + + function addUtil(item: string | UtilItem, fn?: AnyFunction) { + if (typeof item === 'string') { + if (typeof fn === 'function') { + utilsMap[item] = fn; + } + } else { + const fn = parseUtil(item); + addUtil(item.name, fn); + } + } + + const globalUtils: GlobalUtils = { + addUtil, + addUtils(utils) { + Object.keys(utils).forEach(key => addUtil(key, utils[key])); + }, + getUtil(name) { + return utilsMap[name]; + }, + }; + + function parseUtil(utilItem: UtilItem) { + if (utilItem.type === 'function') { + const { content } = utilItem as InternalUtils; + + return codeRuntime.createFnBoundScope(content.value); + } else { + const { + content: { package: packageName, destructuring, exportName, subName }, + } = utilItem as ExternalUtils; + let library: any = packageManager.getLibraryByPackageName(packageName); + + if (library) { + if (destructuring) { + const target = library[exportName!]; + library = subName ? target[subName] : target; + } + + return library; + } + } + } + + return globalUtils; +} diff --git a/runtime/react-renderer/src/renderer.ts b/runtime/react-renderer/src/renderer.ts new file mode 100644 index 000000000..d5a5c0091 --- /dev/null +++ b/runtime/react-renderer/src/renderer.ts @@ -0,0 +1,60 @@ +import { + definePlugin as definePluginFn, + type Plugin, + type PluginSetupContext, +} from '@alilc/runtime-core'; +import { type ComponentType, type PropsWithChildren } from 'react'; +import { type OutletProps } from './components/outlet'; + +export type WrapperComponent = ComponentType>; + +export type Outlet = ComponentType; + +export interface ReactRenderer { + addAppWrapper(appWrapper: WrapperComponent): void; + getAppWrappers(): WrapperComponent[]; + + addRouteWrapper(wrapper: WrapperComponent): void; + getRouteWrappers(): WrapperComponent[]; + + setOutlet(outlet: Outlet): void; + getOutlet(): Outlet | null; +} + +export function createRenderer(): ReactRenderer { + const appWrappers: WrapperComponent[] = []; + const wrappers: WrapperComponent[] = []; + + let outlet: Outlet | null = null; + + return { + 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 interface ReactRendererSetupContext extends PluginSetupContext { + renderer: ReactRenderer; +} + +export function definePlugin(plugin: Plugin) { + return definePluginFn(plugin); +} diff --git a/runtime/react-renderer/src/router.ts b/runtime/react-renderer/src/router.ts new file mode 100644 index 000000000..eb4446cad --- /dev/null +++ b/runtime/react-renderer/src/router.ts @@ -0,0 +1,35 @@ +import { + type Router, + type RouterOptions, + createRouter, +} from '@alilc/runtime-router'; +import { createRouterProvider } from './components/router-view'; +import RouteOutlet from './components/outlet'; +import { type ReactRendererSetupContext } from './renderer'; + +declare module '@alilc/runtime-core' { + interface AppBoosts { + router: Router; + } +} + +const defaultRouterOptions: RouterOptions = { + historyMode: 'browser', + baseName: '/', + routes: [], +}; + +export function initRouter(context: ReactRendererSetupContext) { + const { schema, boosts, appScope, renderer } = context; + const router = createRouter( + schema.getByKey('router') ?? defaultRouterOptions + ); + + appScope.inject('router', router); + boosts.add('router', router); + + const RouterProvider = createRouterProvider(router); + + renderer.addAppWrapper(RouterProvider); + renderer.setOutlet(RouteOutlet); +} diff --git a/runtime/react-renderer/src/signals.ts b/runtime/react-renderer/src/signals.ts new file mode 100644 index 000000000..d95b0ff6c --- /dev/null +++ b/runtime/react-renderer/src/signals.ts @@ -0,0 +1,134 @@ +import { + ref, + computed, + effect, + ReactiveEffect, + type ComputedRef, + type Ref, + getCurrentScope, + isRef, + isReactive, + isShallow, +} from '@vue/reactivity'; +import { + noop, + isObject, + isPlainObject, + isSet, + isMap, +} from '@alilc/runtime-shared'; + +export { ref as createSignal, computed, effect }; +export type { Ref as Signal, ComputedRef as ComputedSignal }; + +const INITIAL_WATCHER_VALUE = {}; + +export function watch( + source: Ref | ComputedRef | object, + cb: (value: any, oldValue: any) => any, + { + deep, + immediate, + }: { + deep?: boolean; + immediate?: boolean; + } = {} +) { + let getter: () => any; + let forceTrigger = false; + + if (isRef(source)) { + getter = () => source.value; + forceTrigger = isShallow(source); + } else if (isReactive(source)) { + getter = () => { + return deep === true + ? source + : traverse(source, deep === false ? 1 : undefined); + }; + forceTrigger = true; + } else { + getter = () => {}; + } + + if (deep) { + const baseGetter = getter; + getter = () => traverse(baseGetter()); + } + + let oldValue = INITIAL_WATCHER_VALUE; + const job = () => { + if (!effect.active || !effect.dirty) { + return; + } + + const newValue = effect.run(); + + if (deep || forceTrigger || !Object.is(newValue, oldValue)) { + cb(newValue, oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue); + oldValue = newValue; + } + }; + + const effect = new ReactiveEffect(getter, noop, job); + + const scope = getCurrentScope(); + const unwatch = () => { + effect.stop(); + if (scope) { + const i = (scope as any).effects.indexOf(effect); + if (i > -1) { + (scope as any).effects.splice(i, 1); + } + } + }; + + // initial run + if (immediate) { + job(); + } else { + oldValue = effect.run(); + } + + return unwatch; +} + +function traverse( + value: unknown, + depth?: number, + currentDepth = 0, + seen?: Set +) { + if (!isObject(value)) { + return value; + } + + if (depth && depth > 0) { + if (currentDepth >= depth) { + return value; + } + currentDepth++; + } + + seen = seen || new Set(); + if (seen.has(value)) { + return value; + } + seen.add(value); + if (isRef(value)) { + traverse(value.value, depth, currentDepth, seen); + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], depth, currentDepth, seen); + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v: any) => { + traverse(v, depth, currentDepth, seen); + }); + } else if (isPlainObject(value)) { + for (const key in value) { + traverse(value[key], depth, currentDepth, seen); + } + } + return value; +} diff --git a/runtime/react-renderer/src/utils/element.ts b/runtime/react-renderer/src/utils/element.ts new file mode 100644 index 000000000..ace87eb36 --- /dev/null +++ b/runtime/react-renderer/src/utils/element.ts @@ -0,0 +1,157 @@ +import { addLeadingSlash } from '@alilc/runtime-shared'; + +export function getElementById(id: string, tag: string = 'div') { + let el = document.getElementById(id); + if (!el) { + el = document.createElement(tag); + el.id = id; + } + + return el; +} + +const enum AssetType { + STYLE = 'style', + SCRIPT = 'script', + UNKONWN = 'unkonwn', +} + +function getAssetTypeByUrl(url: string): AssetType { + const IS_CSS_REGEX = /\.css(\?((?!\.js$).)+)?$/; + const IS_JS_REGEX = /\.[t|j]sx?(\?((?!\.css$).)+)?$/; + const IS_JSON_REGEX = /\.json$/; + + if (IS_CSS_REGEX.test(url)) { + return AssetType.STYLE; + } else if (IS_JS_REGEX.test(url) || IS_JSON_REGEX.test(url)) { + return AssetType.SCRIPT; + } + + return AssetType.UNKONWN; +} + +export async function loadPackageUrls(urls: string[]) { + const styles: string[] = []; + const scripts: string[] = []; + + for (const url of urls) { + const type = getAssetTypeByUrl(url); + + if (type === AssetType.SCRIPT) { + scripts.push(url); + } else if (type === AssetType.STYLE) { + styles.push(url); + } + } + + await Promise.all(styles.map(item => appendExternalCss(item))); + await Promise.all(scripts.map(item => appendExternalScript(item))); +} + +async function appendExternalScript( + url: string, + root: HTMLElement = document.body +): Promise { + if (url) { + const el = getIfExistAssetByUrl(url, 'script'); + 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; + + scriptElement.addEventListener( + 'load', + () => { + resolve(scriptElement); + }, + false + ); + scriptElement.addEventListener('error', error => { + if (root.contains(scriptElement)) { + root.removeChild(scriptElement); + } + reject(error); + }); + + root.appendChild(scriptElement); + }); +} + +async function appendExternalCss( + url: string, + root: HTMLElement = document.head +): Promise { + if (url) { + const el = getIfExistAssetByUrl(url, 'link'); + if (el) return el; + } + + return new Promise((resolve, reject) => { + let el: HTMLLinkElement = document.createElement('link'); + el.rel = 'stylesheet'; + el.href = url; + + el.addEventListener( + 'load', + () => { + resolve(el); + }, + false + ); + el.addEventListener('error', error => { + reject(error); + }); + + root.appendChild(el); + }); +} + +export async function appendExternalStyle( + cssText: string, + root: HTMLElement = document.head +): Promise { + return new Promise((resolve, reject) => { + let el: HTMLStyleElement = document.createElement('style'); + el.innerText = cssText; + + el.addEventListener( + 'load', + () => { + resolve(el); + }, + false + ); + el.addEventListener('error', error => { + reject(error); + }); + + root.appendChild(el); + }); +} + +function getIfExistAssetByUrl( + url: string, + tag: 'link' | 'script' +): HTMLLinkElement | HTMLScriptElement | undefined { + return Array.from(document.getElementsByTagName(tag)).find(item => { + const elUrl = + (item as HTMLLinkElement).href || (item as HTMLScriptElement).src; + + if (/^(https?:)?\/\/([\w.]+\/?)\S*/gi.test(url)) { + // if url === http://xxx.xxx + return url === elUrl; + } else { + // if url === /xx/xx/xx.xx + return `${location.origin}${addLeadingSlash(url)}` === elUrl; + } + }); +} diff --git a/runtime/react-renderer/src/utils/reactive.tsx b/runtime/react-renderer/src/utils/reactive.tsx new file mode 100644 index 000000000..d4227bb45 --- /dev/null +++ b/runtime/react-renderer/src/utils/reactive.tsx @@ -0,0 +1,138 @@ +import { + type AnyObject, + type AnyFunction, + type JSExpression, + isJsExpression, +} from '@alilc/runtime-shared'; +import { processValue } from '@alilc/runtime-core'; +import { + type ComponentType, + memo, + forwardRef, + type ForwardRefRenderFunction, + type PropsWithChildren, +} from 'react'; +import { produce } from 'immer'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { computed, watch } from '../signals'; + +export interface ReactiveStore { + value: Snapshot; + onStateChange: AnyFunction | null; + subscribe: (onStoreChange: () => void) => () => void; + getSnapshot: () => Snapshot; +} + +function createReactiveStore( + target: Record, + valueGetter: (expr: JSExpression) => any +): ReactiveStore { + let isFlushing = false; + let isFlushPending = false; + + const cleanups: Array<() => void> = []; + const waitPathToSetValueMap = new Map(); + + const initValue = processValue( + target, + isJsExpression, + (node: JSExpression, 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: AnyObject; + valueGetter: (expr: JSExpression) => any; + forwardRef?: boolean; +} + +export function reactive( + WrappedComponent: ForwardRefRenderFunction>, + { target, valueGetter, forwardRef: forwardRefOption = true }: ReactiveOptions +) { + const store = createReactiveStore(target, valueGetter); + + function WrapperComponent(props: any, ref: any) { + const actualProps = useSyncExternalStore( + store.subscribe, + store.getSnapshot + ); + return ; + } + + 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< + PropsWithChildren + >; + + Reactived.displayName = WrappedComponent.displayName = displayName; + + return hoistNonReactStatics(Reactived, WrappedComponent); +} diff --git a/runtime/react-renderer/tsconfig.json b/runtime/react-renderer/tsconfig.json new file mode 100644 index 000000000..7460ef428 --- /dev/null +++ b/runtime/react-renderer/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} \ No newline at end of file diff --git a/runtime/react-renderer/vitest.config.ts b/runtime/react-renderer/vitest.config.ts new file mode 100644 index 000000000..e69de29bb diff --git a/runtime/renderer-core/package.json b/runtime/renderer-core/package.json new file mode 100644 index 000000000..17462bca8 --- /dev/null +++ b/runtime/renderer-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@alilc/renderer-core", + "version": "2.0.0-beta.0", + "description": "", + "type": "module", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme", + "dependencies": { + "@alilc/lowcode-types": "1.3.2", + "lodash-es": "^4.17.21" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12" + } +} diff --git a/runtime/renderer-core/src/api/create-app-function.ts b/runtime/renderer-core/src/api/create-app-function.ts new file mode 100644 index 000000000..f2fa210f7 --- /dev/null +++ b/runtime/renderer-core/src/api/create-app-function.ts @@ -0,0 +1,106 @@ +import { + type ProjectSchema, + type Package, + type AnyObject, +} from '@alilc/runtime-shared'; +import { type PackageManager, createPackageManager } from '../core/package'; +import { createPluginManager, type Plugin } from '../core/plugin'; +import { createScope, type CodeScope } from '../core/codeRuntime'; +import { + appBoosts, + type AppBoosts, + type AppBoostsManager, +} from '../core/boosts'; +import { type AppSchema, createAppSchema } from '../core/schema'; + +export interface AppOptionsBase { + schema: ProjectSchema; + packages?: Package[]; + plugins?: Plugin[]; + appScopeValue?: AnyObject; +} + +export interface RenderBase { + mount: (el: HTMLElement) => void | Promise; + unmount: () => void | Promise; +} + +/** + * context for plugin or renderer + */ +export interface AppContext { + schema: AppSchema; + config: Map; + appScope: CodeScope; + packageManager: PackageManager; + boosts: AppBoostsManager; +} + +type AppCreator = ( + appContext: Omit, + appOptions: O +) => Promise<{ renderBase: T; renderer?: any }>; + +export type App = { + schema: ProjectSchema; + config: Map; + readonly boosts: AppBoosts; + + use(plugin: Plugin): Promise; +} & T; + +/** + * 创建应用 + * @param schema + * @param options + * @returns + */ +export function createAppFunction< + O extends AppOptionsBase, + T extends RenderBase = RenderBase +>(appCreator: AppCreator): (options: O) => Promise> { + return async options => { + const { schema, appScopeValue = {} } = options; + const appSchema = createAppSchema(schema); + const appConfig = new Map(); + const packageManager = createPackageManager(); + const appScope = createScope({ + ...appScopeValue, + constants: schema.constants ?? {}, + }); + + const appContext = { + schema: appSchema, + config: appConfig, + appScope, + packageManager, + boosts: appBoosts, + }; + + const { renderBase, renderer } = await appCreator(appContext, options); + const pluginManager = createPluginManager({ + ...appContext, + renderer, + }); + + if (options.plugins?.length) { + await Promise.all(options.plugins.map(p => pluginManager.add(p))); + } + + if (options.packages?.length) { + await packageManager.addPackages(options.packages); + } + + return Object.assign( + { + schema, + config: appConfig, + use: pluginManager.add, + get boosts() { + return appBoosts.value; + }, + }, + renderBase + ); + }; +} diff --git a/runtime/renderer-core/src/api/create-component-function.ts b/runtime/renderer-core/src/api/create-component-function.ts new file mode 100644 index 000000000..790098dd1 --- /dev/null +++ b/runtime/renderer-core/src/api/create-component-function.ts @@ -0,0 +1,224 @@ +import { isJsFunction } from '@alilc/runtime-shared'; +import { + type CodeRuntime, + createCodeRuntime, + type CodeScope, + createScope, +} from '../core/codeRuntime'; +import { throwRuntimeError } from '../core/error'; +import { type ComponentTreeNode, createNode } from '../helper/treeNode'; +import { validateContainerSchema } from '../helper/validator'; + +import type { + RootSchema, + DataSourceEngine, + DataSourceCreator, + AnyObject, + Package, +} from '@alilc/runtime-shared'; + +export interface StateContext { + /** 组件状态 */ + readonly state: AnyObject; + /** 状态设置方法 */ + setState: (newState: AnyObject) => void; +} + +interface ContainerInstanceScope + extends StateContext, + DataSourceEngine { + readonly props: AnyObject | undefined; + + $(ref: string): C | undefined; + + [key: string]: any; +} + +type LifeCycleName = + | 'constructor' + | 'render' + | 'componentDidMount' + | 'componentDidUpdate' + | 'componentWillUnmount' + | 'componentDidCatch'; + +export interface ContainerInstance { + readonly id?: string; + readonly cssText: string | undefined; + readonly codeScope: CodeScope; + + /** 调用生命周期方法 */ + triggerLifeCycle(lifeCycleName: LifeCycleName, ...args: any[]): void; + /** + * 设置 ref 对应的组件实例, 提供给 scope.$() 方式使用 + */ + setRefInstance(ref: string, instance: C): void; + removeRefInstance(ref: string, instance?: C): void; + /** 获取子节点内容 渲染使用 */ + getComponentTreeNodes(): ComponentTreeNode[]; + + destory(): void; +} + +export interface Container { + readonly codeScope: CodeScope; + readonly codeRuntime: CodeRuntime; + + createInstance( + componentsTree: RootSchema, + extraProps?: AnyObject + ): ContainerInstance; +} + +export interface ComponentOptionsBase { + componentsTree: RootSchema; + componentsRecord: Record; + supCodeScope?: CodeScope; + initScopeValue?: AnyObject; + dataSourceCreator: DataSourceCreator; +} + +export function createComponentFunction< + C, + O extends ComponentOptionsBase +>(options: { + stateCreator: (initState: AnyObject) => StateContext; + componentCreator: (container: Container, componentOptions: O) => C; + defaultOptions?: Partial; +}): (componentOptions: O) => C { + const { stateCreator, componentCreator, defaultOptions = {} } = options; + + return componentOptions => { + const finalOptions = Object.assign({}, defaultOptions, componentOptions); + const { + supCodeScope, + initScopeValue = {}, + dataSourceCreator, + } = finalOptions; + + const codeRuntimeScope = + supCodeScope?.createSubScope(initScopeValue) ?? + createScope(initScopeValue); + const codeRuntime = createCodeRuntime(codeRuntimeScope); + + const container: Container = { + get codeScope() { + return codeRuntimeScope; + }, + get codeRuntime() { + return codeRuntime; + }, + + createInstance(componentsTree, extraProps = {}) { + if (!validateContainerSchema(componentsTree)) { + throwRuntimeError('createComponent', 'componentsTree is not valid!'); + } + + const mapRefToComponentInstance: Map = new Map(); + + const initialState = codeRuntime.parseExprOrFn( + componentsTree.state ?? {} + ); + const stateContext = stateCreator(initialState); + + codeRuntimeScope.setValue( + Object.assign( + { + props: codeRuntime.parseExprOrFn({ + ...componentsTree.defaultProps, + ...componentsTree.props, + ...extraProps, + }), + $(ref: string) { + return mapRefToComponentInstance.get(ref); + }, + }, + stateContext, + dataSourceCreator + ? dataSourceCreator( + componentsTree.dataSource ?? ({ list: [] } as any), + stateContext + ) + : {} + ) as ContainerInstanceScope, + true + ); + + if (componentsTree.methods) { + for (const [key, fn] of Object.entries(componentsTree.methods)) { + const customMethod = codeRuntime.createFnBoundScope(fn.value); + if (customMethod) { + codeRuntimeScope.inject(key, customMethod); + } + } + } + + triggerLifeCycle('constructor'); + + function triggerLifeCycle( + lifeCycleName: LifeCycleName, + ...args: any[] + ) { + // keys 用来判断 lifeCycleName 存在于 schema 对象上,不获取原型链上的对象 + if ( + !componentsTree.lifeCycles || + !Object.keys(componentsTree.lifeCycles).includes(lifeCycleName) + ) { + return; + } + + const lifeCycleSchema = componentsTree.lifeCycles[lifeCycleName]; + if (isJsFunction(lifeCycleSchema)) { + const lifeCycleFn = codeRuntime.createFnBoundScope( + lifeCycleSchema.value + ); + if (lifeCycleFn) { + lifeCycleFn.apply(codeRuntime.getScope().value, args); + } + } + } + + const instance: ContainerInstance = { + get id() { + return componentsTree.id; + }, + get cssText() { + return componentsTree.css; + }, + get codeScope() { + return codeRuntimeScope; + }, + + triggerLifeCycle, + setRefInstance(ref, instance) { + mapRefToComponentInstance.set(ref, instance); + }, + removeRefInstance(ref) { + mapRefToComponentInstance.delete(ref); + }, + getComponentTreeNodes() { + const childNodes = componentsTree.children + ? Array.isArray(componentsTree.children) + ? componentsTree.children + : [componentsTree.children] + : []; + const treeNodes = childNodes.map(item => { + return createNode(item, undefined); + }); + + return treeNodes; + }, + + destory() { + mapRefToComponentInstance.clear(); + codeRuntimeScope.setValue({}); + }, + }; + + return instance; + }, + }; + + return componentCreator(container, componentOptions); + }; +} diff --git a/runtime/renderer-core/src/boosts.ts b/runtime/renderer-core/src/boosts.ts new file mode 100644 index 000000000..a3a4f1fa9 --- /dev/null +++ b/runtime/renderer-core/src/boosts.ts @@ -0,0 +1,32 @@ +import { type AnyFunction } from '@alilc/runtime-shared'; +import { createHooks, type Hooks } from '../helper/hook'; +import { type RuntimeError } from './error'; + +export interface AppBoosts {} + +export interface RuntimeHooks { + 'app:error': (error: RuntimeError) => void; + + [key: PropertyKey]: AnyFunction; +} + +export interface AppBoostsManager { + hooks: Hooks; + + readonly value: AppBoosts; + add(name: PropertyKey, value: any, force?: boolean): void; +} + +const boostsValue: AppBoosts = {}; + +export const appBoosts: AppBoostsManager = { + hooks: createHooks(), + + get value() { + return boostsValue; + }, + add(name: PropertyKey, value: any, force = false) { + if ((boostsValue as any)[name] && !force) return; + (boostsValue as any)[name] = value; + }, +}; diff --git a/runtime/renderer-core/src/code-runtime.ts b/runtime/renderer-core/src/code-runtime.ts new file mode 100644 index 000000000..ea65ed0d2 --- /dev/null +++ b/runtime/renderer-core/src/code-runtime.ts @@ -0,0 +1,150 @@ +import { + type AnyFunction, + type AnyObject, + JSExpression, + JSFunction, + isJsExpression, + isJsFunction, +} from '@alilc/runtime-shared'; +import { processValue } from '../utils/value'; + +export interface CodeRuntime { + run(code: string): T | undefined; + createFnBoundScope(code: string): AnyFunction | undefined; + parseExprOrFn(value: AnyObject): any; + + bindingScope(scope: CodeScope): void; + getScope(): CodeScope; +} + +export function createCodeRuntime(scope?: CodeScope): CodeRuntime { + let runtimeScope = scope ?? createScope({}); + + function run(code: string): T | undefined { + if (!code) return undefined; + + try { + return new Function( + 'scope', + `"use strict";return (function(){return (${code})}).bind(scope)();` + )(runtimeScope.value) as T; + } catch (err) { + console.log( + '%c eval error', + 'font-size:13px; background:pink; color:#bf2c9f;', + code, + scope.value, + err + ); + return undefined; + } + } + + function createFnBoundScope(code: string) { + const fn = run(code); + if (typeof fn !== 'function') return undefined; + return fn.bind(runtimeScope.value); + } + + function parseExprOrFn(value: AnyObject) { + return processValue( + value, + data => { + return isJsExpression(data) || isJsFunction(data); + }, + (node: JSExpression | JSFunction) => { + let v; + + if (node.type === 'JSExpression') { + v = run(node.value); + } else if (node.type === 'JSFunction') { + v = createFnBoundScope(node.value); + } + + if (typeof v === 'undefined' && (node as any).mock) { + return (node as any).mock; + } + return v; + } + ); + } + + return { + run, + createFnBoundScope, + parseExprOrFn, + + bindingScope(nextScope) { + runtimeScope = nextScope; + }, + getScope() { + return runtimeScope; + }, + }; +} + +export interface CodeScope { + readonly value: AnyObject; + + inject(name: string, value: any, force?: boolean): void; + setValue(value: AnyObject, replace?: boolean): void; + createSubScope(initValue: AnyObject): CodeScope; +} + +export function createScope(initValue: AnyObject): CodeScope { + const innerScope = { value: initValue }; + const proxyValue = new Proxy(Object.create(null), { + set(target, p, newValue, receiver) { + return Reflect.set(target, p, newValue, receiver); + }, + get(target, p, receiver) { + let valueTarget = innerScope; + + while (valueTarget) { + if (Reflect.has(valueTarget.value, p)) { + return Reflect.get(valueTarget.value, p, receiver); + } + valueTarget = (valueTarget as any).__parent; + } + + return Reflect.get(target, p, receiver); + }, + }); + + function inject(name: string, value: any, force = false): void { + if (innerScope.value[name] && !force) { + console.warn(`${name} 已存在值`); + return; + } + + innerScope.value[name] = value; + } + + function createSubScope(initValue: AnyObject) { + const childScope = createScope(initValue); + + (childScope as any).__raw.__parent = innerScope; + + return childScope; + } + + const scope: CodeScope = { + get value() { + // dev return value + return proxyValue; + }, + inject, + setValue(value, replace = false) { + if (replace) { + innerScope.value = { ...value }; + } else { + innerScope.value = Object.assign({}, innerScope.value, value); + } + }, + createSubScope, + }; + + Object.defineProperty(scope, '__raw', { get: () => innerScope }); + + return scope; +} diff --git a/runtime/renderer-core/src/error.ts b/runtime/renderer-core/src/error.ts new file mode 100644 index 000000000..83805ec07 --- /dev/null +++ b/runtime/renderer-core/src/error.ts @@ -0,0 +1,14 @@ +import { appBoosts } from './boosts'; + +export type ErrorType = string; + +export class RuntimeError extends Error { + constructor(public type: ErrorType, message: string) { + super(message); + appBoosts.hooks.call(`app:error`, this); + } +} + +export function throwRuntimeError(errorType: ErrorType, message: string) { + return new RuntimeError(errorType, message); +} diff --git a/runtime/renderer-core/src/index.ts b/runtime/renderer-core/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/runtime/renderer-core/src/package.ts b/runtime/renderer-core/src/package.ts new file mode 100644 index 000000000..e85bd55a7 --- /dev/null +++ b/runtime/renderer-core/src/package.ts @@ -0,0 +1,167 @@ +import { + type Package, + type ProCodeComponent, + type ComponentMap, + type LowCodeComponent, + isLowCodeComponentPackage, +} from '@alilc/runtime-shared'; + +const packageStore: Map = ((window as any).__PACKAGE_STORE__ ??= + new Map()); + +export interface PackageLoader { + name?: string; + load(packageInfo: Package, thisManager: PackageManager): Promise; + active(packageInfo: Package): boolean; +} + +export interface PackageManager { + /** + * 新增资产包 + * @param packages + */ + addPackages(packages: Package[]): Promise; + /** 通过包名获取资产包信息 */ + getPackageInfo(packageName: string): Package | undefined; + getLibraryByPackageName(packageName: string): any; + setLibraryByPackageName(packageName: string, library: any): void; + /** 新增资产包加载器 */ + addPackageLoader(loader: PackageLoader): void; + + /** 解析组件映射 */ + resolveComponentMaps(componentMaps: ComponentMap[]): void; + /** 获取组件映射对象,key = componentName value = component */ + getComponentsNameRecord( + componentMaps?: ComponentMap[] + ): Record; + /** 通过组件名获取对应的组件 */ + getComponent(componentName: string): C | undefined; + /** 注册组件 */ + registerComponentByName(componentName: string, Component: unknown): void; +} + +export function createPackageManager(): PackageManager { + const packageLoaders: PackageLoader[] = []; + const componentsRecord: Record = {}; + + const packagesRef: Package[] = []; + + async function addPackages(packages: Package[]) { + for (const item of packages) { + const newId = item.package ?? item.id; + const isExist = packagesRef.some(_ => { + const itemId = _.package ?? _.id; + return itemId === newId; + }); + + if (!isExist) { + packagesRef.push(item); + + if (!packageStore.has(newId)) { + const loader = packageLoaders.find(loader => loader.active(item)); + if (!loader) continue; + + try { + const result = await loader.load(item, manager); + if (result) packageStore.set(newId, result); + } catch (e) { + throw e; + } + } + } + } + } + + function getPackageInfo(packageName: string) { + return packagesRef.find(p => p.package === packageName); + } + + function getLibraryByPackageName(packageName: string) { + const packageInfo = getPackageInfo(packageName); + + if (packageInfo) { + return packageStore.get(packageInfo.package ?? packageInfo.id); + } + } + + function setLibraryByPackageName(packageName: string, library: any) { + packageStore.set(packageName, library); + } + + function resolveComponentMaps(componentMaps: ComponentMap[]) { + for (const map of componentMaps) { + if ((map as LowCodeComponent).devMode === 'lowCode') { + const packageInfo = packagesRef.find(_ => { + return _.id === (map as LowCodeComponent).id; + }); + + if (isLowCodeComponentPackage(packageInfo)) { + componentsRecord[map.componentName] = packageInfo; + } + } else { + const npmInfo = map as ProCodeComponent; + + if (packageStore.has(npmInfo.package)) { + const library = packageStore.get(npmInfo.package); + // export { exportName } from xxx exportName === global.libraryName.exportName + // export exportName from xxx exportName === global.libraryName.default || global.libraryName + // export { exportName as componentName } from package + // if exportName == null exportName === componentName; + // const componentName = exportName.subName, if exportName empty subName donot use + const paths = + npmInfo.exportName && npmInfo.subName + ? npmInfo.subName.split('.') + : []; + const exportName = npmInfo.exportName ?? npmInfo.componentName; + + if (npmInfo.destructuring) { + paths.unshift(exportName); + } + + let result = library; + for (const path of paths) { + result = result[path] || result; + } + + const recordName = npmInfo.componentName ?? npmInfo.exportName; + componentsRecord[recordName] = result; + } + } + } + } + + function getComponentsNameRecord(componentMaps?: ComponentMap[]) { + if (componentMaps) { + resolveComponentMaps(componentMaps); + } + + return { ...componentsRecord }; + } + + function getComponent(componentName: string) { + return componentsRecord[componentName]; + } + + function registerComponentByName(componentName: string, Component: unknown) { + componentsRecord[componentName] = Component; + } + + const manager: PackageManager = { + addPackages, + getPackageInfo, + getLibraryByPackageName, + setLibraryByPackageName, + addPackageLoader(loader) { + if (!loader.name || !packageLoaders.some(_ => _.name === loader.name)) { + packageLoaders.push(loader); + } + }, + + resolveComponentMaps, + getComponentsNameRecord, + getComponent, + registerComponentByName, + }; + + return manager; +} diff --git a/runtime/renderer-core/src/plugin.ts b/runtime/renderer-core/src/plugin.ts new file mode 100644 index 000000000..087f1ac48 --- /dev/null +++ b/runtime/renderer-core/src/plugin.ts @@ -0,0 +1,75 @@ +import { type AppContext } from '../api/create-app-function'; + +export interface Plugin { + name: string; // 插件的 name 作为唯一标识,并不可重复。 + setup(setupContext: C): void | Promise; + dependsOn?: string[]; +} + +export interface PluginSetupContext extends AppContext { + [key: string]: any; +} + +export function createPluginManager(context: PluginSetupContext) { + const installedPlugins: Plugin[] = []; + let readyToInstallPlugins: Plugin[] = []; + + const setupContext = new Proxy(context, { + get(target, p, receiver) { + return Reflect.get(target, p, receiver); + }, + set() { + return false; + }, + has(target, p) { + return Reflect.has(target, p); + }, + }); + + async function install(plugin: Plugin) { + if (installedPlugins.some(p => p.name === plugin.name)) return; + + if ( + plugin.dependsOn?.some(dep => !installedPlugins.some(p => p.name === dep)) + ) { + readyToInstallPlugins.push(plugin); + return; + } + + await plugin.setup(setupContext); + installedPlugins.push(plugin); + + // 遍历未安装的插件 寻找 dependsOn 的插件已安装完的插件进行安装 + for (const item of readyToInstallPlugins) { + if ( + item.dependsOn?.every(dep => installedPlugins.some(p => p.name === dep)) + ) { + await item.setup(setupContext); + installedPlugins.push(item); + } + } + + if (readyToInstallPlugins.length) { + readyToInstallPlugins = readyToInstallPlugins.filter(item => + installedPlugins.some(p => p.name === item.name) + ); + } + } + + return { + async add(plugin: Plugin) { + if (installedPlugins.find(item => item.name === plugin.name)) { + console.warn('该插件已安装'); + return; + } + + await install(plugin); + }, + }; +} + +export function definePlugin>( + plugin: P +) { + return plugin; +} diff --git a/runtime/renderer-core/src/schema.ts b/runtime/renderer-core/src/schema.ts new file mode 100644 index 000000000..71c28dd95 --- /dev/null +++ b/runtime/renderer-core/src/schema.ts @@ -0,0 +1,125 @@ +import type { + ProjectSchema, + RootSchema, + ComponentMap, + PageSchema, +} from '@alilc/runtime-shared'; +import { throwRuntimeError } from './error'; +import { set, get } from 'lodash-es'; + +type AppSchemaType = ProjectSchema; + +export interface AppSchema { + getComponentsTrees(): RootSchema[]; + addComponentsTree(tree: RootSchema): void; + removeComponentsTree(id: string): void; + + getComponentsMaps(): ComponentMap[]; + addComponentsMap(componentName: ComponentMap): void; + removeComponentsMap(componentName: string): void; + + getPages(): PageSchema[]; + addPage(page: PageSchema): void; + removePage(id: string): void; + + getByKey(key: K): AppSchemaType[K] | undefined; + updateByKey( + key: K, + updater: AppSchemaType[K] | ((value: AppSchemaType[K]) => AppSchemaType[K]) + ): void; + + getByPath(path: string | string[]): any; + updateByPath( + path: string | string[], + updater: any | ((value: any) => any) + ): void; + + find(predicate: (schema: AppSchemaType) => any): any; +} + +export function createAppSchema(schema: ProjectSchema): AppSchema { + if (!schema.version.startsWith('1.')) { + throwRuntimeError('core', 'schema version must be 1.x.x'); + } + + const schemaRef = structuredClone(schema); + + return { + getComponentsTrees() { + return schemaRef.componentsTree; + }, + addComponentsTree(tree) { + addArrayItem(schemaRef.componentsTree, tree, 'id'); + }, + removeComponentsTree(id) { + removeArrayItem(schemaRef.componentsTree, 'id', id); + }, + + getComponentsMaps() { + return schemaRef.componentsMap; + }, + addComponentsMap(componentsMap) { + addArrayItem(schemaRef.componentsMap, componentsMap, 'componentName'); + }, + removeComponentsMap(componentName) { + removeArrayItem(schemaRef.componentsMap, 'componentName', componentName); + }, + + getPages() { + return schemaRef.pages ?? []; + }, + addPage(page) { + schemaRef.pages ??= []; + addArrayItem(schemaRef.pages, page, 'id'); + }, + removePage(id) { + schemaRef.pages ??= []; + removeArrayItem(schemaRef.pages, 'id', id); + }, + + getByKey(key) { + return schemaRef[key]; + }, + updateByKey(key, updater) { + const value = schemaRef[key]; + + schemaRef[key] = typeof updater === 'function' ? updater(value) : updater; + }, + + find(predicate) { + return predicate(schemaRef); + }, + getByPath(path) { + return get(schemaRef, path); + }, + updateByPath(path, updater) { + set( + schemaRef, + path, + typeof updater === 'function' ? updater(this.getByPath(path)) : updater + ); + }, + }; +} + +function addArrayItem>( + target: T[], + item: T, + comparison: string +) { + const idx = target.findIndex(_ => _[comparison] === item[comparison]); + if (idx > -1) { + target.splice(idx, 1, item); + } else { + target.push(item); + } +} + +function removeArrayItem>( + target: T[], + comparison: string, + comparisonValue: any +) { + const idx = target.findIndex(item => item[comparison] === comparisonValue); + if (idx > -1) target.splice(idx, 1); +} diff --git a/runtime/renderer-core/src/utils/hook.ts b/runtime/renderer-core/src/utils/hook.ts new file mode 100644 index 000000000..c0215e1b6 --- /dev/null +++ b/runtime/renderer-core/src/utils/hook.ts @@ -0,0 +1,134 @@ +import { useCallbacks, type Callback } from '@alilc/runtime-shared'; + +export type HookCallback = (...args: any) => Promise | void; +type HookKeys = keyof T & PropertyKey; + +type InferCallback = HT[HN] extends HookCallback + ? HT[HN] + : never; + +declare global { + interface Console { + // https://developer.chrome.com/blog/devtools-modern-web-debugging/#linked-stack-traces + createTask(name: string): { + run: any>(fn: T) => ReturnType; + }; + } +} + +// https://developer.chrome.com/blog/devtools-modern-web-debugging/#linked-stack-traces +type CreateTask = typeof console.createTask; +const defaultTask: ReturnType = { run: fn => fn() }; +const _createTask: CreateTask = () => defaultTask; +const createTask = + typeof console.createTask !== 'undefined' ? console.createTask : _createTask; + +export interface Hooks< + HooksT extends Record = Record, + HookNameT extends HookKeys = HookKeys +> { + hook( + name: NameT, + fn: InferCallback + ): () => void; + call( + name: NameT, + ...args: Parameters> + ): void; + callAsync( + name: NameT, + ...args: Parameters> + ): Promise; + callParallel( + name: NameT, + ...args: Parameters> + ): Promise; + remove( + name: NameT, + fn?: InferCallback + ): void; +} + +export function createHooks< + HooksT extends Record = Record, + HookNameT extends HookKeys = HookKeys +>(): Hooks { + const hooksMap = new Map>(); + + function hook( + name: NameT, + fn: InferCallback + ) { + if (!name || typeof fn !== 'function') { + return () => {}; + } + + let hooks = hooksMap.get(name); + if (!hooks) { + hooks = useCallbacks(); + hooksMap.set(name, hooks); + } + + hooks.add(fn); + return () => remove(name, fn); + } + + function call( + name: NameT, + ...args: Parameters> + ) { + const hooks = hooksMap.get(name)?.list() ?? []; + + for (const hookFn of hooks) { + hookFn.call(null, ...args); + } + } + + function callAsync( + name: NameT, + ...args: Parameters> + ) { + const hooks = hooksMap.get(name)?.list() ?? []; + const task = createTask(name.toString()); + + return hooks.reduce( + (promise, hookFunction) => + promise.then(() => task.run(() => hookFunction(...args))), + Promise.resolve() + ); + } + + function callParallel( + name: NameT, + ...args: Parameters> + ) { + const hooks = hooksMap.get(name)?.list() ?? []; + const task = createTask(name.toString()); + return Promise.all(hooks.map(hook => task.run(() => hook(...args)))); + } + + function remove( + name: NameT, + fn?: InferCallback + ) { + const hooks = hooksMap.get(name); + if (!hooks) return; + + if (fn) { + hooks.remove(fn); + if (hooks.list.length === 0) { + hooksMap.delete(name); + } + } else { + hooksMap.delete(name); + } + } + + return { + hook, + call, + callAsync, + callParallel, + remove, + }; +} diff --git a/runtime/renderer-core/src/utils/value.ts b/runtime/renderer-core/src/utils/value.ts new file mode 100644 index 000000000..c4eff3a9b --- /dev/null +++ b/runtime/renderer-core/src/utils/value.ts @@ -0,0 +1,40 @@ +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; + } + + return false; +} + +export function processValue( + obj: any, + predicate: (obj: any) => boolean, + processor: (node: any, paths: Array) => any +): any { + const innerProcess = (target: any, paths: Array): any => { + if (Array.isArray(target)) { + return target.map((item, idx) => innerProcess(item, [...paths, idx])); + } + + if (!isPlainObject(target) || isEmptyObject(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; + } + }; + + return innerProcess(obj, []); +} diff --git a/runtime/renderer-core/src/validate/schema.ts b/runtime/renderer-core/src/validate/schema.ts new file mode 100644 index 000000000..7d850cc65 --- /dev/null +++ b/runtime/renderer-core/src/validate/schema.ts @@ -0,0 +1,11 @@ +import { type RootSchema } from '@alilc/runtime-shared'; + +const CONTAINTER_NAME = ['Page', 'Block', 'Component']; + +export function validateContainerSchema(schema: RootSchema): boolean { + if (!CONTAINTER_NAME.includes(schema.componentName)) { + return false; + } + + return true; +} diff --git a/runtime/renderer-core/tsconfig.json b/runtime/renderer-core/tsconfig.json new file mode 100644 index 000000000..674e85d9a --- /dev/null +++ b/runtime/renderer-core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} \ No newline at end of file diff --git a/runtime/renderer-core/vitest.config.ts b/runtime/renderer-core/vitest.config.ts new file mode 100644 index 000000000..e69de29bb diff --git a/runtime/router/package.json b/runtime/router/package.json new file mode 100644 index 000000000..cceb950e2 --- /dev/null +++ b/runtime/router/package.json @@ -0,0 +1,8 @@ +{ + "name": "@alilc/runtime-router", + "version": "1.0.0-beta.0", + "description": "", + "type": "module", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" +} \ No newline at end of file diff --git a/runtime/router/src/guard.ts b/runtime/router/src/guard.ts new file mode 100644 index 000000000..76cf6c477 --- /dev/null +++ b/runtime/router/src/guard.ts @@ -0,0 +1,128 @@ +import { + type RouteLocation, + type RouteLocationRaw, +} from '@alilc/runtime-shared'; +import { isRouteLocation } from './utils/helper'; + +export type NavigationHookAfter = ( + to: RouteLocation, + from: RouteLocation +) => any; + +export type NavigationGuardReturn = + | void + | Error + | RouteLocationRaw + | boolean + | NavigationGuardNextCallback; + +export type NavigationGuardNextCallback = () => any; + +export interface NavigationGuardNext { + (): void; + (error: Error): void; + (location: RouteLocationRaw): void; + (valid: boolean | undefined): void; + (cb: NavigationGuardNextCallback): void; + /** + * Allows to detect if `next` isn't called in a resolved guard. Used + * internally in DEV mode to emit a warning. Commented out to simplify + * typings. + * @internal + */ + _called?: boolean; +} + +/** + * Navigation guard. + */ +export interface NavigationGuard { + (to: RouteLocation, from: RouteLocation, next: NavigationGuardNext): + | NavigationGuardReturn + | Promise; +} + +export function guardToPromiseFn( + guard: NavigationGuard, + to: RouteLocation, + from: RouteLocation +): () => Promise { + return () => + new Promise((resolve, reject) => { + const next: NavigationGuardNext = ( + valid?: boolean | RouteLocationRaw | NavigationGuardNextCallback | Error + ) => { + if (valid === false) { + reject(); + } else if (valid instanceof Error) { + reject(valid); + } else if (isRouteLocation(valid)) { + // todo + // reject( + // createRouterError( + // ErrorTypes.NAVIGATION_GUARD_REDIRECT, + // { + // from: to, + // to: valid + // } + // ) + // ); + reject(); + } else { + resolve(); + } + }; + + // 使用 Promise.resolve 包装允许它与异步和同步守卫一起工作 + const guardReturn = guard.call( + null, + to, + from, + canOnlyBeCalledOnce(next, to, from) + ); + let guardCall = Promise.resolve(guardReturn); + + if (guard.length <= 2) guardCall = guardCall.then(next); + if (guard.length > 2) { + const message = `The "next" callback was never called inside of ${ + guard.name ? '"' + guard.name + '"' : '' + }:\n${guard.toString()}\n. If you are returning a value instead of calling "next", make sure to remove the "next" parameter from your function.`; + + if (typeof guardReturn === 'object' && 'then' in guardReturn) { + guardCall = guardCall.then(resolvedValue => { + if (!next._called) { + console.warn(message); + return Promise.reject(new Error('Invalid navigation guard')); + } + return resolvedValue; + }); + } else if (guardReturn !== undefined) { + if (!next._called) { + console.warn(message); + reject(new Error('Invalid navigation guard')); + return; + } + } + } + guardCall.catch(err => reject(err)); + }); +} + +function canOnlyBeCalledOnce( + next: NavigationGuardNext, + to: RouteLocation, + from: RouteLocation +): NavigationGuardNext { + let called = 0; + return function () { + if (called++ === 1) { + console.warn( + `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}". It should be called exactly one time in each navigation guard. This will fail in production.` + ); + } + next._called = true; + if (called === 1) { + next.apply(null, arguments as any); + } + }; +} diff --git a/runtime/router/src/history.ts b/runtime/router/src/history.ts new file mode 100644 index 000000000..056d1eaf9 --- /dev/null +++ b/runtime/router/src/history.ts @@ -0,0 +1,467 @@ +import { useCallbacks } from '@alilc/runtime-shared'; + +export type HistoryState = Record; +export type HistoryLocation = string; + +export enum NavigationType { + pop = 'pop', + push = 'push', +} + +export enum NavigationDirection { + back = 'back', + forward = 'forward', + unknown = '', +} + +export type NavigationInformation = { + type: NavigationType; + direction: NavigationDirection; + delta: number; +}; + +export type NavigationCallback = ( + to: HistoryLocation, + from: HistoryLocation, + info: NavigationInformation +) => void; + +/** + * history router 的内部实现,用于包裹一层 window.history 的行为 + */ +export interface RouterHistory { + /** + * 基础路径。 + * eg: sub-folder 是 example.com/sub-folder 中的 base path + */ + readonly base: string; + /** current location */ + readonly location: HistoryLocation; + /** current state */ + readonly state: HistoryState; + + /** + * 导航到新的 location,类似于 history 的 pushState 方法 + * @param to 需要导航到的 location + * @param data 可选,新的 state {@link HistoryState} + */ + push(to: HistoryLocation, data?: HistoryState): void; + + /** + * 修改当前的 location,类似于 history 的 replaceState 方法 + * @param to 需要设置的 location + * @param data 可选,新的 state {@link HistoryState} + */ + replace(to: HistoryLocation, data?: HistoryState): void; + + /** + * 载入到会话历史中的某一特定页面,通过与当前页面相对位置来标志 (当前页面的相对位置标志为 0). 如 window.history.go + * @param delta - 页面个数,如果 delta > 0 则前进,如果 delta < 0 则后退 + * @param triggerListeners - 是否触发 history 监听器 + */ + go(delta: number, triggerListeners?: boolean): void; + + /** + * 生成 location 对应的 href + * @param location + * @returns + */ + createHref(location: HistoryLocation): string; + + /** + * 为 history 添加前进或后退的监听事件 + * @param callback - 监听器 + * @returns 返回一个移除该监听器的回调 + */ + listen(callback: NavigationCallback): () => void; + + /** + * 销毁,清除所有的 history 监听事件 + */ + destroy(): void; +} + +type RouterHistoryState = HistoryState & { + back: HistoryLocation | null; + current: HistoryLocation; + forward: HistoryLocation | null; + position: number; + replaced: boolean; +}; + +function buildState( + back: HistoryLocation | null, + current: HistoryLocation, + forward: HistoryLocation | null, + replaced = false, + position = window.history.length +): RouterHistoryState { + return { + name: '__ROUTER_STATE__', + back, + current, + forward, + replaced, + position, + }; +} + +export function createBrowserHistory(base?: string): RouterHistory { + const finalBase = normalizeBase(base); + const { history, location } = window; + + let currentLocation: HistoryLocation = createCurrentLocation( + finalBase, + location + ); + let historyState: RouterHistoryState = history.state; + + if (!historyState) { + doDomHistoryEvent( + currentLocation, + buildState(null, currentLocation, null, true), + true + ); + } + + function doDomHistoryEvent( + to: HistoryLocation, + state: RouterHistoryState, + replace: boolean + ) { + // 处理 hash 情况下的 url + const hashIndex = finalBase.indexOf('#'); + const url = + hashIndex > -1 + ? finalBase.slice(hashIndex) + to + : location.protocol + '//' + location.host + finalBase + to; + + try { + history[replace ? 'replaceState' : 'pushState'](state, '', url); + historyState = state; + } catch (err) { + console.error(err); + } + } + + function replace(to: HistoryLocation, data?: HistoryState) { + const state = Object.assign( + {}, + history.state, + buildState(historyState.back, to, historyState.forward, true), + data, + { position: historyState.position } + ); + + doDomHistoryEvent(to, state, true); + currentLocation = to; + } + + function push(to: HistoryLocation, data?: HistoryState) { + const currentState: RouterHistoryState = Object.assign( + {}, + historyState, + history.state, + { + forward: to, + } + ); + + // 防止当前浏览器的 state 被修改先 replace 一次 + // 将上次的state 的 forward 修改为 to + doDomHistoryEvent(currentState.current, currentState, true); + + const state = Object.assign( + {}, + buildState(currentLocation, to, null), + { position: currentState.position + 1 }, + data + ); + + doDomHistoryEvent(to, state, false); + currentLocation = to; + } + + let listeners = useCallbacks(); + let teardowns = useCallbacks<() => void>(); + + let pauseState: HistoryLocation | null = null; + + /** + * popState 事件监听器 + * popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。 + * 即,在同一文档的两个历史记录条目之间导航会触发该事件。 + * @param event.state - https://developer.mozilla.org/zh-CN/docs/Web/API/Window/popstate_event + */ + function onPopState({ state }: PopStateEvent) { + const to = createCurrentLocation(finalBase, location); + const from: HistoryLocation = currentLocation; + const fromState: RouterHistoryState = historyState; + let delta = 0; + + if (state) { + currentLocation = to; + historyState = state; + + if (pauseState && pauseState === from) { + pauseState = null; + return; + } + delta = fromState ? state.position - fromState.position : 0; + } else { + replace(to); + } + + for (const listener of listeners.list()) { + listener(currentLocation, from, { + delta, + type: NavigationType.pop, + direction: delta + ? delta > 0 + ? NavigationDirection.forward + : NavigationDirection.back + : NavigationDirection.unknown, + }); + } + } + + function pauseListeners() { + pauseState = currentLocation; + } + + function go(delta: number, triggerListeners = true) { + if (!triggerListeners) pauseListeners(); + history.go(delta); + } + + window.addEventListener('popstate', onPopState); + + function listen(listener: NavigationCallback) { + const teardown = listeners.add(listener); + teardowns.add(teardown); + + return teardown; + } + + function destroy() { + listeners.clear(); + + for (const teardown of teardowns.list()) { + teardown(); + } + teardowns.clear(); + + window.removeEventListener('popstate', onPopState); + } + + return { + get base() { + return finalBase; + }, + get location() { + return currentLocation; + }, + get state() { + return historyState; + }, + + createHref(location) { + const BEFORE_HASH_RE = /^[^#]+#/; + return finalBase.replace(BEFORE_HASH_RE, '#') + location; + }, + + push, + replace, + go, + + listen, + destroy, + }; +} + +function normalizeBase(base?: string) { + if (!base) { + // strip full URL origin + base = document.baseURI.replace(/^\w+:\/\/[^\/]+/, ''); + } + + // 处理边界问题 确保是一个浏览器路径 如 /xxx #/xxx + if (base[0] !== '/' && base[0] !== '#') base = '/' + base; + + // 去除末尾斜杆 方便 base + fullPath 的处理 + return removeTrailingSlash(base); +} + +const TRAILING_SLASH_RE = /\/$/; +export const removeTrailingSlash = (path: string) => + path.replace(TRAILING_SLASH_RE, ''); + +function createCurrentLocation(base: string, location: Location) { + const { pathname, search, hash } = location; + // 处理 hash 相关逻辑 + // hash bases like #, /#, #/, #!, #!/, /#!/, or even /folder#end + const hashPos = base.indexOf('#'); + if (hashPos > -1) { + let slicePos = hash.includes(base.slice(hashPos)) + ? base.slice(hashPos).length + : 1; + let pathFromHash = hash.slice(slicePos); + // prepend the starting slash to hash so the url starts with /# + if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash; + + return stripBase(pathFromHash, ''); + } + + const path = stripBase(pathname, base); + + return path + search + hash; +} + +/** + * 从 location.pathname 中剥离 base 路径,不区分大小写 + */ +export function stripBase(pathname: string, base: string): string { + if (!base || !pathname.toLowerCase().startsWith(base.toLowerCase())) { + return pathname; + } + return pathname.slice(base.length) || '/'; +} + +/** + * 创建一个 hash history。 + * 用于没有 host (e.g. `file://`)或不想要更改浏览器路径的 web 应用。 + * @param base - 可选,history 的基础路径。默认为 `location.pathname + location.search` + */ +export function createHashHistory(base?: string): RouterHistory { + base = location.host ? base || location.pathname + location.search : ''; + // 允许用户在中间提供一个“#”,如 “/base/#/app” + if (!base.includes('#')) base += '#'; + + if (!base.endsWith('#/') && !base.endsWith('#')) { + console.warn( + `A hash base must end with a "#":\n"${base}" should be "${base.replace( + /#.*$/, + '#' + )}".` + ); + } + + return createBrowserHistory(base); +} + +/** + * 创建一个基于内存的 history + */ +export function createMemoryHistory(base = ''): RouterHistory { + const finalBase = normalizeBase(base); + + let position = 0; + let historyStack: { + location: HistoryLocation; + state: RouterHistoryState; + }[] = [{ location: '', state: buildState(null, '', null, false, position) }]; + + function pushStack(location: HistoryLocation, state: RouterHistoryState) { + if (position !== historyStack.length) { + // we are in the middle, we remove everything from here in the queue + historyStack.splice(position); + } + + historyStack.push({ location, state }); + } + + const listeners = useCallbacks(); + + function triggerListeners( + to: HistoryLocation, + from: HistoryLocation, + { direction, delta }: Pick + ): void { + const info: NavigationInformation = { + direction, + delta, + type: NavigationType.pop, + }; + for (const callback of listeners.list()) { + callback(to, from, info); + } + } + + return { + get base() { + return finalBase; + }, + get state() { + return historyStack[position].state; + }, + get location() { + return historyStack[position].location; + }, + + createHref(location) { + const BEFORE_HASH_RE = /^[^#]+#/; + return finalBase.replace(BEFORE_HASH_RE, '#') + location; + }, + + replace(to, data) { + const state = Object.assign( + {}, + historyStack[position].state, + { + current: to, + }, + data, + { position } + ); + + // remove current entry and decrement position + historyStack.splice(position, 1); + + pushStack(to, state); + }, + + push(to, data) { + const prevState = Object.assign({}, historyStack[position].state, { + forward: to, + }); + + historyStack.splice(position, 1); + pushStack(prevState.current, prevState); + + const currentState = Object.assign( + {}, + buildState(prevState.current, to, null, false), + data, + { position: ++position } + ); + + pushStack(to, currentState); + }, + go(delta, shouldTriggerListeners = true) { + const from = this.location; + const direction: NavigationDirection = + delta < 0 ? NavigationDirection.back : NavigationDirection.forward; + + position = Math.max( + 0, + Math.min(position + delta, historyStack.length - 1) + ); + + if (shouldTriggerListeners) { + triggerListeners(this.location, from, { + direction, + delta, + }); + } + }, + + listen: listeners.add, + destroy() { + listeners.clear(); + position = 0; + historyStack = [ + { location: '', state: buildState(null, '', null, false, position) }, + ]; + }, + }; +} diff --git a/runtime/router/src/index.ts b/runtime/router/src/index.ts new file mode 100644 index 000000000..109f3f83f --- /dev/null +++ b/runtime/router/src/index.ts @@ -0,0 +1,16 @@ +export { createRouter } from './router'; +export { + createBrowserHistory, + createHashHistory, + createMemoryHistory, +} from './history'; + +export type { RouterHistory } from './history'; +export type { NavigationGuard, NavigationHookAfter } from './guard'; +export type { Router, RouterOptions } from './router'; +export type { RouteParams, LocationQuery, RouteRecord } from './types'; +export type { + RouteLocation, + RouteLocationRaw, + RouteLocationOptions, +} from '@alilc/runtime-shared'; diff --git a/runtime/router/src/matcher.ts b/runtime/router/src/matcher.ts new file mode 100644 index 000000000..b3c28a694 --- /dev/null +++ b/runtime/router/src/matcher.ts @@ -0,0 +1,256 @@ +import { type AnyObject, pick } from '@alilc/runtime-shared'; +import type { RouteRecord, RouteParams } from './types'; +import { + createRouteRecordMatcher, + type RouteRecordMatcher, +} from './utils/record-matcher'; +import { type PathParserOptions } from './utils/path-parser'; + +export interface MatcherLocationAsPath { + path: string; +} +export interface MatcherLocationAsRelative { + params?: Record; +} +export interface MatcherLocationAsName { + name: string; + params?: RouteParams; +} + +/** + * 匹配器的路由参数 + */ +export type MatcherLocationRaw = + | MatcherLocationAsPath + | MatcherLocationAsName + | MatcherLocationAsRelative; + +export type RouteRecordNormalized = Required< + Pick +> & { + /** + * {@link RouteRecord.name} + */ + name: string | undefined; + /** + * {@link RouteRecord.redirect} + */ + redirect: RouteRecord['redirect'] | undefined; +}; + +export interface MatcherLocation { + name: string | undefined; + path: string; + params: RouteParams; + matched: RouteRecord[]; + meta: AnyObject; +} + +export interface RouterMatcher { + addRoute: (record: RouteRecord, parent?: RouteRecordMatcher) => void; + removeRoute: { + (matcher: RouteRecordMatcher): void; + (name: string): void; + }; + getRoutes: () => RouteRecordMatcher[]; + getRecordMatcher: (name: string) => RouteRecordMatcher | undefined; + + /** + * Resolves a location. + * Gives access to the route record that corresponds to the actual path as well as filling the corresponding params objects + * + * @param location - MatcherLocationRaw to resolve to a url + * @param currentLocation - MatcherLocation of the current location + */ + resolve: ( + location: MatcherLocationRaw, + currentLocation: MatcherLocation + ) => MatcherLocation; +} + +export function createRouterMatcher( + records: RouteRecord[], + globalOptions: PathParserOptions +): RouterMatcher { + const matchers: RouteRecordMatcher[] = []; + const matcherMap = new Map(); + + function addRoute(record: RouteRecord, parent?: RouteRecordMatcher) { + const normalizedRecord = normalizeRouteRecord(record); + const options: PathParserOptions = Object.assign( + {}, + globalOptions, + pick(record, ['end', 'sensitive', 'strict']) + ); + + // 如果子路由不是绝对路径,则构建嵌套路由的路径。 + // 仅在子路径不为空且父路径没有尾部斜杠时添加 / 分隔符。 + const { path } = normalizedRecord; + if (parent && path[0] !== '/') { + const parentPath = parent.record.path; + const connectingSlash = + parentPath[parentPath.length - 1] === '/' ? '' : '/'; + normalizedRecord.path = + parent.record.path + (path && connectingSlash + path); + } + + const matcher = createRouteRecordMatcher(normalizedRecord, parent, options); + + if (normalizedRecord.children) { + const children = normalizedRecord.children; + for (let i = 0; i < children.length; i++) { + addRoute(children[i], matcher); + } + } + + if (matcher.record.path) { + matchers.push(matcher); + + if (matcher.record.name) { + matcherMap.set(matcher.record.name, matcher); + } + } + } + + function removeRoute(matcherRef: string | RouteRecordMatcher) { + if (typeof matcherRef === 'string') { + const matcher = matcherMap.get(matcherRef); + if (matcher) { + matcherMap.delete(matcherRef); + matchers.splice(matchers.indexOf(matcher), 1); + matcher.children.forEach(removeRoute); + } + } else { + const index = matchers.indexOf(matcherRef); + if (index > -1) { + matchers.splice(index, 1); + if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name); + matcherRef.children.forEach(removeRoute); + } + } + } + + function getRoutes() { + return matchers; + } + + function getRecordMatcher(name: string) { + return matcherMap.get(name); + } + + function resolve( + location: MatcherLocationRaw, + currentLocation: MatcherLocation + ): MatcherLocation { + let matcher: RouteRecordMatcher | undefined; + let params: RouteParams = {}; + let path: MatcherLocation['path']; + let name: MatcherLocation['name']; + + if ('name' in location && location.name) { + matcher = matcherMap.get(location.name); + + if (!matcher) { + throw new Error( + `Router error: no match for ${JSON.stringify(location)}` + ); + } + + name = matcher.record.name; + // 从当前路径与传入的参数中获取 params + params = Object.assign( + paramsFromLocation( + currentLocation.params, + matcher.keys + .filter(k => { + return !(k.modifier === '?' || k.modifier === '*'); + }) + .map(k => k.name) + ), + location.params + ? paramsFromLocation( + location.params, + matcher.keys.map(k => k.name) + ) + : {} + ); + // throws if cannot be stringified + path = matcher.stringify(params); + } else if ('path' in location) { + path = location.path; + matcher = matchers.find(m => m.re.test(path)); + + if (matcher) { + name = matcher.record.name; + params = Object.assign(params, matcher.parse(path)); + } + } else { + matcher = currentLocation.name + ? matcherMap.get(currentLocation.name) + : matchers.find(m => m.re.test(currentLocation.path)); + + if (!matcher) { + throw new Error( + `no match for ${JSON.stringify(location)}, ${JSON.stringify( + currentLocation + )}` + ); + } + + name = matcher.record.name; + params = Object.assign({}, currentLocation.params, location.params); + path = matcher.stringify(params); + } + + const matched: RouteRecord[] = []; + let parentMatcher: RouteRecordMatcher | undefined = matcher; + while (parentMatcher) { + matched.unshift(parentMatcher.record); + parentMatcher = parentMatcher?.parent; + } + + return { + name, + path, + params, + meta: matcher?.record.meta ?? {}, + matched, + }; + } + + records.forEach(r => addRoute(r)); + + return { + resolve, + + addRoute, + removeRoute, + getRoutes, + getRecordMatcher, + }; +} + +function paramsFromLocation( + params: RouteParams, + keys: (string | number)[] +): RouteParams { + const newParams = {} as RouteParams; + + for (const key of keys) { + if (key in params) newParams[key] = params[key]; + } + + return newParams; +} + +export function normalizeRouteRecord( + record: RouteRecord +): RouteRecordNormalized { + return { + path: record.path, + redirect: record.redirect, + name: record.name, + page: record.page, + children: record.children || [], + }; +} diff --git a/runtime/router/src/router.ts b/runtime/router/src/router.ts new file mode 100644 index 000000000..aa9fb0d1f --- /dev/null +++ b/runtime/router/src/router.ts @@ -0,0 +1,399 @@ +import { + type RouterSchema, + useCallbacks, + type RouteLocation, + type RouteLocationRaw, + type RouteLocationOptions, + noop, +} from '@alilc/runtime-shared'; +import { + createBrowserHistory, + createHashHistory, + createMemoryHistory, + type RouterHistory, + type HistoryState, +} from './history'; +import { createRouterMatcher, type MatcherLocationRaw } from './matcher'; +import { type PathParserOptions } from './utils/path-parser'; +import { parseURL, stringifyURL } from './utils/url'; +import { normalizeQuery } from './utils/query'; +import { isSameRouteLocation } from './utils/helper'; +import type { RouteParams, RouteRecord } from './types'; +import { + type NavigationHookAfter, + type NavigationGuard, + guardToPromiseFn, +} from './guard'; + +export interface Router { + readonly options: RouterOptions; + readonly history: RouterHistory; + + getCurrentRoute: () => RouteLocation; + + addRoute: { + (parentName: string, route: RouteRecord): void; + (route: RouteRecord): void; + }; + removeRoute(name: string): void; + hasRoute(name: string): boolean; + getRoutes(): RouteRecord[]; + + push: (to: RouteLocationRaw) => void; + replace: (to: RouteLocationRaw) => void; + + beforeRouteLeave: (fn: NavigationGuard) => () => void; + afterRouteChange: (fn: NavigationHookAfter) => () => void; +} + +export type RouterOptions = RouterSchema & PathParserOptions; + +const START_LOCATION_NORMALIZED: RouteLocation = { + path: '/', + name: undefined, + params: {}, + query: {}, + hash: '', + fullPath: '/', + matched: [], + meta: {}, + redirectedFrom: undefined, +}; + +export function createRouter(options: RouterOptions): Router { + const { + baseName = '/', + historyMode = 'browser', + routes = [], + ...globalOptions + } = options; + const matcher = createRouterMatcher(routes, globalOptions); + const routerHistory = + historyMode === 'hash' + ? createHashHistory(baseName) + : historyMode === 'memory' + ? createMemoryHistory(baseName) + : createBrowserHistory(baseName); + + const beforeGuards = useCallbacks(); + const afterGuards = useCallbacks(); + + let currentRoute: RouteLocation = START_LOCATION_NORMALIZED; + let pendingLocation = currentRoute; + + function resolve( + rawLocation: RouteLocationRaw, + currentLocation?: RouteLocation + ): RouteLocation & { + href: string; + } { + currentLocation = Object.assign({}, currentLocation || currentRoute); + + if (typeof rawLocation === 'string') { + const locationNormalized = parseURL(rawLocation); + + const matchedRoute = matcher.resolve( + { path: locationNormalized.path }, + currentLocation + ); + + const href = routerHistory.createHref(locationNormalized.fullPath); + + return Object.assign(locationNormalized, matchedRoute, { + query: locationNormalized.query as any, + hash: decodeURIComponent(locationNormalized.hash), + redirectedFrom: undefined, + href, + }); + } + + let matcherLocation: MatcherLocationRaw; + + if ('path' in rawLocation) { + matcherLocation = { ...rawLocation }; + } else { + // 删除无效参数 + const targetParams = { ...rawLocation.params }; + for (const key in targetParams) { + if (targetParams[key] == null) { + delete targetParams[key]; + } + } + + matcherLocation = { + ...rawLocation, + params: rawLocation.params as RouteParams, + }; + currentLocation.params = currentLocation.params; + } + + const matchedRoute = matcher.resolve(matcherLocation, currentLocation); + const hash = rawLocation.hash || ''; + const fullPath = stringifyURL({ + ...rawLocation, + hash, + path: matchedRoute.path, + }); + const href = routerHistory.createHref(fullPath); + + return Object.assign( + { + fullPath, + hash, + query: normalizeQuery(rawLocation.query) as any, + }, + matchedRoute, + { + redirectedFrom: undefined, + href, + } + ); + } + + function addRoute(parentOrRoute: string | RouteRecord, route?: RouteRecord) { + let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined; + let record: RouteRecord; + if (typeof parentOrRoute === 'string') { + parent = matcher.getRecordMatcher(parentOrRoute); + record = route!; + } else { + record = parentOrRoute; + } + + matcher.addRoute(record, parent); + } + function removeRoute(name: string) { + const recordMatcher = matcher.getRecordMatcher(name); + if (recordMatcher) { + matcher.removeRoute(recordMatcher); + } + } + function getRoutes() { + return matcher.getRoutes().map(item => item.record); + } + function hasRoute(name: string) { + return !!matcher.getRecordMatcher(name); + } + + function push(to: RouteLocationRaw) { + return pushOrRedirect(to); + } + function replace(to: RouteLocationRaw) { + return pushOrRedirect({ ...locationAsObject(to), replace: true }); + } + + function locationAsObject( + to: RouteLocationRaw | RouteLocation + ): Exclude | RouteLocation { + return typeof to === 'string' ? parseURL(to) : { ...to }; + } + + async function pushOrRedirect( + to: RouteLocationRaw | RouteLocation, + redirectedFrom?: RouteLocation + ) { + const targetLocation = (pendingLocation = resolve(to)); + const from = currentRoute; + const data: HistoryState | undefined = (to as RouteLocationOptions).state; + const force: boolean | undefined = (to as RouteLocationOptions).force; + const replace = (to as RouteLocationOptions).replace === true; + + const shouldRedirect = getRedirectRecordIfShould(targetLocation); + if (shouldRedirect) { + return pushOrRedirect( + { + ...shouldRedirect, + state: Object.assign({}, data, shouldRedirect.state), + force, + replace, + }, + redirectedFrom || targetLocation + ); + } + + const toLocation = targetLocation as RouteLocation; + toLocation.redirectedFrom = redirectedFrom; + + if (!force && isSameRouteLocation(from, toLocation)) { + throw Error( + '路由错误:重复请求' + JSON.stringify({ to: toLocation, from }) + ); + } + + return navigateTriggerBeforeGuards(toLocation, from) + .catch(() => {}) + .then(() => { + finalizeNavigation(toLocation, from, true, replace, data); + + for (const guard of afterGuards.list()) { + guard(toLocation, from); + } + }); + } + + function getRedirectRecordIfShould(to: RouteLocation) { + const lastMatched = to.matched[to.matched.length - 1]; + + if (lastMatched?.redirect) { + const { redirect } = lastMatched; + let newTargetLocation = + typeof redirect === 'function' ? redirect(to) : redirect; + + if (typeof newTargetLocation === 'string') { + newTargetLocation = + newTargetLocation.includes('?') || newTargetLocation.includes('#') + ? locationAsObject(newTargetLocation) + : { path: newTargetLocation }; + // @ts-expect-error 强制清空参数 + newTargetLocation.params = {}; + } + + if (!('path' in newTargetLocation) && !('name' in newTargetLocation)) { + throw new Error('Invalid redirect'); + } + + return Object.assign( + { + query: to.query, + hash: to.hash, + // path 存在的时候 清空 params + params: 'path' in newTargetLocation ? {} : to.params, + }, + newTargetLocation + ); + } + } + + async function navigateTriggerBeforeGuards( + to: RouteLocation, + from: RouteLocation + ): Promise { + let guards: ((...args: any[]) => Promise)[] = []; + + const canceledNavigationCheck = async (): Promise => { + if (pendingLocation !== to) { + throw Error( + `路由错误:重复导航,from: ${from.fullPath}, to: ${to.fullPath}` + ); + } + return Promise.resolve(); + }; + + try { + guards = []; + const beforeGuardsList = beforeGuards.list(); + + for (const guard of beforeGuardsList) { + guards.push(guardToPromiseFn(guard, to, from)); + } + if (beforeGuardsList.length > 0) guards.push(canceledNavigationCheck); + + return guards.reduce( + (promise, guard) => promise.then(() => guard()), + Promise.resolve() + ); + } catch (err) { + throw err; + } + } + + function finalizeNavigation( + toLocation: RouteLocation, + from: RouteLocation, + isPush: boolean, + replace?: boolean, + data?: HistoryState + ) { + // 重复导航 + if (pendingLocation !== toLocation) { + throw Error( + `路由错误:重复导航,from: ${from.fullPath}, to: ${toLocation.fullPath}` + ); + } + + // 如果不是第一次启动的话 只需要考虑 push + const isFirstNavigation = from === START_LOCATION_NORMALIZED; + + if (isPush) { + if (replace || isFirstNavigation) { + routerHistory.replace(toLocation.fullPath, data); + } else { + routerHistory.push(toLocation.fullPath, data); + } + } + + currentRoute = toLocation; + // markAsReady(); + } + + let removeHistoryListener: undefined | null | (() => void); + function setupListeners() { + if (removeHistoryListener) return; + removeHistoryListener = routerHistory.listen((to, _from, info) => { + const toLocation = resolve(to); + + // 判断是否需要重定向 + const shouldRedirect = getRedirectRecordIfShould(toLocation); + if (shouldRedirect) { + return pushOrRedirect( + Object.assign(shouldRedirect, { replace: true }), + toLocation + ).catch(() => {}); + } + + pendingLocation = toLocation; + const from = currentRoute; + + // 触发路由守卫 + navigateTriggerBeforeGuards(toLocation, from) + .catch(error => { + if (info.delta) { + routerHistory.go(-info.delta, false); + } + return error; + }) + .then((failure: any) => { + failure = failure || finalizeNavigation(toLocation, from, false); + + if (failure) { + if (info.delta) { + // 还原之前的导航 + routerHistory.go(-info.delta, false); + } + } + + for (const guard of afterGuards.list()) { + guard(toLocation, from); + } + }) + .catch(noop); + }); + } + + // init + setupListeners(); + if (currentRoute === START_LOCATION_NORMALIZED) { + push(routerHistory.location).catch(err => { + console.warn('Unexpected error when starting the router:', err); + }); + } + + return { + options, + get history() { + return routerHistory; + }, + + getCurrentRoute: () => currentRoute, + addRoute, + removeRoute, + getRoutes, + hasRoute, + + push, + replace, + + beforeRouteLeave: beforeGuards.add, + afterRouteChange: afterGuards.add, + }; +} diff --git a/runtime/router/src/types.ts b/runtime/router/src/types.ts new file mode 100644 index 000000000..4dfa8b05f --- /dev/null +++ b/runtime/router/src/types.ts @@ -0,0 +1,9 @@ +import { type RouteSchema } from '@alilc/runtime-shared'; +import { type ParsedQs } from 'qs'; +import { type PathParserOptions } from './utils/path-parser'; + +export type RouteRecord = RouteSchema & PathParserOptions; + +export type LocationQuery = ParsedQs; + +export type RouteParams = Record; diff --git a/runtime/router/src/utils/helper.ts b/runtime/router/src/utils/helper.ts new file mode 100644 index 000000000..1dbd496b8 --- /dev/null +++ b/runtime/router/src/utils/helper.ts @@ -0,0 +1,55 @@ +import { + type RouteLocation, + type RouteLocationRaw, +} from '@alilc/runtime-shared'; + +export function isRouteLocation(route: any): route is RouteLocationRaw { + return typeof route === 'string' || (route && typeof route === 'object'); +} + +export function isSameRouteLocation( + a: RouteLocation, + b: RouteLocation +): boolean { + const aLastIndex = a.matched.length - 1; + const bLastIndex = b.matched.length - 1; + + return ( + aLastIndex > -1 && + aLastIndex === bLastIndex && + a.matched[aLastIndex] === b.matched[bLastIndex] && + isSameRouteLocationParams(a.params, b.params) && + a.query?.toString() === b.query?.toString() && + a.hash === b.hash + ); +} + +export function isSameRouteLocationParams( + a: RouteLocation['params'], + b: RouteLocation['params'] +): boolean { + if (Object.keys(a).length !== Object.keys(b).length) return false; + + for (const key in a) { + if (!isSameRouteLocationParamsValue(a[key], b[key])) return false; + } + + return true; +} + +function isSameRouteLocationParamsValue( + a: string | readonly string[], + b: string | readonly string[] +): boolean { + return Array.isArray(a) + ? isEquivalentArray(a, b) + : Array.isArray(b) + ? isEquivalentArray(b, a) + : a === b; +} + +function isEquivalentArray(a: readonly T[], b: readonly T[] | T): boolean { + return Array.isArray(b) + ? a.length === b.length && a.every((value, i) => value === b[i]) + : a.length === 1 && a[0] === b; +} diff --git a/runtime/router/src/utils/path-parser.ts b/runtime/router/src/utils/path-parser.ts new file mode 100644 index 000000000..fc1cc5b7d --- /dev/null +++ b/runtime/router/src/utils/path-parser.ts @@ -0,0 +1,53 @@ +import { + pathToRegexp, + match, + compile, + type Key, + type TokensToRegexpOptions, +} from 'path-to-regexp'; +import type { RouteParams } from '../types'; + +export interface PathParser { + re: RegExp; + /** + * optional = token.modifier === "?" || token.modifier === "*"; + * repeat = token.modifier === "*" || token.modifier === "+"; + */ + keys: Key[]; + /** + * 解析路径中的参数 + */ + parse: (path: string) => RouteParams | undefined; + stringify: (params: RouteParams) => string; +} + +export type PathParserOptions = Pick< + TokensToRegexpOptions, + 'end' | 'strict' | 'sensitive' +>; + +export function createPathParser(path: string, options: PathParserOptions) { + if (!path.startsWith('/')) { + throw new Error( + `Route paths should start with a "/": "${path}" should be "/${path}".` + ); + } + + const keys: Key[] = []; + const re = pathToRegexp(path, keys, options); + const parse = match(path); + const stringify = compile(path, { encode: encodeURIComponent }); + + return { + re, + keys, + parse: (path: string) => { + const parsed = parse(path); + if (!parsed) return undefined; + return parsed.params as RouteParams; + }, + stringify: (params: RouteParams) => { + return stringify(params); + }, + }; +} diff --git a/runtime/router/src/utils/query.ts b/runtime/router/src/utils/query.ts new file mode 100644 index 000000000..a617e771d --- /dev/null +++ b/runtime/router/src/utils/query.ts @@ -0,0 +1,28 @@ +import { type AnyObject } from '@alilc/runtime-shared'; +import type { LocationQuery } from '../types'; + +/** + * casting numbers into strings, removing keys with an undefined value and replacing + * undefined with null in arrays + * + * 将数字转换为字符,去除值为 undefined 的 keys,将数组中的 undefined 转换为 null + * + * @param query - query object to normalize + * @returns a normalized query object + */ +export function normalizeQuery(query: AnyObject | undefined): LocationQuery { + const normalizedQuery: AnyObject = {}; + + for (const key in query) { + const value = query[key]; + if (value !== undefined) { + normalizedQuery[key] = Array.isArray(value) + ? value.map(v => (v == null ? null : '' + v)) + : value == null + ? value + : '' + value; + } + } + + return normalizedQuery; +} diff --git a/runtime/router/src/utils/record-matcher.ts b/runtime/router/src/utils/record-matcher.ts new file mode 100644 index 000000000..d53f068f7 --- /dev/null +++ b/runtime/router/src/utils/record-matcher.ts @@ -0,0 +1,32 @@ +import { + type PathParser, + createPathParser, + type PathParserOptions, +} from './path-parser'; +import type { RouteRecord } from '../types'; + +export interface RouteRecordMatcher extends PathParser { + record: RouteRecord; + parent: RouteRecordMatcher | undefined; + children: RouteRecordMatcher[]; +} + +export function createRouteRecordMatcher( + record: RouteRecord, + parent: RouteRecordMatcher | undefined, + options: PathParserOptions = {} +): RouteRecordMatcher { + const parser = createPathParser(record.path, options); + + const matcher = Object.assign(parser, { + record, + parent, + children: [], + }); + + if (parent) { + parent.children.push(matcher); + } + + return matcher; +} diff --git a/runtime/router/src/utils/url.ts b/runtime/router/src/utils/url.ts new file mode 100644 index 000000000..6c1740713 --- /dev/null +++ b/runtime/router/src/utils/url.ts @@ -0,0 +1,50 @@ +import { type AnyObject } from '@alilc/runtime-shared'; +import { parse, stringify } from 'qs'; +import { type LocationQuery } from '../types'; + +export function parseURL(location: string) { + let path = ''; + let query: LocationQuery = {}; + let searchString = ''; + let hash = ''; + + const hashPos = location.indexOf('#'); + let searchPos = location.indexOf('?'); + if (hashPos < searchPos && hashPos >= 0) { + searchPos = -1; + } + + if (searchPos > -1) { + path = location.slice(0, searchPos); + searchString = location.slice( + searchPos + 1, + hashPos > -1 ? hashPos : location.length + ); + + query = parse(searchString); + } + + if (hashPos > -1) { + path = path || location.slice(0, hashPos); + // keep the # character + hash = location.slice(hashPos, location.length); + } + + path = path || location; + + return { + fullPath: path + (searchString && '?') + searchString + hash, + path, + query, + hash, + }; +} + +export function stringifyURL(location: { + path: string; + query?: AnyObject; + hash?: string; +}): string { + const query: string = location.query ? stringify(location.query) : ''; + return location.path + (query && '?') + query + (location.hash || ''); +} diff --git a/runtime/router/tsconfig.json b/runtime/router/tsconfig.json new file mode 100644 index 000000000..7460ef428 --- /dev/null +++ b/runtime/router/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} \ No newline at end of file diff --git a/runtime/router/vitest.config.ts b/runtime/router/vitest.config.ts new file mode 100644 index 000000000..e69de29bb