• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

这节我们来实现组件联动。

它是动作的一种类型:

比如 amis 里,点击按钮的时候修改视频组件为隐藏。

这种组件和组件之间的关联就叫组件联动:

那它是怎么实现的呢?

其实也很简单:

我们知道,forwardRef + useImperativeHandle 可以让组件暴露一些方法出来:

我们在递归渲染组件 renderComponents 的时候,把组件 ref 收集起来,放到一个 map 里。

key 为组件 id

{
    1111: {
        aaa() {
        }
        bbb() {
        }
    },
    222: {
        ccc() {
        }
        ddd() {
        }
    }
}

这样 id 为 111 的组件想调用 id 为 222 的组件的 ccc 方法,就只需要在动作里加一个配置:

actions: [
    {
        type: "componentMethod",
        config: {
            componentId: 222,
            method: "ccc",
        },
    },
];

然后处理事件的时候,根据这个 componentId 和 method 从 refs 里拿到对应的方法执行就好了。

这样就实现了组件联动。

这个 actions 是配置在 components 的 store 里。

而组件有什么 method 是配置在 componentConfig 的 store 里。

思路理清了,我们来写下代码:

当然,现在的组件没啥好暴露的方法,我们加一个 Modal 组件:

materials/Modal/prod.tsx

import { Modal as AntdModal } from "antd";
import { forwardRef, useImperativeHandle, useState } from "react";
import { CommonComponentProps } from "../../interface";

export interface ModalRef {
    open: () => void;
    close: () => void;
}

const Modal: React.ForwardRefRenderFunction<ModalRef, CommonComponentProps> = (
    { children, title, onOk, onCancel, styles },
    ref
) => {
    const [open, setOpen] = useState(false);

    useImperativeHandle(ref, () => {
        return {
            open: () => {
                setOpen(true);
            },
            close: () => {
                setOpen(false);
            },
        };
    }, []);

    return (
        <AntdModal
            title={title}
            style={styles}
            open={open}
            onCancel={() => {
                onCancel && onCancel();
                setOpen(false);
            }}
            onOk={() => {
                onOk && onOk();
            }}
            destroyOnClose>
            {children}
        </AntdModal>
    );
};

export default forwardRef(Modal);

可以传入 title、onOk、onCancel、styles 的参数,并且暴露了 open、close 方法用于控制弹窗显示隐藏。

然后写下 dev 时的组件:

materials/Modal/dev.tsx

import { useMaterailDrop } from "../../hooks/useMaterailDrop";
import { CommonComponentProps } from "../../interface";

function Modal({ id, children, title, styles }: CommonComponentProps) {
    const { canDrop, drop } = useMaterailDrop(["Button", "Container"], id);

    return (
        <div
            ref={drop}
            style={styles}
            data-component-id={id}
            className={`min-h-[100px] p-[20px] ${canDrop ? "border-[2px] border-[blue]" : "border-[1px] border-[#000]"}`}>
            <h4>{title}</h4>
            <div>{children}</div>
        </div>
    );
}

export default Modal;

dev 时的组件和 prod 时的组件不一样,我们要加上 drop 的处理,,设置 drop 时的高亮,添加 data-compnent-id,并且指定最小高度

在 componentConfig 里配一下:

Modal: {
    name: 'Modal',
    defaultProps: {
        title: '弹窗'
    },
    setter: [
        {
          name: 'title',
          label: '标题',
          type: 'input'
        }
    ],
    stylesSetter: [],
    events: [
        {
            name: 'onOk',
            label: '确认事件',
        },
        {
            name: 'onCancel',
            label: '取消事件'
        },
    ],
    desc: '弹窗',
    dev: ModalDev,
    prod: ModalProd
},

试下效果:

编辑时可以拖拽组件进去,预览时为空,因为默认是隐藏的。

我们先改为默认显示试试:

然后我们设置下属性和样式:

绑定下事件:

和之前的功能能无缝结合。

低代码编辑器的核心完成后,支持不同场景只要增加不同组件就可以了。

然后我们回过头来继续做组件联动:

默认弹窗是隐藏的,我们要通过组件联动的方式,调用它的 open、close 方法来控制。

在 componentConfig 里配置下这两个 methods:

export interface ComponentMethod {
    name: string;
    label: string;
}

export interface ComponentConfig {
    name: string;
    defaultProps: Record<string, any>;
    desc: string;
    setter?: ComponentSetter[];
    stylesSetter?: ComponentSetter[];
    events?: ComponentEvent[];
    methods?: ComponentMethod[];
    dev: any;
    prod: any;
}
methods: [
    {
        name: 'open',
        label: '打开弹窗',
    },
    {
        name: 'close',
        label: '关闭弹窗'
    }
],

