基于 Designable 开发 Taro小程序前端页面可视化搭建工具

2,351 阅读10分钟

基于 Designable 开发 Taro小程序前端页面可视化搭建工具

预览地址

可视化设计器(体积很大,注意流量,最好用PC打开) lowcode-designable-taro-react.vercel.app demo H5(按 F12 切换设备仿真) lowcode-designable-taro-react-mobile.vercel.app 需要VPN

editor mobile

使用演示

todoList

1todolist.gif

制作表单

2simpleform.gif

联动逻辑 配置响应器

3reaction.gif

配置图标

4useIcon.gif

目录介绍

├─ packages
  ├─ editor: 基于 `Designable` + `Formily.js` 实现的页面可视化搭建设计器,使用 `rspack` 构建,并做了兼容Taro组件H5渲染处理
  ├─ mobile: Taro项目demo例子
  ├─ ui: 使用 `@nutui/nutui-react-taro` 组件库做的适配formily的组件

项目启动

依赖安装 本项目用了pnpm去做monorepo 根目录下

npm i -g pnpm
pnpm

Taro Demo运行 packages/mobile 目录下 编译微信小程序或淘宝(支付宝)小程序、h5

pnpm dev:weapp
pnpm dev:alipay
pnpm dev:h5

可视化设计器启动 packages/editor 目录下 packages/editor/start.js 中可修改 Taro Demo 地址

npm start

介绍文章

目前组件比较少,如有需要上生产建议按自身业务搭一套

设计器目录详细介绍

├─ editor
  ├─ src
    ├─ common 一些组件
    ├─ components 物料组件
    ├─ designable designable源代码copy
      ├─ designable-core 核心逻辑
      ├─ designable-formily-setters 右侧属性配置栏中复杂属性配置组件
      ├─ designable-formily-transformer designable的TreeNode与formily的Schema格式互转方法
      ├─ designable-react 设计器界面组件
      ├─ designable-shared 通用方法
    ├─ hooks/useDropTemplate 拖拽物料组件后的处理
    ├─ locales 国际化配置
    ├─ schemas 物料组件右侧属性配置栏配置
    ├─ service 保存页面配置方法
    ├─ widgets 一些组件设计器界面组件
    ├─ app.tsx 设计器主界面
    ├─ index.tsx 入口

前端可视化搭建与designable

已下内容可以结合 github.com/pindjs/desi… formily/antd 目录下的 start 命令启动

designable介绍

组件化搭建领域抽象的最好的搭建引擎,与formily同样的配方,不同的只是解决的不同问题,作为底层搭建引擎,它该有的能力都有,最基础的拖拽,就支持了很多形态的,比如,多选拖拽,跨区域拖拽,跨工作区拖拽,跨iframe拖拽,还有多选,快捷键多选,shift/ctrl加点击交集化多选,还有基于鼠标形态切换的选区式多选,再说说扩展性,它本身内核是一个框架无关的内核,只负责管理模型状态,然后我们想要扩展ui的话,只需要替换ui组件即可,designable本身提供了一系列开箱即用的ui组件,且是绝对遵循组合模式的方案,不搞黑盒插件模式,你想用就用它,不想用就替换它,因为组件本身是无状态的,状态都在内核中管理,所以这就使得了designable的扩展性,极其的强

如上 designable 开源库作者所述,designable 是一个设计器引擎,提供拖拽搭建能力。 我们可以用它来往上层封装出具体产品,比如表单设计器、低代码平台。

前端页面可视化搭建与低代码

首先厘清一下本文的开发范围。一般市面上推出的低代码产品会包含 界面可视化搭建、后端数据存储、应用管理发布等功能,偏向零代码方案面向非开发者;但如 lowcode-engine 阿里低代码引擎文档中所说,低代码本身也不仅仅是为技术小白准备的。在实践中,低代码因为通过组件化、模块化的思路让业务的抽象更加容易,而且在扩展及配置化上带来了更加新鲜的模式探索,技术人员的架构设计成本和实施成本也就降了很多。作为前端开发者,我们接下来先重点关注 web前端页面可视化搭建 如何开发。

低代码渲染有一个简单的公式

低代码渲染公式

