Luban
暂停开发中……低代码平台(low code),支持toc & tob页面。特点:编译型、细粒度自定义UI/交互逻辑、可人工修改代码、可创建模板复用
Install / Use
/learn @HiWayne/LubanREADME
低代码平台 LuBan 鲁班
(暂停开发中……)
能工巧匠-鲁班。可支持 toC、toB 页面的可视化搭建,无需编写代码、可以自定义 UI 和交互逻辑。
项目特点
-
灵活的 UI。可以自由组合组件、定制组件,在 toc 模式甚至可以通过细粒度组件实现几乎各种 UI。比如在 tob 里可以自定义表格每列单元格的内容、弹窗里的内容,在 toc 里可以从 div 层面开始绘制 UI。
-
支持组件之间的自定义状态逻辑。比如我希望:点击按钮 -> 发出请求 -> compute 请求数据 -> 卡片里展示 computed 后的数据。或者希望表单修改后其他地方的内容也跟着变化等等。
-
页面秒上线。可以自定义页面的 url 路径,点击发布后用户立即可访问。跳过传统开发的配置路由、打包构建、部署等过程。极大加快了新业务上线、修复 bug 的速度。
-
页面代码支持人工修改。面对超出平台能力的需求,可以在平台生成的源码(react)基础上人工修改然后发布,平台生成的代码拥有较好的可读性。
-
模板系统。你搭建好的页面可保存成模板。他人可复用你的模板快速搭建页面,并在其基础上继续修改。你还可以邀请别人一起维护模板。
-
编译型平台、有较好的运行时性能。低代码平台产出的页面,仅包含编译后的业务相关的代码,没有额外的运行时逻辑。
技术栈
版本管理工具
使用nvm管理 node 版本、使用pnpm替代 npm
# mac安装nvm方法,其他设备自行查找
brew install nvm
nvm install 17.6.0
nvm use 17.6.0
npm i -g pnpm
前端
react18、styled-component、zustand、typescript
后端
fastify、nodejs、typescript
数据库
redis、mongo
项目运行
Node Version
>= 17.0.0, 推荐 17.6.0
依赖安装
nvm use 17.6.0
pnpm i
规范工具安装
用于 commit 前代码检查、规范 git commit 格式
全局安装过 pnpm、commitizen 的,前两步不需要
npm i -g pnpm commitizen && commitizen init cz-conventional-changelog --force --save --save-exact && npm run husky-prepare
运行
-
需要 mongo 服务
-
需要 redis 服务
mongo、redis 相关配置在/src/backend/config/db.config.ts 里
- 需要同时启动前后端两个 web 服务
# 前端服务,3000端口,用来访问配置后台页面
npm run dev-frontend
# 后端服务,8000端口,用来支持编译、登录等服务
npm run dev-backend
- 浏览器打开 http://localhost:3000
部署
在项目 /src/backend/config/host.config.ts 里可以配置低代码平台产出页面的页面域名、public path(开头有/末尾无/)、cdn域名。
在项目 /src/backend/config/server.config.ts 里可以配置后端服务的端口。
npm run build
npm run zip
npm run build编译打包前后端代码
npm run zip会将相关内容打包成dist.zip,然后你需要做的是:
假设你在服务器使用 luban 文件夹存放 dist.zip
-
将 dist.zip 发送到服务器的
luban文件夹下并解压,这时luban下会多出dist文件夹。服务器需要有 node (推荐 17.6.0) 环境和pnpm,在dist文件夹下执行pnpm i、npm run start,以启动后端服务。 -
外网如何访问:外部(例如 nginx)访问平台前端去
dist文件夹中的dist-frontend目录、外部访问平台后端去服务器的8000端口、外部访问平台产出的页面去luban文件夹下的pages目录
Test
一般不需要手动执行,会在 commit 前自动测试
npm run test
配置后台页面权限
权限配置在路由(/src/frontend/router/routes/*)中的 permissions: string[]
没有该字段视为不需要权限、空数组[]视为仅需登录、['normal_manager'](仅举例,具体值看业务)代表需要普通管理员身份
举例:
const editorRoutes: RouteType[] = [
{
path: '/editor',
element: LazyEditor,
permissions: [], // 需要登录
// permissions: ['normal_manager'] 需要普通管理员身份
// permissions: undefined 不需要任何权限
},
];
FQ
- 该低代码平台前后端的原理是什么,开发遇到的问题有哪些?
生成的页面是由配置产生的。配置的核心部分是 view 字段,它是由 nodeAST(一个最小粒度的、能表示 UI 与逻辑的抽象语法节点)嵌套组成的树,nodeAST 树会交给编译核心,每个 nodeAST 会在经过与其 type 对应的编译插件产出 react 代码。整个树从 root 节点开始,被递归编译成完整的 react 应用代码,编译过程中还有一些优化细节(如组件复用等)。由于生成的只是 react 源码(这部分美化后可以用于代码预览、人工二次编辑),所以还需要编译、构建、压缩成浏览器可执行的代码。由于后端的最终产物是个完整的应用(SPA: html+js),所以低代码平台页面通过前端微服务的方式整合到主应用里用于可视化编辑的实时预览。低代码平台可视化编辑时,需要有拖拽调换位置、点击 UI 展示对应配置等交互,然而最终产物(html)已经与原始的 nodeAST 失去了关联。为了解决这一点,在编辑模式时,低代码平台页面每次添加 UI 时都会生成一个唯一 id(也就是 nodeAST 里的 id),后端编译插件会给 nodeAST 对应组件代码的最外层的 html 元素加上这个 id。低代码平台配置页本地也通过 id 存储了相关数据,于是低代码平台配置页面可以通过这个 html id 知道当前选中的是什么组件、什么 nodeAST,以及它的当前配置,从而可以进行可视化编辑交互。
- 如果平台已有组件不能满足需求怎么办?每个组件,在前端编辑器里可能都需要一套独特的配置面板,维护起来会不会很麻烦?
如果平台已有的 UI 组件不能满足需求需要新增,编译系统在设计时抽象出了【编译插件】这个概念,编译核心与编译插件解耦,只需要新写一个编译插件即可满足新的需求。至于前端编辑器里的配置面板,新的 UI 组件确实可能带来一套完全不同的配置表单,但不需要额外新写。编辑器的渲染核心也已经为需求变化做了解耦,只需给编译插件定义 meta(元数据),即可自动在前端生成新的配置面板。(插件的写法以及 meta 的定义将会在下面的【开发文档】中介绍)
npm run dev后出现Vite Error, /node_modules/...... optimized info should be defined的错误怎么办?
可能是因为新安装了依赖,node_modules/.vite 里没有缓存,试试 sh node_modules/.bin/vite --force。具体原因详见 vite 的 dep-pre-bundling。
- 为什么
.gitignore要忽略__snapshots__?
因为不同机器 styled-components 生成的 className 哈希不同,导致单测的toMatchSnapshot误报
开发指南
前端代码在/src/frontend/*,主要负责后台页面
后端代码在/src/backend/*,主要负责编译(将后台发送过来的页面配置编译成前端代码(浏览器可直接运行的 html、js))、用户系统、模板系统、发布系统、权限系统
低代码搭建的页面中的最小粒度是 UI 模块(在配置中可以看作一个节点(node)),UI 模块都有 type 属性,不同 type 代表不同的 UI 模块,用以呈现各种特定外观、功能、交互的 UI 。后端编译服务也是以 UI 模块为粒度实现的,每个 UI 模块都有一个与之对应的编译插件,负责将 nodeAST(一个表示 UI 和逻辑的 AST(抽象语法树))编译成 react 组件代码,编译核心只是负责将不同的 nodeAST 交给对应的插件编译。toC 页面的编译插件在/backend/generateReactSourceCode/generateFrontstageCodePlugins/*,toB 页面的编译函数在/backend/generateReactSourceCode/generateBackstageCodePlugins/*。
如需新增 UI 模块(组件),在/backend/generateReactSourceCode/generateFrontstageCodePlugins/*或/backend/generateReactSourceCode/generateBackstageCodePlugins/*中新增对应的编译插件即可(还需在同目录的 index.ts 中新增 switch case),从而实现了编译核心逻辑和新增插件的解耦。
UI 模块的类型文件在/backend/types/backstage/index.ts和/backend/types/frontstage/index.ts里
如何编写编译插件?
假设没有配置任何 nodeAST 时编译结果是如下这样的
// 空模块时
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
return <StrictMode></<StrictMode>
}
createRoot(document.getElementById('root')).render(<App />);
让我们看看编译插件不同的写法对编译结果有什么影响:
0. 必须写法(主要和编辑环境下的特殊处理相关)。
/**
* 每个编译插件至少需要接受这三个参数(参数来自同目录下的index.ts,即编译核心)
* nodeAST-该UI节点的ast、id-唯一id、context-上下文信息,里面有用的是development-是否是开发(编辑)环境
*/
const generateCodeOfXxxPlugin = (nodeAST, id, context) => {
// 必须一:
/**
* context的作用除了给 astToReactNodeCodeOfFrontstage 提供参数(这个后面会讲到),另一个必须的作用是:
* 开发环境或者叫编辑环境,是低代码后台编辑页面时所在的环境
* 这种环境下,UI本身应该不能交互,否则会和低代码后台的交互(比如拖拽、点击)冲突
* 所以需要根据context.development决定是否不编译交互相关的代码
* 例子可以看 /src/backend/generateReactSourceCode/generateFrontstageCode/generateCodeOfBasicContainerPlugin.ts 里的onClickCode
*/
// 必须二:
/**
* id的作用除了封装组件时生成唯一name(这个后面会讲到),另一个必须的作用是:
* 低代码后台在配置页面时,可以通过点击某个页面UI,唤起它的配置面板,或者可以拖拽某个UI和其他调换位置等操作
* 但是低代码后台是不知道预览页面里的UI和组件模块的对应关系的
* 所以需要编译时,在development环境下,给组件的最外层元素加上特殊的id,用来标记这是一个组件模块。如果id=1,那就是<div id="luban_1"></div>
* 例子可以看 /src/backend/generateReactSourceCode/generateFrontstageCode/generateCodeOfBasicContainerPlugin.ts 里的createIdAttrInDev,它帮你封装好了根据context.development是否添加id属性的逻辑
*/
};
总结一下必须的写法:1. 如果 UI 模块有交互事件,必须根据 context.development 确定是否不能交互。2. 如果在 development 环境下,最外层元素需要有特殊的 id 属性
下面会讲各种不同的编译写法以及用处,它们可能为了讲述重点,所以省略了一些上述的必须写法,真正开发的时候不要忘记。
1. 编译写法一。当你希望创建组件声明函数。
// generateCodeOfProp用来生成React中的 " prop=xxx" 代码(开头有空格),自动根据不同类型的值生成合适的代码,如果值是undefined则返回空字符串
import { generateCodeOfProp } from '../generateCodeOfProp';
// createGenerateCodeFnReturn是用来生成generateCodeOfXxxPlugin系列插件返回值的工厂函数
import { createGenerateCodeFnReturn } from '../utils';
// 编译图片组件1
const generateCodeOfImage1Plugin = (nodeAST, id, context) => {
const { props } = nodeAST;
// 根据src等配置生成代码
const { src } = props;
const componentName = 'Image';
const componentDeclaration = `const ${componentName} = ({src}) => (<img src={src} />);`;
const componentCall = `<${componentName}${generateCodeOfProp('src', src)} />`;
return createGenerateCodeFnReturn({
componentName,
componentDeclaration,
componentCall,
});
};
// 或者还有一种简化写法
// 编译图片组件2
// id是外部给的,保证唯一
const generateCodeOfImage2Plugin = (nodeAST, id, context) => {
const { props } = nodeAST;
// 根据src等配置生成代码
const { src } = props;
const componentName = `Image_${id}`;
const componentElement = `<img${generateCodeOfProp('src', src)} />`;
return createGenerateCodeFnReturn({
componentName,
componentElement,
});
};
假设 image 的 src 配置的是字符串"url",这是编译结果:
// 写法一的编译结果
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const Image = ({ src }) => <img src={src} />;
const App = () => {
return (
<StrictMode>
<Image src="url" />
</StrictMode>
);
};
createRoot(document.getElementById('root')).render(<App />);
// 简化版写法一的编译结果(假设id是1)
const Image_1 = ({ children }) => <img src="url" />;
const App = () => {
return (
<StrictMode>
<Image_1 />
</StrictMode>
);
};
createRoot(document.getElementById('root')).render(<App />);
编译写法一总结
写法一的特点是,组件会被声明并放在全局作用域
当返回componentName(组件名称)、componentDeclaration(组件声明)、componentCall(组件在 App 中的调用) 或者 返回 componentName、componentElement(组件声明中 return 后面的代码) 时,组件声明(componentDeclaration)会放在全局(如果只有 componentElement,它会被包装成一个函数像 componentDeclaration 一样放在全局),这样代码复用性会比较好,编译体积会更小。全局声明会根据同名 componentName 去重,如果 image 模块被多处使用,componentDeclaration 代码也不会被声明多次。简化版写法只是将 App 中的 reactElement 调用封装到组件里,并不会使代码更少,只是提升了可读性、编译插件写起来更方便,简化版写法的缺点是不支持组件有状态逻辑。
其实比较简单的组件并不需要写法一,因为:
// a
const Image = ({src}) => <img src={src} />
// in App
<Image src="url" />
// b
// in App
<img src="url" />
简单组件(比如上面这种仅一层 img)像 a 这样写反而很多余,还不如像 b 一样直接在 App 里<img />调用。
像<div><div><span></span></div></div>这种多层嵌套的,或者内部有状态逻辑的,才适合封装成组件提取到全局。
下面介绍怎么编译出像 b 一样直接调用的代码
2. 编译写法二。不封装成组件,直接在 App 里调用 reactElement。
// generateCodeOfProp用来生成React中的 "prop=xxx" 代码,自动根据不同类型的值生成合适的写法,如果值是undefined则返回空字符串
import { generateCodeOfProp } from '../generateCodeOfProp';
// createGenerateCodeFnReturn是用来生成generateCodeOfXxxPlugin系列插件返回值的工厂函数
import { createGenerateCodeFnReturn } from '../utils';
// 编译a标签组件
const generateCodeOfAPlugin = (nodeAST, id, context) => {
const { props } = nodeAST;
// 根据href等配置生成代码,text是a标签的文本
const { href, target, text } = props;
// 叫componentElement也可以
const componentCall = `<a${generateCodeOfProp(
'href',
href,
)}${generateCodeOfProp('target', target)}>${text || ''}</a>`;
return createGenerateCodeFnReturn({
// 给componentElement属性也可以
componentCall,
canHoist: false,
});
};
假设 href: "url", target: "_blank", text: "跳转",编译结果如下:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const App = () => {
return (
<StrictMode>
<a href="url" target="_blank">
跳转
</a>
</StrictMode>
);
};
createRoot(document.getElementById('root')).render(<App />);
编译写法二总结
canHoist: false 代表不可以提升到全局声明,于是它只会在 App 里直接调用(直接使用 componentCall 或 componentElement 的代码)。相比写法一更适合简单组件的编译,可以不需要 componentName,因为没有声明,不需要根据 name 去重。缺点是不支持状态逻辑的编写,因为它连组件声明都没有。如果复杂组件使用这种方式编译,会造成代码冗余、可读性差的问题,建议用写法一。
编译写法三。组件如何接收 children。
有些组件是有子组件的,比如 BasicContainer 这个容器,只是负责包裹子组件,必须有 children 负责实际展示 UI。下面
Related Skills
bluebubbles
337.4kUse when you need to send or manage iMessages via BlueBubbles (recommended iMessage integration). Calls go through the generic message tool with channel="bluebubbles".
node-connect
337.4kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
slack
337.4kUse when you need to control Slack from OpenClaw via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
frontend-design
83.2kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
