• 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 中,选中不同组件会在右边展示对应的属性:

编辑属性,会修改 json 中的内容:

我们只要在选中组件的时候,在右边展示组件对应属性的表单就行了。

不同组件的属性是不同的,这部分明显是在 componentConfig 里配置。

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[];
    component: any;
}

先给 Button 加一下:

用 setter 属性来保存属性表单的配置,这里有 type、text 两个属性,就是两个表单项。

{
  name: 'type',
  label: '按钮类型',
  type: 'select',
  options: [
    {label: '主按钮', value: 'primary'},
    {label: '次按钮', value: 'default'},
  ],
},
{
  name: 'text',
  label: '文本',
  type: 'input',
}

name 是字段名、label 是前面的文案,type 是表单类型。

select 类型的表单多一个 options 来配置选项。

在 Setting 组件里取出 curComponentId 对应的属性,渲染成表单就好了:

其实 Setting 部分不只是设置属性,还可以设置样式、绑定事件:

我们先预留出位置来:

components/Setting/index.tsx

import { Segmented } from "antd";
import { useState } from "react";
import { useComponetsStore } from "../../stores/components";
import { ComponentAttr } from "./ComponentAttr";
import { ComponentEvent } from "./ComponentEvent";
import { ComponentStyle } from "./ComponentStyle";

export function Setting() {
    const { curComponentId } = useComponetsStore();

    const [key, setKey] = useState < string > "属性";

    if (!curComponentId) return null;

    return (
        <div>
            <Segmented
                value={key}
                onChange={setKey}
                block
                options={["属性", "样式", "事件"]}
            />
            <div>
                {key === "属性" && <ComponentAttr />}
                {key === "样式" && <ComponentStyle />}
                {key === "事件" && <ComponentEvent />}
            </div>
        </div>
    );
}

components/Setting/ComponentAttr.tsx

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

components/Setting/ComponentStyle.tsx

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

components/Setting/ComponentEvent.tsx

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

如果 curComponentId 为 null,也就是没有选中的组件,就 return null。

用 antd 的 Segmentd 组件来做上面的 tab。

然后分别用 ComponentAttr、ComponentStyle、ComponentEvent 组件渲染组件的属性、样式、事件。

没啥问题。

然后来写 ComponentAttr 组件:

import { Form, Input, Select } from "antd";
import { useEffect } from "react";
import {
    ComponentConfig,
    ComponentSetter,
    useComponentConfigStore,
} from "../../stores/component-config";
import { useComponetsStore } from "../../stores/components";

export function ComponentAttr() {
    const [form] = Form.useForm();

    const { curComponentId, curComponent, updateComponentProps } =
        useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    useEffect(() => {
        const data = form.getFieldsValue();
        form.setFieldsValue({ ...data, ...curComponent?.props });
    }, [curComponent]);

    if (!curComponentId || !curComponent) return null;

    function renderFormElememt(setting: ComponentSetter) {
        const { type, options } = setting;

        if (type === "select") {
            return <Select options={options} />;
        } else if (type === "input") {
            return <Input />;
        }
    }

    function valueChange(changeValues: ComponentConfig) {
        if (curComponentId) {
            updateComponentProps(curComponentId, changeValues);
        }
    }

    return (
        <Form
            form={form}
            onValuesChange={valueChange}
            labelCol={{ span: 8 }}
            wrapperCol={{ span: 14 }}>
            <Form.Item label="组件id">
                <Input value={curComponent.id} disabled />
            </Form.Item>
            <Form.Item label="组件名称">
                <Input value={curComponent.name} disabled />
            </Form.Item>
            <Form.Item label="组件描述">
                <Input value={curComponent.desc} disabled />
            </Form.Item>
            {componentConfig[curComponent.name]?.setter?.map((setter) => (
                <Form.Item
                    key={setter.name}
                    name={setter.name}
                    label={setter.label}>
                    {renderFormElememt(setter)}
                </Form.Item>
            ))}
        </Form>
    );
}

首先,如果 curComponentId 为 null,也就是没有选中组件的时候,返回 null

当 curComponent 变化的时候,把 props 设置到表单用于回显数据:

当表单 value 变化的时候,同步到 store:

下面就是表单项目,分别渲染 id、name、desc 属性,还有组件对应的 setter:

id、name、desc 都不可修改,设置 disabled。

setter 要根据类型来渲染不同的表单组件,比如 Select、Input。

测试下:

可以看到,当切换到 Page、Container、Button 组件的时候,展示了对应属性的表单。

现在按钮类型、文本都是可以修改的,画布区会同步变化:

没啥问题。

