Rambler Group的经验:我们如何开始完全控制前端React组件的形成和行为


创建现代Web应用程序的方法有很多,但是每个团队都不可避免地面临着相同的问题集:如何分配前后职责,如何最小化重复逻辑的外观-例如,在验证数据时,使用哪些库工作,如何确保可靠性前后之间的透明运输并记录代码。

我们认为,我们成功地实现了一个在复杂性和利润之间取得平衡的解决方案的好例子,该解决方案已成功用于基于Symfony和React的生产中。

在积极开发的Web产品中计划后端API的开发时,我们可以选择哪种数据交换格式,该Web产品包含具有相关字段和复杂业务逻辑的动态表单?

  • SWAGGER是一个不错的选择,提供文档和便捷的调试工具。 此外,有一些Symfony库可以自动执行该过程,但不幸的是,事实证明JSON模式更可取。
  • JSON模式-此选项由前端开发人员提供。 他们已经有了允许他们显示表单的库。 这决定了我们的选择。 该格式允许您描述可以在浏览器中完成的原始检查。 也有描述该方案所有可能选项的文档。
  • GraphQL还很年轻。 没有太多的服务器端和前端库。 在创建系统时,将来不会考虑它-创建API的最佳方法,将有单独的文章;
  • SOAP-具有严格的数据类型和构建文档的能力,但是要与React交朋友并不容易。 对于相同数量的可用传输数据,SOAP也具有更大的开销。

所有这些格式都不能完全满足我们的需求,因此我不得不编写自己的收割机。 类似的方法可以为任何特定应用提供高效的解决方案,但这会带来风险:

  • 错误的可能性很高;
  • 通常不是100%的文档和测试覆盖率;
  • 由于软件API的封闭性,“模块化”较低。 通常,此类解决方案以整体形式编写,并不意味着以组件形式在项目之间共享,因为这需要特殊的体系结构(请阅读开发成本);
  • 高水平的新开发人员进入。 了解自行车的所有凉爽之处可能要花费很长时间。

因此,根据惯例,最好使用通用且稳定的库(例如npm的左键盘)-最好的代码是您从未编写但解决了业务问题的代码。 Rambler Group的广告技术中Web应用程序后端的开发在Symfony进行。 我们不会详细介绍框架的所有已使用组件,下面我们将讨论主要部分,在此基础上实现工作-Symfony形式 。 前端使用React和相应的库来扩展JSON Schema以实现WEB细节-React JSON Schema Form

总体工作计划:



这种方法有很多优点:

  • 文档是开箱即用的,构建自动测试的能力也是如此-再次根据计划进行;
  • 所有传输的数据都被键入;
  • 可以传输有关基本验证规则的信息;
    由于Mozilla React JSON Schema库,在React中快速集成了传输层;
  • 通过引导集成从包装盒中生成前端Web组件的能力;
  • 逻辑分组,一组验证和HTML元素的可能值以及所有业务逻辑都在一个点上进行控制-在后端,没有重复的代码;
  • 尽可能简单地将应用程序移植到其他平台上-视图或控件部分是分开的(请参见上一段),而不是React和浏览器,Android或iOS应用程序可以呈现和处理用户请求;

让我们更详细地看一下组件及其交互方案。

最初, JSON Schema允许您描述可以在客户端上完成的原始检查,例如绑定或键入方案的各个部分:

const schema = { "title": "A registration form", "description": "A simple form example.", "type": "object", "required": [ "firstName", "lastName" ], "properties": { "firstName": { "type": "string", "title": "First name" }, "lastName": { "type": "string", "title": "Last name" }, "password": { "type": "string", "title": "Password", "minLength": 3 }, "telephone": { "type": "string", "title": "Telephone", "minLength": 10 } } } 

为了使用前端方案,流行的React JSON Schema Form库提供了JSON Schema用于Web开发所需的附加组件:

uiSchema -JSON Schema本身确定要传递的参数的类型,但这不足以构建Web应用程序。 例如,类型为String的字段可以表示为<input ... />或<textarea ... />,这是重要的细微差别,并考虑到需要为客户端正确绘制图表的情况。 UiSchema还可以传达这些细微差别,例如,对于上面介绍的JSON Schema,您可以指定以下uiSchema的可视化Web组件:

 const uiSchema = { "firstName": { "ui:autofocus": true, "ui:emptyValue": "" }, "age": { "ui:widget": "updown", "ui:title": "Age of person", "ui:description": "(earthian year)" }, "bio": { "ui:widget": "textarea" }, "password": { "ui:widget": "password", "ui:help": "Hint: Make it strong!" }, "date": { "ui:widget": "alt-datetime" }, "telephone": { "ui:options": { "inputType": "tel" } } } 