按这个公式的理解,可视化搭建中有三个角色

  • 组件库。前端展示组件,不利用 JSONSchema 也可以像 antd 一样普通的用代码展示出来。
  • 协议和渲染器。渲染器根据协议解析 JSONSchema 最终展示出组件。
  • 可视化设计器。基于渲染器展示界面,加入拖拽和配置能力,高效产出 JSONSchema。

可以说 组件库渲染器 是基础,有了这两消费端已经可以进行渲染了,可视化设计器 是锦上添花。

渲染器 大概要做以下内容:

  • 获取源码组件
  • 解析组件的 props
  • 获取组件的 children
  • 保留并传入上下文,包括循环上下文,插槽上下文等;
  • 节点更新,当参数变化时需要更新对应的节点

协议、渲染器 与 组件库

designable 本身只提供拖拉拽等能力,协议、渲染器 主要依赖 formily表单解决方案 的协议驱动能力(标准JSON-Schema)

formily协议绑定

组件适配协议,要基于@formily的api进行改造,适配协议后好处就是我们可以依赖 Formily MVVM的能力,不止可以渲染页面,还可以低成本地 支配 页面,如 表单校验、异步数据源、页面联动逻辑,最简单的例子是根据用户行为决定一个组件的显示和隐藏,就这么一个简单的需求很多低代码产品都不支持。

怎么接入可以参考@formily/antd

如果是使用第三方组件库,那么根据主框架是 react 或者 vue 可以分别用 @formily/react / @formily/vue UI桥接层。 formily架构

formily协议驱动

本项目的H5和小程序动态渲染最核心的依赖是 Formily.jsJSON Schema 渲染能力 先可看官方文档 协议驱动简单介绍 Schema协议详细介绍

以todolist中每一项任务右边的删除按钮为示例

{
  "type": "void", // 类型 void类型表单项就是没有关联表单数据
  "title": "Icon", // 标题 一般标题用于显示在FormItem(x-decorator 字段 UI 包装器组件)
  "x-component": "Icon", // 字段 UI 组件,对应在SchemaField创建时传入的组件,SchemaField 组件是专门用于解析JSON-Schema动态渲染表单的组件
  // https://react.formilyjs.org/zh-CN/api/components/schema-field
  "x-component-props": { // 字段 UI 组件属性,就是业务组件Props能拿到的参数
    "iconName": "check-disabled",
    "style": { // 组件样式
    },
    "eventsConfig": { // 本项目实现的事件配置
      // 点击事件表达式
      // $开头的变量是内置表达式作用域,主要用于在表达式中实现各种联动关系
      // https://react.formilyjs.org/zh-CN/api/shared/schema#%E5%86%85%E7%BD%AE%E8%A1%A8%E8%BE%BE%E5%BC%8F%E4%BD%9C%E7%94%A8%E5%9F%9F
      "scriptClick": "{{ () => { console.log($index, $array); $array.remove($index)} }}"
    }
  },
  "x-designable-id": "956d01mudrx",
  "x-index": 1
}

开发自己的formily组件

本项目使用 @nutui/nutui-react-taro nutui.jd.com/taro/react/… 可参考packages/ui/src/components目录

formily的字段模型核心包含了两类字段模型:数据型字段和虚数据型字段 数据型字段(Field),核心是负责维护表单数据(表单提交时候的值)。 虚数据型字段(VoidField),你可以理解为它就是一个阉割了数据维护能力的 Field,所以它更多的是作为容器维护一批字段的 UI 形式。 字段模型

在ui/src/components目录中,Widget开头的组件和Button是VoidField,只用来展示UI的,不跟表单数据做关联, 其余的像 CheckBox、DatePicker、Input组件是跟表单数据做关联的,用 @formily/react 中的 connect, mapProps 方法包装组件来连接表单,最基础的数据型组件要求Props有 valueonChange

Form组件

Form组件是地基,用 @formily/react 中的 FormProvider 组件接收一个Form实例,为children提供formily表单context。 本项目里ui/src/components里面 实现了Form组件和FormPage组件, FormPage组件除了formily提供的能力外就是一个Taro的View组件, Form组件则多了nutui Form组件的样式

FormPage组件代码如下