然后在 ActionModal 里支持选择组件联动的方式:

import { Modal, Segmented } from "antd";
import { useEffect, useState } from "react";
import { GoToLink, GoToLinkConfig } from "./actions/GoToLink";
import { ShowMessage, ShowMessageConfig } from "./actions/ShowMessage";
import { CustomJS, CustomJSConfig } from "./actions/CustomJS";
import {
    ComponentMethod,
    ComponentMethodConfig,
} from "./actions/ComponentMethod";

export type ActionConfig =
    | GoToLinkConfig
    | ShowMessageConfig
    | CustomJSConfig
    | ComponentMethodConfig;

export interface ActionModalProps {
    visible: boolean;
    action?: ActionConfig;
    handleOk: (config?: ActionConfig) => void;
    handleCancel: () => void;
}

export function ActionModal(props: ActionModalProps) {
    const { visible, action, handleOk, handleCancel } = props;

    const map = {
        goToLink: "访问链接",
        showMessage: "消息提示",
        customJS: "自定义 JS",
        componentMethod: "组件方法",
    };

    const [key, setKey] = useState<string>("访问链接");
    const [curConfig, setCurConfig] = useState<ActionConfig>();

    useEffect(() => {
        if (action?.type) {
            setKey(map[action.type]);
        }
    }, [action]);

    return (
        <Modal
            title="事件动作配置"
            width={800}
            open={visible}
            okText="确认"
            cancelText="取消"
            onOk={() => handleOk(curConfig)}
            onCancel={handleCancel}>
            <div className="h-[500px]">
                <Segmented
                    value={key}
                    onChange={setKey}
                    block
                    options={["访问链接", "消息提示", "组件方法", "自定义 JS"]}
                />
                {key === "访问链接" && (
                    <GoToLink
                        key="goToLink"
                        value={action?.type === "goToLink" ? action.url : ""}
                        onChange={(config) => {
                            setCurConfig(config);
                        }}
                    />
                )}
                {key === "消息提示" && (
                    <ShowMessage
                        key="showMessage"
                        value={
                            action?.type === "showMessage"
                                ? action.config
                                : undefined
                        }
                        onChange={(config) => {
                            setCurConfig(config);
                        }}
                    />
                )}
                {key === "组件方法" && (
                    <ComponentMethod
                        key="showMessage"
                        value={
                            action?.type === "componentMethod"
                                ? action.config
                                : undefined
                        }
                        onChange={(config) => {
                            setCurConfig(config);
                        }}
                    />
                )}
                {key === "自定义 JS" && (
                    <CustomJS
                        key="customJS"
                        value={action?.type === "customJS" ? action.code : ""}
                        onChange={(config) => {
                            setCurConfig(config);
                        }}
                    />
                )}
            </div>
        </Modal>
    );
}

实现下这个 ComponentMethod 组件:

Setting/actions/ComponentMethod.tsx

import { useEffect, useState } from "react";
import {
    Component,
    getComponentById,
    useComponetsStore,
} from "../../../stores/components";
import { Select, TreeSelect } from "antd";
import { useComponentConfigStore } from "../../../stores/component-config";

export interface ComponentMethodConfig {
    type: "componentMethod";
    config: {
        componentId: number;
        method: string;
    };
}

export interface ComponentMethodProps {
    value?: string;
    onChange?: (config: ComponentMethodConfig) => void;
}

export function ComponentMethod(props: ComponentMethodProps) {
    const { components, curComponentId } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();
    const [selectedComponent, setSelectedComponent] =
        useState<Component | null>();

    function componentChange(value: number) {
        if (!curComponentId) return;

        setSelectedComponent(getComponentById(value, components));
    }

    return (
        <div className="mt-[40px]">
            <div className="flex items-center gap-[10px]">
                <div>组件:</div>
                <div>
                    <TreeSelect
                        style={{ width: 500, height: 50 }}
                        treeData={components}
                        fieldNames={{
                            label: "name",
                            value: "id",
                        }}
                        onChange={(value) => {
                            componentChange(value);
                        }}
                    />
                </div>
            </div>
            {componentConfig[selectedComponent?.name || ""] && (
                <div className="flex items-center gap-[10px] mt-[20px]">
                    <div>方法:</div>
                    <div>
                        <Select
                            style={{ width: 500, height: 50 }}
                            options={componentConfig[
                                selectedComponent?.name || ""
                            ].methods?.map((method) => ({
                                label: method.label,
                                value: method.name,
                            }))}
                            onChange={(value) => {}}
                        />
                    </div>
                </div>
            )}
        </div>
    );
}

就是两个 Select,一个选择组件、一个选择组件的方法。

需要加一个 selectedComponent 的 state 来记录选中的组件。