可以在此处看到 Live Playground示例。

通过使用该方案,标准的引导程序组件将在几行中实现前端渲染:

 render(( <Form schema={schema} uiSchema={uiSchema} /> ), document.getElementById("app")); 

如果引导程序随附的标准窗口小部件不适合您并且您需要自定义-对于某些数据类型,可以在uiSchema中指定自己的模板,在编写本文时,支持stringnumberintegerboolean

FormData-包含表单数据,例如:

 { "firstName": "Chuck", "lastName": "Norris", "age": 78, "bio": "Roundhouse kicking asses since 1940", "password": "noneed" } 

渲染后,小部件将被填充此数据-对于编辑表单以及为我们为相关字段和复杂表单添加的一些自定义机制很有用,更多内容在下文中。

您可以在插件页面上详细了解设置和使用上述部分的所有细微差别。

开箱即用的库仅允许您使用这三个部分,但是对于功能完善的Web应用程序,您需要添加许多功能:

错误 -还必须能够将各种后端检查的错误传递给用户,并且错误可以是简单的验证错误-例如,注册用户时登录的唯一性,也可以是基于业务逻辑的更复杂的错误-即 我们必须能够自定义其(错误)数量和显示的通知文本。 为此,除上述内容外,将“错误”部分添加到传输的数据集中-对于每个字段,此处定义了要渲染的错误列表

动作方法 -用于将用户准备的数据发送到后端,添加了两个属性,其中包含执行处理的控制器后端的URL和HTTP传递方法

结果,为了前后之间的通信,我们得到了带有以下部分的json:

 { "action": "https://...", "method": "POST", "errors":{}, "schema":{}, "formData":{}, "uiSchema":{} } 

但是如何在后端生成此数据? 在创建系统时,尚无现成的库可让您将Symfony Form转换为JSON Schema。 现在它们已经出现了,但是有缺点-例如, LiformBundle相当自由地解释JSON Schema,并可以自行决定更改标准,因此,不幸的是,我不得不编写自己的实现。

作为生成的基础,使用了标准的Symfony形式 。 使用builder并添加必要的字段就足够了:
表格范例
 $builder ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]); 


在输出中,此形式转换为以下形式的电路:
JsonSchema示例
 { "action": "//localhost/create.json", "method": "POST", "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" }, "formData": { "title": "", "description": "", "year": "", "genre": "" }, "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "mainForm" } } 


所有将表单转换为JSON的代码均已关闭,并且仅在Rambler Group中使用,如果社区对此主题感兴趣,我们将以github仓库中的bundle格式对其进行重构

让我们看一些其他方面,如果没有这些方面,构建现代Web应用程序将很困难:

现场验证


使用symfony验证器进行设置,该验证器描述了验证对象的规则(验证器的示例):

 <property name="title"> <constraint name="Length"> <option name="min">1</option> <option name="max">255</option> <option name="minMessage">title.min</option> <option name="maxMessage">title.max</option> </constraint> <constraint name="NotBlank"> <option name="message">title.not_blank</option> </constraint> </property> 


在此示例中,类型为NotBlank的约束条件通过将一个字段添加到方案的必填字段数组来修改方案,类型为长度的约束条件添加了属性schema-> properties-> title-> maxLength和schema-> properties-> title-> minLength,验证应该已经考虑了在前端。

分组项目


在现实生活中,简单形式很可能是规则的例外。 例如,一个项目可能具有一个包含大量字段的表单,并且在固定列表中提供所有内容并不是最佳选择-我们必须照顾好我们应用程序的用户:

显而易见的决定是将表单分为控制元素的逻辑组,以便用户更轻松地导航并减少错误:

如您所知,Symfony表单的功能非常强大-例如,可以从其他表单继承表单,这很方便,但是在我们的案例中有缺点。 在当前实现中,JSON Schema中的顺序确定在浏览器中绘制表单元素的顺序;继承可能违反此顺序。 一种选择是对元素进行分组,例如:

嵌套表格示例
 $info = $builder ->create('info',FormType::class,['inherit_data'=>true]) ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]); $builder ->add($info) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]); 


该表格将转换为以下形式的电路:

