【Amis源码阅读】低代码如何实现交互?(上)
2025年11月19日 17:40
基于 6.13.0 版本
前期回顾
前言
- 组件渲染搞定了,那组件如何进行交互呢?
amis提出了事件动作的概念,在监听到事件后通过动作做出反应- 事件:
- 渲染器事件:组件内部执行的事件,会暴露给外部监听。比如初始化、点击、值变化等事件
- 广播事件:全局事件,其他组件可在自身监听相关广播事件
- 动作:监听到事件时,希望执行的逻辑。比如打开弹窗、
toast提示、刷新接口等
- 事件:
- 本篇先聊事件的工作逻辑,从常用的渲染器事件入手(渲染器等同组件)
渲染器事件
onEvent事件监听
-
amis支持onEvent的形式监听组件事件的触发时机,比如组件被点击时触发一个toast。那写入onEvent中的动作是何时何地被执行的呢?
{
"onEvent": {
"click": {
"actions": [
{
"actionType": "toast",
"args": {
"msgType": "success",
"msg": "点击成功"
}
}
]
}
}
}
组件中的事件触发
- 以常见的
Page组件中init(初始化)事件为例,它实际就是在类组件的componentDidMount生命周期(挂载阶段)中触发了一次,dispatchEvent就是事件的入口了
// packages/amis/src/renderers/Page.tsx
export default class Page extends React.Component<PageProps> {
...
async componentDidMount() {
const {
initApi,
initFetch,
initFetchOn,
store,
messages,
data,
dispatchEvent,
env
} = this.props;
this.mounted = true;
const rendererEvent = await dispatchEvent('init', data, this);
...
}
}
- 再以
Tpl组件中的click(点击)、mouseenter(鼠标移入)、mouseleave(鼠标移出)事件为例,可以直观的看出他们就是在组件绑定的onClick、onMouseEnter、onMouseLeave事件中执行了一遍dispatchEvent - 此时可以推测出,
onEvent应该是在dispatchEvent中被执行了
// packages/amis/src/renderers/Tpl.tsx
export interface TplSchema extends BaseSchema {
...
@autobind
handleClick(e: React.MouseEvent<HTMLDivElement>) {
const {dispatchEvent, data} = this.props;
dispatchEvent(e, data);
}
@autobind
handleMouseEnter(e: React.MouseEvent<any>) {
const {dispatchEvent, data} = this.props;
dispatchEvent(e, data);
}
@autobind
handleMouseLeave(e: React.MouseEvent<any>) {
const {dispatchEvent, data} = this.props;
dispatchEvent(e, data);
}
render() {
return (
<Component
...
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
{...testIdBuilder?.getChild('tpl')?.getTestId()}
>
...
</Component>
);
}
}
工作流
触发事件(dispatchEvent)
-
broadcast参数可忽略,没用(factory.tsx中的类型定义也说明了这点) -
rendererEventListeners是个全局变量(事件队列),所有的事件监听器都存储在这 -
dispatchEvent流程-
bindEvent绑定事件,返回unbindEvent销毁函数 -
createRendererEvent创建事件对象 - 事件队列里的事件按权重排序确定执行优先级
- 遍历事件队列,若有事件有
debounce属性,就设置防抖,同时设置executing为真;若没有就直接执行事件(runAction) - 遍历事件队列的时候会通过
checkExecuted函数计数,当遍历完毕后(也就意味着事件都执行完毕了,会等待防抖的事件执行完),执行unbindEvent销毁事件
-
// packages/amis-core/src/utils/renderer-event.ts
let rendererEventListeners: RendererEventListener[] = [];
...
// 触发事件
export async function dispatchEvent(
e: string | React.MouseEvent<any>,
renderer: React.Component<RendererProps>,
scoped: IScopedContext,
data: any,
broadcast?: RendererEvent<any>
): Promise<RendererEvent<any> | void> {
let unbindEvent: ((eventName?: string) => void) | null | undefined = null;
const eventName = typeof e === 'string' ? e : e.type;
const from = renderer?.props.id || renderer?.props.name || '';
...
broadcast && renderer.props.onBroadcast?.(e as string, broadcast, data);
if (!broadcast) {
const eventConfig = renderer?.props?.onEvent?.[eventName];
if (!eventConfig) {
// 没命中也没关系
return Promise.resolve();
}
unbindEvent = bindEvent(renderer);
}
// 没有可处理的监听
if (!rendererEventListeners.length) {
return Promise.resolve();
}
// 如果是广播动作,就直接复用
const rendererEvent =
broadcast ||
createRendererEvent(eventName, {
env: renderer?.props?.env,
nativeEvent: e,
data,
scoped
});
// 过滤&排序
const listeners = rendererEventListeners
.filter(
(item: RendererEventListener) =>
item.type === eventName &&
(broadcast
? true
: item.renderer === renderer &&
item.actions === renderer.props?.onEvent?.[eventName].actions)
)
.sort(
(prev: RendererEventListener, next: RendererEventListener) =>
next.weight - prev.weight
);
let executedCount = 0;
const checkExecuted = () => {
executedCount++;
if (executedCount === listeners.length) {
unbindEvent?.(eventName);
}
};
for (let listener of listeners) {
const {
wait = 100,
trailing = true,
leading = false,
maxWait = 10000
} = listener?.debounce || {};
if (listener?.debounce) {
const debounced = debounce(
async () => {
await runActions(listener.actions, listener.renderer, rendererEvent);
checkExecuted();
},
wait,
{
trailing,
leading,
maxWait
}
);
rendererEventListeners.forEach(item => {
// 找到事件队列中正在执行的事件加上标识,下次待执行队列就会把这个事件过滤掉
if (
item.renderer === listener.renderer &&
listener.type === item.type
) {
item.executing = true;
item.debounceInstance = debounced;
}
});
debounced();
} else {
await runActions(listener.actions, listener.renderer, rendererEvent);
checkExecuted();
}
if (listener?.track) {
const {id: trackId, name: trackName} = listener.track;
renderer?.props?.env?.tracker({
eventType: listener.type,
eventData: {
trackId,
trackName
}
});
}
// 停止后续监听器执行
if (rendererEvent.stoped) {
break;
}
}
return Promise.resolve(rendererEvent);
}
绑定事件(bindEvent)
- 所谓绑定事件就是把事件推入事件队列
- 首先会遍历
onEvent中的内容 - 然后处理防抖场景:如果存在相同的事件且在防抖时间内(
executing为真),则取消旧事件防抖并移除旧事件,把新事件加入事件队列。比如说,事件队列中存有用户触发了3次的事件a(假设都在防抖时间内),则前2次事件在bindEvent阶段会被删除,只保留第3次事件 - 如果不存在上述情况,直接加入事件队列
- 最终都是返回解绑事件的函数(从事件队列中移除)
// packages/amis-core/src/utils/renderer-event.ts
// 绑定事件
export const bindEvent = (renderer: any) => {
if (!renderer) {
return undefined;
}
const listeners: EventListeners = renderer.props.$schema.onEvent;
if (listeners) {
// 暂存
for (let key of Object.keys(listeners)) {
const listener = rendererEventListeners.find(
(item: RendererEventListener) =>
item.renderer === renderer &&
item.type === key &&
item.actions === listeners[key].actions
);
// 存在相同的事件且在防抖时间内
if (listener?.executing) {
listener?.debounceInstance?.cancel?.();
rendererEventListeners = rendererEventListeners.filter(
(item: RendererEventListener) =>
!(
item.renderer === listener.renderer && item.type === listener.type
)
);
listener.actions.length &&
rendererEventListeners.push({
renderer,
type: key,
debounce: listener.debounce || null,
track: listeners[key].track || null,
weight: listener.weight || 0,
actions: listener.actions
});
}
if (!listener && listeners[key].actions?.length) {
rendererEventListeners.push({
renderer,
type: key,
debounce: listeners[key].debounce || null,
track: listeners[key].track || null,
weight: listeners[key].weight || 0,
actions: listeners[key].actions
});
}
}
return (eventName?: string) => {
// eventName用来避免过滤广播事件
rendererEventListeners = rendererEventListeners.filter(
(item: RendererEventListener) =>
// 如果 eventName 为 undefined,表示全部解绑,否则解绑指定事件
eventName === undefined
? item.renderer !== renderer
: item.renderer !== renderer || item.type !== eventName
);
};
}
return undefined;
};
执行动作(runActions)
- 这里只是一个执行动作的前置处理
- 遍历动作,通过
getActionByType查找动作实例,若没有则判断是否是组件专有动作(组件都有可调用),若再没有则判断是否是打开页面相关的动作,若还是没有则直接调用组件自定义的动作 - 实际的动作执行还是在
runAction中,等下一篇再完整的分析动作相关流程
// packages/amis-core/src/actions/Action.ts
export const runActions = async (
actions: ListenerAction | ListenerAction[],
renderer: ListenerContext,
event: any
) => {
if (!Array.isArray(actions)) {
actions = [actions];
}
for (const actionConfig of actions) {
let actionInstrance = getActionByType(actionConfig.actionType);
// 如果存在指定组件ID,说明是组件专有动作
if (
!actionInstrance &&
(actionConfig.componentId || actionConfig.componentName)
) {
actionInstrance = [
'static',
'nonstatic',
'show',
'visibility',
'hidden',
'enabled',
'disabled',
'usability'
].includes(actionConfig.actionType)
? getActionByType('status')
: getActionByType('component');
} else if (['url', 'link', 'jump'].includes(actionConfig.actionType)) {
// 打开页面动作
actionInstrance = getActionByType('openlink');
}
// 找不到就通过组件专有动作完成
if (!actionInstrance) {
actionInstrance = getActionByType('component');
}
try {
// 这些节点的子节点运行逻辑由节点内部实现
await runAction(actionInstrance, actionConfig, renderer, event);
} catch (e) {
...
}
if (event.stoped) {
break;
}
}
};
设计特性
全局事件管理
- 通过
rendererEventListeners队列统一管理,和react的事件委托有点类似 - 支持跨组件通信
- 支持全局权重排序、防抖
延迟绑定,执行完销毁
- 事件都是触发后,在
bindEvent中绑定的(加入事件队列),减少内存占用 - 然后执行完毕会立即销毁,避免内存泄漏
广播事件
- 独立于渲染器事件的全局事件,基于
BroadcastChannel类实现
工作流
- 由于是全局事件,肯定得优先绑定了
绑定事件入口
- 组件渲染(渲染流程可参考之前的组件渲染篇)时绑定
- 组件生成时都会传入
childRef,直接在组件的ref上通过bindGlobalEvent绑定了广播事件
// packages/amis-core/src/SchemaRenderer.tsx
import {
bindEvent,
bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';
export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
@autobind
childRef(ref: any) {
...
while (ref?.getWrappedInstance?.()) {
ref = ref.getWrappedInstance();
}
...
if (ref) {
// 这里无法区分监听的是不是广播,所以又bind一下,主要是为了绑广播
this.unbindEvent?.();
this.unbindGlobalEvent?.();
this.unbindEvent = bindEvent(ref);
this.unbindGlobalEvent = bindGlobalEvent(ref);
}
...
}
render(): JSX.Element | null {
...
let component = supportRef ? (
<Component {...props} ref={this.childRef} storeRef={this.storeRef} />
) : (
<Component
{...props}
forwardedRef={this.childRef}
storeRef={this.storeRef}
/>
);
...
return this.props.env.enableAMISDebug ? (
<DebugWrapper renderer={renderer}>{component}</DebugWrapper>
) : (
component
);
}
}
绑定事件(bindGlobalEventForRenderer)
- 这里并未区分广播事件
- 遍历事件,创建
BroadcastChannel对象,推入bcs广播事件队列 - 挂载
onmessage消息监听,接收到广播时通过runActions触发动作 - 最终返回一个注销广播实例的函数
- 小疑问:这里直接把
renderer.props.$schema.onEvent中所有的动作都绑定了广播事件,虽然统一管理了广播事件的绑定,但是绑定了很多多余的动作,这里实际可以判断actionType为broadcast才绑定?
// packages/amis-core/src/utils/renderer-event.ts
export const bindGlobalEventForRenderer = (renderer: any) => {
...
const listeners: EventListeners = renderer.props.$schema.onEvent;
let bcs: Array<{
renderer: any;
bc: BroadcastChannel;
}> = [];
if (listeners) {
for (let key of Object.keys(listeners)) {
const listener = listeners[key];
...
const bc = new BroadcastChannel(key);
bcs.push({
renderer: renderer,
bc
});
bc.onmessage = e => {
const { eventName, data } = e.data;
const rendererEvent = createRendererEvent(eventName, {
env: renderer?.props?.env,
nativeEvent: eventName,
scoped: renderer?.context,
data
});
// 过滤掉当前的广播事件,避免循环广播
const actions = listener.actions.filter(
a => !(a.actionType === 'broadcast' && a.eventName === eventName)
);
runActions(actions, renderer, rendererEvent);
};
}
return () => {
bcs
.filter(item => item.renderer === renderer)
.forEach(item => item.bc.close());
};
}
return void 0;
};
触发事件(dispatchGlobalEventForRenderer)
- 广播动作
packages/amis-core/src/actions/BroadcastAction.ts中调用了dispatchGlobalEventForRenderer - 代码较短,内部直接调用
dispatchGlobalEvent方法,然后创建BroadcastChannel实例发送消息,然后关闭,齐活! - 接收消息的地方就是上文
bindGlobalEventForRenderer中挂载了onmessage事件的地方,不赘述
// packages/amis-core/src/utils/renderer-event.ts
export async function dispatchGlobalEventForRenderer(
eventName: string,
renderer: React.Component<RendererProps>,
scoped: IScopedContext,
data: any,
broadcast: RendererEvent<any>
) {
...
dispatchGlobalEvent(eventName, data);
}
export async function dispatchGlobalEvent(eventName: string, data: any) {
...
const bc = new BroadcastChannel(eventName);
bc.postMessage({
eventName,
data
});
bc.close();
}
解绑事件
- 广播事件是长期绑定的,只有在组件卸载时才解绑
// packages/amis-core/src/SchemaRenderer.tsx
import {
bindEvent,
bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';
export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
componentWillUnmount() {
this.unbindEvent?.();
this.unbindGlobalEvent?.();
}
}
总结
-
amis的事件管理还是挺值得学习的 - 渲染器事件就是在组件的执行过程中开了个口子,支持插入想执行的逻辑
- 广播事件就是依赖
BroadcastChannel的原生功能 - 下篇再写动作,脑子不够用了