Symfony如何实现可复用的AJAX Select2选择表单类型
问题描述
我想基于Symfony表单类型封装一个可复用的AJAX下拉选择器(基于select2),花了不少时间调试还是达不到预期效果。
根据我了解的规则,表单字段添加后不能直接修改配置,只能用新的配置重新添加字段。Symfony官方文档也给出了用事件动态增改表单的示例。
我已经能在表单里正常用AJAX下拉选择器了,但是还没做到完全复用,当前的实现特点:
- 表单字段继承Doctrine EntityType,完整支持数据映射等原生功能
- 字段初始化时设置
'choices' => [],,避免Doctrine默认加载数据库中全部实体 - 编辑场景下的已有选项在
FormEvents::PRE_SET_DATA事件中添加 - 提交的选项在
FormEvents::PRE_SUBMIT事件中添加
现在的配置只在父表单中写事件监听才能正常运行,我希望封装的AjaxNodeType能完全独立复用,父表单不需要关心任何数据处理和事件逻辑。我现在父表单和子表单里都写了事件监听,合理的逻辑应该是只在子表单里实现就够了。
是不是单个字段类型没办法在内部替换自身?还是我的实现逻辑有问题?有没有其他动态修改choices的方案?
可正常运行的父表单实现
class MyResultElementType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add( 'fixedNode', AjaxNodeType::class, [ 'label' => 'Fixed node', 'required' => false, ] ); $builder->addEventListener( FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) { if ($event->getData()) { $fixedNode = $event->getData()->getFixedNode(); // 这里运行正常,但放到子表单就失效 if ($fixedNode) { $name = 'fixedNode'; $parentForm = $event->getForm(); $options = $parentForm->get($name)->getConfig()->getOptions(); $options['choices'] = [$fixedNode]; $parentForm->add($name, AjaxNodeType::class, $options); } } }, 1000 ); $builder->addEventListener( FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) { $data = $event->getData()['fixedNode']; if ($data) { $name = 'fixedNode'; $parentForm = $event->getForm(); // 必须把提交的节点添加到选项列表,否则提交值会被判定为无效 $node = $this->entityManager->find(Node::class, $data); $options = $parentForm->get($name)->getConfig()->getOptions(); $options['choices'] = [$node]; $parentForm->add($name, AjaxNodeType::class, $options); } } ); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults( [ 'data_class' => MyResultElement::class, 'method' => 'POST', ] ); } }
无法正常运行的子表单/独立选择器实现
提交POST请求时,fixedNode字段的值不会被赋值给表单绑定的数据实体。
class AjaxNodeType extends AbstractType { /** @var EntityManager */ private $entityManager; public function __construct( EntityManager $entityManager ) { $this->entityManager = $entityManager; } public function buildForm(FormBuilderInterface $builder, array $options) { // 这里逻辑失效,但放到父表单就正常 $builder->addEventListener( FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) { if ($event->getData()) { $fixedNode = $event->getData(); $name = $event->getForm()->getName(); $parentForm = $event->getForm()->getParent(); $options = $parentForm->get($name)->getConfig()->getOptions(); $newChoices = [$fixedNode]; // 判断选项是否已经匹配,避免陷入无限循环 if ($options['choices'] !== $newChoices) { $options['choices'] = $newChoices; $parentForm->add($name, AjaxNodeType::class, $options); } } }, 1000 ); $builder->addEventListener( FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) { if ($event->getData()) { $name = $event->getForm()->getName(); $data = $event->getData(); $parentForm = $event->getForm()->getParent(); // 必须把提交的节点添加到选项列表,否则提交值会被判定为无效 $node = $this->entityManager->find(Node::class, $data); $options = $parentForm->get($name)->getConfig()->getOptions(); $options['choices'] = [$node]; $parentForm->add($name, self::class, $options); } }, 1000 ); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults( [ 'class' => Node::class, // 避免Doctrine加载全部节点 'choices' => [], ] ); } public function getParent(): string { return EntityType::class; } }
解决方案
你实现失效的核心原因是:在子字段的事件中直接调用父表单重新添加当前字段,会触发表单重建的时序问题,打断字段的数据映射流程,导致提交的值无法正常绑定到实体。
不需要用动态重写字段的方案,直接使用Symfony原生提供的choice_loader选项自定义选项加载逻辑,就可以实现完全独立复用的AJAX选择器:
1. 自定义选项加载器
首先实现ChoiceLoaderInterface接口,专门处理AJAX场景下的选项按需加载:
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; use Symfony\Component\Form\ChoiceList\ArrayChoiceList; use Doctrine\ORM\EntityManagerInterface; class AjaxEntityChoiceLoader implements ChoiceLoaderInterface { private $em; private $entityClass; public function __construct(EntityManagerInterface $em, string $entityClass) { $this->em = $em; $this->entityClass = $entityClass; } /** * 加载完整选项列表,初始渲染时只返回空列表,避免加载全量数据 */ public function loadChoiceList($value = null) { return new ArrayChoiceList([], $value); } /** * 提交时根据传入的ID值加载对应实体 */ public function loadChoicesForValues(array $values, $value = null) { if (empty($values)) { return []; } return $this->em->getRepository($this->entityClass)->findBy(['id' => $values]); } /** * 编辑场景下根据已选中的实体生成对应的ID值 */ public function loadValuesForChoices(array $choices, $value = null) { $values = []; foreach ($choices as $choice) { if ($choice instanceof $this->entityClass && method_exists($choice, 'getId')) { $values[] = (string) $choice->getId(); } } return $values; } }
2. 重构AjaxNodeType
去掉原有操作父表单的事件监听,改用自定义的选项加载器:
class AjaxNodeType extends AbstractType { private EntityManagerInterface $entityManager; public function __construct(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'class' => Node::class, 'choice_loader' => function (Options $options) { return new AjaxEntityChoiceLoader($this->entityManager, $options['class']); }, ]); } public function getParent(): string { return EntityType::class; } }
3. 父表单直接使用
父表单不需要添加任何额外的事件监听,直接引入字段即可:
class MyResultElementType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('fixedNode', AjaxNodeType::class, [ 'label' => '固定节点', 'required' => false, ]); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => MyResultElement::class, 'method' => 'POST', ]); } }
该方案完全兼容EntityType的所有原生特性,支持数据映射、验证,同时不会加载全量实体,也不会出现字段重建导致的映射失效问题,AjaxNodeType可以在任意表单中直接复用。
内容的提问来源于stack exchange,提问作者Patrick Winkelmayer
相关产品推荐
相关产品推荐

