• 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.小册总结

这节我们来做下大纲和预览的功能。

大纲就是树形展示组件结构:

顺便把源码也做一下:

预览则是展示编辑好的页面:

我们先来实现下左边的大纲和源码。

创建 components/MaterialWrapper/index.tsx

import { Segmented } from "antd";
import { useState } from "react";
import { Material } from "../Material";
import { Outline } from "../Outline";
import { Source } from "../Source";

export function MaterialWrapper() {
    const [key, setKey] = useState < string > "物料";

    return (
        <div>
            <Segmented
                value={key}
                onChange={setKey}
                block
                options={["物料", "大纲", "源码"]}
            />
            <div className="pt-[20px]">
                {key === "物料" && <Material />}
                {key === "大纲" && <Outline />}
                {key === "源码" && <Source />}
            </div>
        </div>
    );
}

同样用 Segmented 组件来写 tab。

然后创建 Outline、Source 组件:

components/Outline/index.tsx

export function Outline() {
    return <div>Outline</div>;
}

components/Source/index.tsx

export function Source() {
    return <div>Source</div>;
}

把 editor/index.tsx 里的 Materail 换成 MaterialWrapper

试一下:

这样,tab 切换就完成了,并且之前的物料拖拽依然是正常的。

然后实现下大纲和源码。

大纲就是树形展示组件树:

用 antd 的 Tree 组件就行

import { Tree } from "antd";
import { useComponetsStore } from "../../stores/components";

export function Outline() {
    const { components, setCurComponentId } = useComponetsStore();

    return (
        <Tree
            fieldNames={{ title: "desc", key: "id" }}
            treeData={components as any}
            showLine
            defaultExpandAll
            onSelect={([selectedKey]) => {
                setCurComponentId(selectedKey as number);
            }}
        />
    );
}

title 是指定用哪个属性作为标题,key 是指定哪个属性作为 key。

选中的时候切换 curComponentId。

看下效果:

用 Tree 组件很简单就完成了。

然后是 Source,这个就更简单了,直接用 monaco editor 展示 json:

import MonacoEditor, { OnMount } from "@monaco-editor/react";
import { useComponetsStore } from "../../stores/components";

export function Source() {
    const { components } = useComponetsStore();

    const handleEditorMount: OnMount = (editor, monaco) => {
        editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyJ, () => {
            editor.getAction("editor.action.formatDocument")?.run();
        });
    };

    return (
        <MonacoEditor
            height={"100%"}
            path="components.json"
            language="json"
            onMount={handleEditorMount}
            value={JSON.stringify(components, null, 2)}
            options={{
                fontSize: 14,
                scrollBeyondLastLine: false,
                minimap: {
                    enabled: false,
                },
                scrollbar: {
                    verticalScrollbarSize: 6,
                    horizontalScrollbarSize: 6,
                },
            }}
        />
    );
}

把 components 用 JSON.stringify 格式化后展示就行。

高度有点小,在 MaterialWrapper 设置下 height:

h-[calc(100vh-60px-30px-20px)]

就是 100 的视口高度减去 header、tab 还有 padding 之后剩下的。

然后我们再实现下预览功能:

有同学说,预览和画布区不一样也是遍历 json 递归渲染组件么?

对,但是渲染的组件不同。

就拿日期组件来说:

编辑的时候不响应点击事件,预览的时候才有反应。

这是因为编辑的组件做了处理:

每个组件都要区分编辑和预览两种状态,甚至渲染的内容都不同。

所以,我们最好是编辑和预览状态的组件分开写:

改下 ComponentConfig,添加 dev、prod 属性。

然后我们给 Page、Button、Container 组件都添加两种状态的:

dev.tsx 就是之前的 index.tsx

我们只看 prod.tsx

Button 组件:

import { Button as AntdButton } from "antd";
import { CommonComponentProps } from "../../interface";

const Button = ({ id, type, text, styles }: CommonComponentProps) => {
    return (
        <AntdButton type={type} style={styles}>
            {text}
        </AntdButton>
    );
};

export default Button;

和 dev 状态差不多,只不过不用带 data-component-id 了

Container 组件:

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

const Container = ({ id, children, styles }: CommonComponentProps) => {
    return (
        <div style={styles} className={`p-[20px]`}>
            {children}
        </div>
    );
};

export default Container;

不用带 border,也不用处理 drop 事件。

Page 组件:

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

function Page({ id, name, children, styles }: CommonComponentProps) {
    return (
        <div className="p-[20px]" style={{ ...styles }}>
            {children}
        </div>
    );
}

export default Page;

不用带 h-[100%] 了,这个只是编辑的时候需要。

然后在 ComponentConfig 里注册下:

import { create } from "zustand";
import ContainerDev from "../materials/Container/dev";
import ContainerProd from "../materials/Container/prod";
import ButtonDev from "../materials/Button/dev";
import ButtonProd from "../materials/Button/prod";
import PageDev from "../materials/Page/dev";
import PageProd from "../materials/Page/prod";

export interface ComponentSetter {
    name: string;
    label: string;
    type: string;
    [key: string]: any;
}

export interface ComponentConfig {
    name: string;
    defaultProps: Record<string, any>;
    desc: string;
    setter?: ComponentSetter[];
    stylesSetter?: ComponentSetter[];
    dev: any;
    prod: any;
}

interface State {
    componentConfig: { [key: string]: ComponentConfig };
}

