mirror of
https://github.com/alibaba/lowcode-engine.git
synced 2026-02-08 06:55:37 +00:00
feat: init branch files
This commit is contained in:
parent
d7dfde5452
commit
7ce883e8f6
@ -4,7 +4,8 @@
|
|||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"useWorkspaces": true,
|
"useWorkspaces": true,
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"runtime/*"
|
||||||
],
|
],
|
||||||
"command": {
|
"command": {
|
||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
|
|||||||
28
package.json
28
package.json
@ -2,7 +2,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"runtime/*"
|
||||||
],
|
],
|
||||||
"nohoist": [
|
"nohoist": [
|
||||||
"**/css-modules-typescript-loader",
|
"**/css-modules-typescript-loader",
|
||||||
@ -44,30 +45,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"del": "^6.1.1",
|
||||||
"execa": "^5.1.1",
|
"execa": "^5.1.1",
|
||||||
"f2elint": "^2.0.1",
|
"f2elint": "^4.2.1",
|
||||||
"gulp": "^4.0.2",
|
"gulp": "^4.0.2",
|
||||||
"husky": "^7.0.4",
|
"husky": "^7.0.4",
|
||||||
"lerna": "^4.0.0",
|
"lerna": "^4.0.0",
|
||||||
"typescript": "4.6.2",
|
"typescript": "^5.4.2",
|
||||||
"yarn": "^1.22.17",
|
"yarn": "^1.22.17",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"@types/react-router": "5.1.18",
|
"rollup": "^4.13.0",
|
||||||
"@alilc/build-plugin-lce": "^0.0.5",
|
"vite": "^5.1.6",
|
||||||
"babel-jest": "^26.5.2",
|
"vitest": "^1.3.1"
|
||||||
"@alilc/lowcode-test-mate": "^1.0.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17.0 <18"
|
"node": ">=14.17.0"
|
||||||
},
|
|
||||||
"tnpm": {
|
|
||||||
"mode": "yarn",
|
|
||||||
"lockfile": "enable"
|
|
||||||
},
|
|
||||||
"resolutions": {
|
|
||||||
"typescript": "4.6.2",
|
|
||||||
"react-error-overlay": "6.0.9"
|
|
||||||
},
|
},
|
||||||
"repository": "git@github.com:alibaba/lowcode-engine.git"
|
"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