import React, { createContext, useContext } from 'react'
import { Form as FormType, ObjectField } from '@formily/core'
import {
  ExpressionScope,
  FormProvider,
  JSXComponent,
  useParentForm,
} from '@formily/react'
import { View } from '@tarojs/components'

import { PreviewText } from '../PreviewText'


export const FormPage = ({
  form,
  component,
  previewTextPlaceholder,
  className,
  style,
  children,
}) => {
  const top = useParentForm()
  // 重要的是这里 我们的Form组件就简单的用Taro的View组件包住子组件渲染
  // ExpressionScope是用context来给 json-schema 表达式传递局部作用域,我们可以用它当做数据源
  // PreviewText.Placeholder也是一个context 给预览态显示文本一个缺省值,目前也不重要
  const renderContent = (_form: FormType | ObjectField) => (
    <ExpressionScope value={{ $$form: _form }}>
      <PreviewText.Placeholder value={previewTextPlaceholder}>
        <View className={className} style={style}>
          {children}
        </View>
      </PreviewText.Placeholder>
    </ExpressionScope>
  )
  if (form)
    // 最重要的是这里,有FormProvider才能提供fomily在react组件中的一系列能力
    return <FormProvider form={form}>{renderContent(form)}</FormProvider>
  if (!top) throw new Error('must pass form instance by createForm')
  return renderContent(top)
}

export default FormPage

FormItem

nutui 提供了一些表单组件 nutui-FormItem

如图所见FormItem的作用就是显示label、必填、校验文案等,并且让表单布局更加美观,我们需要混入formily能力。 我们要改造一下FormItem的最外层,要让designable属性能够挂到dom上,并且阉割掉原来UI库有关Form的功能,化为己用。 用 @formily/reactconnectmapProps 来让FormItem组件可以链接到表单

UI组件适配formily

组件适配formily最简单的处理的话只需要用connect包裹 需要修改表单field属性映射到组件props的话就要使用mapProps react.formilyjs.org/zh-CN/api/s…

import React from 'react'
import { connect, mapProps, mapReadPretty } from '@formily/react'
import { Input as Component } from '@nutui/nutui-react-taro'
import {
  InputProps,
} from '@nutui/nutui-react-taro/dist/types/index'

import { PreviewText } from '../PreviewText'

import { typePropsFields } from '../type'

type typeProps = typePropsFields & InputProps & {
  clearIcon: typeIconImageProps
}

import { getIconImageConfig, typeIconImageProps } from '../Icon/IconImage'

export const Input = connect(
  ({ clearIcon, ...props }: typeProps) => {
    // 图标配置封装
    const propNames = ['clearIcon']
    const IconImageConfig = getIconImageConfig(propNames, {
      clearIcon,
    })
    if (IconImageConfig.noActiveIcon) {
      IconImageConfig.icon = IconImageConfig.noActiveIcon
      delete IconImageConfig.noActiveIcon
    }
    if (props.type === 'number') {
      const onChange = props.onChange
      props.onChange = (val) => {
        onChange(Number(val))
      }
    }
    return <Component {...props}></Component>
  },
  mapProps((props, field) => {
    return {
      ...props,
    }
  }),
  mapReadPretty(PreviewText.Input)
)

formily的Field模型里像value、onChange、disabled等表单属性跟大多数UI组件库是适配的,nutui的input组件可以配置clearIcon,我们可以做额外的适配。

SchemaField

最后我们用 createSchemaField 注册一下适配好的UI组件,创建一个用于解析JSON-Schema动态渲染表单的组件,并 在本项目 packages/ui/src/components/SchemaField.ts中,创建SchemaField并导出在设计器和实际项目中使用

import { createSchemaField } from '@formily/react'

import { ArrayViews } from './ArrayViews'
import { Button } from './Button'
import { Checkbox } from './Checkbox'
import { DatePicker } from './DatePicker'
import { Form } from './Form'
import { FormItem } from './FormItem'
import { Icon } from './Icon'
import { Image } from './Image'
import { Input } from './Input'
import { InputNumber } from './InputNumber'
import { Radio } from './Radio'
import { Range } from './Range'
import { Rate } from './Rate'
import { Switch } from './Switch'
import { Text } from './Text'
import { TextArea } from './TextArea'
import { WidgetBase } from './WidgetBase'
import { WidgetCell } from './WidgetCell'
import { WidgetCellGroup } from './WidgetCellGroup'
import { WidgetList } from './WidgetList'
import { WidgetPopup } from './WidgetPopup'