interface Action {
    registerComponent: (name: string, componentConfig: ComponentConfig) => void;
}

export const useComponentConfigStore = create<State & Action>((set) => ({
    componentConfig: {
        Container: {
            name: "Container",
            defaultProps: {},
            desc: "容器",
            dev: ContainerDev,
            prod: ContainerProd,
        },
        Button: {
            name: "Button",
            defaultProps: {
                type: "primary",
                text: "按钮",
            },
            setter: [
                {
                    name: "type",
                    label: "按钮类型",
                    type: "select",
                    options: [
                        { label: "主按钮", value: "primary" },
                        { label: "次按钮", value: "default" },
                    ],
                },
                {
                    name: "text",
                    label: "文本",
                    type: "input",
                },
            ],
            stylesSetter: [
                {
                    name: "width",
                    label: "宽度",
                    type: "inputNumber",
                },
                {
                    name: "height",
                    label: "高度",
                    type: "inputNumber",
                },
            ],
            desc: "按钮",
            dev: ButtonDev,
            prod: ButtonProd,
        },
        Page: {
            name: "Page",
            defaultProps: {},
            desc: "页面",
            dev: PageDev,
            prod: PageProd,
        },
    },
    registerComponent: (name, componentConfig) =>
        set((state) => {
            return {
                ...state,
                componentConfig: {
                    ...state.componentConfig,
                    [name]: componentConfig,
                },
            };
        }),
}));

然后 EditArea 里面渲染也改一下:

先看下效果:

功能正常。

然后加一个 Preview 组件:

components/Prview/index.tsx

import React from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components";

export function Preview() {
    const { components } = useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    function renderComponents(components: Component[]): React.ReactNode {
        return components.map((component: Component) => {
            const config = componentConfig?.[component.name];

            if (!config?.prod) {
                return null;
            }

            return React.createElement(
                config.prod,
                {
                    key: component.id,
                    id: component.id,
                    name: component.name,
                    styles: component.styles,
                    ...config.defaultProps,
                    ...component.props,
                },
                renderComponents(component.children || [])
            );
        });
    }

    return <div>{renderComponents(components)}</div>;
}

这个组件比 EditArea 简单,只要把 json 递归渲染成 prod 的组件就行。

然后在 store 添加一个 mode 的 state 用来切换编辑、预览状态:

mode: "edit" | "preview";
setMode: (mode: State['mode']) => void;
mode: 'edit',
setMode: (mode) => set({mode}),

然后渲染的时候用 mode 区分下:

import { Allotment } from "allotment";
import "allotment/dist/style.css";
import { Header } from "./components/Header";
import { EditArea } from "./components/EditArea";
import { Setting } from "./components/Setting";
import { MaterialWrapper } from "./components/MaterialWrapper";
import { useComponetsStore } from "./stores/components";
import { Preview } from "./components/Preivew";

export default function ReactPlayground() {
    const { mode } = useComponetsStore();

    return (
        <div className="h-[100vh] flex flex-col">
            <div className="h-[60px] flex items-center border-b-[1px] border-[#000]">
                <Header />
            </div>
            {mode === "edit" ? (
                <Allotment>
                    <Allotment.Pane
                        preferredSize={240}
                        maxSize={300}
                        minSize={200}>
                        <MaterialWrapper />
                    </Allotment.Pane>
                    <Allotment.Pane>
                        <EditArea />
                    </Allotment.Pane>
                    <Allotment.Pane
                        preferredSize={300}
                        maxSize={500}
                        minSize={300}>
                        <Setting />
                    </Allotment.Pane>
                </Allotment>
            ) : (
                <Preview />
            )}
        </div>
    );
}

根据 mode 来渲染不同的足迹啊。

然后在 Header 加个预览按钮来切换 mode

import { Button, Space } from "antd";
import { useComponetsStore } from "../../stores/components";

export function Header() {
    const { mode, setMode, setCurComponentId } = useComponetsStore();

    return (
        <div className="w-[100%] h-[100%]">
            <div className="h-[50px] flex justify-between items-center px-[20px]">
                <div>低代码编辑器</div>
                <Space>
                    {mode === "edit" && (
                        <Button
                            onClick={() => {
                                setMode("preview");
                                setCurComponentId(null);
                            }}
                            type="primary">
                            预览
                        </Button>
                    )}
                    {mode === "preview" && (
                        <Button
                            onClick={() => {
                                setMode("edit");
                            }}
                            type="primary">
                            退出预览
                        </Button>
                    )}
                </Space>
            </div>
        </div>
    );
}

加个预览、退出预览按钮,点击切换 mode。

当 mode 切换为 edit 时,还要把 curComponentId 置空

测试下:

这样,预览功能就完成了。

当然,现在组件比较少,后面多加一些组件就好了。

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

git reset --hard 1db99bed7d588ac86fd0bdc006fad433f031cd31

总结

这节我们实现了源码、大纲、预览的功能。

源码和大纲比较简单,就是 json 的不同形式的展示,分别用 @monaco-editor/react 和 Tree 组件来做。

预览功能也是递归渲染 json 为组件树,但是组件不一样,预览和编辑状态的组件要分开写。

我们在 store 加了一个 mode 的状态,切换 mode 来切换渲染的内容。

这样,从编辑到预览的流程就打通了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
71.低代码编辑器:组件属性、样式编辑
Next
73.低代码编辑器:事件绑定