diff --git a/docs/docs/specs/lowcode-spec.md b/docs/docs/specs/lowcode-spec.md index 5e2755ff7..ce9a54ee6 100644 --- a/docs/docs/specs/lowcode-spec.md +++ b/docs/docs/specs/lowcode-spec.md @@ -1513,7 +1513,6 @@ webpack.config.js # 项目工程配置,包含插件配置及自定义 webpack | -------------- | ---------------------------------- | ------ | ------ | ------ | ---------------------------------------------- | | path | 当前解析后的路径 | String | - | - | 必填 | | hash | 当前路径的 hash 值,以 # 开头 | String | - | - | 必填 | -| href | 当前的全部路径 | String | - | - | 必填 | | params | 匹配到的路径参数 | Object | - | - | 必填 | | query | 当前的路径 query 对象 | Object | - | - | 必填,代表当前地址的 search 属性的对象 | | name | 匹配到的路由记录名 | String | - | - | 选填 | diff --git a/runtime/renderer-core/package.json b/runtime/renderer-core/package.json index 2be5985c5..84c7cdfcc 100644 --- a/runtime/renderer-core/package.json +++ b/runtime/renderer-core/package.json @@ -6,8 +6,10 @@ "bugs": "https://github.com/alibaba/lowcode-engine/issues", "homepage": "https://github.com/alibaba/lowcode-engine/#readme", "license": "MIT", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "scripts": { - "build": "", + "build": "tsc", "test": "vitest --run", "test:watch": "vitest" }, diff --git a/runtime/renderer-core/src/api/app.ts b/runtime/renderer-core/src/api/app.ts index 25c97157b..87f672d70 100644 --- a/runtime/renderer-core/src/api/app.ts +++ b/runtime/renderer-core/src/api/app.ts @@ -22,7 +22,7 @@ export interface AppBase { */ export interface AppContext { schema: AppSchema; - config: Map; + config: PlainObject; appScope: CodeScope; packageManager: PackageManager; boosts: AppBoostsManager; @@ -35,14 +35,14 @@ type AppCreator = ( export type App = { schema: Project; - config: Map; + config: PlainObject; readonly boosts: AppBoosts; use(plugin: Plugin): Promise; } & T; /** - * 创建应用 + * 创建 createApp 的辅助函数 * @param schema * @param options * @returns @@ -55,9 +55,9 @@ export function createAppFunction { - const { schema, appScopeValue = {} } = options; + const { schema, appScopeValue } = options; const appSchema = createAppSchema(schema); - const appConfig = new Map(); + const appConfig = {}; const packageManager = createPackageManager(); const appScope = createScope({ ...appScopeValue, diff --git a/runtime/renderer-core/src/api/component.ts b/runtime/renderer-core/src/api/component.ts index 15ad19b1b..89e4611a8 100644 --- a/runtime/renderer-core/src/api/component.ts +++ b/runtime/renderer-core/src/api/component.ts @@ -1,136 +1,42 @@ -import { CreateContainerOptions, createContainer } from '../container'; -import { createCodeRuntime, createScope } from '../code-runtime'; -import { throwRuntimeError } from '../utils/error'; -import { validateContainerSchema } from '../validator/schema'; - -export interface ComponentOptionsBase { - componentsTree: RootSchema; - componentsRecord: Record; - dataSourceCreator: DataSourceCreator; -} - -export function createComponentFunction>(options: { - stateCreator: (initState: AnyObject) => StateContext; - componentCreator: (container: Container, componentOptions: O) => C; - defaultOptions?: Partial; -}): (componentOptions: O) => C { - const { stateCreator, componentCreator, defaultOptions = {} } = options; - +import { type CreateContainerOptions, createContainer, type Container } from '../container'; +import type { PlainObject, InstanceStateApi } from '../types'; + +export type CreateComponentBaseOptions = Omit< + CreateContainerOptions, + 'stateCreator' +>; + +/** + * 创建 createComponent 的辅助函数 + * createComponent = createComponentFunction(() => component) + */ +export function createComponentFunction< + ComponentT, + InstanceT, + LifeCycleNameT extends string, + O extends CreateComponentBaseOptions, +>( + stateCreator: (initState: PlainObject) => InstanceStateApi, + componentCreator: ( + container: Container, + componentOptions: O, + ) => ComponentT, +): (componentOptions: O) => ComponentT { return (componentOptions) => { - const finalOptions = Object.assign({}, defaultOptions, componentOptions); - const { supCodeScope, initScopeValue = {}, dataSourceCreator } = finalOptions; - - const codeRuntimeScope = - supCodeScope?.createSubScope(initScopeValue) ?? createScope(initScopeValue); - const codeRuntime = createCodeRuntime(codeRuntimeScope); - - const container: Container = { - get codeScope() { - return codeRuntimeScope; - }, - get codeRuntime() { - return codeRuntime; - }, - - createInstance(componentsTree, extraProps = {}) { - if (!validateContainerSchema(componentsTree)) { - throwRuntimeError('createComponent', 'componentsTree is not valid!'); - } - - const mapRefToComponentInstance: Map = new Map(); - - const initialState = codeRuntime.parseExprOrFn(componentsTree.state ?? {}); - const stateContext = stateCreator(initialState); - - codeRuntimeScope.setValue( - Object.assign( - { - props: codeRuntime.parseExprOrFn({ - ...componentsTree.defaultProps, - ...componentsTree.props, - ...extraProps, - }), - $(ref: string) { - return mapRefToComponentInstance.get(ref); - }, - }, - stateContext, - dataSourceCreator - ? dataSourceCreator(componentsTree.dataSource ?? ({ list: [] } as any), stateContext) - : {}, - ) as ContainerInstanceScope, - true, - ); - - if (componentsTree.methods) { - for (const [key, fn] of Object.entries(componentsTree.methods)) { - const customMethod = codeRuntime.createFnBoundScope(fn.value); - if (customMethod) { - codeRuntimeScope.inject(key, customMethod); - } - } - } - - triggerLifeCycle('constructor'); - - function triggerLifeCycle(lifeCycleName: LifeCycleNameT, ...args: any[]) { - // keys 用来判断 lifeCycleName 存在于 schema 对象上,不获取原型链上的对象 - if ( - !componentsTree.lifeCycles || - !Object.keys(componentsTree.lifeCycles).includes(lifeCycleName) - ) { - return; - } - - const lifeCycleSchema = componentsTree.lifeCycles[lifeCycleName]; - if (isJsFunction(lifeCycleSchema)) { - const lifeCycleFn = codeRuntime.createFnBoundScope(lifeCycleSchema.value); - if (lifeCycleFn) { - lifeCycleFn.apply(codeRuntime.getScope().value, args); - } - } - } - - const instance: ContainerInstance = { - get id() { - return componentsTree.id; - }, - get cssText() { - return componentsTree.css; - }, - get codeScope() { - return codeRuntimeScope; - }, - - triggerLifeCycle, - setRefInstance(ref, instance) { - mapRefToComponentInstance.set(ref, instance); - }, - removeRefInstance(ref) { - mapRefToComponentInstance.delete(ref); - }, - getComponentTreeNodes() { - const childNodes = componentsTree.children - ? Array.isArray(componentsTree.children) - ? componentsTree.children - : [componentsTree.children] - : []; - const treeNodes = childNodes.map((item) => { - return createComponentTreeNode(item, undefined); - }); - - return treeNodes; - }, - - destory() { - mapRefToComponentInstance.clear(); - codeRuntimeScope.setValue({}); - }, - }; - - return instance; - }, - }; + const { + supCodeScope, + initScopeValue = {}, + dataSourceCreator, + componentsTree, + } = componentOptions; + + const container = createContainer({ + supCodeScope, + initScopeValue, + stateCreator, + dataSourceCreator, + componentsTree, + }); return componentCreator(container, componentOptions); }; diff --git a/runtime/renderer-core/src/code-runtime.ts b/runtime/renderer-core/src/code-runtime.ts index d28a3fb2a..0d9a2e7cc 100644 --- a/runtime/renderer-core/src/code-runtime.ts +++ b/runtime/renderer-core/src/code-runtime.ts @@ -76,17 +76,20 @@ export function createCodeRuntime(scopeOrValue: PlainObject = {}): CodeRuntime { }; } -export interface CodeScope { - readonly value: PlainObject; +export interface CodeScope { + readonly value: T; - inject(name: string, value: any, force?: boolean): void; - setValue(value: PlainObject, replace?: boolean): void; - createSubScope(initValue?: PlainObject): CodeScope; + inject(name: K, value: T[K], force?: boolean): void; + setValue(value: T, replace?: boolean): void; + createSubScope(initValue: O): CodeScope; } -export function createScope(initValue: PlainObject = {}): CodeScope { +export function createScope( + initValue: T, +): CodeScope { const innerScope = { value: initValue }; - const proxyValue = new Proxy(Object.create(null), { + + const proxyValue: T = new Proxy(Object.create(null), { set(target, p, newValue, receiver) { return Reflect.set(target, p, newValue, receiver); }, @@ -104,29 +107,17 @@ export function createScope(initValue: PlainObject = {}): CodeScope { }, }); - 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: PlainObject = {}) { - const childScope = createScope(initValue); - - (childScope as any).__raw.__parent = innerScope; - - return childScope; - } - - const scope: CodeScope = { + const scope: CodeScope = { get value() { // dev return value return proxyValue; }, - inject, + inject(name, value, force = false): void { + if (innerScope.value[name] && !force) { + return; + } + innerScope.value[name] = value; + }, setValue(value, replace = false) { if (replace) { innerScope.value = { ...value }; @@ -134,7 +125,13 @@ export function createScope(initValue: PlainObject = {}): CodeScope { innerScope.value = Object.assign({}, innerScope.value, value); } }, - createSubScope, + createSubScope(initValue: O) { + const childScope = createScope(initValue); + + (childScope as any).__raw.__parent = innerScope; + + return childScope; + }, }; Object.defineProperty(scope, Symbol.for(SYMBOL_SIGN), { get: () => true }); diff --git a/runtime/renderer-core/src/container.ts b/runtime/renderer-core/src/container.ts index 5a55479f6..d0ae9db1a 100644 --- a/runtime/renderer-core/src/container.ts +++ b/runtime/renderer-core/src/container.ts @@ -4,6 +4,7 @@ import type { ComponentTree, InstanceDataSourceApi, InstanceStateApi, + NodeType, } from './types'; import { type CodeScope, type CodeRuntime, createCodeRuntime, createScope } from './code-runtime'; import { isJSFunction } from './utils/type-guard'; @@ -48,7 +49,13 @@ export interface CreateContainerOptions { export function createContainer( options: CreateContainerOptions, ): Container { - const { componentsTree, supCodeScope, initScopeValue, stateCreator, dataSourceCreator } = options; + const { + componentsTree, + supCodeScope, + initScopeValue = {}, + stateCreator, + dataSourceCreator, + } = options; validContainerSchema(componentsTree); @@ -151,7 +158,6 @@ export function createContainer( createWidgets() { if (!componentsTree.children) return []; - return componentsTree.children.map((item) => createWidget(item)); }, }; diff --git a/runtime/renderer-core/src/index.ts b/runtime/renderer-core/src/index.ts index 79822e6bb..9af567f34 100644 --- a/runtime/renderer-core/src/index.ts +++ b/runtime/renderer-core/src/index.ts @@ -5,11 +5,14 @@ export { createCodeRuntime, createScope } from './code-runtime'; export { definePlugin } from './plugin'; export { createWidget } from './widget'; export { createContainer } from './container'; +export { createHookStore, useEvent } from './utils/hook'; +export * from './utils/type-guard'; +export * from './utils/value'; +export * from './widget'; /* --------------- types ---------------- */ export * from './types'; export type { CodeRuntime, CodeScope } from './code-runtime'; export type { Plugin, PluginSetupContext } from './plugin'; export type { PackageManager, PackageLoader } from './package'; -export type { Container } from './container'; -export type { Widget, TextWidget, ComponentWidget } from './widget'; +export type { Container, CreateContainerOptions } from './container'; diff --git a/runtime/renderer-core/src/package.ts b/runtime/renderer-core/src/package.ts index 2a756401e..2a16051ba 100644 --- a/runtime/renderer-core/src/package.ts +++ b/runtime/renderer-core/src/package.ts @@ -24,9 +24,11 @@ export interface PackageManager { /** 解析组件映射 */ resolveComponentMaps(componentMaps: ComponentMap[]): void; /** 获取组件映射对象,key = componentName value = component */ - getComponentsNameRecord(componentMaps?: ComponentMap[]): Record; + getComponentsNameRecord( + componentMaps?: ComponentMap[], + ): Record; /** 通过组件名获取对应的组件 */ - getComponent(componentName: string): C | undefined; + getComponent(componentName: string): C | LowCodeComponent | undefined; /** 注册组件 */ registerComponentByName(componentName: string, Component: unknown): void; } diff --git a/runtime/renderer-core/src/schema.ts b/runtime/renderer-core/src/schema.ts index 83fb57e98..c63617042 100644 --- a/runtime/renderer-core/src/schema.ts +++ b/runtime/renderer-core/src/schema.ts @@ -11,9 +11,9 @@ export interface AppSchema { addComponentsMap(componentName: ComponentMap): void; removeComponentsMap(componentName: string): void; - getPages(): PageConfig[]; - addPage(page: PageConfig): void; - removePage(id: string): void; + getPageConfigs(): PageConfig[]; + addPageConfig(page: PageConfig): void; + removePageConfig(id: string): void; getByKey(key: K): Project[K] | undefined; updateByKey( @@ -55,14 +55,14 @@ export function createAppSchema(schema: Project): AppSchema { removeArrayItem(schemaRef.componentsMap, 'componentName', componentName); }, - getPages() { + getPageConfigs() { return schemaRef.pages ?? []; }, - addPage(page) { + addPageConfig(page) { schemaRef.pages ??= []; addArrayItem(schemaRef.pages, page, 'id'); }, - removePage(id) { + removePageConfig(id) { schemaRef.pages ??= []; removeArrayItem(schemaRef.pages, 'id', id); }, @@ -72,8 +72,7 @@ export function createAppSchema(schema: Project): AppSchema { }, updateByKey(key, updater) { const value = schemaRef[key]; - - schemaRef[key] = typeof updater === 'function' ? updater(value) : updater; + schemaRef[key] = typeof updater === 'function' ? (updater as any)(value) : updater; }, find(predicate) { diff --git a/runtime/renderer-core/src/types/material.ts b/runtime/renderer-core/src/types/material.ts index a55513f7d..84867f068 100644 --- a/runtime/renderer-core/src/types/material.ts +++ b/runtime/renderer-core/src/types/material.ts @@ -1,14 +1,14 @@ import { Package } from './specs/asset-spec'; -import { Project, ComponentMap } from './specs/lowcode-spec'; +import { ComponentTree } from './specs/lowcode-spec'; export interface ProCodeComponent extends Package { package: string; type: 'proCode'; } -export interface LowCodeComponent extends Package { +export interface LowCodeComponent extends Omit { id: string; type: 'lowCode'; componentName: string; - schema: Project; + schema: ComponentTree; } diff --git a/runtime/renderer-core/src/types/specs/lowcode-spec.ts b/runtime/renderer-core/src/types/specs/lowcode-spec.ts index c8e31a348..41dacbf7d 100644 --- a/runtime/renderer-core/src/types/specs/lowcode-spec.ts +++ b/runtime/renderer-core/src/types/specs/lowcode-spec.ts @@ -189,20 +189,28 @@ export interface ComponentTreeNodeProps { /** 组件内联样式 */ style?: JSONObject | JSExpression; /** 组件 ref 名称 */ - ref?: string | JSExpression; + ref?: string; [key: string]: any; } +export interface NPMUtil { + name: string; + type: 'npm'; + content: Omit; +} + +export interface FunctionUtil { + name: string; + type: 'function'; + content: JSFunction; +} + /** * https://lowcode-engine.cn/site/docs/specs/lowcode-spec#24-%E5%B7%A5%E5%85%B7%E7%B1%BB%E6%89%A9%E5%B1%95%E6%8F%8F%E8%BF%B0aa * 用于描述物料开发过程中,自定义扩展或引入的第三方工具类(例如:lodash 及 moment),增强搭建基础协议的扩展性,提供通用的工具类方法的配置方案及调用 API。 */ -export interface Util { - name: string; - type: 'npm' | 'function'; - content: ComponentMap | JSFunction; -} +export type Util = NPMUtil | FunctionUtil; /** * https://lowcode-engine.cn/site/docs/specs/lowcode-spec#25-%E5%9B%BD%E9%99%85%E5%8C%96%E5%A4%9A%E8%AF%AD%E8%A8%80%E6%94%AF%E6%8C%81aa @@ -307,7 +315,7 @@ export interface JSONObject { */ export interface JSSlot { type: 'JSSlot'; - value: 1; + value: ComponentTreeNode | ComponentTreeNode[]; params?: string[]; } diff --git a/runtime/renderer-core/src/types/specs/runtime-api.ts b/runtime/renderer-core/src/types/specs/runtime-api.ts index d092dc8c2..ab23daa28 100644 --- a/runtime/renderer-core/src/types/specs/runtime-api.ts +++ b/runtime/renderer-core/src/types/specs/runtime-api.ts @@ -50,10 +50,16 @@ export interface InstanceDataSourceApi { reloadDataSource: () => void; } +/** + * 应用级别的公共函数或第三方扩展 + */ export interface UtilsApi { utils: Record; } +/** + * 国际化相关 API + */ export interface IntlApi { /** * 返回语料字符串 @@ -72,4 +78,106 @@ export interface IntlApi { setLocale(locale: string): void; } -export interface RouterApi {} +/** + * 路由 Router API:封装了原生的 History、Location 等 api,提供统一的调用方法 + * 得益于 HTML 5 新的 History api 的规范出现,SPA 大行其道,其中 SPA 的路由起到了非常重大的作用 + */ +export interface RouterApi { + /** + * 获取当前解析后的路由信息 + */ + getCurrentLocation(): RouteLocation; + /** + * 路由跳转方法,跳转到指定的路径或者 `Route` + */ + push(location: RawRouteLocation): void | Promise; + /** + * 路由跳转方法,与 `push` 的区别在于不会增加一条历史记录而是替换当前的历史记录 + */ + replace(location: RawRouteLocation): void | Promise; + /** + * 返回上一页,同 `history.back` + */ + back(): void; + /** + * 跳转下一页,同 `history.forward` + */ + forward(): void; + /** + * 跳转到当前页面的相对位置,同 `history.go` + * @param delta 相对于当前页面你要去往历史页面的位置 + */ + go(delta: number): void; + /** + * 路由跳转前的守卫方法 + */ + beforeRouteLeave( + guard: (to: RouteLocation, from: RouteLocation) => boolean | Promise, + ): () => void; + /** + * 路由跳转后的钩子函数 + */ + afterRouteChange(guard: (to: RouteLocation, from: RouteLocation) => any): () => void; +} + +export interface RawLocationAsPath { + path: string; +} +export interface RawLocationAsRelative { + params?: Record; +} +export interface RawLocationAsName { + name: string; + params?: Record; +} + +export type RawLocation = RawLocationAsPath | RawLocationAsRelative | RawLocationAsName; + +export interface RawLocationOptions { + searchParams?: URLSearchParams; + hash?: string; + state?: History['state']; +} + +/** + * 允许用户输入的路径参数类型 + */ +export type RawRouteLocation = string | (RawLocation & RawLocationOptions); + +/** + * 路由的当前信息,模拟 window.location + */ +export interface RouteLocation { + /** + * 匹配到的路由记录名 + */ + name: string | undefined; + /** + * 当前解析后的路径 + */ + path: string; + /** + * 当前路径的 hash 值,以 # 开头 + */ + hash: string; + /** + * 匹配到的路径参数 + */ + params: Record | undefined; + /** + * 当前的路径 URLSearchParams 对象 + */ + searchParams: URLSearchParams | undefined; + /** + * 包括 search 和 hash 在内的完整地址 + */ + fullPath: string; + /** + * 匹配到的路由记录元数据 + */ + meta: PlainObject | undefined; + /** + * 重定向之前的路由,在跳转到当前路径之前的路由记录 + */ + redirectedFrom: RouteLocation | undefined; +} diff --git a/runtime/renderer-core/src/utils/guid.ts b/runtime/renderer-core/src/utils/guid.ts new file mode 100644 index 000000000..7081041bf --- /dev/null +++ b/runtime/renderer-core/src/utils/guid.ts @@ -0,0 +1,8 @@ +let idStart = 0x0907; + +/** + * Generate unique id + */ +export function guid(): number { + return idStart++; +} diff --git a/runtime/renderer-core/src/utils/type-guard.ts b/runtime/renderer-core/src/utils/type-guard.ts index 8cfa4e499..78795f000 100644 --- a/runtime/renderer-core/src/utils/type-guard.ts +++ b/runtime/renderer-core/src/utils/type-guard.ts @@ -1,4 +1,4 @@ -import type { JSExpression, JSFunction, I18nNode } from '../types'; +import type { JSExpression, JSFunction, JSSlot, I18nNode, LowCodeComponent } from '../types'; import { isPlainObject } from 'lodash-es'; export function isJSExpression(v: unknown): v is JSExpression { @@ -13,6 +13,14 @@ export function isJSFunction(v: unknown): v is JSFunction { ); } +export function isJSSlot(v: unknown): v is JSSlot { + return isPlainObject(v) && (v as any).type === 'JSSlot' && (v as any).value; +} + export function isI18nNode(v: unknown): v is I18nNode { return isPlainObject(v) && (v as any).type === 'i18n' && typeof (v as any).key === 'string'; } + +export function isLowCodeComponentSchema(v: unknown): v is LowCodeComponent { + return isPlainObject(v) && (v as any).type === 'lowCode' && (v as any).schema; +} diff --git a/runtime/renderer-core/src/utils/value.ts b/runtime/renderer-core/src/utils/value.ts index c4eff3a9b..aafcba6a0 100644 --- a/runtime/renderer-core/src/utils/value.ts +++ b/runtime/renderer-core/src/utils/value.ts @@ -14,14 +14,14 @@ export function someValue(obj: any, predicate: (data: any) => boolean) { export function processValue( obj: any, predicate: (obj: any) => boolean, - processor: (node: any, paths: Array) => any + processor: (node: any, paths: Array) => any, ): any { const innerProcess = (target: any, paths: Array): any => { if (Array.isArray(target)) { return target.map((item, idx) => innerProcess(item, [...paths, idx])); } - if (!isPlainObject(target) || isEmptyObject(target)) return target; + if (!isPlainObject(target) || isEmpty(target)) return target; if (!someValue(target, predicate)) return target; if (predicate(target)) { diff --git a/runtime/renderer-core/src/widget.ts b/runtime/renderer-core/src/widget.ts index be3c06308..fbc558a11 100644 --- a/runtime/renderer-core/src/widget.ts +++ b/runtime/renderer-core/src/widget.ts @@ -1,35 +1,32 @@ import type { NodeType, ComponentTreeNode, ComponentTreeNodeProps } from './types'; import { isJSExpression, isI18nNode } from './utils/type-guard'; +import { guid } from './utils/guid'; export class Widget { - protected _raw: Data; protected proxyElements: Element[] = []; protected renderObject: Element | undefined; - constructor(data: Data) { - this._raw = data; + constructor(public raw: Data) { this.init(); } protected init() {} - get raw() { - return this._raw; + get key(): string { + return (this.raw as any)?.id ?? `${guid()}`; } - setRenderObject(el: Element) { - this.renderObject = el; - } - getRenderObject() { - return this.renderObject; + mapRenderObject(mapper: (widget: Widget) => Element | undefined) { + this.renderObject = mapper(this); + return this; } addProxyELements(el: Element) { - this.proxyElements.push(el); + this.proxyElements.unshift(el); } - build(builder: (elements: Element[]) => Element) { - return builder(this.proxyElements); + build(builder: (elements: Element[]) => C): C { + return builder(this.renderObject ? [...this.proxyElements, this.renderObject] : []); } } @@ -53,11 +50,11 @@ export class ComponentWidget extends Widget { private _propsValue: ComponentTreeNodeProps = {}; protected init() { - if (this._raw.props) { - this._propsValue = this._raw.props; + if (this.raw.props) { + this._propsValue = this.raw.props; } - if (this._raw.children) { - this._children = this._raw.children.map((child) => createWidget(child)); + if (this.raw.children) { + this._children = this.raw.children.map((child) => createWidget(child)); } } @@ -65,19 +62,19 @@ export class ComponentWidget extends Widget { return this.raw.componentName; } get props() { - return this._propsValue; - } - get children() { - return this._children; + return this._propsValue ?? {}; } get condition() { - return this._raw.condition ?? true; + return this.raw.condition !== false; } get loop() { - return this._raw.loop; + return this.raw.loop; } get loopArgs() { - return this._raw.loopArgs ?? ['item', 'index']; + return this.raw.loopArgs ?? ['item', 'index']; + } + get children() { + return this._children; } } diff --git a/runtime/renderer-core/tsconfig.json b/runtime/renderer-core/tsconfig.json index 674e85d9a..b1224f98e 100644 --- a/runtime/renderer-core/tsconfig.json +++ b/runtime/renderer-core/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, "include": ["src"] -} \ No newline at end of file +} diff --git a/runtime/renderer-react/package.json b/runtime/renderer-react/package.json index 6ca770984..66e367743 100644 --- a/runtime/renderer-react/package.json +++ b/runtime/renderer-react/package.json @@ -6,8 +6,10 @@ "bugs": "https://github.com/alibaba/lowcode-engine/issues", "homepage": "https://github.com/alibaba/lowcode-engine/#readme", "license": "MIT", + "module": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "", + "build": "tsc", "test": "vitest" }, "dependencies": { @@ -20,8 +22,11 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@alilc/runtime-router": "1.0.0-beta.0", "@testing-library/react": "^14.2.0", "@types/lodash-es": "^4.17.12", + "@types/hoist-non-react-statics": "^3.3.5", + "@types/use-sync-external-store": "^0.0.6", "@types/react": "^18.2.67", "@types/react-dom": "^18.2.22", "jsdom": "^24.0.0" diff --git a/runtime/renderer-react/src/api/create-app.tsx b/runtime/renderer-react/src/api/app.tsx similarity index 56% rename from runtime/renderer-react/src/api/create-app.tsx rename to runtime/renderer-react/src/api/app.tsx index c03af5d36..7440cf42b 100644 --- a/runtime/renderer-react/src/api/create-app.tsx +++ b/runtime/renderer-react/src/api/app.tsx @@ -1,34 +1,47 @@ import { type App, - type RenderBase, + type AppBase, createAppFunction, type AppOptionsBase, } from '@alilc/renderer-core'; import { type ComponentType } from 'react'; import { type Root, createRoot } from 'react-dom/client'; +import { createRouter } from '@alilc/runtime-router'; import { createRenderer } from '../renderer'; import AppComponent from '../components/app'; -import { intlPlugin } from '../plugins/intl'; -import { globalUtilsPlugin } from '../plugins/utils'; -import { initRouter } from '../router'; +import { createIntl } from '../runtime-api/intl'; +import { createRuntimeUtils } from '../runtime-api/utils'; export interface AppOptions extends AppOptionsBase { - dataSourceCreator: DataSourceCreator; + dataSourceCreator: any; faultComponent?: ComponentType; } -export interface ReactRender extends RenderBase {} +export interface ReactRender extends AppBase {} export type ReactApp = App; export const createApp = createAppFunction(async (context, options) => { - const renderer = createRenderer(); - const appContext = { ...context, renderer }; + const { schema, packageManager, appScope, boosts } = context; + + // router + // todo: transform config + const router = createRouter(schema.getByKey('router') as any); + + appScope.inject('router', router); - initRouter(appContext); + // i18n + const i18nMessages = schema.getByKey('i18n') ?? {}; + const defaultLocale = schema.getByPath('config.defaultLocale') ?? 'zh-CN'; + const intl = createIntl(i18nMessages, defaultLocale); - options.plugins ??= []; - options.plugins!.unshift(globalUtilsPlugin, intlPlugin); + appScope.inject('intl', intl); + + // utils + const runtimeUtils = createRuntimeUtils(schema.getByKey('utils') ?? [], packageManager); + + appScope.inject('utils', runtimeUtils.utils); + boosts.add('runtimeUtils', runtimeUtils); // set config if (options.faultComponent) { @@ -37,6 +50,8 @@ export const createApp = createAppFunction(async (conte context.config.set('dataSourceCreator', options.dataSourceCreator); let root: Root | undefined; + const renderer = createRenderer(); + const appContext = { ...context, renderer }; const reactRender: ReactRender = { async mount(el) { @@ -56,7 +71,7 @@ export const createApp = createAppFunction(async (conte }; return { - renderBase: reactRender, + appBase: reactRender, renderer, }; }); diff --git a/runtime/renderer-react/src/api/component.tsx b/runtime/renderer-react/src/api/component.tsx new file mode 100644 index 000000000..c8c491db2 --- /dev/null +++ b/runtime/renderer-react/src/api/component.tsx @@ -0,0 +1,5 @@ +import { createComponent as internalCreate, ComponentOptions } from '../component'; + +export function createComponent(options: ComponentOptions) { + return internalCreate(options); +} diff --git a/runtime/renderer-react/src/api/create-component.tsx b/runtime/renderer-react/src/api/create-component.tsx deleted file mode 100644 index 3567ba46e..000000000 --- a/runtime/renderer-react/src/api/create-component.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import { - type StateContext, - type ComponentOptionsBase, - createComponentFunction, - type ComponentTreeNode, - type ComponentNode, - someValue, - type ContainerInstance, - processValue, - createNode, - type CodeRuntime, - createCodeRuntime, -} from '@alilc/runtime-core'; -import { isPlainObject } from 'lodash-es'; -import { - type AnyObject, - type Package, - type JSSlot, - type JSFunction, - isJsExpression, - isJsSlot, - isLowCodeComponentPackage, - isJsFunction, - type JSExpression, -} from '@alilc/runtime-shared'; -import { - useEffect, - type ComponentType, - type ReactNode, - type ElementType, - forwardRef, - ForwardedRef, - useMemo, - createElement, - type CSSProperties, - useRef, -} from 'react'; -import { createSignal, watch } from '../signals'; -import { appendExternalStyle } from '../helper/element'; -import { reactive } from '../helper/reactive'; - -function reactiveStateCreator(initState: AnyObject): StateContext { - const proxyState = createSignal(initState); - - return { - get state() { - return proxyState.value; - }, - setState(newState) { - if (!isPlainObject(newState)) { - throw Error('newState mush be a object'); - } - for (const key of Object.keys(newState)) { - proxyState.value[key] = newState[key]; - } - }, - }; -} - -/** - * 作为组件树节点在转换为 reactNode 的过程中的中间介质对象 - * 提供对外拓展、修改的能力 - */ -export interface ConvertedTreeNode { - type: ComponentTreeNode['type']; - /** 节点对应的值 */ - raw: ComponentTreeNode; - /** 转换时所在的上下文 */ - context: { - codeRuntime: CodeRuntime; - [key: string]: any; - }; - /** 用于渲染的组件,只存在于树节点为组件节点的情况 */ - rawComponent?: ComponentType | undefined; - - /** 获取节点对应的 reactNode,被用于渲染 */ - getReactNode(): ReactNode; - /** 设置节点对应的 reactNode */ - setReactNode(element: ReactNode): void; -} - -export interface CreateComponentOptions> extends ComponentOptionsBase { - displayName?: string; - - beforeNodeCreateComponent?(convertedNode: ConvertedTreeNode): void; - nodeCreatedComponent?(convertedNode: ConvertedTreeNode): void; - nodeComponentRefAttached?(node: ComponentNode, instance: ElementType): void; - componentDidMount?(): void; - componentWillUnmount?(): void; -} - -export interface LowCodeComponentProps { - id?: string; - /** CSS 类名 */ - className?: string; - /** style */ - style?: CSSProperties; - - [key: string]: any; -} - -export const createComponent = createComponentFunction< - ComponentType, - CreateComponentOptions ->({ - stateCreator: reactiveStateCreator, - componentCreator: ({ codeRuntime, createInstance }, componentOptions) => { - const { - displayName = '__LowCodeComponent__', - componentsTree, - componentsRecord, - - beforeNodeCreateComponent, - nodeCreatedComponent, - nodeComponentRefAttached, - componentDidMount, - componentWillUnmount, - - ...extraOptions - } = componentOptions; - - const lowCodeComponentCache = new Map>(); - - function getComponentByName( - componentName: string, - componentsRecord: Record | Package>, - ) { - const Component = componentsRecord[componentName]; - if (!Component) { - return undefined; - } - - if (isLowCodeComponentPackage(Component)) { - if (lowCodeComponentCache.has(componentName)) { - return lowCodeComponentCache.get(componentName); - } - - const componentsTree = Component.schema as any; - const LowCodeComponent = createComponent({ - ...extraOptions, - displayName: componentsTree.componentName, - componentsRecord, - componentsTree, - }); - - lowCodeComponentCache.set(componentName, LowCodeComponent); - - return LowCodeComponent; - } - - return Component; - } - - function createConvertedTreeNode( - rawNode: ComponentTreeNode, - codeRuntime: CodeRuntime, - ): ConvertedTreeNode { - let elementValue: ReactNode = null; - - const node: ConvertedTreeNode = { - type: rawNode.type, - raw: rawNode, - context: { codeRuntime }, - - getReactNode() { - return elementValue; - }, - setReactNode(element) { - elementValue = element; - }, - }; - - if (rawNode.type === 'component') { - node.rawComponent = getComponentByName(rawNode.data.componentName, componentsRecord); - } - - return node; - } - - function createReactElement( - node: ComponentTreeNode, - codeRuntime: CodeRuntime, - instance: ContainerInstance, - componentsRecord: Record | Package>, - ) { - const convertedNode = createConvertedTreeNode(node, codeRuntime); - - beforeNodeCreateComponent?.(convertedNode); - - if (!convertedNode.getReactNode()) { - if (convertedNode.type === 'string') { - convertedNode.setReactNode(convertedNode.raw.data as string); - } else if (convertedNode.type === 'expression') { - const rawValue = convertedNode.raw.data as JSExpression; - - function Text(props: any) { - return props.text; - } - Text.displayName = 'Text'; - - const ReactivedText = reactive(Text, { - target: { - text: rawValue, - }, - valueGetter: (node) => codeRuntime.parseExprOrFn(node), - }); - - convertedNode.setReactNode(); - } else if (convertedNode.type === 'component') { - const createReactElementByNode = () => { - const Component = convertedNode.rawComponent; - if (!Component) return null; - - const rawNode = convertedNode.raw as ComponentNode; - const { - id, - componentName, - condition = true, - loop, - loopArgs = ['item', 'index'], - props: nodeProps = {}, - } = rawNode.data; - - // condition为 Falsy 的情况下 不渲染 - if (!condition) return null; - // loop 为数组且为空的情况下 不渲染 - if (Array.isArray(loop) && loop.length === 0) return null; - - function createElementWithProps( - Component: ComponentType, - props: AnyObject, - codeRuntime: CodeRuntime, - key: string, - children: ReactNode[] = [], - ) { - const { ref, ...componentProps } = props; - - const refFunction = (ins: any) => { - if (ins) { - if (ref) instance.setRefInstance(ref as string, ins); - nodeComponentRefAttached?.(rawNode, ins); - } - }; - - // 先将 jsslot, jsFunction 对象转换 - const finalProps = processValue( - componentProps, - (node) => isJsSlot(node) || isJsFunction(node), - (node: JSSlot | JSFunction) => { - if (isJsSlot(node)) { - if (node.value) { - const nodes = (Array.isArray(node.value) ? node.value : [node.value]).map( - (n) => createNode(n, undefined), - ); - - if (node.params?.length) { - return (...args: any[]) => { - const params = node.params!.reduce((prev, cur, idx) => { - return (prev[cur] = args[idx]); - }, {} as AnyObject); - const subCodeScope = codeRuntime.getScope().createSubScope(params); - const subCodeRuntime = createCodeRuntime(subCodeScope); - - return nodes.map((n) => - createReactElement(n, subCodeRuntime, instance, componentsRecord), - ); - }; - } else { - return nodes.map((n) => - createReactElement(n, codeRuntime, instance, componentsRecord), - ); - } - } - } else if (isJsFunction(node)) { - return codeRuntime.parseExprOrFn(node); - } - - return null; - }, - ); - - if (someValue(finalProps, isJsExpression)) { - function Props(props: any) { - return createElement( - Component, - { - ...props, - key, - ref: refFunction, - }, - children, - ); - } - Props.displayName = 'Props'; - - const Reactived = reactive(Props, { - target: finalProps, - valueGetter: (node) => codeRuntime.parseExprOrFn(node), - }); - - return ; - } else { - return createElement( - Component, - { - ...finalProps, - key, - ref: refFunction, - }, - children, - ); - } - } - - const currentComponentKey = id || componentName; - - let element: any = createElementWithProps( - Component, - nodeProps, - codeRuntime, - currentComponentKey, - rawNode.children?.map((n) => - createReactElement(n, codeRuntime, instance, componentsRecord), - ), - ); - - if (loop) { - const genLoopElements = (loopData: any[]) => { - return loopData.map((item, idx) => { - const loopArgsItem = loopArgs[0] ?? 'item'; - const loopArgsIndex = loopArgs[1] ?? 'index'; - const subCodeScope = codeRuntime.getScope().createSubScope({ - [loopArgsItem]: item, - [loopArgsIndex]: idx, - }); - const subCodeRuntime = createCodeRuntime(subCodeScope); - - return createElementWithProps( - Component, - nodeProps, - subCodeRuntime, - `loop-${currentComponentKey}-${idx}`, - rawNode.children?.map((n) => - createReactElement(n, subCodeRuntime, instance, componentsRecord), - ), - ); - }); - }; - - if (isJsExpression(loop)) { - function Loop(props: any) { - if (!Array.isArray(props.loop)) { - return null; - } - - return genLoopElements(props.loop); - } - Loop.displayName = 'Loop'; - - const ReactivedLoop = reactive(Loop, { - target: { - loop, - }, - valueGetter: (expr) => codeRuntime.parseExprOrFn(expr), - }); - - element = createElement(ReactivedLoop, { - key: currentComponentKey, - }); - } else { - element = genLoopElements(loop as any[]); - } - } - - if (isJsExpression(condition)) { - function Condition(props: any) { - if (props.condition) { - return element; - } - return null; - } - Condition.displayName = 'Condition'; - - const ReactivedCondition = reactive(Condition, { - target: { - condition, - }, - valueGetter: (expr) => codeRuntime.parseExprOrFn(expr), - }); - - return createElement(ReactivedCondition, { - key: currentComponentKey, - }); - } - - return element; - }; - - convertedNode.setReactNode(createReactElementByNode()); - } - } - - nodeCreatedComponent?.(convertedNode); - - const finalElement = convertedNode.getReactNode(); - // if finalElement is null, todo.. - return finalElement; - } - - const LowCodeComponent = forwardRef(function ( - props: LowCodeComponentProps, - ref: ForwardedRef, - ) { - const { id, className, style, ...extraProps } = props; - const isMounted = useRef(false); - - const instance = useMemo(() => { - return createInstance(componentsTree, extraProps); - }, []); - - useEffect(() => { - let styleEl: HTMLElement | undefined; - const scopeValue = instance.codeScope.value; - - // init dataSource - scopeValue.reloadDataSource(); - - if (instance.cssText) { - appendExternalStyle(instance.cssText).then((el) => { - styleEl = el; - }); - } - - // trigger lifeCycles - componentDidMount?.(); - instance.triggerLifeCycle('componentDidMount'); - - // 当 state 改变之后调用 - const unwatch = watch(scopeValue.state, (_, oldVal) => { - if (isMounted.current) { - instance.triggerLifeCycle('componentDidUpdate', props, oldVal); - } - }); - - isMounted.current = true; - - return () => { - styleEl?.parentNode?.removeChild(styleEl); - - componentWillUnmount?.(); - instance.triggerLifeCycle('componentWillUnmount'); - unwatch(); - - isMounted.current = false; - }; - }, [instance]); - - return ( -
- {instance - .getComponentTreeNodes() - .map((n) => createReactElement(n, codeRuntime, instance, componentsRecord))} -
- ); - }); - - LowCodeComponent.displayName = displayName; - - return LowCodeComponent; - }, -}); diff --git a/runtime/renderer-react/src/component.tsx b/runtime/renderer-react/src/component.tsx new file mode 100644 index 000000000..e75e4024a --- /dev/null +++ b/runtime/renderer-react/src/component.tsx @@ -0,0 +1,418 @@ +import { + createComponentFunction, + isLowCodeComponentSchema, + createCodeRuntime, + TextWidget, + ComponentWidget, + isJSExpression, + processValue, + isJSFunction, + isJSSlot, + someValue, +} from '@alilc/renderer-core'; +import { isPlainObject } from 'lodash-es'; +import { forwardRef, useRef, useEffect, createElement, useMemo } from 'react'; +import { createSignal, watch } from './signals'; +import { appendExternalStyle } from './utils/element'; +import { reactive } from './utils/reactive'; + +import type { + CreateComponentBaseOptions, + PlainObject, + InstanceStateApi, + LowCodeComponent as LowCodeComponentSchema, + CodeRuntime, + IntlApi, + JSSlot, + JSFunction, + I18nNode, +} from '@alilc/renderer-core'; +import type { + ComponentType, + ReactInstance, + CSSProperties, + ForwardedRef, + ReactNode, + ReactElement, +} from 'react'; + +export type ReactComponentLifeCycle = + | 'constructor' + | 'render' + | 'componentDidMount' + | 'componentDidUpdate' + | 'componentWillUnmount' + | 'componentDidCatch'; + +export interface ComponentOptions> + extends CreateComponentBaseOptions { + componentsRecord: Record; + intl: IntlApi; + displayName?: string; + + beforeElementCreate?(widget: TextWidget | ComponentWidget): void; + componentRefAttached?(widget: ComponentWidget, instance: ReactInstance): void; +} + +export interface LowCodeComponentProps { + id?: string; + /** CSS 类名 */ + className?: string; + /** style */ + style?: CSSProperties; + + [key: string]: any; +} + +export const createComponent = createComponentFunction< + ComponentType, + ReactInstance, + ReactComponentLifeCycle, + ComponentOptions +>(reactiveStateCreator, (container, options) => { + const { + componentsRecord, + intl, + displayName = '__LowCodeComponent__', + beforeElementCreate, + componentRefAttached, + + ...extraOptions + } = options; + const lowCodeComponentCache = new Map>(); + + function getComponentByName(componentName: string) { + const Component = componentsRecord[componentName]; + if (!Component) { + return undefined; + } + + if (isLowCodeComponentSchema(Component)) { + if (lowCodeComponentCache.has(componentName)) { + return lowCodeComponentCache.get(componentName); + } + + const LowCodeComponent = createComponent({ + ...extraOptions, + intl, + displayName: Component.componentName, + componentsRecord, + componentsTree: Component.schema, + }); + + lowCodeComponentCache.set(componentName, LowCodeComponent); + + return LowCodeComponent; + } + + return Component; + } + + function createReactElement( + widget: TextWidget> | ComponentWidget>, + codeRuntime: CodeRuntime, + ) { + beforeElementCreate?.(widget); + + return widget.build((elements) => { + if (elements.length > 0) { + const RenderObject = elements[elements.length - 1]; + const Wrappers = elements.slice(0, elements.length - 1); + + const buildRenderElement = () => { + if (widget instanceof TextWidget) { + if (widget.type === 'string') { + return createElement(RenderObject, { key: widget.key, text: widget.raw }); + } else { + return createElement( + reactive(RenderObject, { + target: + widget.type === 'expression' ? { text: widget.raw } : (widget.raw as I18nNode), + valueGetter(expr) { + return codeRuntime.parseExprOrFn(expr); + }, + }), + { key: widget.key }, + ); + } + } else if (widget instanceof ComponentWidget) { + const { condition, loop, loopArgs } = widget; + + // condition为 Falsy 的情况下 不渲染 + if (!condition) return null; + // loop 为数组且为空的情况下 不渲染 + if (Array.isArray(loop) && loop.length === 0) return null; + + function createElementWithProps( + Component: ComponentType, + widget: ComponentWidget>, + codeRuntime: CodeRuntime, + key?: string, + ): ReactElement { + const { ref, ...componentProps } = widget.props; + const componentKey = key ?? widget.key; + + const attachRef = (ins: ReactInstance) => { + if (ins) { + if (ref) container.setInstance(ref as string, ins); + componentRefAttached?.(widget, ins); + } else { + if (ref) container.removeInstance(ref); + } + }; + + // 先将 jsslot, jsFunction 对象转换 + const finalProps = processValue( + componentProps, + (node) => isJSFunction(node) || isJSSlot(node), + (node: JSSlot | JSFunction) => { + if (isJSSlot(node)) { + if (node.value) { + const widgets = (Array.isArray(node.value) ? node.value : [node.value]).map( + (v) => new ComponentWidget>(v), + ); + + if (node.params?.length) { + return (...args: any[]) => { + const params = node.params!.reduce((prev, cur, idx) => { + return (prev[cur] = args[idx]); + }, {} as PlainObject); + const subCodeScope = codeRuntime.getScope().createSubScope(params); + const subCodeRuntime = createCodeRuntime(subCodeScope); + + return widgets.map((n) => createReactElement(n, subCodeRuntime)); + }; + } else { + return widgets.map((n) => createReactElement(n, codeRuntime)); + } + } + } else if (isJSFunction(node)) { + return codeRuntime.parseExprOrFn(node); + } + + return null; + }, + ); + + const childElements = widget.children.map((child) => + createReactElement(child, codeRuntime), + ); + + if (someValue(finalProps, isJSExpression)) { + const PropsWrapper = (props: PlainObject) => + createElement( + Component, + { + ...props, + key: componentKey, + ref: attachRef, + }, + childElements, + ); + + PropsWrapper.displayName = 'PropsWrapper'; + + return createElement( + reactive(PropsWrapper, { + target: finalProps, + valueGetter: (node) => codeRuntime.parseExprOrFn(node), + }), + { key: componentKey }, + ); + } else { + return createElement( + Component, + { + ...finalProps, + key: componentKey, + ref: attachRef, + }, + childElements, + ); + } + } + + let element: ReactElement | ReactElement[] = createElementWithProps( + RenderObject, + widget, + codeRuntime, + ); + + 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( + RenderObject, + widget, + subCodeRuntime, + `loop-${widget.key}-${idx}`, + ); + }); + }; + + if (isJSExpression(loop)) { + function Loop(props: { loop: boolean }) { + if (!Array.isArray(props.loop)) { + return null; + } + return <>{genLoopElements(props.loop)}; + } + Loop.displayName = 'Loop'; + + const ReactivedLoop = reactive(Loop, { + target: { + loop, + }, + valueGetter: (expr) => codeRuntime.parseExprOrFn(expr), + }); + + element = createElement(ReactivedLoop, { + key: widget.key, + }); + } else { + element = genLoopElements(loop as any[]); + } + } + + if (isJSExpression(condition)) { + function Condition(props: any) { + if (props.condition) { + return element; + } + return null; + } + Condition.displayName = 'Condition'; + + const ReactivedCondition = reactive(Condition, { + target: { + condition, + }, + valueGetter: (expr) => codeRuntime.parseExprOrFn(expr), + }); + + element = createElement(ReactivedCondition, { + key: widget.key, + }); + } + + return element; + } + + return null; + }; + + const element = buildRenderElement(); + + return Wrappers.reduce((prevElement, CurWrapper) => { + return createElement(CurWrapper, { key: widget.key }, prevElement); + }, element); + } + }); + } + + const LowCodeComponent = forwardRef(function ( + props: LowCodeComponentProps, + ref: ForwardedRef, + ) { + const { id, className, style } = props; + const isMounted = useRef(false); + + useEffect(() => { + const scopeValue = container.codeRuntime.getScope().value; + + // init dataSource + scopeValue.reloadDataSource(); + + let styleEl: HTMLElement | undefined; + const cssText = container.getCssText(); + if (cssText) { + appendExternalStyle(cssText).then((el) => { + styleEl = el; + }); + } + + // trigger lifeCycles + // componentDidMount?.(); + container.triggerLifeCycle('componentDidMount'); + + // 当 state 改变之后调用 + const unwatch = watch(scopeValue.state, (_, oldVal) => { + if (isMounted.current) { + container.triggerLifeCycle('componentDidUpdate', props, oldVal); + } + }); + + isMounted.current = true; + + return () => { + // componentWillUnmount?.(); + container.triggerLifeCycle('componentWillUnmount'); + styleEl?.parentNode?.removeChild(styleEl); + unwatch(); + isMounted.current = false; + }; + }, []); + + const widgets = useMemo(() => { + return container.createWidgets>().map((widget) => + widget.mapRenderObject((widget) => { + if (widget instanceof TextWidget) { + if (widget.type === 'i18n') { + function IntlText(props: { key: string; params: Record }) { + return <>{intl.i18n(props.key, props.params)}; + } + IntlText.displayName = 'IntlText'; + return IntlText; + } + + function Text(props: { text: string }) { + return <>{props.text}; + } + Text.displayName = 'Text'; + return Text; + } else if (widget instanceof ComponentWidget) { + return getComponentByName(widget.raw.componentName); + } + }), + ); + }, []); + + return ( +
+ {widgets.map((widget) => createReactElement(widget, container.codeRuntime))} +
+ ); + }); + + LowCodeComponent.displayName = displayName; + + return LowCodeComponent; +}); + +function reactiveStateCreator(initState: PlainObject): InstanceStateApi { + const proxyState = createSignal(initState); + + return { + get state() { + return proxyState.value; + }, + setState(newState) { + if (!isPlainObject(newState)) { + throw Error('newState mush be a object'); + } + + proxyState.value = { + ...proxyState.value, + ...newState, + }; + }, + }; +} diff --git a/runtime/renderer-react/src/components/app.tsx b/runtime/renderer-react/src/components/app.tsx index cd89a7a00..63a072589 100644 --- a/runtime/renderer-react/src/components/app.tsx +++ b/runtime/renderer-react/src/components/app.tsx @@ -1,6 +1,7 @@ import { AppContext, type AppContextObject } from '../context/app'; -import { createComponent } from '../api/createComponent'; +import { createComponent } from '../component'; import Route from './route'; +import { createRouterProvider } from './router-view'; export default function App({ context }: { context: AppContextObject }) { const { schema, config, renderer, packageManager, appScope } = context; @@ -15,8 +16,7 @@ export default function App({ context }: { context: AppContextObject }) { if (Component?.devMode === 'lowCode') { const componentsMap = schema.getComponentsMaps(); - const componentsRecord = - packageManager.getComponentsNameRecord(componentsMap); + const componentsRecord = packageManager.getComponentsNameRecord(componentsMap); const Layout = createComponent({ componentsTree: Component.schema, @@ -24,6 +24,7 @@ export default function App({ context }: { context: AppContextObject }) { dataSourceCreator: config.get('dataSourceCreator'), supCodeScope: appScope, + intl: appScope.value.intl, }); return Layout; @@ -54,5 +55,11 @@ export default function App({ context }: { context: AppContextObject }) { }, element); } - return {element}; + const RouterProvider = createRouterProvider(appScope.value.router); + + return ( + + {element} + + ); } diff --git a/runtime/renderer-react/src/components/outlet.tsx b/runtime/renderer-react/src/components/outlet.tsx index 4d91eaa6b..62c7be1b1 100644 --- a/runtime/renderer-react/src/components/outlet.tsx +++ b/runtime/renderer-react/src/components/outlet.tsx @@ -1,43 +1,28 @@ -import type { PageSchema, PageContainerSchema } from '@alilc/runtime-shared'; +import type { PageConfig, ComponentTree } from '@alilc/renderer-core'; import { useAppContext } from '../context/app'; -import { createComponent } from '../api/createComponent'; -import { PAGE_EVENTS } from '../events'; +import { createComponent } from '../component'; export interface OutletProps { - pageSchema: PageSchema; - componentsTree?: PageContainerSchema | undefined; + pageConfig: PageConfig; + componentsTree?: ComponentTree | undefined; [key: string]: any; } export default function Outlet({ pageSchema, componentsTree }: OutletProps) { - const { schema, config, packageManager, appScope, boosts } = useAppContext(); + const { schema, config, packageManager, appScope } = useAppContext(); const { type = 'lowCode' } = pageSchema; if (type === 'lowCode' && componentsTree) { const componentsMap = schema.getComponentsMaps(); - const componentsRecord = - packageManager.getComponentsNameRecord(componentsMap); + const componentsRecord = packageManager.getComponentsNameRecord(componentsMap); const LowCodeComponent = createComponent({ supCodeScope: appScope, dataSourceCreator: config.get('dataSourceCreator'), componentsTree, componentsRecord, - - beforeNodeCreateComponent(node) { - boosts.hooks.call(PAGE_EVENTS.COMPONENT_BEFORE_NODE_CREATE, node); - }, - nodeCreatedComponent(result) { - boosts.hooks.call(PAGE_EVENTS.COMPONENT_NODE_CREATED, result); - }, - nodeComponentRefAttached(node, instance) { - boosts.hooks.call( - PAGE_EVENTS.COMPONENT_NODE_REF_ATTACHED, - node, - instance - ); - }, + intl: appScope.value.intl, }); return ; diff --git a/runtime/renderer-react/src/components/route.tsx b/runtime/renderer-react/src/components/route.tsx index 150d61257..422a52527 100644 --- a/runtime/renderer-react/src/components/route.tsx +++ b/runtime/renderer-react/src/components/route.tsx @@ -1,28 +1,21 @@ -import { usePageSchema } from '../context/router'; +import { usePageConfig } from '../context/router'; import { useAppContext } from '../context/app'; +import RouteOutlet from './outlet'; export default function Route(props: any) { const { schema, renderer } = useAppContext(); - const pageSchema = usePageSchema(); - const Outlet = renderer.getOutlet(); + const pageConfig = usePageConfig(); + const Outlet = renderer.getOutlet() ?? RouteOutlet; - if (Outlet && pageSchema) { + if (Outlet && pageConfig) { let componentsTree; - const { type = 'lowCode', treeId } = pageSchema; + const { type = 'lowCode', mappingId } = pageConfig; if (type === 'lowCode') { - componentsTree = schema - .getComponentsTrees() - .find(item => item.id === treeId); + componentsTree = schema.getComponentsTrees().find((item) => item.id === mappingId); } - return ( - - ); + return ; } return null; diff --git a/runtime/renderer-react/src/components/router-view.tsx b/runtime/renderer-react/src/components/router-view.tsx index 9f0ebfe64..5861e8b19 100644 --- a/runtime/renderer-react/src/components/router-view.tsx +++ b/runtime/renderer-react/src/components/router-view.tsx @@ -1,28 +1,24 @@ import { type Router } from '@alilc/runtime-router'; import { useState, useLayoutEffect, useMemo, type ReactNode } from 'react'; -import { - RouterContext, - RouteLocationContext, - PageSchemaContext, -} from '../context/router'; +import { RouterContext, RouteLocationContext, PageConfigContext } 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()); + const [location, setCurrentLocation] = useState(router.getCurrentLocation()); useLayoutEffect(() => { - const remove = router.afterRouteChange(to => setCurrentLocation(to)); + const remove = router.afterRouteChange((to) => setCurrentLocation(to)); return () => remove(); }, []); const pageSchema = useMemo(() => { - const pages = schema.getPages(); + const pages = schema.getPageConfigs(); const matched = location.matched[location.matched.length - 1]; if (matched) { - const page = pages.find(item => matched.page === item.id); + const page = pages.find((item) => matched.page === item.id); return page; } @@ -32,9 +28,7 @@ export const createRouterProvider = (router: Router) => { return ( - - {children} - + {children} ); diff --git a/runtime/renderer-react/src/context/app.ts b/runtime/renderer-react/src/context/app.ts index ed43149b4..5212475d8 100644 --- a/runtime/renderer-react/src/context/app.ts +++ b/runtime/renderer-react/src/context/app.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import { type AppContext as AppContextType } from '@alilc/runtime-core'; +import { type AppContext as AppContextType } from '@alilc/renderer-core'; import { type ReactRenderer } from '../renderer'; export interface AppContextObject extends AppContextType { diff --git a/runtime/renderer-react/src/context/router.ts b/runtime/renderer-react/src/context/router.ts index 3ecabef2f..e0c4b647f 100644 --- a/runtime/renderer-react/src/context/router.ts +++ b/runtime/renderer-react/src/context/router.ts @@ -1,5 +1,5 @@ -import { type Router, type RouteLocation } from '@alilc/runtime-router'; -import { type PageSchema } from '@alilc/runtime-shared'; +import { type Router, type RouteLocationNormalized } from '@alilc/runtime-router'; +import { type PageConfig } from '@alilc/renderer-core'; import { createContext, useContext } from 'react'; export const RouterContext = createContext({} as any); @@ -8,10 +8,10 @@ RouterContext.displayName = 'RouterContext'; export const useRouter = () => useContext(RouterContext); -export const RouteLocationContext = createContext({ +export const RouteLocationContext = createContext({ name: undefined, path: '/', - query: {}, + searchParams: undefined, params: {}, hash: '', fullPath: '/', @@ -24,10 +24,8 @@ RouteLocationContext.displayName = 'RouteLocationContext'; export const useRouteLocation = () => useContext(RouteLocationContext); -export const PageSchemaContext = createContext( - undefined -); +export const PageConfigContext = createContext(undefined); -PageSchemaContext.displayName = 'PageContext'; +PageConfigContext.displayName = 'PageConfigContext'; -export const usePageSchema = () => useContext(PageSchemaContext); +export const usePageConfig = () => useContext(PageConfigContext); diff --git a/runtime/renderer-react/src/index.ts b/runtime/renderer-react/src/index.ts index e69de29bb..4063e8c56 100644 --- a/runtime/renderer-react/src/index.ts +++ b/runtime/renderer-react/src/index.ts @@ -0,0 +1,2 @@ +export * from './api/app'; +export * from './api/component'; diff --git a/runtime/renderer-react/src/plugins/intl/index.ts b/runtime/renderer-react/src/plugins/intl/index.ts deleted file mode 100644 index 962e1c052..000000000 --- a/runtime/renderer-react/src/plugins/intl/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 '../../utils/reactive'; -import { createIntl } from './intl'; - -export { createIntl }; - -declare module '@alilc/renderer-core' { - interface AppBoosts { - intl: ReturnType; - } -} - -export const intlPlugin = definePlugin({ - name: 'intl', - setup({ schema, appScope, boosts }) { - const i18nMessages = schema.getByKey('i18n') ?? {}; - const defaultLocale = schema.getByPath('config.defaultLocale') ?? 'zh-CN'; - const intl = createIntl(i18nMessages, defaultLocale); - - appScope.setValue(intl); - boosts.add('intl', intl); - - boosts.hooks.hook(PAGE_EVENTS.COMPONENT_BEFORE_NODE_CREATE, (node) => { - if (node.type === 'i18n') { - const { key, params } = node.raw.data; - - let element: ReactNode; - - if (someValue(params, isJsExpression)) { - function IntlText(props: any) { - return intl.i18n(key, props.params); - } - IntlText.displayName = 'IntlText'; - - const Reactived = reactive(IntlText, { - target: { - params, - }, - valueGetter(expr) { - return node.context.codeRuntime.parseExprOrFn(expr); - }, - }); - - element = createElement(Reactived, { key }); - } else { - element = intl.i18n(key, params ?? {}); - } - - node.setReactNode(element); - } - }); - }, -}); diff --git a/runtime/renderer-react/src/plugins/utils/index.ts b/runtime/renderer-react/src/plugins/utils/index.ts deleted file mode 100644 index e514a0041..000000000 --- a/runtime/renderer-react/src/plugins/utils/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createCodeRuntime, type PackageManager } from '@alilc/runtime-core'; -import { - type UtilItem, - type InternalUtils, - type ExternalUtils, - type AnyFunction, -} from '@alilc/runtime-shared'; -import { definePlugin } from '../../renderer'; - -declare module '@alilc/runtime-core' { - interface AppBoosts { - globalUtils: GlobalUtils; - } -} - -export interface GlobalUtils { - getUtil(name: string): AnyFunction; - - addUtil(utilItem: UtilItem): void; - addUtil(name: string, fn: AnyFunction): void; - - addUtils(utils: Record): void; -} - -export const globalUtilsPlugin = definePlugin({ - name: 'globalUtils', - setup({ schema, appScope, packageManager, boosts }) { - const utils = schema.getByKey('utils') ?? []; - const globalUtils = createGlobalUtils(packageManager); - - const utilsProxy = new Proxy(Object.create(null), { - get(_, p: string) { - return globalUtils.getUtil(p); - }, - set() { - return false; - }, - has(_, p: string) { - return Boolean(globalUtils.getUtil(p)); - }, - }); - - utils.forEach(globalUtils.addUtil); - - appScope.inject('utils', utilsProxy); - boosts.add('globalUtils', globalUtils); - }, -}); - -function createGlobalUtils(packageManager: PackageManager) { - const codeRuntime = createCodeRuntime(); - const utilsMap: Record = {}; - - function addUtil(item: string | UtilItem, fn?: AnyFunction) { - if (typeof item === 'string') { - if (typeof fn === 'function') { - utilsMap[item] = fn; - } - } else { - const fn = parseUtil(item); - addUtil(item.name, fn); - } - } - - const globalUtils: GlobalUtils = { - addUtil, - addUtils(utils) { - Object.keys(utils).forEach(key => addUtil(key, utils[key])); - }, - getUtil(name) { - return utilsMap[name]; - }, - }; - - function parseUtil(utilItem: UtilItem) { - if (utilItem.type === 'function') { - const { content } = utilItem as InternalUtils; - - return codeRuntime.createFnBoundScope(content.value); - } else { - const { - content: { package: packageName, destructuring, exportName, subName }, - } = utilItem as ExternalUtils; - let library: any = packageManager.getLibraryByPackageName(packageName); - - if (library) { - if (destructuring) { - const target = library[exportName!]; - library = subName ? target[subName] : target; - } - - return library; - } - } - } - - return globalUtils; -} diff --git a/runtime/renderer-react/src/renderer.ts b/runtime/renderer-react/src/renderer.ts index d5a5c0091..959d646e7 100644 --- a/runtime/renderer-react/src/renderer.ts +++ b/runtime/renderer-react/src/renderer.ts @@ -2,7 +2,7 @@ import { definePlugin as definePluginFn, type Plugin, type PluginSetupContext, -} from '@alilc/runtime-core'; +} from '@alilc/renderer-core'; import { type ComponentType, type PropsWithChildren } from 'react'; import { type OutletProps } from './components/outlet'; diff --git a/runtime/renderer-react/src/router.ts b/runtime/renderer-react/src/router.ts deleted file mode 100644 index 617f38473..000000000 --- a/runtime/renderer-react/src/router.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/renderer-core' { - interface AppBoosts { - router: Router; - } -} - -const defaultRouterOptions: RouterOptions = { - historyMode: 'browser', - baseName: '/', - routes: [], -}; - -export function initRouter(context: ReactRendererSetupContext) { - const { schema, boosts, appScope, renderer } = context; - const router = createRouter(schema.getByKey('router') ?? defaultRouterOptions); - - appScope.inject('router', router); - boosts.add('router', router); - - const RouterProvider = createRouterProvider(router); - - renderer.addAppWrapper(RouterProvider); - renderer.setOutlet(RouteOutlet); -} diff --git a/runtime/renderer-react/src/plugins/intl/intl.tsx b/runtime/renderer-react/src/runtime-api/intl/index.tsx similarity index 87% rename from runtime/renderer-react/src/plugins/intl/intl.tsx rename to runtime/renderer-react/src/runtime-api/intl/index.tsx index 8a5a300ba..af8c8956e 100644 --- a/runtime/renderer-react/src/plugins/intl/intl.tsx +++ b/runtime/renderer-react/src/runtime-api/intl/index.tsx @@ -3,13 +3,11 @@ import { createSignal, computed } from '../../signals'; export function createIntl( messages: Record>, - defaultLocale: string + defaultLocale: string, ) { const allMessages = createSignal(messages); const currentLocale = createSignal(defaultLocale); - const currentMessages = computed( - () => allMessages.value[currentLocale.value] - ); + const currentMessages = computed(() => allMessages.value[currentLocale.value]); return { i18n(key: string, params: Record) { diff --git a/runtime/renderer-react/src/plugins/intl/parser.ts b/runtime/renderer-react/src/runtime-api/intl/parser.ts similarity index 100% rename from runtime/renderer-react/src/plugins/intl/parser.ts rename to runtime/renderer-react/src/runtime-api/intl/parser.ts diff --git a/runtime/renderer-react/src/runtime-api/utils.ts b/runtime/renderer-react/src/runtime-api/utils.ts new file mode 100644 index 000000000..270e7a76e --- /dev/null +++ b/runtime/renderer-react/src/runtime-api/utils.ts @@ -0,0 +1,72 @@ +import { + createCodeRuntime, + type PackageManager, + type AnyFunction, + type Util, + type UtilsApi, +} from '@alilc/renderer-core'; + +export interface RuntimeUtils extends UtilsApi { + addUtil(utilItem: Util): void; + addUtil(name: string, fn: AnyFunction): void; +} + +export function createRuntimeUtils( + utilSchema: Util[], + packageManager: PackageManager, +): RuntimeUtils { + const codeRuntime = createCodeRuntime(); + const utilsMap: Record = {}; + + function addUtil(item: string | Util, fn?: AnyFunction) { + if (typeof item === 'string') { + if (typeof fn === 'function') { + utilsMap[item] = fn; + } + } else { + const fn = parseUtil(item); + addUtil(item.name, fn); + } + } + + function parseUtil(utilItem: Util) { + if (utilItem.type === 'function') { + const { content } = utilItem; + + return codeRuntime.createFnBoundScope(content.value); + } else { + const { + content: { package: packageName, destructuring, exportName, subName }, + } = utilItem; + let library: any = packageManager.getLibraryByPackageName(packageName!); + + if (library) { + if (destructuring) { + const target = library[exportName!]; + library = subName ? target[subName] : target; + } + + return library; + } + } + } + + utilSchema.forEach((item) => addUtil(item)); + + const utilsProxy = new Proxy(Object.create(null), { + get(_, p: string) { + return utilsMap[p]; + }, + set() { + return false; + }, + has(_, p: string) { + return Boolean(utilsMap[p]); + }, + }); + + return { + addUtil, + utils: utilsProxy, + }; +} diff --git a/runtime/renderer-react/src/signals.ts b/runtime/renderer-react/src/signals.ts index 6e8fac9ec..bd5d606a8 100644 --- a/runtime/renderer-react/src/signals.ts +++ b/runtime/renderer-react/src/signals.ts @@ -1,3 +1,4 @@ +import { type PlainObject } from '@alilc/renderer-core'; import { ref, computed, @@ -114,7 +115,7 @@ function traverse(value: unknown, depth?: number, currentDepth = 0, seen?: Set { +export interface ReactiveStore { value: Snapshot; onStateChange: AnyFunction | null; subscribe: (onStoreChange: () => void) => () => void; getSnapshot: () => Snapshot; } -function createReactiveStore( +function createReactiveStore( target: Record, - valueGetter: (expr: JSExpression) => any + valueGetter: (expr: JSExpression) => any, ): ReactiveStore { let isFlushing = false; let isFlushPending = false; @@ -34,25 +28,21 @@ function createReactiveStore( 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); - } - }); + const initValue = processValue(target, isJSExpression, (node: JSExpression, paths) => { + const computedValue = computed(() => valueGetter(node)); + const unwatch = watch(computedValue, (newValue) => { + waitPathToSetValueMap.set(paths, newValue); - cleanups.push(unwatch); + if (!isFlushPending && !isFlushing) { + isFlushPending = true; + Promise.resolve().then(genValue); + } + }); - return computedValue.value; - } - ); + cleanups.push(unwatch); + + return computedValue.value; + }); const genValue = () => { isFlushPending = false; @@ -89,7 +79,7 @@ function createReactiveStore( return () => { store.onStateChange = null; - cleanups.forEach(c => c()); + cleanups.forEach((c) => c()); cleanups.length = 0; }; }, @@ -102,35 +92,32 @@ function createReactiveStore( } interface ReactiveOptions { - target: AnyObject; + target: PlainObject; valueGetter: (expr: JSExpression) => any; forwardRef?: boolean; } -export function reactive( - WrappedComponent: ForwardRefRenderFunction>, - { target, valueGetter, forwardRef: forwardRefOption = true }: ReactiveOptions -) { +export function reactive( + WrappedComponent: ComponentType, + { target, valueGetter, forwardRef: forwardRefOption = true }: ReactiveOptions, +): ComponentType { const store = createReactiveStore(target, valueGetter); function WrapperComponent(props: any, ref: any) { - const actualProps = useSyncExternalStore( - store.subscribe, - store.getSnapshot - ); - return ; + const actualProps = useSyncExternalStore(store.subscribe, store.getSnapshot); + + return createElement(WrappedComponent, { + ...props, + ...actualProps, + ref, + }); } - const componentName = - WrappedComponent.displayName || WrappedComponent.name || 'Component'; + 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 - >; + const _Reactived = forwardRefOption ? forwardRef(WrapperComponent) : WrapperComponent; + const Reactived = memo(_Reactived) as unknown as ComponentType>; Reactived.displayName = WrappedComponent.displayName = displayName; diff --git a/runtime/renderer-react/tsconfig.json b/runtime/renderer-react/tsconfig.json index 8e11bb7b7..b265a1d12 100644 --- a/runtime/renderer-react/tsconfig.json +++ b/runtime/renderer-react/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "outDir": "dist", "paths": { - "@alilc/*": ["runtime/*/src"] + "@alilc/*": ["runtime/*"], + "@alilc/runtime-router": ["runtime/router"] } - } + }, + "include": ["src"] } diff --git a/runtime/router/package.json b/runtime/router/package.json index 5701eb7c8..38281000a 100644 --- a/runtime/router/package.json +++ b/runtime/router/package.json @@ -6,11 +6,20 @@ "bugs": "https://github.com/alibaba/lowcode-engine/issues", "homepage": "https://github.com/alibaba/lowcode-engine/#readme", "license": "MIT", + "module": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "", + "build": "tsc", "test": "vitest" }, "dependencies": { - "@alilc/renderer-core": "^2.0.0-beta.0" + "@alilc/renderer-core": "^2.0.0-beta.0", + "lodash-es": "^4.17.21", + "path-to-regexp": "^6.2.1", + "qs": "^6.12.0" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "@types/qs": "^6.9.13" } } diff --git a/runtime/router/src/guard.ts b/runtime/router/src/guard.ts index 76cf6c477..7c39a9383 100644 --- a/runtime/router/src/guard.ts +++ b/runtime/router/src/guard.ts @@ -1,72 +1,38 @@ -import { - type RouteLocation, - type RouteLocationRaw, -} from '@alilc/runtime-shared'; +import { RawRouteLocation } from '@alilc/renderer-core'; +import { type RouteLocationNormalized } from './types'; import { isRouteLocation } from './utils/helper'; export type NavigationHookAfter = ( - to: RouteLocation, - from: RouteLocation + to: RouteLocationNormalized, + from: RouteLocationNormalized, ) => 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; -} +export type NavigationGuardReturn = undefined | Error | RawRouteLocation | boolean; /** * Navigation guard. */ export interface NavigationGuard { - (to: RouteLocation, from: RouteLocation, next: NavigationGuardNext): - | NavigationGuardReturn - | Promise; + ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + ): NavigationGuardReturn | Promise; } export function guardToPromiseFn( guard: NavigationGuard, - to: RouteLocation, - from: RouteLocation + to: RouteLocationNormalized, + from: RouteLocationNormalized, ): () => Promise { return () => new Promise((resolve, reject) => { - const next: NavigationGuardNext = ( - valid?: boolean | RouteLocationRaw | NavigationGuardNextCallback | Error - ) => { + const next = (valid?: boolean | RawRouteLocation | Error) => { if (valid === false) { reject(); } else if (valid instanceof Error) { reject(valid); } else if (isRouteLocation(valid)) { - // todo - // reject( - // createRouterError( - // ErrorTypes.NAVIGATION_GUARD_REDIRECT, - // { - // from: to, - // to: valid - // } - // ) - // ); + // todo reject (error) reject(); } else { resolve(); @@ -74,55 +40,10 @@ export function guardToPromiseFn( }; // 使用 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.`; + const guardReturn = guard.call(null, to, from); - 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)); + return Promise.resolve(guardReturn) + .then(next) + .catch((err) => reject(err)); }); } - -function canOnlyBeCalledOnce( - next: NavigationGuardNext, - to: RouteLocation, - from: RouteLocation -): NavigationGuardNext { - let called = 0; - return function () { - if (called++ === 1) { - console.warn( - `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}". It should be called exactly one time in each navigation guard. This will fail in production.` - ); - } - next._called = true; - if (called === 1) { - next.apply(null, arguments as any); - } - }; -} diff --git a/runtime/router/src/history.ts b/runtime/router/src/history.ts index 056d1eaf9..83a0b98ba 100644 --- a/runtime/router/src/history.ts +++ b/runtime/router/src/history.ts @@ -1,4 +1,4 @@ -import { useCallbacks } from '@alilc/runtime-shared'; +import { useEvent } from '@alilc/renderer-core'; export type HistoryState = Record; export type HistoryLocation = string; @@ -23,7 +23,7 @@ export type NavigationInformation = { export type NavigationCallback = ( to: HistoryLocation, from: HistoryLocation, - info: NavigationInformation + info: NavigationInformation, ) => void; /** @@ -94,7 +94,7 @@ function buildState( current: HistoryLocation, forward: HistoryLocation | null, replaced = false, - position = window.history.length + position = window.history.length, ): RouterHistoryState { return { name: '__ROUTER_STATE__', @@ -110,25 +110,14 @@ export function createBrowserHistory(base?: string): RouterHistory { const finalBase = normalizeBase(base); const { history, location } = window; - let currentLocation: HistoryLocation = createCurrentLocation( - finalBase, - location - ); + let currentLocation: HistoryLocation = createCurrentLocation(finalBase, location); let historyState: RouterHistoryState = history.state; if (!historyState) { - doDomHistoryEvent( - currentLocation, - buildState(null, currentLocation, null, true), - true - ); + doDomHistoryEvent(currentLocation, buildState(null, currentLocation, null, true), true); } - function doDomHistoryEvent( - to: HistoryLocation, - state: RouterHistoryState, - replace: boolean - ) { + function doDomHistoryEvent(to: HistoryLocation, state: RouterHistoryState, replace: boolean) { // 处理 hash 情况下的 url const hashIndex = finalBase.indexOf('#'); const url = @@ -150,7 +139,7 @@ export function createBrowserHistory(base?: string): RouterHistory { history.state, buildState(historyState.back, to, historyState.forward, true), data, - { position: historyState.position } + { position: historyState.position }, ); doDomHistoryEvent(to, state, true); @@ -158,14 +147,9 @@ export function createBrowserHistory(base?: string): RouterHistory { } function push(to: HistoryLocation, data?: HistoryState) { - const currentState: RouterHistoryState = Object.assign( - {}, - historyState, - history.state, - { - forward: to, - } - ); + const currentState: RouterHistoryState = Object.assign({}, historyState, history.state, { + forward: to, + }); // 防止当前浏览器的 state 被修改先 replace 一次 // 将上次的state 的 forward 修改为 to @@ -175,15 +159,15 @@ export function createBrowserHistory(base?: string): RouterHistory { {}, buildState(currentLocation, to, null), { position: currentState.position + 1 }, - data + data, ); doDomHistoryEvent(to, state, false); currentLocation = to; } - let listeners = useCallbacks(); - let teardowns = useCallbacks<() => void>(); + let listeners = useEvent(); + let teardowns = useEvent<() => void>(); let pauseState: HistoryLocation | null = null; @@ -293,8 +277,7 @@ function normalizeBase(base?: string) { } const TRAILING_SLASH_RE = /\/$/; -export const removeTrailingSlash = (path: string) => - path.replace(TRAILING_SLASH_RE, ''); +export const removeTrailingSlash = (path: string) => path.replace(TRAILING_SLASH_RE, ''); function createCurrentLocation(base: string, location: Location) { const { pathname, search, hash } = location; @@ -302,9 +285,7 @@ function createCurrentLocation(base: string, location: Location) { // 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 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; @@ -339,10 +320,7 @@ export function createHashHistory(base?: string): RouterHistory { if (!base.endsWith('#/') && !base.endsWith('#')) { console.warn( - `A hash base must end with a "#":\n"${base}" should be "${base.replace( - /#.*$/, - '#' - )}".` + `A hash base must end with a "#":\n"${base}" should be "${base.replace(/#.*$/, '#')}".`, ); } @@ -370,12 +348,12 @@ export function createMemoryHistory(base = ''): RouterHistory { historyStack.push({ location, state }); } - const listeners = useCallbacks(); + const listeners = useEvent(); function triggerListeners( to: HistoryLocation, from: HistoryLocation, - { direction, delta }: Pick + { direction, delta }: Pick, ): void { const info: NavigationInformation = { direction, @@ -411,7 +389,7 @@ export function createMemoryHistory(base = ''): RouterHistory { current: to, }, data, - { position } + { position }, ); // remove current entry and decrement position @@ -428,12 +406,9 @@ export function createMemoryHistory(base = ''): RouterHistory { historyStack.splice(position, 1); pushStack(prevState.current, prevState); - const currentState = Object.assign( - {}, - buildState(prevState.current, to, null, false), - data, - { position: ++position } - ); + const currentState = Object.assign({}, buildState(prevState.current, to, null, false), data, { + position: ++position, + }); pushStack(to, currentState); }, @@ -442,10 +417,7 @@ export function createMemoryHistory(base = ''): RouterHistory { const direction: NavigationDirection = delta < 0 ? NavigationDirection.back : NavigationDirection.forward; - position = Math.max( - 0, - Math.min(position + delta, historyStack.length - 1) - ); + position = Math.max(0, Math.min(position + delta, historyStack.length - 1)); if (shouldTriggerListeners) { triggerListeners(this.location, from, { @@ -459,9 +431,7 @@ export function createMemoryHistory(base = ''): RouterHistory { destroy() { listeners.clear(); position = 0; - historyStack = [ - { location: '', state: buildState(null, '', null, false, position) }, - ]; + historyStack = [{ location: '', state: buildState(null, '', null, false, position) }]; }, }; } diff --git a/runtime/router/src/index.ts b/runtime/router/src/index.ts index 109f3f83f..35115c10c 100644 --- a/runtime/router/src/index.ts +++ b/runtime/router/src/index.ts @@ -1,16 +1,7 @@ export { createRouter } from './router'; -export { - createBrowserHistory, - createHashHistory, - createMemoryHistory, -} from './history'; +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'; +export * from './types'; diff --git a/runtime/router/src/matcher.ts b/runtime/router/src/matcher.ts index b3c28a694..fc4404c44 100644 --- a/runtime/router/src/matcher.ts +++ b/runtime/router/src/matcher.ts @@ -1,76 +1,69 @@ -import { type AnyObject, pick } from '@alilc/runtime-shared'; -import type { RouteRecord, RouteParams } from './types'; -import { - createRouteRecordMatcher, - type RouteRecordMatcher, -} from './utils/record-matcher'; +import { type PlainObject, type RawLocation } from '@alilc/renderer-core'; +import { pick } from 'lodash-es'; +import { createRouteRecordMatcher, type RouteRecordMatcher } from './utils/record-matcher'; import { type PathParserOptions } from './utils/path-parser'; -export interface MatcherLocationAsPath { - path: string; -} -export interface MatcherLocationAsRelative { - params?: Record; -} -export interface MatcherLocationAsName { - name: string; - params?: RouteParams; -} +import type { RouteRecord, RouteParams, RouteLocationNormalized } from './types'; -/** - * 匹配器的路由参数 - */ -export type MatcherLocationRaw = - | MatcherLocationAsPath - | MatcherLocationAsName - | MatcherLocationAsRelative; - -export type RouteRecordNormalized = Required< - Pick -> & { +export interface RouteRecordNormalized { /** * {@link RouteRecord.name} */ - name: string | undefined; + name: RouteRecord['name']; + path: RouteRecord['path']; + page: string; + meta: PlainObject; /** * {@link RouteRecord.redirect} */ - redirect: RouteRecord['redirect'] | undefined; -}; - -export interface MatcherLocation { - name: string | undefined; - path: string; - params: RouteParams; - matched: RouteRecord[]; - meta: AnyObject; + redirect: RouteRecord['redirect']; + children: RouteRecord[]; } +/** + * 作为 matcher 解析 location 的关键参数及输出内容 + */ +export type MatcherLocation = Pick< + RouteLocationNormalized, + 'name' | 'path' | 'params' | 'matched' | 'meta' +>; + +/** + * 路由匹配器 + */ export interface RouterMatcher { + /** + * 新增路由记录 + */ addRoute: (record: RouteRecord, parent?: RouteRecordMatcher) => void; + /** + * 删除路由记录 + */ removeRoute: { (matcher: RouteRecordMatcher): void; (name: string): void; }; - getRoutes: () => RouteRecordMatcher[]; + /** + * 获取所有的路由匹配对象 + */ + getRecordMatchers: () => 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 + * 允许访问与实际路径对应的路由记录并加入相应的 params * * @param location - MatcherLocationRaw to resolve to a url * @param currentLocation - MatcherLocation of the current location */ - resolve: ( - location: MatcherLocationRaw, - currentLocation: MatcherLocation - ) => MatcherLocation; + resolve: (location: RawLocation, currentLocation: MatcherLocation) => MatcherLocation; } export function createRouterMatcher( records: RouteRecord[], - globalOptions: PathParserOptions + globalOptions: PathParserOptions, ): RouterMatcher { const matchers: RouteRecordMatcher[] = []; const matcherMap = new Map(); @@ -80,7 +73,7 @@ export function createRouterMatcher( const options: PathParserOptions = Object.assign( {}, globalOptions, - pick(record, ['end', 'sensitive', 'strict']) + pick(record, ['end', 'sensitive', 'strict']), ); // 如果子路由不是绝对路径,则构建嵌套路由的路径。 @@ -88,10 +81,9 @@ export function createRouterMatcher( 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 connectingSlash = parentPath[parentPath.length - 1] === '/' ? '' : '/'; + + normalizedRecord.path = parent.record.path + (path && connectingSlash + path); } const matcher = createRouteRecordMatcher(normalizedRecord, parent, options); @@ -130,18 +122,11 @@ export function createRouterMatcher( } } - function getRoutes() { - return matchers; - } - function getRecordMatcher(name: string) { return matcherMap.get(name); } - function resolve( - location: MatcherLocationRaw, - currentLocation: MatcherLocation - ): MatcherLocation { + function resolve(location: RawLocation, currentLocation: MatcherLocation): MatcherLocation { let matcher: RouteRecordMatcher | undefined; let params: RouteParams = {}; let path: MatcherLocation['path']; @@ -151,34 +136,32 @@ export function createRouterMatcher( matcher = matcherMap.get(location.name); if (!matcher) { - throw new Error( - `Router error: no match for ${JSON.stringify(location)}` - ); + throw new Error(`Router error: no match for ${JSON.stringify(location)}`); } name = matcher.record.name; // 从当前路径与传入的参数中获取 params params = Object.assign( paramsFromLocation( - currentLocation.params, + currentLocation.params ?? {}, matcher.keys - .filter(k => { + .filter((k) => { return !(k.modifier === '?' || k.modifier === '*'); }) - .map(k => k.name) + .map((k) => k.name), ), location.params ? paramsFromLocation( location.params, - matcher.keys.map(k => k.name) + 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)); + matcher = matchers.find((m) => m.re.test(path)); if (matcher) { name = matcher.record.name; @@ -187,13 +170,11 @@ export function createRouterMatcher( } else { matcher = currentLocation.name ? matcherMap.get(currentLocation.name) - : matchers.find(m => m.re.test(currentLocation.path)); + : matchers.find((m) => m.re.test(currentLocation.path)); if (!matcher) { throw new Error( - `no match for ${JSON.stringify(location)}, ${JSON.stringify( - currentLocation - )}` + `no match for ${JSON.stringify(location)}, ${JSON.stringify(currentLocation)}`, ); } @@ -218,24 +199,23 @@ export function createRouterMatcher( }; } - records.forEach(r => addRoute(r)); + records.forEach((r) => addRoute(r)); return { resolve, addRoute, removeRoute, - getRoutes, + + getRecordMatchers() { + return matchers; + }, getRecordMatcher, }; } -function paramsFromLocation( - params: RouteParams, - keys: (string | number)[] -): RouteParams { +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]; } @@ -243,14 +223,13 @@ function paramsFromLocation( return newParams; } -export function normalizeRouteRecord( - record: RouteRecord -): RouteRecordNormalized { +export function normalizeRouteRecord(record: RouteRecord): RouteRecordNormalized { return { path: record.path, redirect: record.redirect, name: record.name, - page: record.page, + page: record.page || '', + meta: record['meta'] || {}, children: record.children || [], }; } diff --git a/runtime/router/src/router.ts b/runtime/router/src/router.ts index aa9fb0d1f..6375fb5c2 100644 --- a/runtime/router/src/router.ts +++ b/runtime/router/src/router.ts @@ -1,11 +1,11 @@ import { - type RouterSchema, - useCallbacks, + type RouterApi, + type RouterConfig, type RouteLocation, - type RouteLocationRaw, - type RouteLocationOptions, - noop, -} from '@alilc/runtime-shared'; + useEvent, + type RawRouteLocation, + type RawLocationOptions, +} from '@alilc/renderer-core'; import { createBrowserHistory, createHashHistory, @@ -13,46 +13,42 @@ import { type RouterHistory, type HistoryState, } from './history'; -import { createRouterMatcher, type MatcherLocationRaw } from './matcher'; +import { createRouterMatcher } 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'; +import type { RouteParams, RouteRecord, RouteLocationNormalized } from './types'; +import { type NavigationHookAfter, type NavigationGuard, guardToPromiseFn } from './guard'; + +export interface RouterOptions extends RouterConfig, PathParserOptions { + routes: RouteRecord[]; +} -export interface Router { +export interface Router extends RouterApi { readonly options: RouterOptions; readonly history: RouterHistory; - getCurrentRoute: () => RouteLocation; + getCurrentLocation(): RouteLocationNormalized; - addRoute: { - (parentName: string, route: RouteRecord): void; - (route: RouteRecord): void; - }; + resolve( + rawLocation: RawRouteLocation, + currentLocation?: RouteLocationNormalized, + ): RouteLocationNormalized; + + addRoute(route: RouteRecord): void; removeRoute(name: string): void; - hasRoute(name: string): boolean; getRoutes(): RouteRecord[]; - - push: (to: RouteLocationRaw) => void; - replace: (to: RouteLocationRaw) => void; + hasRoute(name: string): boolean; beforeRouteLeave: (fn: NavigationGuard) => () => void; afterRouteChange: (fn: NavigationHookAfter) => () => void; } -export type RouterOptions = RouterSchema & PathParserOptions; - -const START_LOCATION_NORMALIZED: RouteLocation = { +const START_LOCATION: RouteLocationNormalized = { path: '/', name: undefined, params: {}, - query: {}, + searchParams: undefined, hash: '', fullPath: '/', matched: [], @@ -60,54 +56,50 @@ const START_LOCATION_NORMALIZED: RouteLocation = { redirectedFrom: undefined, }; -export function createRouter(options: RouterOptions): Router { - const { - baseName = '/', - historyMode = 'browser', - routes = [], - ...globalOptions - } = options; +const defaultRouterOptions: RouterOptions = { + historyMode: 'browser', + baseName: '/', + routes: [], +}; + +export function createRouter(options: RouterOptions = defaultRouterOptions): 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); + ? createMemoryHistory(baseName) + : createBrowserHistory(baseName); - const beforeGuards = useCallbacks(); - const afterGuards = useCallbacks(); + const beforeGuards = useEvent(); + const afterGuards = useEvent(); - let currentRoute: RouteLocation = START_LOCATION_NORMALIZED; - let pendingLocation = currentRoute; + let currentLocation: RouteLocationNormalized = START_LOCATION; + let pendingLocation = currentLocation; function resolve( - rawLocation: RouteLocationRaw, - currentLocation?: RouteLocation - ): RouteLocation & { + rawLocation: RawRouteLocation, + currentLocation?: RouteLocationNormalized, + ): RouteLocationNormalized & { href: string; } { - currentLocation = Object.assign({}, currentLocation || currentRoute); + currentLocation = Object.assign({}, currentLocation || currentLocation); if (typeof rawLocation === 'string') { const locationNormalized = parseURL(rawLocation); - - const matchedRoute = matcher.resolve( - { path: locationNormalized.path }, - currentLocation - ); - + const matchedRoute = matcher.resolve({ path: locationNormalized.path }, currentLocation); const href = routerHistory.createHref(locationNormalized.fullPath); return Object.assign(locationNormalized, matchedRoute, { - query: locationNormalized.query as any, + searchParams: locationNormalized.searchParams, hash: decodeURIComponent(locationNormalized.hash), redirectedFrom: undefined, href, }); } - let matcherLocation: MatcherLocationRaw; + let matcherLocation: RawRouteLocation; if ('path' in rawLocation) { matcherLocation = { ...rawLocation }; @@ -140,13 +132,13 @@ export function createRouter(options: RouterOptions): Router { { fullPath, hash, - query: normalizeQuery(rawLocation.query) as any, + searchParams: rawLocation.searchParams, }, matchedRoute, { redirectedFrom: undefined, href, - } + }, ); } @@ -169,34 +161,33 @@ export function createRouter(options: RouterOptions): Router { } } function getRoutes() { - return matcher.getRoutes().map(item => item.record); + return matcher.getRecordMatchers().map((item) => item.record); } function hasRoute(name: string) { return !!matcher.getRecordMatcher(name); } - function push(to: RouteLocationRaw) { + function push(to: RawRouteLocation) { return pushOrRedirect(to); } - function replace(to: RouteLocationRaw) { - return pushOrRedirect({ ...locationAsObject(to), replace: true }); + function replace(to: RawRouteLocation) { + return pushOrRedirect({ ...locationAsObject(to) }, true); } function locationAsObject( - to: RouteLocationRaw | RouteLocation - ): Exclude | RouteLocation { + to: RawRouteLocation | RouteLocation, + ): Exclude | RouteLocation { return typeof to === 'string' ? parseURL(to) : { ...to }; } async function pushOrRedirect( - to: RouteLocationRaw | RouteLocation, - redirectedFrom?: RouteLocation + to: RawRouteLocation | RouteLocation, + replace = false, + 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 from = currentLocation; + const data: HistoryState | undefined = (to as RawLocationOptions).state; const shouldRedirect = getRedirectRecordIfShould(targetLocation); if (shouldRedirect) { @@ -204,20 +195,17 @@ export function createRouter(options: RouterOptions): Router { { ...shouldRedirect, state: Object.assign({}, data, shouldRedirect.state), - force, - replace, }, - redirectedFrom || targetLocation + replace, + redirectedFrom || targetLocation, ); } - const toLocation = targetLocation as RouteLocation; + const toLocation = targetLocation as RouteLocationNormalized; toLocation.redirectedFrom = redirectedFrom; - if (!force && isSameRouteLocation(from, toLocation)) { - throw Error( - '路由错误:重复请求' + JSON.stringify({ to: toLocation, from }) - ); + if (isSameRouteLocation(from, toLocation)) { + throw Error('路由错误:重复请求' + JSON.stringify({ to: toLocation, from })); } return navigateTriggerBeforeGuards(toLocation, from) @@ -231,21 +219,21 @@ export function createRouter(options: RouterOptions): Router { }); } - function getRedirectRecordIfShould(to: RouteLocation) { + function getRedirectRecordIfShould( + to: RouteLocationNormalized, + ): Exclude | undefined { const lastMatched = to.matched[to.matched.length - 1]; if (lastMatched?.redirect) { const { redirect } = lastMatched; - let newTargetLocation = - typeof redirect === 'function' ? redirect(to) : redirect; + 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 = {}; + (newTargetLocation as any).params = {}; } if (!('path' in newTargetLocation) && !('name' in newTargetLocation)) { @@ -254,27 +242,25 @@ export function createRouter(options: RouterOptions): Router { return Object.assign( { - query: to.query, + searchParams: to.searchParams, hash: to.hash, // path 存在的时候 清空 params params: 'path' in newTargetLocation ? {} : to.params, }, - newTargetLocation + newTargetLocation, ); } } async function navigateTriggerBeforeGuards( - to: RouteLocation, - from: RouteLocation + to: RouteLocationNormalized, + from: RouteLocationNormalized, ): Promise { let guards: ((...args: any[]) => Promise)[] = []; const canceledNavigationCheck = async (): Promise => { if (pendingLocation !== to) { - throw Error( - `路由错误:重复导航,from: ${from.fullPath}, to: ${to.fullPath}` - ); + throw Error(`路由错误:重复导航,from: ${from.fullPath}, to: ${to.fullPath}`); } return Promise.resolve(); }; @@ -288,31 +274,26 @@ export function createRouter(options: RouterOptions): Router { } if (beforeGuardsList.length > 0) guards.push(canceledNavigationCheck); - return guards.reduce( - (promise, guard) => promise.then(() => guard()), - Promise.resolve() - ); + return guards.reduce((promise, guard) => promise.then(() => guard()), Promise.resolve()); } catch (err) { throw err; } } function finalizeNavigation( - toLocation: RouteLocation, - from: RouteLocation, + toLocation: RouteLocationNormalized, + from: RouteLocationNormalized, isPush: boolean, replace?: boolean, - data?: HistoryState + data?: HistoryState, ) { // 重复导航 if (pendingLocation !== toLocation) { - throw Error( - `路由错误:重复导航,from: ${from.fullPath}, to: ${toLocation.fullPath}` - ); + throw Error(`路由错误:重复导航,from: ${from.fullPath}, to: ${toLocation.fullPath}`); } // 如果不是第一次启动的话 只需要考虑 push - const isFirstNavigation = from === START_LOCATION_NORMALIZED; + const isFirstNavigation = from === START_LOCATION; if (isPush) { if (replace || isFirstNavigation) { @@ -322,7 +303,7 @@ export function createRouter(options: RouterOptions): Router { } } - currentRoute = toLocation; + currentLocation = toLocation; // markAsReady(); } @@ -335,18 +316,15 @@ export function createRouter(options: RouterOptions): Router { // 判断是否需要重定向 const shouldRedirect = getRedirectRecordIfShould(toLocation); if (shouldRedirect) { - return pushOrRedirect( - Object.assign(shouldRedirect, { replace: true }), - toLocation - ).catch(() => {}); + return pushOrRedirect(shouldRedirect, true, toLocation).catch(() => {}); } pendingLocation = toLocation; - const from = currentRoute; + const from = currentLocation; // 触发路由守卫 navigateTriggerBeforeGuards(toLocation, from) - .catch(error => { + .catch((error) => { if (info.delta) { routerHistory.go(-info.delta, false); } @@ -366,25 +344,30 @@ export function createRouter(options: RouterOptions): Router { guard(toLocation, from); } }) - .catch(noop); + .catch(() => {}); }); } // init setupListeners(); - if (currentRoute === START_LOCATION_NORMALIZED) { - push(routerHistory.location).catch(err => { + if (currentLocation === START_LOCATION) { + push(routerHistory.location).catch((err) => { console.warn('Unexpected error when starting the router:', err); }); } + const go = (delta: number) => routerHistory.go(delta); + return { - options, + get options() { + return options; + }, get history() { return routerHistory; }, + getCurrentLocation: () => currentLocation, - getCurrentRoute: () => currentRoute, + resolve, addRoute, removeRoute, getRoutes, @@ -392,6 +375,9 @@ export function createRouter(options: RouterOptions): Router { push, replace, + back: () => go(-1), + forward: () => go(1), + go, beforeRouteLeave: beforeGuards.add, afterRouteChange: afterGuards.add, diff --git a/runtime/router/src/types.ts b/runtime/router/src/types.ts index 4dfa8b05f..4b7adb6d6 100644 --- a/runtime/router/src/types.ts +++ b/runtime/router/src/types.ts @@ -1,9 +1,22 @@ -import { type RouteSchema } from '@alilc/runtime-shared'; -import { type ParsedQs } from 'qs'; -import { type PathParserOptions } from './utils/path-parser'; +import type { + RouteRecord as RouterRecordSpec, + RouteLocation, + PlainObject, + RawRouteLocation, +} from '@alilc/renderer-core'; +import type { PathParserOptions } from './utils/path-parser'; -export type RouteRecord = RouteSchema & PathParserOptions; +export interface RouteRecord extends RouterRecordSpec, PathParserOptions { + meta?: PlainObject; + redirect?: + | string + | RawRouteLocation + | ((to: RouteLocationNormalized) => string | RawRouteLocation); + children?: RouteRecord[]; +} -export type LocationQuery = ParsedQs; +export interface RouteLocationNormalized extends RouteLocation { + matched: RouteRecord[]; +} export type RouteParams = Record; diff --git a/runtime/router/src/utils/helper.ts b/runtime/router/src/utils/helper.ts index 1dbd496b8..f8384b68e 100644 --- a/runtime/router/src/utils/helper.ts +++ b/runtime/router/src/utils/helper.ts @@ -1,15 +1,13 @@ -import { - type RouteLocation, - type RouteLocationRaw, -} from '@alilc/runtime-shared'; +import type { RawRouteLocation } from '@alilc/renderer-core'; +import type { RouteLocationNormalized } from '../types'; -export function isRouteLocation(route: any): route is RouteLocationRaw { +export function isRouteLocation(route: any): route is RawRouteLocation { return typeof route === 'string' || (route && typeof route === 'object'); } export function isSameRouteLocation( - a: RouteLocation, - b: RouteLocation + a: RouteLocationNormalized, + b: RouteLocationNormalized, ): boolean { const aLastIndex = a.matched.length - 1; const bLastIndex = b.matched.length - 1; @@ -19,15 +17,18 @@ export function isSameRouteLocation( aLastIndex === bLastIndex && a.matched[aLastIndex] === b.matched[bLastIndex] && isSameRouteLocationParams(a.params, b.params) && - a.query?.toString() === b.query?.toString() && + a.searchParams?.toString() === b.searchParams?.toString() && a.hash === b.hash ); } export function isSameRouteLocationParams( - a: RouteLocation['params'], - b: RouteLocation['params'] + a: RouteLocationNormalized['params'], + b: RouteLocationNormalized['params'], ): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + if (Object.keys(a).length !== Object.keys(b).length) return false; for (const key in a) { @@ -38,14 +39,14 @@ export function isSameRouteLocationParams( } function isSameRouteLocationParamsValue( - a: string | readonly string[], - b: string | readonly string[] + a: undefined | string | string[], + b: undefined | string | string[], ): boolean { return Array.isArray(a) ? isEquivalentArray(a, b) : Array.isArray(b) - ? isEquivalentArray(b, a) - : a === b; + ? isEquivalentArray(b, a) + : a === b; } function isEquivalentArray(a: readonly T[], b: readonly T[] | T): boolean { diff --git a/runtime/router/src/utils/query.ts b/runtime/router/src/utils/query.ts deleted file mode 100644 index a617e771d..000000000 --- a/runtime/router/src/utils/query.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type AnyObject } from '@alilc/runtime-shared'; -import type { LocationQuery } from '../types'; - -/** - * casting numbers into strings, removing keys with an undefined value and replacing - * undefined with null in arrays - * - * 将数字转换为字符,去除值为 undefined 的 keys,将数组中的 undefined 转换为 null - * - * @param query - query object to normalize - * @returns a normalized query object - */ -export function normalizeQuery(query: AnyObject | undefined): LocationQuery { - const normalizedQuery: AnyObject = {}; - - for (const key in query) { - const value = query[key]; - if (value !== undefined) { - normalizedQuery[key] = Array.isArray(value) - ? value.map(v => (v == null ? null : '' + v)) - : value == null - ? value - : '' + value; - } - } - - return normalizedQuery; -} diff --git a/runtime/router/src/utils/url.ts b/runtime/router/src/utils/url.ts index 6c1740713..e4a4b6e8c 100644 --- a/runtime/router/src/utils/url.ts +++ b/runtime/router/src/utils/url.ts @@ -1,10 +1,10 @@ -import { type AnyObject } from '@alilc/runtime-shared'; -import { parse, stringify } from 'qs'; -import { type LocationQuery } from '../types'; +/** + * todo: replace to URL API + */ export function parseURL(location: string) { let path = ''; - let query: LocationQuery = {}; + let searchParams: URLSearchParams | undefined; let searchString = ''; let hash = ''; @@ -16,12 +16,9 @@ export function parseURL(location: string) { if (searchPos > -1) { path = location.slice(0, searchPos); - searchString = location.slice( - searchPos + 1, - hashPos > -1 ? hashPos : location.length - ); + searchString = location.slice(searchPos + 1, hashPos > -1 ? hashPos : location.length); - query = parse(searchString); + searchParams = new URLSearchParams(searchString); } if (hashPos > -1) { @@ -35,16 +32,16 @@ export function parseURL(location: string) { return { fullPath: path + (searchString && '?') + searchString + hash, path, - query, + searchParams, hash, }; } export function stringifyURL(location: { path: string; - query?: AnyObject; + searchParams?: URLSearchParams; hash?: string; }): string { - const query: string = location.query ? stringify(location.query) : ''; - return location.path + (query && '?') + query + (location.hash || ''); + const searchStr = location.searchParams ? location.searchParams.toString() : ''; + return location.path + (searchStr && '?') + searchStr + (location.hash || ''); } diff --git a/runtime/router/tsconfig.json b/runtime/router/tsconfig.json index b4e69ae1f..8f7852d8e 100644 --- a/runtime/router/tsconfig.json +++ b/runtime/router/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "dist" - } + "outDir": "dist", + "paths": { + "@alilc/*": ["runtime/*"] + } + }, + "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index a06366141..2203cc737 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "declaration": true, - "lib": ["es2015", "dom"], + "lib": ["DOM", "ESNext", "DOM.Iterable"], // Target latest version of ECMAScript. "target": "esnext", // Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'.