主题
Day 1:组件上传与元数据解析
今天完成什么
- 设计自定义组件的元数据格式(
meta.json) - 实现组件上传功能(支持
.tsx文件) - 解析元数据并在组件列表里展示自定义组件
接在基础课程哪里
这是基础课程 Day 12 的扩展,不是独立项目。
今天第一步:复制 Day 12 的完整项目代码,在它的基础上继续开发。
bash
# 复制基础课程项目
cp -r react-lowcode-infocard react-lowcode-custom-components
cd react-lowcode-custom-components
# 安装依赖
npm install确认项目能正常运行:
bash
npm run dev打开 http://localhost:5173,应该能看到基础课程的完整功能。
核心概念
1. 组件元数据(Component Metadata)
要让平台知道"这个自定义组件长什么样、有哪些配置项",需要元数据。
元数据包含:
- 组件名称和描述
- 分类(按钮、表单、数据展示...)
- Props 定义(类型、默认值、标签)
- 版本号
类似于 npm 的 package.json,但专门描述 React 组件。
2. 组件注册表(Component Registry)
平台需要一个"组件仓库",存储所有可用组件:
- 内置组件(如
InfoCard) - 用户上传的自定义组件
注册表是一个 Map 或对象,key 是组件名,value 是组件定义(元数据 + 组件函数)。
3. 文件上传 API
浏览器的 <input type="file"> + FileReader API 可以读取用户选择的文件内容。
typescript
// 读取文件内容
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
console.log(content); // 文件内容
};
reader.readAsText(file);动手前的目录
今天会新增这些文件:
text
src/
├─ types/
│ ├─ componentMeta.ts # 组件元数据类型定义 [新增]
│ └─ componentRegistry.ts # 组件注册表类型定义 [新增]
├─ services/
│ ├─ componentRegistry.ts # 组件注册服务 [新增]
│ └─ componentStorage.ts # 组件存储服务 [新增]
└─ components/
├─ ComponentUploader.tsx # 组件上传器 [新增]
└─ ComponentSidebar.tsx # 扩展支持自定义组件 [修改]分步实现
第 1 步:定义组件元数据类型
新建 src/types/componentMeta.ts:
typescript
/**
* 组件元数据 - 描述一个自定义组件的结构和配置
*/
export interface ComponentMeta {
/** 组件唯一标识(英文,用于注册) */
name: string;
/** 显示名称(中文,显示在组件列表) */
displayName: string;
/** 组件描述 */
description: string;
/** 分类 */
category: 'basic' | 'form' | 'data' | 'layout' | 'other';
/** 图标(可选,暂时用 emoji) */
icon?: string;
/** 版本号 */
version: string;
/** Props 定义 */
props: {
[propName: string]: PropMeta;
};
}
/**
* 单个 Prop 的元数据
*/
export interface PropMeta {
/** 类型 */
type: 'string' | 'number' | 'boolean' | 'enum';
/** 默认值 */
default: any;
/** 显示标签 */
label: string;
/** 描述 */
description?: string;
/** 如果是 enum 类型,这是可选值 */
options?: string[];
}
/**
* 组件定义 - 元数据 + 组件函数 + 源码
*/
export interface ComponentDefinition {
/** 元数据 */
meta: ComponentMeta;
/** 组件函数(编译后) */
component: React.ComponentType<any> | null;
/** 源代码 */
sourceCode: string;
/** 来源 */
source: 'builtin' | 'custom';
/** 创建时间 */
createdAt: string;
}第 2 步:定义组件注册表类型
新建 src/types/componentRegistry.ts:
typescript
import { ComponentDefinition } from './componentMeta';
/**
* 组件注册表 - 存储所有可用组件
*/
export interface ComponentRegistry {
[componentName: string]: ComponentDefinition;
}第 3 步:实现组件存储服务
新建 src/services/componentStorage.ts:
typescript
import { ComponentDefinition } from '../types/componentMeta';
const STORAGE_KEY = 'lowcode_custom_components';
/**
* 组件存储服务 - 负责持久化自定义组件
*/
export const componentStorage = {
/**
* 保存组件到 localStorage
*/
save(name: string, definition: ComponentDefinition): void {
const components = this.loadAll();
components[name] = definition;
localStorage.setItem(STORAGE_KEY, JSON.stringify(components));
},
/**
* 加载所有自定义组件
*/
loadAll(): Record<string, ComponentDefinition> {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return {};
try {
return JSON.parse(data);
} catch (error) {
console.error('解析组件存储数据失败:', error);
return {};
}
},
/**
* 删除组件
*/
remove(name: string): void {
const components = this.loadAll();
delete components[name];
localStorage.setItem(STORAGE_KEY, JSON.stringify(components));
},
/**
* 清空所有自定义组件
*/
clear(): void {
localStorage.removeItem(STORAGE_KEY);
}
};第 4 步:实现组件注册服务
新建 src/services/componentRegistry.ts:
typescript
import { ComponentDefinition, ComponentMeta } from '../types/componentMeta';
import { ComponentRegistry } from '../types/componentRegistry';
import { componentStorage } from './componentStorage';
/**
* 组件注册服务 - 管理所有可用组件(内置 + 自定义)
*/
class ComponentRegistryService {
private registry: ComponentRegistry = {};
constructor() {
// 初始化时加载本地存储的自定义组件
this.loadCustomComponents();
}
/**
* 注册内置组件
*/
registerBuiltin(name: string, component: React.ComponentType<any>, meta: ComponentMeta): void {
this.registry[name] = {
meta,
component,
sourceCode: '',
source: 'builtin',
createdAt: new Date().toISOString()
};
}
/**
* 注册自定义组件(暂时不包含 component 函数,Day 2 会编译)
*/
registerCustom(meta: ComponentMeta, sourceCode: string): void {
const definition: ComponentDefinition = {
meta,
component: null, // Day 2 会编译成组件函数
sourceCode,
source: 'custom',
createdAt: new Date().toISOString()
};
this.registry[meta.name] = definition;
// 持久化到 localStorage
componentStorage.save(meta.name, definition);
}
/**
* 获取组件定义
*/
get(name: string): ComponentDefinition | undefined {
return this.registry[name];
}
/**
* 获取所有组件
*/
getAll(): ComponentRegistry {
return { ...this.registry };
}
/**
* 获取所有自定义组件
*/
getCustomComponents(): ComponentDefinition[] {
return Object.values(this.registry).filter(def => def.source === 'custom');
}
/**
* 删除组件
*/
remove(name: string): void {
delete this.registry[name];
componentStorage.remove(name);
}
/**
* 加载本地存储的自定义组件
*/
private loadCustomComponents(): void {
const stored = componentStorage.loadAll();
Object.entries(stored).forEach(([name, definition]) => {
this.registry[name] = definition;
});
}
}
// 导出单例
export const componentRegistry = new ComponentRegistryService();第 5 步:实现组件上传器
新建 src/components/ComponentUploader.tsx:
typescript
import React, { useState } from 'react';
import { ComponentMeta } from '../types/componentMeta';
import { componentRegistry } from '../services/componentRegistry';
interface Props {
onUploadSuccess?: () => void;
}
export function ComponentUploader({ onUploadSuccess }: Props) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setError(null);
try {
// 今天只支持上传两个文件:meta.json + Component.tsx
// Day 5 会扩展支持整个文件夹
const metaFile = Array.from(files).find(f => f.name === 'meta.json');
const tsxFile = Array.from(files).find(f => f.name.endsWith('.tsx'));
if (!metaFile || !tsxFile) {
throw new Error('请同时选择 meta.json 和 .tsx 文件');
}
// 读取 meta.json
const metaContent = await readFileAsText(metaFile);
const meta: ComponentMeta = JSON.parse(metaContent);
// 读取 .tsx 源码
const sourceCode = await readFileAsText(tsxFile);
// 注册组件
componentRegistry.registerCustom(meta, sourceCode);
alert(`组件 "${meta.displayName}" 上传成功!`);
// 清空输入
e.target.value = '';
onUploadSuccess?.();
} catch (err: any) {
setError(err.message || '上传失败');
console.error('组件上传失败:', err);
} finally {
setUploading(false);
}
};
return (
<div style={{ padding: '16px', borderBottom: '1px solid #e0e0e0' }}>
<div style={{ marginBottom: '8px', fontSize: '14px', fontWeight: 'bold' }}>
上传自定义组件
</div>
<input
type="file"
multiple
accept=".tsx,.json"
onChange={handleFileSelect}
disabled={uploading}
style={{ fontSize: '12px', marginBottom: '8px' }}
/>
{uploading && (
<div style={{ color: '#1890ff', fontSize: '12px' }}>上传中...</div>
)}
{error && (
<div style={{ color: '#ff4d4f', fontSize: '12px' }}>{error}</div>
)}
<div style={{ fontSize: '11px', color: '#999', marginTop: '8px' }}>
提示:同时选择 meta.json 和 .tsx 文件
</div>
</div>
);
}
/**
* 读取文件内容为文本
*/
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target?.result as string);
};
reader.onerror = () => {
reject(new Error(`读取文件 ${file.name} 失败`));
};
reader.readAsText(file);
});
}第 6 步:扩展组件侧边栏
修改 src/components/ComponentSidebar.tsx,支持展示自定义组件:
typescript
import React, { useState, useEffect } from 'react';
import { ComponentUploader } from './ComponentUploader';
import { componentRegistry } from '../services/componentRegistry';
import { ComponentDefinition } from '../types/componentMeta';
export function ComponentSidebar() {
const [customComponents, setCustomComponents] = useState<ComponentDefinition[]>([]);
// 加载自定义组件列表
const loadCustomComponents = () => {
setCustomComponents(componentRegistry.getCustomComponents());
};
useEffect(() => {
loadCustomComponents();
}, []);
return (
<div style={{
width: '240px',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#fafafa'
}}>
{/* 上传组件 */}
<ComponentUploader onUploadSuccess={loadCustomComponents} />
{/* 内置组件 */}
<div style={{ padding: '16px' }}>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px' }}>
内置组件
</div>
<div
style={{
padding: '12px',
backgroundColor: 'white',
border: '1px solid #e0e0e0',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📇 InfoCard
</div>
</div>
{/* 自定义组件 */}
<div style={{ padding: '16px', paddingTop: 0 }}>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px' }}>
自定义组件 ({customComponents.length})
</div>
{customComponents.length === 0 ? (
<div style={{ fontSize: '12px', color: '#ccc', textAlign: 'center', padding: '20px' }}>
暂无自定义组件
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{customComponents.map(def => (
<div
key={def.meta.name}
style={{
padding: '12px',
backgroundColor: 'white',
border: '1px solid #e0e0e0',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span>{def.meta.icon || '🧩'}</span>
<span>{def.meta.displayName}</span>
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
{def.meta.description}
</div>
<div style={{ fontSize: '10px', color: '#ccc', marginTop: '4px' }}>
v{def.meta.version}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}完整代码
上面就是今天新增的所有代码。确保你的项目结构如下:
text
src/
├─ types/
│ ├─ config.ts
│ ├─ componentMeta.ts [新增]
│ └─ componentRegistry.ts [新增]
├─ services/
│ ├─ componentRegistry.ts [新增]
│ └─ componentStorage.ts [新增]
├─ components/
│ ├─ ComponentSidebar.tsx [修改]
│ ├─ ComponentUploader.tsx [新增]
│ └─ ... (其他组件不变)
└─ ...运行效果
启动项目:
bash
npm run dev验证功能
- 左侧组件列表:能看到"上传自定义组件"区域和"内置组件"、"自定义组件"分类
- 准备测试文件:创建两个文件用于测试上传
meta.json:
json
{
"name": "CustomButton",
"displayName": "自定义按钮",
"description": "一个支持多种样式的按钮组件",
"category": "basic",
"icon": "🔘",
"version": "1.0.0",
"props": {
"text": {
"type": "string",
"default": "点击我",
"label": "按钮文字"
},
"variant": {
"type": "enum",
"options": ["primary", "secondary", "danger"],
"default": "primary",
"label": "按钮类型"
},
"disabled": {
"type": "boolean",
"default": false,
"label": "是否禁用"
}
}
}CustomButton.tsx:
tsx
import React from 'react';
interface Props {
text: string;
variant: 'primary' | 'secondary' | 'danger';
disabled: boolean;
}
export default function CustomButton({ text, variant, disabled }: Props) {
const colors = {
primary: '#1890ff',
secondary: '#52c41a',
danger: '#ff4d4f'
};
return (
<button
disabled={disabled}
style={{
padding: '8px 16px',
backgroundColor: disabled ? '#ccc' : colors[variant],
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: '14px'
}}
>
{text}
</button>
);
}上传测试:
- 点击"选择文件"
- 同时选中
meta.json和CustomButton.tsx - 点击确定
预期结果:
- 弹出"组件 '自定义按钮' 上传成功!"
- 左侧"自定义组件"列表显示新上传的组件
- 显示组件图标、名称、描述、版本号
常见错误
错误 1:请同时选择 meta.json 和 .tsx 文件
原因:只选了一个文件,或者文件名不对。
解决:
- 确保 meta.json 文件名完全匹配
- 确保 tsx 文件以
.tsx结尾 - 选择文件时按住 Ctrl(Windows)或 Command(Mac)多选
错误 2:JSON.parse 失败
原因:meta.json 格式错误。
解决:
- 用 JSON 校验工具检查 meta.json
- 注意逗号、引号、括号是否匹配
- 不要有多余的逗号
错误 3:上传后刷新页面,自定义组件消失
原因:localStorage 存储正常,但 componentRegistry 没有加载。
解决:
- 检查
componentRegistry构造函数是否调用了loadCustomComponents - 打开浏览器控制台 → Application → Local Storage → 检查数据是否存在
动手改一改
- 支持删除组件:给每个自定义组件卡片加一个"删除"按钮
- 显示上传时间:格式化显示
createdAt(用date-fns或手写) - 支持导出组件:点击组件卡片,能下载它的源码和 meta.json
验收清单
- [ ] 能看到"上传自定义组件"区域
- [ ] 能同时选择 meta.json 和 .tsx 文件
- [ ] 上传成功后弹出提示
- [ ] 左侧列表显示上传的组件
- [ ] 显示组件图标、名称、描述、版本号
- [ ] 刷新页面后自定义组件还在(localStorage 持久化)
- [ ] 上传错误时有明确的错误提示
今日总结
学到了什么
- 组件元数据设计:用 JSON 描述组件的结构和配置
- 文件上传 API:
FileReader读取用户选择的文件 - 组件注册表:管理内置组件和自定义组件
- localStorage 持久化:自定义组件保存到本地
今天的限制
- 上传的组件还不能渲染(
component是null) - 必须手动选择两个文件,不能直接上传文件夹
- 没有编译 JSX/TSX,只是存储了源码
明天做什么
Day 2 会引入 @babel/standalone,把用户上传的 .tsx 源码编译成可执行的组件函数,这样就能在预览区渲染自定义组件了。
今天的基础工作做完了,明天开始进入核心:动态编译!
进行中