mirror of
https://github.com/alibaba/lowcode-engine.git
synced 2025-12-11 18:42:56 +00:00
feat: init branch files
This commit is contained in:
parent
d7dfde5452
commit
7ce883e8f6
@ -4,7 +4,8 @@
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"runtime/*"
|
||||
],
|
||||
"command": {
|
||||
"bootstrap": {
|
||||
|
||||
28
package.json
28
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"
|
||||
}
|
||||
|
||||
11
runtime/react-renderer/package.json
Normal file
11
runtime/react-renderer/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
65
runtime/react-renderer/src/api/create-app.tsx
Normal file
65
runtime/react-renderer/src/api/create-app.tsx
Normal file
@ -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<any>;
|
||||
}
|
||||
|
||||
export interface ReactRender extends RenderBase {}
|
||||
|
||||
export type ReactApp = App<ReactRender>;
|
||||
|
||||
export const createApp = createAppFunction<AppOptions, ReactRender>(
|
||||
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(<AppComponent context={appContext} />);
|
||||
},
|
||||
unmount() {
|
||||
if (root) {
|
||||
root.unmount();
|
||||
root = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
renderBase: reactRender,
|
||||
renderer,
|
||||
};
|
||||
}
|
||||
);
|
||||
498
runtime/react-renderer/src/api/create-component.tsx
Normal file
498
runtime/react-renderer/src/api/create-component.tsx
Normal file
@ -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<any> | undefined;
|
||||
|
||||
/** 获取节点对应的 reactNode,被用于渲染 */
|
||||
getReactNode(): ReactNode;
|
||||
/** 设置节点对应的 reactNode */
|
||||
setReactNode(element: ReactNode): void;
|
||||
}
|
||||
|
||||
export interface CreateComponentOptions<C = ComponentType<any>>
|
||||
extends ComponentOptionsBase<C> {
|
||||
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<LowCodeComponentProps>,
|
||||
CreateComponentOptions
|
||||
>({
|
||||
stateCreator: reactiveStateCreator,
|
||||
componentCreator: ({ codeRuntime, createInstance }, componentOptions) => {
|
||||
const {
|
||||
displayName = '__LowCodeComponent__',
|
||||
componentsTree,
|
||||
componentsRecord,
|
||||
|
||||
beforeNodeCreateComponent,
|
||||
nodeCreatedComponent,
|
||||
nodeComponentRefAttached,
|
||||
componentDidMount,
|
||||
componentWillUnmount,
|
||||
|
||||
...extraOptions
|
||||
} = componentOptions;
|
||||
|
||||
const lowCodeComponentCache = new Map<string, ComponentType<any>>();
|
||||
|
||||
function getComponentByName(
|
||||
componentName: string,
|
||||
componentsRecord: Record<string, ComponentType<any> | 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<string, ComponentType<any> | 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(<ReactivedText key={rawValue.value} />);
|
||||
} 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<any>,
|
||||
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 <Reactived key={key} />;
|
||||
} 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<any>
|
||||
) {
|
||||
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 (
|
||||
<div id={id} className={className} style={style} ref={ref}>
|
||||
{instance
|
||||
.getComponentTreeNodes()
|
||||
.map(n =>
|
||||
createReactElement(n, codeRuntime, instance, componentsRecord)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LowCodeComponent.displayName = displayName;
|
||||
|
||||
return LowCodeComponent;
|
||||
},
|
||||
});
|
||||
58
runtime/react-renderer/src/components/app.tsx
Normal file
58
runtime/react-renderer/src/components/app.tsx
Normal file
@ -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<any>(componentsMap);
|
||||
|
||||
const Layout = createComponent({
|
||||
componentsTree: Component.schema,
|
||||
componentsRecord,
|
||||
|
||||
dataSourceCreator: config.get('dataSourceCreator'),
|
||||
supCodeScope: appScope,
|
||||
});
|
||||
|
||||
return Layout;
|
||||
}
|
||||
|
||||
return Component;
|
||||
}
|
||||
}
|
||||
|
||||
const Layout = getLayoutComponent();
|
||||
|
||||
let element = <Route />;
|
||||
|
||||
if (wrappers.length > 0) {
|
||||
element = wrappers.reduce((preElement, CurrentWrapper) => {
|
||||
return <CurrentWrapper>{preElement}</CurrentWrapper>;
|
||||
}, element);
|
||||
}
|
||||
|
||||
if (Layout) {
|
||||
const layoutProps = schema.getByPath('config.layout.props') ?? {};
|
||||
element = <Layout {...layoutProps}>{element}</Layout>;
|
||||
}
|
||||
|
||||
if (appWrappers.length > 0) {
|
||||
element = appWrappers.reduce((preElement, CurrentWrapper) => {
|
||||
return <CurrentWrapper>{preElement}</CurrentWrapper>;
|
||||
}, element);
|
||||
}
|
||||
|
||||
return <AppContext.Provider value={context}>{element}</AppContext.Provider>;
|
||||
}
|
||||
47
runtime/react-renderer/src/components/outlet.tsx
Normal file
47
runtime/react-renderer/src/components/outlet.tsx
Normal file
@ -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<any>(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 <LowCodeComponent />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
29
runtime/react-renderer/src/components/route.tsx
Normal file
29
runtime/react-renderer/src/components/route.tsx
Normal file
@ -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 (
|
||||
<Outlet
|
||||
{...props}
|
||||
pageSchema={pageSchema}
|
||||
componentsTree={componentsTree}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
42
runtime/react-renderer/src/components/router-view.tsx
Normal file
42
runtime/react-renderer/src/components/router-view.tsx
Normal file
@ -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 (
|
||||
<RouterContext.Provider value={router}>
|
||||
<RouteLocationContext.Provider value={location}>
|
||||
<PageSchemaContext.Provider value={pageSchema}>
|
||||
{children}
|
||||
</PageSchemaContext.Provider>
|
||||
</RouteLocationContext.Provider>
|
||||
</RouterContext.Provider>
|
||||
);
|
||||
};
|
||||
};
|
||||
0
runtime/react-renderer/src/index.ts
Normal file
0
runtime/react-renderer/src/index.ts
Normal file
57
runtime/react-renderer/src/plugins/intl/index.ts
Normal file
57
runtime/react-renderer/src/plugins/intl/index.ts
Normal file
@ -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<typeof createIntl>;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
35
runtime/react-renderer/src/plugins/intl/intl.tsx
Normal file
35
runtime/react-renderer/src/plugins/intl/intl.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { parse, compile } from './parser';
|
||||
import { createSignal, computed } from '../../signals';
|
||||
|
||||
export function createIntl(
|
||||
messages: Record<string, Record<string, string>>,
|
||||
defaultLocale: string
|
||||
) {
|
||||
const allMessages = createSignal(messages);
|
||||
const currentLocale = createSignal(defaultLocale);
|
||||
const currentMessages = computed(
|
||||
() => allMessages.value[currentLocale.value]
|
||||
);
|
||||
|
||||
return {
|
||||
i18n(key: string, params: Record<string, string>) {
|
||||
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<string, string>) {
|
||||
allMessages.value[locale] = {
|
||||
...allMessages.value[locale],
|
||||
...messages,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
99
runtime/react-renderer/src/plugins/intl/parser.ts
Normal file
99
runtime/react-renderer/src/plugins/intl/parser.ts
Normal file
@ -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<Token> {
|
||||
const tokens: Array<Token> = [];
|
||||
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<string, any> | 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<string, any>)[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;
|
||||
}
|
||||
98
runtime/react-renderer/src/plugins/utils/index.ts
Normal file
98
runtime/react-renderer/src/plugins/utils/index.ts
Normal file
@ -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<string, AnyFunction>): 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<string, AnyFunction> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
60
runtime/react-renderer/src/renderer.ts
Normal file
60
runtime/react-renderer/src/renderer.ts
Normal file
@ -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<PropsWithChildren<{}>>;
|
||||
|
||||
export type Outlet = ComponentType<OutletProps>;
|
||||
|
||||
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<ReactRendererSetupContext>) {
|
||||
return definePluginFn(plugin);
|
||||
}
|
||||
35
runtime/react-renderer/src/router.ts
Normal file
35
runtime/react-renderer/src/router.ts
Normal file
@ -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);
|
||||
}
|
||||
134
runtime/react-renderer/src/signals.ts
Normal file
134
runtime/react-renderer/src/signals.ts
Normal file
@ -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<T = any>(
|
||||
source: Ref<T> | ComputedRef<T> | 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<unknown>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
157
runtime/react-renderer/src/utils/element.ts
Normal file
157
runtime/react-renderer/src/utils/element.ts
Normal file
@ -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<HTMLElement> {
|
||||
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<HTMLElement> {
|
||||
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<HTMLElement> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
138
runtime/react-renderer/src/utils/reactive.tsx
Normal file
138
runtime/react-renderer/src/utils/reactive.tsx
Normal file
@ -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<Snapshot = AnyObject> {
|
||||
value: Snapshot;
|
||||
onStateChange: AnyFunction | null;
|
||||
subscribe: (onStoreChange: () => void) => () => void;
|
||||
getSnapshot: () => Snapshot;
|
||||
}
|
||||
|
||||
function createReactiveStore<Snapshot = AnyObject>(
|
||||
target: Record<string, any>,
|
||||
valueGetter: (expr: JSExpression) => any
|
||||
): ReactiveStore<Snapshot> {
|
||||
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<Snapshot> = {
|
||||
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<TProps extends AnyObject = AnyObject>(
|
||||
WrappedComponent: ForwardRefRenderFunction<PropsWithChildren<TProps>>,
|
||||
{ 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 <WrappedComponent {...props} {...actualProps} ref={ref} />;
|
||||
}
|
||||
|
||||
const componentName =
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
const displayName = `Reactive(${componentName})`;
|
||||
|
||||
const _Reactived = forwardRefOption
|
||||
? forwardRef(WrapperComponent)
|
||||
: WrapperComponent;
|
||||
const Reactived = memo(_Reactived) as unknown as ComponentType<
|
||||
PropsWithChildren<TProps>
|
||||
>;
|
||||
|
||||
Reactived.displayName = WrappedComponent.displayName = displayName;
|
||||
|
||||
return hoistNonReactStatics(Reactived, WrappedComponent);
|
||||
}
|
||||
3
runtime/react-renderer/tsconfig.json
Normal file
3
runtime/react-renderer/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
0
runtime/react-renderer/vitest.config.ts
Normal file
0
runtime/react-renderer/vitest.config.ts
Normal file
15
runtime/renderer-core/package.json
Normal file
15
runtime/renderer-core/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
106
runtime/renderer-core/src/api/create-app-function.ts
Normal file
106
runtime/renderer-core/src/api/create-app-function.ts
Normal file
@ -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<void>;
|
||||
unmount: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* context for plugin or renderer
|
||||
*/
|
||||
export interface AppContext {
|
||||
schema: AppSchema;
|
||||
config: Map<string, any>;
|
||||
appScope: CodeScope;
|
||||
packageManager: PackageManager;
|
||||
boosts: AppBoostsManager;
|
||||
}
|
||||
|
||||
type AppCreator<O, T> = (
|
||||
appContext: Omit<AppContext, 'renderer'>,
|
||||
appOptions: O
|
||||
) => Promise<{ renderBase: T; renderer?: any }>;
|
||||
|
||||
export type App<T extends RenderBase = RenderBase> = {
|
||||
schema: ProjectSchema;
|
||||
config: Map<string, any>;
|
||||
readonly boosts: AppBoosts;
|
||||
|
||||
use(plugin: Plugin): Promise<void>;
|
||||
} & T;
|
||||
|
||||
/**
|
||||
* 创建应用
|
||||
* @param schema
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export function createAppFunction<
|
||||
O extends AppOptionsBase,
|
||||
T extends RenderBase = RenderBase
|
||||
>(appCreator: AppCreator<O, T>): (options: O) => Promise<App<T>> {
|
||||
return async options => {
|
||||
const { schema, appScopeValue = {} } = options;
|
||||
const appSchema = createAppSchema(schema);
|
||||
const appConfig = new Map<string, any>();
|
||||
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
|
||||
);
|
||||
};
|
||||
}
|
||||
224
runtime/renderer-core/src/api/create-component-function.ts
Normal file
224
runtime/renderer-core/src/api/create-component-function.ts
Normal file
@ -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<C = any>
|
||||
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<C = any> {
|
||||
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<C> {
|
||||
componentsTree: RootSchema;
|
||||
componentsRecord: Record<string, C | Package>;
|
||||
supCodeScope?: CodeScope;
|
||||
initScopeValue?: AnyObject;
|
||||
dataSourceCreator: DataSourceCreator;
|
||||
}
|
||||
|
||||
export function createComponentFunction<
|
||||
C,
|
||||
O extends ComponentOptionsBase<C>
|
||||
>(options: {
|
||||
stateCreator: (initState: AnyObject) => StateContext;
|
||||
componentCreator: (container: Container, componentOptions: O) => C;
|
||||
defaultOptions?: Partial<O>;
|
||||
}): (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<string, C> = 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<C>,
|
||||
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<C> = {
|
||||
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);
|
||||
};
|
||||
}
|
||||
32
runtime/renderer-core/src/boosts.ts
Normal file
32
runtime/renderer-core/src/boosts.ts
Normal file
@ -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<RuntimeHooks>;
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
150
runtime/renderer-core/src/code-runtime.ts
Normal file
150
runtime/renderer-core/src/code-runtime.ts
Normal file
@ -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<T = unknown>(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<T = unknown>(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;
|
||||
}
|
||||
14
runtime/renderer-core/src/error.ts
Normal file
14
runtime/renderer-core/src/error.ts
Normal file
@ -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);
|
||||
}
|
||||
0
runtime/renderer-core/src/index.ts
Normal file
0
runtime/renderer-core/src/index.ts
Normal file
167
runtime/renderer-core/src/package.ts
Normal file
167
runtime/renderer-core/src/package.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import {
|
||||
type Package,
|
||||
type ProCodeComponent,
|
||||
type ComponentMap,
|
||||
type LowCodeComponent,
|
||||
isLowCodeComponentPackage,
|
||||
} from '@alilc/runtime-shared';
|
||||
|
||||
const packageStore: Map<string, any> = ((window as any).__PACKAGE_STORE__ ??=
|
||||
new Map());
|
||||
|
||||
export interface PackageLoader {
|
||||
name?: string;
|
||||
load(packageInfo: Package, thisManager: PackageManager): Promise<any>;
|
||||
active(packageInfo: Package): boolean;
|
||||
}
|
||||
|
||||
export interface PackageManager {
|
||||
/**
|
||||
* 新增资产包
|
||||
* @param packages
|
||||
*/
|
||||
addPackages(packages: Package[]): Promise<void>;
|
||||
/** 通过包名获取资产包信息 */
|
||||
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<C = unknown>(
|
||||
componentMaps?: ComponentMap[]
|
||||
): Record<string, C>;
|
||||
/** 通过组件名获取对应的组件 */
|
||||
getComponent<C = unknown>(componentName: string): C | undefined;
|
||||
/** 注册组件 */
|
||||
registerComponentByName(componentName: string, Component: unknown): void;
|
||||
}
|
||||
|
||||
export function createPackageManager(): PackageManager {
|
||||
const packageLoaders: PackageLoader[] = [];
|
||||
const componentsRecord: Record<string, any> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
75
runtime/renderer-core/src/plugin.ts
Normal file
75
runtime/renderer-core/src/plugin.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { type AppContext } from '../api/create-app-function';
|
||||
|
||||
export interface Plugin<C extends PluginSetupContext = PluginSetupContext> {
|
||||
name: string; // 插件的 name 作为唯一标识,并不可重复。
|
||||
setup(setupContext: C): void | Promise<void>;
|
||||
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<C extends PluginSetupContext, P = Plugin<C>>(
|
||||
plugin: P
|
||||
) {
|
||||
return plugin;
|
||||
}
|
||||
125
runtime/renderer-core/src/schema.ts
Normal file
125
runtime/renderer-core/src/schema.ts
Normal file
@ -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<RootSchema>;
|
||||
|
||||
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<K extends keyof AppSchemaType>(key: K): AppSchemaType[K] | undefined;
|
||||
updateByKey<K extends keyof AppSchemaType>(
|
||||
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<T extends Record<string, any>>(
|
||||
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<T extends Record<string, any>>(
|
||||
target: T[],
|
||||
comparison: string,
|
||||
comparisonValue: any
|
||||
) {
|
||||
const idx = target.findIndex(item => item[comparison] === comparisonValue);
|
||||
if (idx > -1) target.splice(idx, 1);
|
||||
}
|
||||
134
runtime/renderer-core/src/utils/hook.ts
Normal file
134
runtime/renderer-core/src/utils/hook.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { useCallbacks, type Callback } from '@alilc/runtime-shared';
|
||||
|
||||
export type HookCallback = (...args: any) => Promise<void> | void;
|
||||
type HookKeys<T> = keyof T & PropertyKey;
|
||||
|
||||
type InferCallback<HT, HN extends keyof HT> = 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: <T extends () => any>(fn: T) => ReturnType<T>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.chrome.com/blog/devtools-modern-web-debugging/#linked-stack-traces
|
||||
type CreateTask = typeof console.createTask;
|
||||
const defaultTask: ReturnType<CreateTask> = { run: fn => fn() };
|
||||
const _createTask: CreateTask = () => defaultTask;
|
||||
const createTask =
|
||||
typeof console.createTask !== 'undefined' ? console.createTask : _createTask;
|
||||
|
||||
export interface Hooks<
|
||||
HooksT extends Record<PropertyKey, any> = Record<PropertyKey, HookCallback>,
|
||||
HookNameT extends HookKeys<HooksT> = HookKeys<HooksT>
|
||||
> {
|
||||
hook<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
fn: InferCallback<HooksT, NameT>
|
||||
): () => void;
|
||||
call<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
...args: Parameters<InferCallback<HooksT, NameT>>
|
||||
): void;
|
||||
callAsync<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
...args: Parameters<InferCallback<HooksT, NameT>>
|
||||
): Promise<void>;
|
||||
callParallel<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
...args: Parameters<InferCallback<HooksT, NameT>>
|
||||
): Promise<void[]>;
|
||||
remove<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
fn?: InferCallback<HooksT, NameT>
|
||||
): void;
|
||||
}
|
||||
|
||||
export function createHooks<
|
||||
HooksT extends Record<PropertyKey, any> = Record<PropertyKey, HookCallback>,
|
||||
HookNameT extends HookKeys<HooksT> = HookKeys<HooksT>
|
||||
>(): Hooks<HooksT, HookNameT> {
|
||||
const hooksMap = new Map<HookNameT, Callback<HookCallback>>();
|
||||
|
||||
function hook<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
fn: InferCallback<HooksT, NameT>
|
||||
) {
|
||||
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<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
...args: Parameters<InferCallback<HooksT, NameT>>
|
||||
) {
|
||||
const hooks = hooksMap.get(name)?.list() ?? [];
|
||||
|
||||
for (const hookFn of hooks) {
|
||||
hookFn.call(null, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function callAsync<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
...args: Parameters<InferCallback<HooksT, NameT>>
|
||||
) {
|
||||
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<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
...args: Parameters<InferCallback<HooksT, NameT>>
|
||||
) {
|
||||
const hooks = hooksMap.get(name)?.list() ?? [];
|
||||
const task = createTask(name.toString());
|
||||
return Promise.all(hooks.map(hook => task.run(() => hook(...args))));
|
||||
}
|
||||
|
||||
function remove<NameT extends HookNameT>(
|
||||
name: NameT,
|
||||
fn?: InferCallback<HooksT, NameT>
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
40
runtime/renderer-core/src/utils/value.ts
Normal file
40
runtime/renderer-core/src/utils/value.ts
Normal file
@ -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<string | number>) => any
|
||||
): any {
|
||||
const innerProcess = (target: any, paths: Array<string | number>): 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, []);
|
||||
}
|
||||
11
runtime/renderer-core/src/validate/schema.ts
Normal file
11
runtime/renderer-core/src/validate/schema.ts
Normal file
@ -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;
|
||||
}
|
||||
4
runtime/renderer-core/tsconfig.json
Normal file
4
runtime/renderer-core/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
0
runtime/renderer-core/vitest.config.ts
Normal file
0
runtime/renderer-core/vitest.config.ts
Normal file
8
runtime/router/package.json
Normal file
8
runtime/router/package.json
Normal file
@ -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"
|
||||
}
|
||||
128
runtime/router/src/guard.ts
Normal file
128
runtime/router/src/guard.ts
Normal file
@ -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<NavigationGuardReturn>;
|
||||
}
|
||||
|
||||
export function guardToPromiseFn(
|
||||
guard: NavigationGuard,
|
||||
to: RouteLocation,
|
||||
from: RouteLocation
|
||||
): () => Promise<void> {
|
||||
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<NavigationRedirectError>(
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
467
runtime/router/src/history.ts
Normal file
467
runtime/router/src/history.ts
Normal file
@ -0,0 +1,467 @@
|
||||
import { useCallbacks } from '@alilc/runtime-shared';
|
||||
|
||||
export type HistoryState = Record<string | number, any>;
|
||||
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<NavigationCallback>();
|
||||
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<NavigationCallback>();
|
||||
|
||||
function triggerListeners(
|
||||
to: HistoryLocation,
|
||||
from: HistoryLocation,
|
||||
{ direction, delta }: Pick<NavigationInformation, 'direction' | 'delta'>
|
||||
): 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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
16
runtime/router/src/index.ts
Normal file
16
runtime/router/src/index.ts
Normal file
@ -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';
|
||||
256
runtime/router/src/matcher.ts
Normal file
256
runtime/router/src/matcher.ts
Normal file
@ -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<string, string | string[]>;
|
||||
}
|
||||
export interface MatcherLocationAsName {
|
||||
name: string;
|
||||
params?: RouteParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配器的路由参数
|
||||
*/
|
||||
export type MatcherLocationRaw =
|
||||
| MatcherLocationAsPath
|
||||
| MatcherLocationAsName
|
||||
| MatcherLocationAsRelative;
|
||||
|
||||
export type RouteRecordNormalized = Required<
|
||||
Pick<RouteRecord, 'path' | 'page' | 'children'>
|
||||
> & {
|
||||
/**
|
||||
* {@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<string, RouteRecordMatcher>();
|
||||
|
||||
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 || [],
|
||||
};
|
||||
}
|
||||
399
runtime/router/src/router.ts
Normal file
399
runtime/router/src/router.ts
Normal file
@ -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<NavigationGuard>();
|
||||
const afterGuards = useCallbacks<NavigationHookAfter>();
|
||||
|
||||
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<RouteLocationRaw, string> | 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<any> {
|
||||
let guards: ((...args: any[]) => Promise<any>)[] = [];
|
||||
|
||||
const canceledNavigationCheck = async (): Promise<any> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
9
runtime/router/src/types.ts
Normal file
9
runtime/router/src/types.ts
Normal file
@ -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<string, string | string[]>;
|
||||
55
runtime/router/src/utils/helper.ts
Normal file
55
runtime/router/src/utils/helper.ts
Normal file
@ -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<T>(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;
|
||||
}
|
||||
53
runtime/router/src/utils/path-parser.ts
Normal file
53
runtime/router/src/utils/path-parser.ts
Normal file
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
28
runtime/router/src/utils/query.ts
Normal file
28
runtime/router/src/utils/query.ts
Normal file
@ -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;
|
||||
}
|
||||
32
runtime/router/src/utils/record-matcher.ts
Normal file
32
runtime/router/src/utils/record-matcher.ts
Normal file
@ -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;
|
||||
}
|
||||
50
runtime/router/src/utils/url.ts
Normal file
50
runtime/router/src/utils/url.ts
Normal file
@ -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 || '');
|
||||
}
|
||||
3
runtime/router/tsconfig.json
Normal file
3
runtime/router/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
0
runtime/router/vitest.config.ts
Normal file
0
runtime/router/vitest.config.ts
Normal file
Loading…
x
Reference in New Issue
Block a user