当然,现在我们组件还不多,之后组件多了以后,表单项类型会更多。

到时候扩展这里就可以了:

扩展更多的 setter 类型,支持 radio、checkbox 等表单项。

还有,现在这里贴的比较紧,我们加个 padding:

好多了。

然后我们再来写下样式的编辑:

在 components 的 store 添加 styles 和更新 styles 的方法:

updateComponentStyles: (componentId: number, styles: CSSProperties) => void;
updateComponentStyles: (componentId, styles) =>
    set((state) => {
        const component = getComponentById(componentId, state.components);
        if (component) {
            component.styles = { ...component.styles, ...styles };

            return { components: [...state.components] };
        }

        return { components: [...state.components] };
    });

在渲染组件的时候传进去:

给渲染的组件参数加一个 styles 参数:

把 styles 渲染出来:

Button 组件:

Container 组件:

Page 组件:

然后我们在 addComponent 的时候加上个 styles 试试:

生效了。

这样我们就把 styles 保存在了 json 里,并且渲染的时候设置到了组件。

然后做下 styles 的编辑就好了。

amis 的样式编辑上面是一些 css 的样式可以选择,下面还可以直接写 css:

而且每个组件配置的样式都不同:

这个也和组件 props 一样,需要在 componentConfig 配下表单项:

stylesSetter?: ComponentSetter[]
stylesSetter: [
    {
        name: 'width',
        label: '宽度',
        type: 'inputNumber',
    },
    {
        name: 'height',
        label: '高度',
        type: 'inputNumber',
    }
],

然后在 ComponentStyle 里面渲染下:

import { Form, Input, InputNumber, Select } from "antd";
import { CSSProperties, useEffect } from "react";
import {
    ComponentConfig,
    ComponentSetter,
    useComponentConfigStore,
} from "../../stores/component-config";
import { useComponetsStore } from "../../stores/components";

export function ComponentStyle() {
    const [form] = Form.useForm();

    const { curComponentId, curComponent, updateComponentStyles } =
        useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    useEffect(() => {
        const data = form.getFieldsValue();
        form.setFieldsValue({ ...data, ...curComponent?.styles });
    }, [curComponent]);

    if (!curComponentId || !curComponent) return null;

    function renderFormElememt(setting: ComponentSetter) {
        const { type, options } = setting;

        if (type === "select") {
            return <Select options={options} />;
        } else if (type === "input") {
            return <Input />;
        } else if (type === "inputNumber") {
            return <InputNumber />;
        }
    }

    function valueChange(changeValues: CSSProperties) {
        if (curComponentId) {
            updateComponentStyles(curComponentId, changeValues);
        }
    }

    return (
        <Form
            form={form}
            onValuesChange={valueChange}
            labelCol={{ span: 8 }}
            wrapperCol={{ span: 14 }}>
            {componentConfig[curComponent.name]?.stylesSetter?.map((setter) => (
                <Form.Item
                    key={setter.name}
                    name={setter.name}
                    label={setter.label}>
                    {renderFormElememt(setter)}
                </Form.Item>
            ))}
        </Form>
    );
}

和 ComponentAttr 没啥区别,就是把更新方法换成 updateComponentStyles

测试下:

可以看到,样式修改生效了。

Button 组件支持的样式配置肯定不是 width、height,后面再完善就行。

我们把直接写 css 的方式也实现下:

或者用类似 tailwind 的原子化 className 的方式,让用户自己选择,添加 className 也行:

这样比写 css 上手成本低一些。

用 @monaco-editor/react 来做 css 编辑器,它自带了代码提示功能。

npm install --save @monaco-editor/react

封装个组件:

components/Setting/CssEditor.tsx

import MonacoEditor, { OnMount, EditorProps } from "@monaco-editor/react";
import { editor } from "monaco-editor";
import { useEffect, useRef } from "react";

export interface EditorFile {
    name: string;
    value: string;
    language: string;
}

interface Props {
    value: string;
    onChange?: EditorProps["onChange"];
    options?: editor.IStandaloneEditorConstructionOptions;
}

export default function CssEditor(props: Props) {
    const { value, onChange, options } = props;

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

    return (
        <MonacoEditor
            height={"100%"}
            path="component.css"
            language="css"
            onMount={handleEditorMount}
            onChange={onChange}
            value={value}
            options={{
                fontSize: 14,
                scrollBeyondLastLine: false,
                minimap: {
                    enabled: false,
                },
                scrollbar: {
                    verticalScrollbarSize: 6,
                    horizontalScrollbarSize: 6,
                },
                ...options,
            }}
        />
    );
}