嵌套的JsonSchema示例
 "schema": { "properties": { "info": { "properties": { "title": { "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" } }, "required": [ "title", "description" ], "type": "object" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "info", "year", "genre", "available" ], "type": "object" } 


和相应的uiSchema
 "uiSchema": { "info": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "ui:widget": "form" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "group" } 


这种分组方法不适合我们,因为数据的形式开始取决于表示形式,并且不能以API或其他形式使用。 决定在uiSchema中使用其他参数而不破坏当前的JSON Schema标准。 结果,在交响乐形式中添加了以下类型的附加选项:

 'fieldset' => [ 'groups' => [ [ 'type' => 'base', 'name' => 'info', 'fields' => ['title', 'description'], 'order' => ['title', 'description'] ] ], 'type' => 'base' ] 

这将转换为以下方案:

 "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, 


完整版的架构和uiSchema
 "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1989", "1990" ], "enumNames": [ "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "boolean", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" } 

 "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, "ui:widget": "fieldset" } 


因为在前端, 我们使用React库不支持此功能,所以我必须自己添加此功能。 通过添加新元素“ ui:group”,我们有机会使用当前的API完全控制对元素和表单进行分组的过程。

动态表格


如果一个字段依赖于另一个字段,例如,子类别的下拉列表取决于所选的类别,该怎么办?



Symfony FORM允许我们使用事件创建动态表单 ,但是不幸的是,尽管此功能在最新版本中出现 ,但JSON Schema在实现时不支持此功能。 最初的想法是将整个列表提供给一个Enum和EnumNames对象,基于该对象可以过滤值:

 { "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [ "eccentric", "romantic", "grotesque" ], "enumNames": [ { "title": "sgenre.choice.eccentric", "genre": "comedy" }, { "title": "sgenre.choice.romantic", "genre": "comedy" }, { "title": "sgenre.choice.grotesque", "genre": "comedy" } ], "type": "string", "title": "label.genre" } }, "type": "object" } 

但是使用这种方法,对于每个这样的元素,有必要在前端编写其自己的处理,更不用说当这些对象中有多个或一个元素依赖于多个列表时,一切都会变得非常复杂。 此外,为了正确处理和呈现所有依赖关系,发送到前端的数据量显着增长。 例如,想象一下一个由三个相互关联的区域(国家,城市,街道)组成的表单的图形。 需要发送到后端到前端的大量初始数据可能会使瘦客户机感到不安,并且,如您所记得,我们必须照顾好我们的用户。 因此,决定通过添加自定义属性来实现动态:

  • SchemaID-方案的属性,包含控制器的地址,用于处理当前输入的FormData并更新当前窗体的方案(如果业务逻辑需要);
  • 重新加载-告知前端该字段中的更改通过向后端发送表单数据来启动电路更新的属性;

SchemaID的存在可能看起来像是重复的-毕竟,有一个action属性,但是在这里我们谈论的是责任划分-SchemaID控制器负责模式UISchema的中间更新,而动作控制器执行必要的业务操作-例如,创建或更新对象,并且不允许将表单的一部分作为产生验证检查,加上这些内容,该方案开始看起来像这样:

 { "schemaId": "//localhost/schema.json", "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [], "enumNames": [], "type": "string", "title": "label.sgenre" } }, "uiSchema": { "genre": { "ui:options": { "reload": true }, "ui:widget": "select", "ui:help": "title.genre" }, "sgenre": { "ui:widget": "select", "ui:help": "title.sgenre" }, "ui:widget": "mainForm" }, "type": "object" } 

如果更改“流派”字段,则前端将带有当前数据的整个表单发送到后端,作为响应,接收呈现该表单所需的一组部分:

 { action: “https://...”, method: "POST", schema:{} formData:{} uiSchema:{} } 

然后呈现而不是当前表单。 发送后确切更改的内容取决于背面,字段的组成或数量可能会发生变化等。 -应用程序的业务逻辑将需要的任何更改。

结论


由于对标准方法进行了少量扩展,我们获得了许多附加功能,这些功能使我们能够完全控制前端React组件的形成和行为,基于业务逻辑构建动态电路,为验证规则的形成提供单一要点以及快速灵活地创建新VIEW部件的能力-例如,移动或台式机应用程序。 进行这样大胆的实验时,您需要记住该标准,并以此为基础并保持向后兼容。 可以在前端使用其他任何库来代替React,主要是为JSON Schema编写一个传输适配器并连接一些表单呈现库。 Bootstrap在React上工作得很好,因为我们有使用该技术堆栈的经验,但是我们所讨论的方法并不限制您选择技术。 代替Symfony,还可以使用任何其他框架来允许将表单转换为JSON Schema格式。

更新:您可以从1:15:00开始查看关于Symfony Moscow Meetup#14的报告。

Source: https://habr.com/ru/post/zh-CN417023/


All Articles