export const SchemaField = createSchemaField({
  components: {
    ArrayViews,
    Button,
    Checkbox,
    DatePicker,
    Form,
    FormItem,
    Icon,
    Image,
    Input,
    InputNumber,
    Radio,
    Range,
    Rate,
    Switch,
    Text,
    TextArea,
    WidgetBase,
    WidgetCell,
    WidgetCellGroup,
    WidgetList,
    WidgetPopup,
  },
})

组件库准备好了之后,我们可以选择用 rollup 打包,也可以选择在项目中直接使用 tsx 文件。

designable可视化设计器使用Taro组件

以下内容可参考本项目 packages/editor 目录

由于 Taro 跨端的特性,让组件库在 h5 环境下展示是一定可以的,不过有两种方案:

  1. 用完整的Taro项目,接入designable API和组件,适配好在PC上的展示 (这种方式打包较慢,要Taro4.0提供vite打包后才快 可参考仓库github.com/SHRaymondJ/…
  2. 设计器适配部分Taro在h5中的逻辑,不使用Taro打包

本项目使用方案二

首先 @tarojs/components 使⽤了 Stencil 去实现了⼀个基于 WebComponents 且遵循微信⼩程序规范的组件库,用 reactify-wc 让React项目中能够使用 WebComponentstenciljs 打包的组件产物中有 defineCustomElements,调用一下才可以把 WebComponents 注册到浏览器中 taro-h5-webComponents 我们在设计器项目中需要把该方法导出来用一下,还需要引入Taro组件样式 其余H5页面处理可以看 node_modules/@tarojs/taro-loader/lib/h5.js

在设计器 main.tsx

import React from 'react'
import { findDOMNode, render, unstable_batchedUpdates } from 'react-dom'
import ReactDOM, { createRoot } from 'react-dom/client'
import { defineCustomElements } from '@tarojs/components/dist/esm/loader.js'
import { createReactApp } from '@tarojs/plugin-framework-react/dist/runtime'
import { createH5NativeComponentConfig } from '@tarojs/plugin-framework-react/dist/runtime'

import App from './app'

// Taro H5 初始化
Object.assign(ReactDOM, { findDOMNode, render, unstable_batchedUpdates }) // Taro H5对于React18的处理
defineCustomElements(window) // 注册WebComponents组件
const appObj = createReactApp(App, React, ReactDOM, {
  appId: 'root'
})
createH5NativeComponentConfig(null, React, ReactDOM) // Taro页面管理逻辑和Hooks初始化
appObj.onLaunch()

打包配置参考 plugin-framework-react 这个taro包中的处理 在 'node_modules/@tarojs/plugin-framework-react/dist/index.js' 文件中,有个 modifyH5WebpackChain 方法来处理编译到H5时的webpack配置

function modifyH5WebpackChain(ctx, framework, chain) {
    var _a;
    setLoader$1(framework, chain);
    setPlugin(ctx, framework, chain);
    const { isBuildNativeComp = false } = ((_a = ctx.runOpts) === null || _a === void 0 ? void 0 : _a.options) || {};
    const externals = {};
    if (isBuildNativeComp) {
        // Note: 该模式不支持 prebundle 优化,不必再处理
        externals.react = {
            commonjs: 'react',
            commonjs2: 'react',
            amd: 'react',
            root: 'React'
        };
        externals['react-dom'] = {
            commonjs: 'react-dom',
            commonjs2: 'react-dom',
            amd: 'react-dom',
            root: 'ReactDOM'
        };
        if (framework === 'preact') {
            externals.preact = 'preact';
        }
        chain.merge({
            externalsType: 'umd'
        });
    }
    chain.merge({
        externals,
        module: {
            rule: {
                'process-import-taro-h5': {
                    test: /taro-h5[\\/]dist[\\/]api[\\/]taro/,
                    loader: require.resolve('./api-loader')
                }
            }
        },
    });
    chain.merge({
        externals,
        module: {
            rule: {
                'process-import-taro-harmony-hybrid': {
                    test: /plugin-platform-harmony-hybrid[\\/]dist[\\/]api[\\/]apis[\\/]taro/,
                    loader: require.resolve('./api-loader')
                }
            }
        },
    });
}
function setLoader$1(framework, chain) {
    function customizer(object = '', sources = '') {
        if ([object, sources].every(e => typeof e === 'string'))
            return object + sources;
    }
    chain.plugin('mainPlugin')
        .tap(args => {
        args[0].loaderMeta = lodash.mergeWith(getLoaderMeta(framework), args[0].loaderMeta, customizer);
        return args;
    });
}
function setPlugin(ctx, framework, chain) {
    var _a, _b;
    const config = ctx.initialConfig;
    const webpackConfig = chain.toConfig();
    const isProd = webpackConfig.mode === 'production';
    if (!isProd && ((_b = (_a = config.h5) === null || _a === void 0 ? void 0 : _a.devServer) === null || _b === void 0 ? void 0 : _b.hot) !== false) {
        // 默认开启 fast-refresh
        if (framework === 'react') {
            chain
                .plugin('fastRefreshPlugin')
                .use(require('@pmmmwh/react-refresh-webpack-plugin'));
        }
        else if (framework === 'preact') {
            chain
                .plugin('hotModuleReplacementPlugin')
                .use(require('webpack').HotModuleReplacementPlugin);
            chain
                .plugin('fastRefreshPlugin')
                .use(require('@prefresh/webpack'));
        }
    }
}

所以设计器打包需要额外添加以下Taro配置

export default {
  resolve: {
    modules: ['node_modules'],
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
    alias: {
      '@tarojs/components$': '@tarojs/components/dist-h5/react', // taro3.6及以上为  @tarojs/components/lib/react
      '@tarojs/taro': '@tarojs/taro-h5',
    },
  },
  module: {
    rules: [
      {
        test: /taro-h5[\\/]dist[\\/]index/,
        loader: require.resolve(
          '@tarojs/plugin-framework-react/dist/api-loader.js'
        ),
      },
      ...
    ],
  },
  ...
}

这样就可以获得一个残缺的 Taro h5 React 环境,会有一些api不支持,比如路由跳转。

组件封装物料

@designable/core 提供了两个api createResource 创建资源基础信息,用于左侧拖拽组件 createBehavior 创建组件的行为,locals、propsSchema 可以描述右侧属性配置栏中可以配置的属性

组件的Behavior,大致就是描述一下组件有哪些属性需要在设计器上配置的,可以配置哪些内容,还有设计器与组件的交互,例如点击、拖拉这个组件会有什么反应。 给组件添加资源,简单的理解就是添加一些在设计器展示的内容,比如需要展示在左边组件区,那就需要一个icon designable-antd-left 有了这些配置,组件就变成了 低代码物料

designable里面可以先实现一个Field组件来定义默认的Behavior和Resource

Field.Behavior = createBehavior({
  name: 'Field',
  selector: 'Field',
  designerLocales: AllLocales.Field,
  designerProps: {
    ...behaviorOfResizeAndtranslate,
  },
})

用formily的Input组件做designable物料组件,通过 extends: ['Field'] 来继承默认的Behavior和Resource

import React from 'react'
import { Input as component } from 'ui-nutui-react-taro'

import {
  createBehavior,
  createResource,
} from '@/designable/designable-core/src'
import { DnFC } from '@/designable/designable-react/src'

import { AllLocales } from '../../locales'
import { AllSchemas } from '../../schemas'
import { createFieldSchema } from '../Field'
import { iconimageDesignableConfig } from '../shared'

export const Input: DnFC<React.ComponentProps<typeof component>> = component

const { imgsProperties, imgsLocales } = iconimageDesignableConfig([
  {
    name: 'clearIcon',
    locale: '清除图标',
  },
])

const propsSchema = createFieldSchema({
  component: {
    type: 'object',
    properties: {
      type: {
        type: 'string',
        enum: ['text', 'number', 'digit', 'idcard'],
        'x-decorator': 'FormItem',
        'x-component': 'Select',
        'x-component-props': {
          defaultValue: 'text',
        },
      },
      placeholder: {
        type: 'string',
        'x-decorator': 'FormItem',
        'x-component': 'Input',
      },
      align: {
        type: 'string',
        enum: ['left', 'center', 'right'],
        'x-decorator': 'FormItem',
        'x-component': 'Select',
        'x-component-props': {
          defaultValue: 'left',
        },
      },
      maxLength: {
        type: 'number',
        'x-decorator': 'FormItem',
        'x-component': 'NumberPicker',
      },
      clearable: {
        type: 'boolean',
        'x-decorator': 'FormItem',
        'x-component': 'Switch',
      },
      confirmType: {
        type: 'string',
        enum: [
          {
            label: '发送',
            value: 'send',
          },
          {
            label: '搜索',
            value: 'search',
          },
          {
            label: '下一个',
            value: 'next',
          },
          {
            label: '前往',
            value: 'go',
          },
          {
            label: '完成',
            value: 'done',
          },
        ],
        'x-decorator': 'FormItem',
        'x-component': 'Select',
        'x-component-props': {
          defaultValue: 'done',
        },
      },
      password: {
        type: 'boolean',
        'x-decorator': 'FormItem',
        'x-component': 'Switch',
      },
      ...imgsProperties,
    },
  },
  props: {
    'component-events-group': [],
  },
}) as any

Input.Behavior = createBehavior({
  name: 'Input',
  extends: ['Field'],
  selector: (node) => node.props['x-component'] === 'Input',
  designerProps: {
    propsSchema,
    defaultProps: {},
  },
  designerLocales: {
    'zh-CN': {
      title: '输入框',
      settings: {
        'x-component-props': {
          type: '输入框类型',
          placeholder: 'placeholder',
          align: '输入框内容对齐方式',
          maxLength: '限制最长输入字符',
          clearable: '展示清除Icon',
          confirmType: '键盘右下角按钮的文字(小程序)',
          password: '是否是密码',
          ...imgsLocales,
        },
      },
    },
  },
})

Input.Resource = createResource({
  icon: 'InputSource',
  elements: [
    {
      componentName: 'Field',
      props: {
        type: 'string',
        title: 'Input',
        'x-decorator': 'FormItem',
        'x-component': 'Input',
      },
    },
  ],
})

Input属性配置 如图所见右侧属性配置中 字段属性 是默认的表单属性配置,组件属性部分通常就是针对每个组件额外字段处理部分,在 propsSchema 中定义

designable使用物料

准备预览运行面板,使用 Form组件SchemaField组件 提供运行时渲染能力,与实际消费端的区别是需要用 designable 提供的 transformToSchema 把拖拉拽面板中的组件树转成JSON协议。

import React, { useMemo } from 'react'
import { createForm } from '@formily/core'
import { createSchemaField } from '@formily/react'
import {
    Input,
    ...
} from '@formily/antd'
import { Card, Slider, Rate } from 'antd' // 一些简单的布局组件或输入组件,即使不适配也能用起来
import { TreeNode } from '@pind/designable-core'
import { transformToSchema } from '@pind/designable-formily-transformer'

const SchemaField = createSchemaField({
  components: {
    Input,
    Card,
    ...
  },
})

export interface IPreviewWidgetProps {
  tree: TreeNode
}

export const PreviewWidget: React.FC<IPreviewWidgetProps> = (props) => {
  const form = useMemo(() => createForm(), [])
  const { form: formProps, schema } = transformToSchema(props.tree)
  return (
    <Form {...formProps} form={form}>
      <SchemaField schema={schema} />
    </Form>
  )
}

app.tsx中,把物料组件导入, 放入 ResourceWidgetComponentTreeWidget 两个设计器组件中。 放入 ResourceWidget 是为了在设计器左侧展示可用物料组件 ComponentTreeWidget 则是组件树渲染器,需要注册一下物料组件

              <ResourceWidget
                title="sources.Inputs"
                sources={[Input, InputNumber, TextArea, Checkbox, Radio, Rate, Switch]}
              />
              <ResourceWidget
                title="sources.Displays"
                sources={[Button, Icon, Image, Text]}
              />
              <ResourceWidget title="sources.Arrays" sources={[ArrayViews]} />
              <ResourceWidget
                title="sources.Layouts"
                sources={[
                  Form,
                  WidgetBase,
                  WidgetCell,
                  WidgetCellGroup,
                  WidgetList,
                  WidgetPopup,
                ]}
              />
              <ViewPanel type="DESIGNABLE">
                {() => (
                  <ComponentTreeWidget
                    className="ComponentTreeWidget"
                    components={{
                      ArrayViews,
                      Button,
                      Checkbox,
                      Form,
                      FormPage,
                      Field,
                      Icon,
                      Image,
                      Input,
                      InputNumber,
                      Radio,
                      Rate,
                      Switch,
                      Text,
                      TextArea,
                      WidgetBase,
                      WidgetCell,
                      WidgetCellGroup,
                      WidgetList,
                      WidgetPopup,
                    }}
                  />
                )}
              </ViewPanel>
              <ViewPanel type="JSONTREE" scrollable={false}>
                {(tree, onChange) => {
                  return <SchemaEditorWidget tree={tree} onChange={onChange} />
                }}
              </ViewPanel>
              <ViewPanel type="PREVIEW">
                {(tree) => <PreviewWidget tree={tree} />}
              </ViewPanel>

Taro小程序H5渲染JSONSchema

适配小程序端

我们开发好的组件库就是基于Taro组件标签写的,所以在Taro项目编译到H5或小程序就能在想要的平台中展示设计好的页面中。 要渲染 可视化搭建设计器 产出 JSONSchema,需要使用 @formily/core + @formily/react 以及组件库中的 SchemaField

但是要用在小程序端最主要有两个问题 1.在PC设计器上,设置组件样式的单位是px,需要转换为rem

这个问题主要是使用 Taro.pxTransform 将以px为单位的数字转为 以rem为单位的字符串,配合正则就可以实现对某段style进行转换

const pxToRem = (str) => {
  const reg = /(\d+(\.\d*)?)+(px)/gi
  return String(str).replace(reg, function (x) {
    const val = x.replace(/px/gi, '')
    return Taro.pxTransform(Number(val))
  })
}

对JSONSchema进行递归转换单位就解决了这个问题

2.formily Schema 联动协议需要动态执行JS代码,但小程序不能使用 Function/eval,需要一个JS写的JS解释器

举一个上篇文章中的例子,表单中有字段为a的输入框和字段为b的输入框,单a的值为 'hidden' 时把b隐藏掉 howToReaction howToReaction

那么这里需要动态执行的代码(表达式)是

$form.values.a !== 'hidden'

@formily/json-schema 中源码是使用 new Function 去执行的,把动态代码置于 formily作用域 运行以获得访问表单数据的能力

var Registry = {
    silent: false,
    compile: function (expression, scope) {
        if (scope === void 0) { scope = {}; }
        if (Registry.silent) {
            try {
                return new Function('$root', "with($root) { return (".concat(expression, "); }"))(scope);
            }
            catch (_a) { }
        }
        else {
            return new Function('$root', "with($root) { return (".concat(expression, "); }"))(scope);
        }
    },
};

@formily/json-schema 中导出的 Schema 里面 registerCompiler 的方法允许使用者注册自己的逻辑 本项目用JS写的JS解释器去运行动态代码

export function miniCompiler(expression, scope, isStatement?) {
  if (scope === void 0) {
    scope = {}
  }
  const scopeKey = Object.keys(scope).filter((str) => str.includes('$'))
  scopeKey.forEach((key) => {
    const reg = new RegExp(`\\${key}`, 'g')
    expression = expression.replace(reg, 'scope.' + key)
  })
  const bridge = { current: null }
  const context = vm.createContext({ bridge, expression, scope, console })
  try {
    if (isStatement) {
      vm.runInContext(`${expression} `, context)
      return
    }
    vm.runInContext(`bridge.current = ${expression} `, context)
  } catch (err) {
    console.error(err)
  }
  return bridge.current
}

页面渲染

  • @formily/core 导出的 createForm 创建form实例
  • 用fomrily组件库中的 Form 组件(实际上用了@formily/react 导出的 FormProvider)桥接form实例与UI
  • 用fomrily组件库中的 SchemaField 组件(实际上用了@formily/react 导出的 createSchemaField)去渲染 JOSNSchemaJOSNSchema 使用前先处理一遍 style 单位

具体参考 packages/mobile/src/pages/index/index.tsx