Published on

Erda Form内测发布

Authors
  • avatar
    Name
    McDaddy(戣蓦)
    Twitter

Erda Form内测发布

Erda Form是一个基于formily封装的高性能场景化的Form组件。也许与你之前遇到的Form都不一样

Form痛点集合

  • 无法支持具体场景,比如域之间的联动
    • A域触发B域的变化,势必需要在页面上增添一个新的state。如果复杂表单,state难以控制与维护
    • 逻辑散乱,表单的逻辑遍布了整个页面,无法做到内聚
    • 在配置化的使用场景下,表单布局很不灵活
      • 表单中插入一些无关的片段
      • 不规则的表单排布,甚至嵌套排布
      • 分步表单
      • 自增表单
  • 性能问题,更改单个域会引起整个表单甚至页面的重渲染
  • 配置表单时,组件的属性做不到ts校验

formily简介

什么是formily?

formily是阿里的一个表单解决方案,是阿里底座级的开发工具

为什么需要formily?

  • Formily基本支持中台所能遇到的所有场景
  • O(1)级别的渲染性能
  • 开箱即用,并且有针对antd的封装
  • 支持组件协议,自定义组件接入成本低

缺点是入门上手的门槛比较高,学习成本大

formily有什么特性

json-schema 同时描述数据与UI

什么是json-schema? 是一种描述JSON数据结构的方式

如何描述下面的数据结构?

{
  "productId": 1,
  "productName": "A green door",
  "price": 12.50,
  "tags": [ "home", "green" ]
}

用json来描述json数据结构

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/product.schema.json",
  "title": "Product",
  "description": "A product from Acme's catalog",
  "type": "object",
  "properties": {
    "productId": {
      "description": "The unique identifier for a product",
      "type": "integer"
    },
    "productName": {
      "description": "Name of the product",
      "type": "string"
    },
    "price": {
      "description": "The price of the product",
      "type": "number",
      "exclusiveMinimum": 0
    },
    "tags": {
      "description": "Tags for the product",
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 1,
      "uniqueItems": true
    }
  },
  "required": [ "productId", "productName", "price" ]
}

想象一下,这就是一个product的表单,那是不是就可以完美描述我们需要维护提交的form表单数据,同时因为json-schema自带校验功能,我们只需要遵照它的规范来写字段条件就可以完成对表单的校验

那么同时,如果我们想在配置中完成对UI的描述,那么就需要去扩展这个协议。formily的做法是增加了一系列x-开头的属性,做到不污染原始的json-schema规则。

  • x-decorator:包裹字段的组件
  • x-component: 字段组件
  • x-component-props: 字段组件的props
  • ……
const schema = {
  type: 'object',
  properties: {
    input: {
      title: '输入框',
      type: 'string',
      'x-decorator': 'FormItem',
      'x-component': 'Input',
    },
  }
}

effect集中控制整个表单的逻辑

如果我们要实现字段联动,不再需要在页面中额外定义state,而是在createForm时做一个集中的管理,这与我们日常熟悉的表单联动有比较大的区别

import React from 'react'
import { createForm, onFieldValueChange } from '@formily/core'
import { createSchemaField } from '@formily/react'
import { Form, FormItem, Input, Select } from '@formily/antd'
import { Button } from 'antd'

const form = createForm({
    effects() {
      onFieldValueChange('select', (field) => {
        form.setFieldState('input', (state) => {
          //对于初始联动,如果字段找不到,setFieldState会将更新推入更新队列,直到字段出现再执行操作
          state.display = field.value
        })
      })
    },
})

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem,
    Select,
  },
})

const schema = {
  type: 'object',
  properties: {
    select: {
      title: '控制者',
      type: 'string',
      'x-decorator': 'FormItem',
      'x-component': 'Select',
      enum:[
        { label: '显示', value: 'visible' },
        { label: '隐藏', value: 'none' },
        { label: '隐藏-保留值', value: 'hidden' },
      ]
    },
    input: {
      title: '受控者',
      type: 'string',
      'x-decorator': 'FormItem',
      'x-component': 'Input',
    },
  }
}