测试下:

这样,组件方法的选择就完成了。

我们再处理下 value 和 onChange,做下数据的保存和回显:

import { useEffect, useState } from "react";
import {
    Component,
    getComponentById,
    useComponetsStore,
} from "../../../stores/components";
import { Select, TreeSelect } from "antd";
import { useComponentConfigStore } from "../../../stores/component-config";

export interface ComponentMethodConfig {
    type: "componentMethod";
    config: {
        componentId: number;
        method: string;
    };
}

export interface ComponentMethodProps {
    value?: ComponentMethodConfig["config"];
    onChange?: (config: ComponentMethodConfig) => void;
}

export function ComponentMethod(props: ComponentMethodProps) {
    const { value, onChange } = props;
    const { components, curComponentId } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();
    const [selectedComponent, setSelectedComponent] =
        useState<Component | null>();

    const [curId, setCurId] = useState<number>();
    const [curMethod, setCurMethod] = useState<string>();

    useEffect(() => {
        if (value) {
            setCurId(value.componentId);
            setCurMethod(value.method);

            setSelectedComponent(
                getComponentById(value.componentId, components)
            );
        }
    }, [value]);

    function componentChange(value: number) {
        if (!curComponentId) return;

        setCurId(value);
        setSelectedComponent(getComponentById(value, components));
    }

    function componentMethodChange(value: string) {
        if (!curComponentId || !selectedComponent) return;

        setCurMethod(value);

        onChange?.({
            type: "componentMethod",
            config: {
                componentId: selectedComponent?.id,
                method: value,
            },
        });
    }

    return (
        <div className="mt-[40px]">
            <div className="flex items-center gap-[10px]">
                <div>组件:</div>
                <div>
                    <TreeSelect
                        style={{ width: 500, height: 50 }}
                        treeData={components}
                        fieldNames={{
                            label: "name",
                            value: "id",
                        }}
                        value={curId}
                        onChange={(value) => {
                            componentChange(value);
                        }}
                    />
                </div>
            </div>
            {componentConfig[selectedComponent?.name || ""] && (
                <div className="flex items-center gap-[10px] mt-[20px]">
                    <div>方法:</div>
                    <div>
                        <Select
                            style={{ width: 500, height: 50 }}
                            options={componentConfig[
                                selectedComponent?.name || ""
                            ].methods?.map((method) => ({
                                label: method.label,
                                value: method.name,
                            }))}
                            value={curMethod}
                            onChange={(value) => {
                                componentMethodChange(value);
                            }}
                        />
                    </div>
                </div>
            )}
        </div>
    );
}

然后还要在动作列表回显下:

{
    item.type === "componentMethod" ? (
        <div
            key="componentMethod"
            className="border border-[#aaa] m-[10px] p-[10px] relative">
            <div className="text-[blue]">组件方法</div>
            <div>
                {getComponentById(item.config.componentId, components)?.desc}
            </div>
            <div>{item.config.componentId}</div>
            <div>{item.config.method}</div>
            <div
                style={{
                    position: "absolute",
                    top: 10,
                    right: 30,
                    cursor: "pointer",
                }}
                onClick={() => editAction(item, index)}>
                <EditOutlined />
            </div>
            <div
                style={{
                    position: "absolute",
                    top: 10,
                    right: 10,
                    cursor: "pointer",
                }}
                onClick={() => deleteAction(event, index)}>
                <DeleteOutlined />
            </div>
        </div>
    ) : null;
}

测试下:

添加、编辑都没问题。

然后我们在 Preview 里做下事件处理:

收集所有的 refs,按照 id 来索引,调用方法的时候根据 componentId 和 method 来调用。

const componentRefs = useRef<Record<string, any>>({});
ref: (ref: Record<string, any>) => { componentRefs.current[component.id] = ref; },
else if(action.type === 'componentMethod') {
    const component = componentRefs.current[action.config.componentId];

    if (component) {
      component[action.config.method]?.();
    }
}

测试下:

这样,组件联动就完成了。

案例代码上传了小册仓库,可以切换到这个 commit 查看:

git reset --hard 909a148d0145db4b7ce93ce2f16f676f87c37013

总结

这节我们实现了组件联动,也就是一个组件可以调用另一个组件的方法。

原理就是组件通过 forwardRef + useImperativeHandle 暴露一些方法,然后在 action 里配置 componentId、method。

这样预览的时候收集所有组件的 ref,事件触发的时候根据配置调用对应 componentId 的对应 method。

这样,我们支持了内置动作、自定义 JS、组件联动,事件绑定的功能就比较完整了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
75.低代码编辑器:自定义JS
Next
77.低代码编辑器:拖拽优化、Table组件