之前写 react playground 的时候用过 monoco editor。

这里配置差不多。

支持 cmd + J 快捷键来格式化。

然后在 ComponentStyle 组件里用一下:

<div className="h-[200px] border-[1px] border-[#ccc]">
    <CssEditor value={`.comp{\n\n}`} />
</div>

试一下:

然后做下自定义 css 到 store 的同步:

onChange 的时候打印下值:

触发有点频繁了,我们引入 lodash 做下 debounce:

npm install --save lodash-es
npm install --save-dev @types/lodash-es

加个 500ms 的 debounce。

这样就好多了。

然后把它保存到 store:

store 里保存的是 对象,而现在拿到的是 css 字符串,需要 parse 一下。

用 style-to-object 这个包:

调用下:

const handleEditorChange = debounce((value) => {
    setCss(value);

    let css: Record<string, any> = {};

    try {
        const cssStr = value
            .replace(/\/\*.*\*\//, "") // 去掉注释 /** */
            .replace(/(\.?[^{]+{)/, "") // 去掉 .comp {
            .replace("}", ""); // 去掉 }

        styleToObject(cssStr, (name, value) => {
            css[
                name.replace(/-\w/, (item) =>
                    item.toUpperCase().replace("-", "")
                )
            ] = value;
        });

        console.log(css);
        updateComponentStyles(curComponentId, css);
    } catch (e) {}
}, 500);

style-to-object 只支持 style 的 parse:

我们需要把注释、.comp { } 去掉

只保留中间部分。

然后 parse 完之后是 font-size、border-color 这种,转为驼峰之后更新到 store。

试一下:

可以看到,打印了 css parse 之后的对象并且更新到的 store。

中间的组件也应用了这个样式。

这时候上面的样式表单,下面直接写的 css 都能生效:

但有个问题:

删除这些 css 后,左边的样式不会消失。

因为我们更新 styles 的时候和已有的 style 做了合并:

所以在编辑器里删除 css,合并后依然保留着之前的样式。

我们支持下整个替换就好了:

component.styles = replace ? { ...styles } : { ...component.styles, ...styles };

如果 replace 参数传了 true,就整个替换 styles。

然后用的时候指定 replace 为 true:

updateComponentStyles(
    curComponentId,
    { ...form.getFieldsValue(), ...css },
    true
);

测试下:

现在两部分样式都会生效。

删除下面编辑器的样式也生效:

现在还有个问题,切换选中的组件的时候,表单没清空:

reset 一下就好了:

form.resetFields();

表单好了,下面的编辑器也重置下:

声明一个 css 的 state,curComponent 改变的时候设置 store 里的内容到 state。

然后 toCSSStr 方法就是拼接 css 字符串的。

要注意 with、height 要补 px,因为上面的表单的值保存的是数字。

const [css, setCss] = useState<string>(`.comp{\n\n}`);

useEffect(() => {
    form.resetFields();

    const data = form.getFieldsValue();
    form.setFieldsValue({ ...data, ...curComponent?.styles });

    setCss(toCSSStr(curComponent?.styles!));
}, [curComponent]);

function toCSSStr(css: Record<string, any>) {
    let str = `.comp {\n`;
    for (let key in css) {
        let value = css[key];
        if (!value) {
            continue;
        }
        if (
            ["width", "height"].includes(key) &&
            !value.toString().endsWith("px")
        ) {
            value += "px";
        }

        str += `\t${key}: ${value};\n`;
    }
    str += `}`;
    return str;
}

测试下:

这样,当选中的组件切换的时候,样式的切换就完成了。

但还有一个问题:

当样式改变的时候,编辑框的大小不会跟着改变。

但我们设置了 components 变化会 updatePosition 了呀:

这是因为 components 变了,到渲染完成,然后再 getBoundingClientRect 拿到改变后的宽高是有一段时间的。

加个延迟就好了:

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

git reset --hard 32a88a2f26100be09727cb6ba1c7c33d5f491523

总结

这节我们实现了属性和样式的编辑。

在 componentConfig 里加了 setter、stylesSetter 来保存不同组件的属性、样式表单配置。

然后在 Setting 区域渲染对应的表单。

表单变化的时候,修改 components 里对应的 styles、props 信息,传入组件渲染。

样式编辑我们还支持直接写 css,用 @monaco-editor/react 做的编辑器,然后编辑完用 style-to-object 转为对象后保存到 store。

当然,现在 setter 的表单配置不够完善,当后面新加组件的时候,需要什么表单类型再扩展就行。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
70.低代码编辑器:画布区click展示编辑框
Next
72.低代码编辑器:预览、大纲