export default () => (
  <Form form={form} labelCol={6} wrapperCol={10}>
    <SchemaField schema={schema} />
    <Button onClick={() => console.log(form.values)}>提交</Button>
  </Form>
)

路径系统

为了解决字段寻址的问题,我们在实际操作中

  • 很多时候表单的数据不是一层的,也许会有多层
  • 有时候需要批量地处理字段

而这些都被封装在@formily/path这个包中

// 如何在初始化时,根据条件批量禁用部分字段
createForm({
  effects: () => {
    onFormMount(() => {
      form.setValues(eventDetail);
      if (eventDetail) {
        form.setFieldState('a.b.c', (state) => {
          state.componentProps = { ...state.componentProps, disabled: true };
        });
        if (eventDetail.source === 'default') {
          form.setFieldState('*(!description,customRemark)', (state) => {
            state.componentProps = { ...state.componentProps, disabled: true };
          });
        }
      }
    });
  },
});

生命周期

formily提供了针对Form和Field两者几乎所有生命周期的函数事件钩子,这样就实现了对整个form全局的掌控

协议驱动

使用json-schema来描述数据和UI,从而我们可以做到用一份静态的json配置来实现一个动态form表单,而上面的effects,可以通过x-reactions来实现

{
  "type": "object",
  "properties": {
    "source": {
      "type": "string",
      "title": "Source",
      "x-component": "Input",
      "x-component-props": {
        "placeholder": "请输入"
      }
    },
    "target": {
      "type": "string",
      "title": "Target",
      "x-component": "Input",
      "x-component-props": {
        "placeholder": "请输入"
      },
      "x-reactions": [
        {
          "dependencies": ["source"],
          "when": "{{$deps[0] == '123'}}",
          "fulfill": {
            "state": {
              "visible": true
            }
          },
          "otherwise": {
            "state": {
              "visible": false
            }
          }
        }
      ]
    }
  }
}

这就是一个非常纯粹的后端驱动前端的协议,前端在这里扮演的只是一个解析生成器的角色,通过后端返回的json配置,动态生成一个组件,而当这个组件生成渲染完毕之后,随即和后端解绑了,该调接口调接口,该调三方调三方。因为你返回JSON配置的服务,未必和真正的后端业务服务是一起的,这样就非常得轻量,结构清晰,同时性能又非常好。

分层架构

优雅的分层设计

img

formily原理简析

精确渲染

这是formily性能优秀的关键。之前form解决方案性能差的一个主要原因是form在联动或者校验之类的操作时,无法避免全量渲染,即我目标只想通过改变A和B,但不幸,整个页面都被重新render了,虽然React有diff算法,可以避免实际的DOM操作,但是计算量不可避免,所以就需要一个精确渲染的方案。

1.x

先回顾下formily 1.x的方案。概念是所谓的分布式状态管理,即每个字段都自己管自己的状态,每次输入时,都只渲染当前字段,变更值后更新自己的状态同时自动同步到formState,然后FormState再把最新的状态下行同步给所有子字段,如果哪个字段是被联动的,那就会二次触发这个循环。看起来这是一个无限循环,那么怎么停下来呢? 答案就是做脏数据检查,简单得说就是判断前后两次state是不是一样。

假设有100个字段且都有默认值,那么在初始化时,每个字段都会做一次上行的同步,然后form再下行同步给100个字段(包括自己),如此下来,总共要做100的平方次同步计算。同时,一个state可能有N个属性, 脏数据检查需要对比每个属性是否变化,假设只有10个属性,那么一个form初始化就要做1w * 10 = 10w次的对比计算。 从而产生性能问题。后期1.x通过利用immer的特性和惰性同步两个优化,基本把这个性能问题解决了。

img

2.x

