先交代下背景,我最近在把一个中后台项目的表单模块整体迁到Designable + formily这套低代码方案上,目标很直接:运营和产品能自己在页面上拖表单,不用每次新增字段都来找前端。折腾了差不多一周,大部分时间都耗在本地开发环境的适配和自定义组件的接入上。中间踩的坑,有的翻遍 GitHub issue 都找不到标准答案,最后是自己断点调试加读源码才弄明白的。
这篇文章就把我在本地运行Designable的formily表单扩展时遇到的典型问题、排查过程和最终解决方案整理出来。如果你正准备做类似的事情,或者已经在Designable里接formily但被各种本地报错卡住,这篇应该能帮你省下不少时间。
1. 这个组合到底在做什么,先理清架构再动手
1.1 Designable 和 formily 各自负责哪一环
先说清楚这两个项目的关系,很多初次接触的人会把它们搞混。Designable是一个可视化低代码搭建方案,它本身不负责表单的数据管理和校验,它只解决“用户在界面上拖拖拽拽,生成一份描述界面的 Schema JSON”这个问题。而formily是表单状态管理和渲染方案,它负责把 Schema JSON 真正翻译成可交互、可校验、可联动的表单页面。
两者配合起来就是一条完整的链路:
Designable 设计器(拖拽) -> 生成 Schema JSON -> formily 渲染器(运行时) -> 真正的表单页面我项目里的做法是:管理端进入“表单设计”页面,加载Designable设计器,运营人员拖好组件、配好校验规则,点保存把 Schema JSON 提交到后端;业务端根据“表单编码”拉取 Schema JSON,再交给@formily/react的SchemaField组件渲染出来。
1.2 为什么选择这套方案,而不是自己造轮子
之前我们表单页面是纯手写的,每个页面一个 Formik + Yup 配置,字段一多代码量膨胀很快。后来评估了form-render、x-render和Designable + formily,最终选后者的原因有三个:
第一,formily的 Schema 描述能力和联动能力足够强。x-reactions这种响应式联动声明,可以实现字段显隐、禁用、赋值等常见交互,不需要额外写业务代码。
第二,Designable提供了完整的“设计器外壳”。组件面板、画布、属性配置面板都是现成的,虽然二次开发定制也有学习成本,但比从零写一个拖拽引擎要靠谱得多。
第三,社区的生态插件相对成熟。formily官方维护了antd、element等组件库的桥接层,组件扩展只需要写一次注册代码,设计器和渲染器都能复用。
1.3 本地开发的完整链路图
本地跑通这套东西,需要同时存在两个上下文:
一个是设计器上下文,跑的是Designable,它内部维护一份 Schema JSON 的草稿状态,画布中渲染的自定义组件其实是“设计态”的展示模型,不真正参与表单校验。
另一个是渲染器上下文,跑的是formily,它接收 Schema JSON 后真正创建表单状态,执行校验、联动、提交。
这两套上下文可以放在同一个应用里,也可以分到两个应用里。像我为了调试方便,在本地用一个create-react-app启动前端,同时启动了设计器页面和表单预览页面,Schema JSON 直接通过内存变量传递,不走接口,这样排查问题最直接。
提示:我第一次接的时候没想清楚这两层的关系,结果在设计器里拖了组件,预览页死活不渲染。后来发现是只注册到了设计器,没在渲染器里注册,表单组件一运行就报“找不到对应组件”。这个点后续详细讲。
2. 本地跑起来之前的那些环境坑
2.1 Node 版本和包管理器选型
这套组合对 Node 版本有隐性要求,不是说你用最新版就行。我本机的 Node 从 14 换到 16 再换到 18,各有各的问题。最后锁在 Node 16.20.2 + pnpm 7.x,这个组合跑Designable官方示例的 monorepo 最稳。
如果你用的是 npm 而不是 pnpm,大概率会在安装依赖时报一堆peerDep冲突,因为Designable仓库本身是用 pnpm workspace 管理的,各个@designable/*包之间存在工作区级别的依赖引用,npm 处理不了 workspace 协议。
具体安装命令:
# 使用 pnpm 安装,注意版本 npm install -g pnpm@7 pnpm install2.2 版本锁定是最容易翻车的地方
Designable的版本迭代和formily的版本迭代并不同步。我踩过一个大坑:跑Designable官方示例时,它自带的是@formily/react@2.0.x,但是我业务项目里已经装了@formily/react@2.2.x,结果在本地一启动,@designable/react内部引用的SchemaField和我业务代码引用的SchemaField根本不是同一个,导致组件注册无效。
这个问题的本质是:@designable/*包将@formily/*作为 peerDependency,如果你不显式对齐版本,pnpm 的依赖提升会把两个版本都装进来,最终出现“双份 formily 实例”。
我的建议是统一锁定版本,以下这组版本是我实测稳定运行的:
| 包名 | 版本 |
|---|---|
| @designable/core | 2.0.4 |
| @designable/react | 2.0.4 |
| @designable/formily | 2.0.4 |
| @formily/core | 2.2.x |
| @formily/react | 2.2.x |
| @formily/antd | 2.2.x |
| antd | 4.24.x |
提醒:
@designable/formily这个包很关键,它提供了设计器和 formily 之间的桥接层,包括SchemaField的设计态渲染适配。不要漏装。
2.3 依赖安装后的启动报错“Invalid hook call”
我第一次pnpm install完启动项目,直接报了一个非常经典的 React 错误:
Warning: Invalid hook call. Hooks can only be called inside of the body of a function component.排查步骤是先在node_modules里搜索了所有react的安装路径,果然出现了两个 React 实例。一个是业务项目自己的,另一个是某个@designable包内嵌的依赖项导致提升出来的。
解决方案是在项目的package.json中加resolutions强制统一 React 版本:
{ "resolutions": { "react": "^18.2.0", "react-dom": "^18.2.0" } }然后删掉 node_modules 和锁文件重新安装:
rm -rf node_modules pnpm-lock.yaml pnpm install这一步做完,问题基本解决。后续如果再遇到 hook 相关报错,优先怀疑是不是依赖树里出现了两个 React 或两个 @formily/react,用npm ls react或pnpm why react就能确认。
3. 表单扩展的核心套路:把自定义组件同时注册到两个上下文
3.1 扩展一个组件,需要做哪些事
前面提到过,Designable + formily是两套上下文。所以你想扩展一个新组件(比如一个“手机号输入框”或者更复杂的业务组件),至少要做四件事:
- 写一个符合 formily 约定的普通表单组件。
- 用
registerSchemaField注册到 formily 渲染器,让运行时能渲染。 - 用
Resource、createBehavior、createFieldSchema注册到 Designable 设计器,让拖拽面板里能显示、拖出来。 - 如果需要配置属性,还要通过
createSettingsForm编写属性配置面板,让设计器右侧能编辑字段属性。
这四件事你听着可能觉得多,其实代码结构梳理好之后,任何一个新组件基本就是复制粘贴改改。
3.2 第一步:写一个 formily 字段组件
假设我要封装一个“评分组件”,底层基于 antd 的Rate。formily 的字段组件本质上就是一个普通的 React 组件,接收value和onChange作为受控属性:
// components/RateField.tsx import React from 'react'; import { Rate } from 'antd'; import { connect, mapProps, mapReadPretty } from '@formily/react'; import { Rate as RatePreview } from './RatePreview'; const RateField = (props: any) => { const { value, onChange, disabled } = props; return <Rate value={value} onChange={onChange} disabled={disabled} />; }; export const RateFieldComponent = connect( RateField, mapProps({ disabled: 'disabled', }), mapReadPretty(RatePreview) );这里特别注意connect和mapProps这个写法,这是 formily 对外部组件的“适配层”,它会把 formily 内部的状态映射成组件能识别的 props。不要直接暴露那个没有包装过的RateField,否则你在 Schema 里配置disabled、placeholder等属性都不会生效。
3.3 第二步:注册到 formily 渲染器
在应用入口或者模块初始化的地方,调用registerSchemaField:
// register.ts import { registerSchemaField } from '@formily/react'; import { RateFieldComponent } from './components/RateField'; registerSchemaField({ Rate: RateFieldComponent, });这一步做完,渲染器里遇到"type": "string", "x-component": "Rate"的 Schema 节点时,就会用你的组件去渲染。你可以用下面这个 Schema 在渲染器里快速验证:
{ "type": "object", "properties": { "rate": { "type": "number", "title": "评分", "x-component": "Rate", "x-component-props": { "allowHalf": true } } } }3.4 第三步:注册到 Designable 设计器
这一步是很多人的拦路虎,也是本地运行最容易报错的地方。先看代码:
// designerRegister.ts import { createResource, createBehavior, createFieldSchema } from '@designable/core'; import { RateFieldComponent } from './components/RateField'; const RateSchema = createFieldSchema({ type: 'number', 'x-component': 'Rate', 'x-component-props': { allowHalf: true, }, }); const RateBehavior = createBehavior({ name: 'Rate', extends: ['Field'], selector: 'Rate', designerProps: { title: '评分', propsSchema: createSettingsForm({ // 属性面板配置,可配置 allowHalf、disabled 等 }), }, }); export const RateResource = createResource({ title: '评分', icon: 'RateIcon', elements: [ { componentName: 'Field', schema: RateSchema, }, ], });上面这段代码表示:在设计器左侧的组件面板中,新增一个名为“评分”的资源。用户拖出来的时候,会在画布上生成一个Field节点,这个节点背着上面那段 Schema。
然后把这几个注册项交给设计器:
import { createDesigner } from '@designable/core'; import { Designer } from '@designable/react'; import { RateResource, RateBehavior } from './designerRegister'; const designer = createDesigner({ engines: { Resource: [RateResource], Behavior: [RateBehavior], }, }); export const DesignerApp = () => { return ( <Designer designer={designer}> {/* 这里放 Canvas、SettingsPanel 等组件 */} </Designer> ); };3.5 第四步:属性配置面板的扩展
createSettingsForm是基于 formily 的 Schema 来描述属性面板的。比如我要给“评分”组件加一个“是否允许半选”的开关,可以这样配置:
import { createSettingsForm } from '@designable/formily'; const RateSettings = createSettingsForm({ 'x-component-props.allowHalf': { type: 'boolean', title: '允许半选', 'x-decorator': 'FormItem', 'x-component': 'Switch', }, });然后把这个RateSettings传给createBehavior的designerProps.propsSchema。这样,在设计器里选中“评分”组件,右侧属性面板就会显示“允许半选”这个开关,修改后直接同步到当前选中节点的 Schema 中。
到这里,一个最简单但完整的自定义表单扩展就闭环了。设计器能拖,渲染器能出真表单,属性面板能改配置。
4. 本地运行的高频报错与问题排查实录
4.1 设计器里能显示组件,但渲染器报“Field 不存在”
这个场景很典型:在设计器画布里把组件拖出来了,schema 里也有对应节点,但是切到预览页,控制台报类似:
[@formily/react] SchemaField: field component not found, name: 'Rate'原因基本就是渲染器上下文里没有注册这个组件。检查一遍registerSchemaField是否在渲染器入口执行了,组件名是否和 Schema 里的x-component完全一致(大小写敏感)。
这个坑的隐蔽之处在于:如果你同时启动了多个入口文件(比如designer.tsx和preview.tsx),很容易在designer.tsx里注册了,但preview.tsx里没写注册代码。我是在一个应用里同时挂两个页面,注册逻辑写在了公共模块,结果preview页面因为模块加载顺序问题没执行到注册代码,后来改成在preview页面显式 import 一次注册模块才解决。
4.2 样式全部错乱,formily 组件和 antd 组件混在一起
本地跑起来后,表单组件出现了明显的样式问题:按钮大小不一致、表单项间距被吃掉、弹层位置不对。排查下来是@formily/antd的样式和业务项目的 antd 样式产生了冲突,本质上是版本不一致导致的。
@formily/antd2.2.x 对应的是antd@4.x,如果你业务项目里用的是antd@5.x,那么表单样式必然乱。antd 5的 CSS-in-JS 机制和antd 4的 less 变量机制差异很大,@formily/antd底层的表单布局依赖的还是antd 4的Form结构。
解决方案有两种:
第一种,把业务项目的antd降到4.24.x,与 formily 对齐,这是最省事的方式。
第二种,保持antd@5,改用@formily/antd-v5这个桥接包。这个包是社区维护的,适配了 antd 5 的 API。我当时为了稳定起见选择了降级到 antd 4,因为团队其他模块还大量依赖 antd 4 的组件,统一在一个大版本下风险更小。
注意:如果在本地看到
Cannot read properties of undefined (reading 'Item')这类报错,通常就是@formily/antd和当前 antd 大版本不匹配,Form 组件的引用方式变了。
4.3 热更新频繁失效,改完代码页面白屏
本地开发免不了开热更新。这套组合里,Designable的Designer组件内部状态很重,热更新时 React Fast Refresh 很难正确地保留它的状态,经常出现改一个文件,整个页面白屏,必须手动刷新。
我的处理办法是:把设计器页面和预览页面拆到两个路由,预览页面不加载Designer组件,只加载SchemaField渲染逻辑。这样改表单扩展组件时,只刷新预览页面,避免Designer内部状态被破坏。
还有一个小技巧:对于扩展组件的调试,不要每次都进设计器拖拽验证,而是写一个固定的测试 Schema,专门跑渲染器。这样排查问题速度至少快一倍。
4.4 pnpm 模式下出现“双份 React”导致的 socket 报错
这个现象很诡异,表现是本地启动没问题,但只要一操作设计器画布,控制台就报:
Error: Minified React error #321; visit https://reactjs.org/docs/error-decoder.html?invariant=321React 错误 #321 就是“Invalid hook call”的 minified 版本。原因和我前面说的类似,依赖树里有多个 React 副本。在 pnpm 的严格模式下,@designable/react解析到了它自己 node_modules 下的 React,而不是你项目根目录的 React。
我的排查和解决手段分三步:
第一步,pnpm why react查看 React 的解析路径,确认是有多个实例。
第二步,在项目根目录package.json添加resolutions统一版本并重新安装,这一步前面提到过。
第三步,也是后来真正稳定的方案,在项目根目录添加.npmrc配置:
public-hoist-pattern[]=*react* public-hoist-pattern[]=*formily*这个配置让 pnpm 把 React 和 formily 相关的包都提升到根目录 node_modules,避免不同包各自引用私有副本。
4.5 表格速查:本地运行常见报错与解法
| 报错特征 | 根本原因 | 解决方案 |
|---|---|---|
| Invalid hook call / React error #321 | 依赖树存在多个 React 实例 | 检查pnpm why react,用 resolutions 统一版本,配置 public-hoist-pattern |
| field component not found | 组件只注册到设计器,渲染器未注册 | 检查渲染器入口是否执行 registerSchemaField,组件名是否大小写一致 |
| antd 组件样式错乱 | @formily/antd 与 antd 大版本不匹配 | 将 antd 统一到 4.24.x,或切换 @formily/antd-v5 |
Cannot read properties of undefined (reading 'Item') | 桥接包引用 Form 结构不存在 | 确认 @formily/antd 版本与 antd 版本匹配 |
| 热更新白屏 | Designer 内部状态不适合 Fast Refresh | 设计器与预览页拆成独立路由,改组件时只刷新预览页 |
| pnpm install 报 peerDep 冲突 | workspace 协议依赖 | 使用 pnpm 7.x,不要用 npm 安装@designable/*包 |
5. 一些值得说透的底层经验和避坑建议
5.1 理解 Schema 的“两段式”生命期,能少踩一半坑
很多人出问题是因为没理解 Sc hema 在设计和渲染两个阶段的差异。在设计器阶段,Schema 的x-component字段对应的是“资源名称”,比如Rate,设计器用它来匹配createBehavior里注册的行为描述;在渲染阶段,x-component字段被 formily 用来匹配registerSchemaField里注册的“实际组件”。
这也就是为什么我建议把“组件注册”这件事抽象成独立模块,设计器和渲染器各自 import。以我目前团队定的规范为例,每个自定义组件目录下必须有一个register.tsx,里面包含了createFieldSchema、createBehavior、createResource、registerSchemaField四件套的调用,设计器入口和渲染器入口都 import 一次这个文件,确保两套上下文都能拿到。
5.2 本地调自定义组件时,用最小化 schema 验证
调试自定义组件时,不要一上来就打开完整的设计器去拖拽测试。那样链路太长,中间任何一个环节出错,你都不知道是组件问题、注册问题还是设计器配置问题。
我习惯的做法是,在项目里放一个单独的“调试页”,直接在代码里写死一份最小 schema:
const schema = { type: 'object', properties: { rate: { type: 'number', title: '评分', 'x-component': 'Rate', 'x-component-props': { allowHalf: true, }, }, }, }; export const RateDebugPage = () => { const form = useMemo(() => createForm(), []); return ( <FormProvider form={form}> <SchemaField schema={schema} /> <button onClick={() => console.log(form.values)}>提交</button> </FormProvider> ); };把这份 schema 渲染成真实表单,先验证这个组件在渲染器中正常工作,再进入设计器链路测试拖拽行为和属性配置。两层分开验证,定位问题的效率完全是两个级别。
5.3 版本治理和升级策略
最后说一个长期问题,Designable的更新其实并不算活跃,formily则还在迭代。这两者的版本一旦错位,本地运行可能不报错,但线上偶现“组件行为异常”这种难查的问题。所以我的建议是:锁死版本,不要随意升级。
我把版本号写进package.json时全部用的是精确版本,不用^或~,并且每次升级前先在本地跑完自动化冒烟用例,确认设计器拖拽、属性面板、渲染器三端都正常,再考虑合并到主干。这套流程在团队里跑了一个月,几乎没再遇到底层版本的幺蛾子。
另外,如果你想深入研究,建议直接读@designable/formily的源码,里面那个DesignableField组件是把设计态表单和运行时表单打通的关键,理解了它,你就真正掌握了这个方案的核心。