feat: init branch files

This commit is contained in:
1ncounter 2024-03-13 11:25:30 +08:00
parent d7dfde5452
commit 7ce883e8f6
50 changed files with 4181 additions and 17 deletions

View File

@ -4,7 +4,8 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": [
"packages/*"
"packages/*",
"runtime/*"
],
"command": {
"bootstrap": {

View File

@ -2,7 +2,8 @@
"private": true,
"workspaces": {
"packages": [
"packages/*"
"packages/*",
"runtime/*"
],
"nohoist": [
"**/css-modules-typescript-loader",
@ -44,30 +45,25 @@
}
},
"devDependencies": {
"@alilc/build-plugin-lce": "^0.0.5",
"@alilc/lowcode-test-mate": "^1.0.1",
"@types/react-router": "5.1.18",
"babel-jest": "^26.5.2",
"del": "^6.1.1",
"execa": "^5.1.1",
"f2elint": "^2.0.1",
"f2elint": "^4.2.1",
"gulp": "^4.0.2",
"husky": "^7.0.4",
"lerna": "^4.0.0",
"typescript": "4.6.2",
"typescript": "^5.4.2",
"yarn": "^1.22.17",
"rimraf": "^3.0.2",
"@types/react-router": "5.1.18",
"@alilc/build-plugin-lce": "^0.0.5",
"babel-jest": "^26.5.2",
"@alilc/lowcode-test-mate": "^1.0.1"
"rollup": "^4.13.0",
"vite": "^5.1.6",
"vitest": "^1.3.1"
},
"engines": {
"node": ">=14.17.0 <18"
},
"tnpm": {
"mode": "yarn",
"lockfile": "enable"
},
"resolutions": {
"typescript": "4.6.2",
"react-error-overlay": "6.0.9"
"node": ">=14.17.0"
},
"repository": "git@github.com:alibaba/lowcode-engine.git"
}

View 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"
}
}

View 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,
};
}
);

View 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;
},
});

View 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>;
}

View 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;
}

View 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;
}

View 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>
);
};
};

View File

View 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);
}
});
},
});

View 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,
};
},
};
}

View 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;
}

View 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;
}

View 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);
}

View 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);
}

View 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;
}

View 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;
}
});
}

View 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);
}

View File

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

View 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"
}
}

View 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
);
};
}

View 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);
};
}

View 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;
},
};

View 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;
}

View 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);
}

View File

View 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;
}

View 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;
}

View 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);
}

View 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,
};
}

View 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, []);
}

View 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;
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

View File

View 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
View 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);
}
};
}

View 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) },
];
},
};
}

View 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';

View 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 || [],
};
}

View 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,
};
}

View 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[]>;

View 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;
}

View 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);
},
};
}

View 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;
}

View 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;
}

View 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 || '');
}

View File

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File