然后formily 2.x完全重写了这块逻辑,原因是之前这个方案,不论如何优化,都是需要一个主动的同步过程,即A变了主要一个机制主动去通知B。那么有没有一种方案可以做到响应式,即这个通知是被动产生的,不需要主动去触发。作者从mobx那里得到了灵感

这里我们先回顾和了解下Mobx,首先对比下它和redux的使用区别

redux的过程

  • 派发action
  • reducer接收action,依据原有的state,结合action和payload,产生一个新的state,返回给store
  • UI和store绑定,接收到store状态的更新,进而更新视图

mobx的过程

  • 在store中定义被观察的状态,做一个深度代理
  • 在UI中,可以直接操作状态,mobx会有一个响应式的监听
  • 监听到变化后,要么产生computed values,要么触发Reaction(其实就是个effect)
  • UI与store绑定,会根据状态更改的粒度去更新UI

步骤简单,心智负担小

它不需要去定义繁琐的action,所有的改动都是自然而然的,和我们操作代码的习惯一般,在我们直接修改状态的同时,就把更新与副作用都实现了。 从某种意义上来说,redux是主动的,因为需要主动发起action,而mobx是被动的,副作用是手动修改状态后被动发生的。这点和vue的响应式思想是很类似的

性能高效

举个例子, 假设下面的message是一个组件的props

let message = mobx.observable({
    title: "Foo",
  	age: 19,
    author: {
        name: "Michel"
    },
    likes: [
        "John", "Sara"
    ]
});

mobx.autorun(() => {
    console.log(message.title) // 打印两次
})
console.log(message.title)
mobx.autorun(() => {
    console.log(message.age) // 只会打印一次
})
message.title = 'abc'
  • 如果是通过redux和组件关联,那么假设,组件里只用到message中的author属性,那么message.title改变后,这个组件是会被重新渲染的,即使组件中根本没用到这个属性。因为整个message是一个新的state。虽然通过dom diff可以避免实际的dom操作,但是渲染这个步骤是确实发生了
  • 如果是通过mobx和组件关联,同样情况下,改变title,不会使得只引用author的组件重新渲染,这就是说它高效的原因

那么Mobx是怎么实现响应式的么?

深度代理

首先,做到响应式,肯定不是一层能响应,比如{ a:1 }改变a的值可以响应,同时如{ a: { b: 1} }当修改b的时候也要能做到响应式,这里就需要一个深度代理

function deepProxy(val, handler) {
  // 如果是非对象就直接返回其本身,或者说只代理对象
  if (typeof val !== "object") {
    return val;
  }
  // 从直觉上讲,我们应该先创建自身的proxy,然后遍历属性,创建各自的proxy然后添加回自身
  // 但是这样会有一个问题,当创建子属性的proxy后,赋值回来的时候,因为父的proxy已经建立好了
  // 此时就会无缘无故触发了父的set方法,比如一个val有100个属性,那么就相当于这个proxy被修改了100次,触发100次set
  // 所以,只能做一个类似后续遍历的操作,先把子都代理好,然后再来代理父
  for (const key in val) {
    val[key] = deepProxy(val[key], handler);
  }
  return new Proxy(val, handler()); // 子都搞定了,最后创建自身的proxy
}

function createObservable(val) {
  // 统一定义proxy的handler
  const handler = () => ({
    get(target, key) {
      return Reflect.get(target, key);
    },
    set(target, key, value) {
      return Reflect.set(target, key, value);
    },
  });
  return deepProxy(val, handler);
}

const obj = createObservable({ a: { b:2 } });
obj.a.b = 3 // 此时obj.a.b的set会被调用
console.log(obj);

依赖收集

有了代理之后,相当于知道了哪些对象是可被观察的同时是可响应的。但可被观察对象不代表改变了就会触发副作用,需要代码来决定去监听某些部分的变化。比如下面的代码,除了初始化,仅会在author变化时执行,而其他属性变化时不会执行

autorun(() => {
  console.log(message.author);
})

大致原理

  • 每次调用autorun,会把回调函数暂存到nowFn
  • autorun会在初始化执行一次handler,而handler中一定会调用到属性的get,即.
  • 每次创建代理对象时,创建一个reaction对象,当触发get时,调用collect方法收集依赖
  • reaction中定义一个store,是为了如果某个属性,被多个autorun回调,那么就要存成一个数组
  • handler第一次执行结束,nowFn重新变成null
  • 当set值时,调用reaction.run方法,取出所有存起来的handler回调一起执行
  • 注意 收集依赖时console.log(obj.a.b)a和b的get都会被调用
  • 注意 改值时,obj.a.b = 3 只会在b的set上执行,而a是不会被执行到的,所以只会执行b上面的reaction收集回调
  • 如果写成obj.a = 3,此时b的reaction就不会执行了,会执行a的reaction,而此时虽然不是直接改b,但是因为b已经不存在,所以会打印undefined
// 全局定义,只有一个
let nowFn = null;

class Reaction {
  constructor() {
    this.store = [];
  }

  collect() {
    // 这个判断是和end清除nowFn结合使用
    // 如果我是在autorun之外触发了get,那就不应该被收集依赖,这里就确保只有autorun里面的变量被收集
    if (nowFn) {
      this.store.push(nowFn);
    }
  }

  run() {
    if (this.store.length) {
      this.store.forEach((w) => w());
    }
  }

  static start(handler) {
    nowFn = handler;
  }

  static end() {
    nowFn = null;
  }
}

const autorun = (handler) => {
  Reaction.start(handler);
  handler();
  Reaction.end();
};

function createObservable(val) {
  const handler = () => {
    const reaction = new Reaction(); // 每次创建代理都有一个reaction实例
    return {
      get(target, key) {
        reaction.collect(); // handler首次执行时收集依赖
        return Reflect.get(target, key);
      },
      set(target, key, value) {
        const r = Reflect.set(target, key, value);
        reaction.run(); // 修改变量时执行存储的回调
        return r;
      },
    };
  };
  return deepProxy(val, handler);
}

const obj = createObservable({ a: { b: 2 } });

autorun(() => {
    console.log(obj.a.b);
})

obj.a = 3

在fomily中,在createForm时,我们传入了effects,这就做到了在初始化实现依赖收集,哪些字段是关联的都在一开始被确定下来。这样,如上的例子中,select字段变化了,会因为响应式自动触发注册的回调,而不需要去广播这个变化,而被关联的字段input也会被精确更新。

Erda Form

既然formily如此牛逼,那是不是直接推formily就好了?我认为还是有些遗憾

  • json-schema的配置方法和Erda的render form差距比较大,切换成本太高
  • 很多共用属性需要重复配置,比如x-decorator,form grid等,这些可以通过手段减免掉
  • ts提示不友好,无法保证我们输入的组件属性都是正确的

在如何createForm上面和formily保持一致,没有任何改变,封装主要是针对字段配置,不需要写x- 非常像nusi的form builder

const formFieldsList = createFields([
    {
      component: Input,
      name: 'id',
      title: '事件id',
      required: true,
      customProps: {
        maxLength: 16,
      },
      validator: EN_UNDERLINE_REG,
    },
])
  
// 等价于以下, 自动封装了布局信息
const schema = {
  type: 'object',
  properties: {
    layout: {
      type: 'void',
      'x-component': 'FormLayout',
      'x-component-props': { layout: 'vertical' },
      properties: {
        grid: {
          type: 'void',
          'x-component': 'FormGrid',
          'x-component-props': {
            maxColumns: 1,
          },
          properties: {
            id: {
              title: '事件id',
              type: 'string',
              'x-decorator': 'FormItem',
              'x-component': 'Input',
              'x-component-props': { maxLength: 16 },
              'x-validator': EN_UNDERLINE_REG
            },
          },
        },
      },
    },
  },
}

同时在封装后,可以实现组件props的校验

What else?

嵌套表单

动态表单