Angular ng-click 事件侦听器在 $compile 后与 ng-click 相乘

Angular ng-click events listeners multiplying with ng-click after $compile

Whosebug 社区,您好!

我正在使用 AngularJS 和 jQuery 开发一个 Symfony 3 项目。我创建了一个集合指令来与 Symfony 表单构建器交互,以添加和删除集合字段类型的行。该指令有一个独立的作用域,它设置一个名为 prototypeControl 的双向绑定变量。在 twig 模板站点上,我调用 prototype-control="{{ form.vars.id|camel_case }}Prototype" 来获取集合字段的唯一 ID,因此这将适用于单个表单中的多个集合字段。奇怪的是,如果我在 prototype-control 属性中将变量名称设置为 prototypeControl,一切正常。添加和删​​除按钮适用于加载时存在的集合行,删除按钮适用于动态添加的行。我需要这个来处理自定义变量名,这样我就可以处理来自页面控制器的独立作用域函数。

长话短说,当我使用带有表单 ID 的变量使其唯一时,我可以从 javscript 控制台触发函数,但是无论动态添加什么字段,删除按钮都没有任何影响。使删除按钮起作用的唯一方法是将 $compile 注入指令并编译 directvie 元素。

$compile(element.contents())(scope);

尝试使用和不使用 .contents() 方法。这种方法看起来一切都很好,删除对现有元素和动态添加的元素都有效,添加按钮有效,但无论出于何种原因,添加按钮上的 ng-click 似乎每次都会增加听众。所以下次我单击该按钮时,它会添加两行,然后添加四行,依此类推。

我尝试在 DOM 的不同级别上进行编译,但删除按钮永远不起作用。我尝试过的示例元素是删除按钮本身,存储在本地容器变量中的 DOM,尝试过原型 HTML 本身,以及 .prototype-row。 None 其中似乎对 $compile 有任何影响。只有编译元素变量似乎有效。

指令如下:

($_ => {
    $_.app.directive('formCollection', [
        '$compile',
        ($compile) => ({
            restrict: 'C',
            require: '^form', // Grab the form controller from the parent <form> element,
            scope: {
                prototypeControl: '=',
            },
            link(scope, element, attr, form) {
                // Declare prototypeControl as an object
                scope.prototypeControl = {};

                // Store the prototype markup in the scope (the template generated by Symfony)
                scope.prototype = attr.collectionPrototype;

                // Determine what the the next row id will be on add
                let row = element.find('.prototype-row').last().data('row');

                // Set the nextRow scope variable
                if (typeof row !== 'undefined') {
                    // Next number in the sequence
                    scope.nextRow = row + 1;
                }
                else {
                    // There are no rows on page load. Setting the default to zero
                    scope.nextRow = 0;
                }

                // Add prototype row (add button)
                scope.prototypeControl.add = ($event) => {
                    if (typeof $event !== 'undefined') {
                        // Prevent Default
                        $event.preventDefault();
                    }

                    // Get the element that will contain dynamically added prototype form rows
                    let container = element.find('.prototype-container');

                    // Replace the __name__ placeholder with the row id (typically the next number in the sequence)
                    let prototype = scope.prototype.replace(/__name__/g, scope.nextRow);

                    // Appened the prototype form row to the end of the prototype form rows container
                    angular.element(prototype).appendTo(container);

                    // Re-compiles the entire directive element and children to allow events like ng-click to fire on
                    // dynamically added prototype form rows
                    $compile(element.contents())(scope);

                    // Increase the nextRow scope variable
                    scope.nextRow++;
                };

                // Remove prototype row (remove button)
                scope.prototypeControl.remove = ($event) => {
                    // Prevent Default
                    $event.preventDefault();

                    // Get the button element that was clicked
                    let el = angular.element($event.target);

                    // Get the entire prototype form row (for removal)
                    let prototypeRow = el.parents('.prototype-row');

                    // Remove the row from the dom (If orphan-removal is set to true on the model, the ORM will automatically
                    // delete the entity from the database)
                    prototypeRow.remove();
                };

                // Manual control to add a row (omits the $event var)
                scope.prototypeControl.addRow = () => {
                    scope.prototypeControl.add();
                };

                // Manual control to remove a row by passing in the row id
                scope.prototypeControl.removeRow = (row) => {
                    // Find the prototype form row by the row id
                    let el = angular.element(`.prototype-row[data-row="${row}"]`);

                    // If the element is found, remove it from the DOM
                    if (el.length) {
                        el.remove();
                    }
                };
            }
        })]);
})(Unicorn);

这是服务器端收集的 twig 模板块。

{%- block collection_widget -%}
    {% if prototype is defined and prototype %}
        {% set prototypeVars = {} %}

        {% set prototypeHtml = '<div class="prototype-row" data-row="__name__">' %}

        {% set prototypeHtml = prototypeHtml ~ form_widget(prototype, prototypeVars) %}

        {% if allow_delete is defined and allow_delete %}
            {% set prototypeHtml = prototypeHtml ~ '<div class="input-action input-action-delete">' %}
            {% set prototypeHtml = prototypeHtml ~ '<a href="#" class="btn btn-secondary btn-destructive btn-small prototype-remove" ng-click="' ~ form.vars.id|camel_case ~ 'Prototype.remove($event)" data-field="' ~ prototype.vars.id|camel_case ~ '">' ~ deleteButtonText|trans({}, translation_domain)| raw  ~ '</a>' %}
            {% set prototypeHtml = prototypeHtml ~ '</div>' %}
        {% endif %}

        {% set prototypeHtml = prototypeHtml ~ '</div>' %}

        <div class="form-collection" prototype-control="{{ form.vars.id|camel_case }}Prototype" data-collection-prototype="{{ prototypeHtml|e('html') }}">
            {% for field in form %}
                <div class="prototype-row" data-row="{{ field.vars.name }}">
                    {{ form_widget(field) }}
                    {{ form_errors(field) }}
                    {% if allow_delete is defined and allow_delete %}
                        <div class="input-action input-action-delete">
                            <a href="#" class="btn btn-secondary btn-destructive btn-small prototype-remove" ng-click="{{ form.vars.id|camel_case }}Prototype.remove($event)" data-field="{{ field.vars.id|camel_case }}">{{ deleteButtonText|trans({}, translation_domain)| raw }}</a>
                        </div>
                    {% endif %}
                </div>
            {% endfor %}
            <div class="prototype-container"></div>
            {% if allow_add is defined and allow_add %}
                <div class="input-action input-action-add">
                    <a href="#" class="btn btn-secondary btn-small" ng-click="{{ form.vars.id|camel_case }}Prototype.add($event)" data-collection="{{ form.vars.id|camel_case }}">{{ form.vars.addButtonText|trans({}, translation_domain) }}</a>
                </div>
            {% endif %}
            {{ form_errors(form) }}
        </div>
    {% else %}
        {{- block('form_widget') -}}
    {% endif %}
{%- endblock collection_widget -%}

这是我正在测试的特定集合的实际模板。这是使用 add() 方法动态添加到 DOM 的 data-collection-prototype 的内容:

<div class="prototype-row" data-row="__name__">
    <div id="proposal_recipients___name__Container">
        <div class="form-item form-item-contact">
            <div id="proposal_recipients___name___contactContainer">
                <div class="form-item form-item-first-name"><label class="control-label required"
                                                                   for="proposalRecipientsNameContactFirstName">First
                    Name<span class="field-required">*</span></label>


                    <input type="text" id="proposalRecipientsNameContactFirstName"
                           name="proposal[recipients][__name__][contact][firstName]" required="required"
                           ng-model="proposalDetails.proposal.recipients[__name__]._contact.firstName"
                           ng-init="proposalDetails.proposal.recipients[__name__]._contact.firstName=''" class="input"/>
                </div>
                <div class="form-item form-item-last-name"><label class="control-label required"
                                                                  for="proposalRecipientsNameContactLastName">Last
                    Name<span class="field-required">*</span></label>


                    <input type="text" id="proposalRecipientsNameContactLastName"
                           name="proposal[recipients][__name__][contact][lastName]" required="required"
                           ng-model="proposalDetails.proposal.recipients[__name__]._contact.lastName"
                           ng-init="proposalDetails.proposal.recipients[__name__]._contact.lastName=''" class="input"/>
                </div>
                <div class="form-item form-item-email"><label class="control-label required"
                                                              for="proposalRecipientsNameContactEmail">Email
                    Address<span class="field-required">*</span></label> <input type="email"
                                                                                id="proposalRecipientsNameContactEmail"
                                                                                name="proposal[recipients][__name__][contact][email]"
                                                                                required="required"
                                                                                ng-model="proposalDetails.proposal.recipients[__name__]._contact.email"
                                                                                ng-init="proposalDetails.proposal.recipients[__name__]._contact.email=''"
                                                                                class="input"/></div>
                <div class="form-item form-item-phone"><label class="control-label"
                                                              for="proposalRecipientsNameContactPhone">Phone</label>


                    <input type="phone" id="proposalRecipientsNameContactPhone"
                           name="proposal[recipients][__name__][contact][phone]"
                           ng-model="proposalDetails.proposal.recipients[__name__]._contact.phone"
                           ng-init="proposalDetails.proposal.recipients[__name__]._contact.phone=''" class="input"/>

                </div>
            </div>
        </div>
        <div class="form-item form-item-company"><label class="control-label required"
                                                        for="proposalRecipientsNameCompany">Company<span
                class="field-required">*</span></label>


            <input type="text" id="proposalRecipientsNameCompany" name="proposal[recipients][__name__][company]"
                   required="required" ng-model="proposalDetails.proposal.recipients[__name__]._company"
                   ng-init="proposalDetails.proposal.recipients[__name__]._company=''" class="input"/>
        </div>
        <div class="form-item form-item-title"><label class="control-label required" for="proposalRecipientsNameTitle">Title<span
                class="field-required">*</span></label>


            <input type="text" id="proposalRecipientsNameTitle" name="proposal[recipients][__name__][title]"
                   required="required" ng-model="proposalDetails.proposal.recipients[__name__]._title"
                   ng-init="proposalDetails.proposal.recipients[__name__]._title=''" class="input"/>
        </div>
        <div class="form-item form-item-role"><label class="control-label required" for="proposalRecipientsNameRole">Role<span
                class="field-required">*</span></label>


            <select id="proposalRecipientsNameRole" name="proposal[recipients][__name__][role]" required="required"
                    ng-model="proposalDetails.proposal.recipients[__name__]._role"
                    ng-init="proposalDetails.proposal.recipients[__name__]._role=''" class="hide-search"
                    data-show-search="0" chosen="chosen" data-allow-single-deselect="true" data-placeholder="Select"
                    tabindex="-1">
                <option value="" selected="selected">Select</option>
                <option value="ROLE_PROPOSAL_SIGNER">Signer</option>
                <option value="ROLE_PROPOSAL_READER">Reader</option>
            </select>
        </div>
    </div>
    <div class="input-action input-action-delete"><a href="#"
                                                     class="btn btn-secondary btn-destructive btn-small prototype-remove"
                                                     ng-click="proposalRecipientsPrototype.remove($event)"
                                                     data-field="proposalRecipientsName">Remove Recipient</a></div>
</div>

我仍然会通过这个来寻找答案。如果我弄明白了,我会 post 回到这里。

希望外面的人 运行 参与其中一两次。

谢谢!

好吧,经过一些调整,我终于让它工作了。我终于弄清楚使用两个指令会阻止添加按钮相乘,因为添加按钮只存在于父指令中,添加的元素只有两个子指令。所以我决定为 .prototype-container div 创建一个指令,让原型行在其中。这样编译 .prototype-container 元素将注册动态添加的 ng-click 事件,而无需更改原型模板在数据属性中的存储方式。

我很好奇是否有人看到了更简洁的方法。

我会 post 为任何可能有兴趣使用 AngularJS 处理带有 Symfony 集合类型的添加和删除按钮的人修复。我还将 post CollectionTypeExtension 供那些想在他们的项目中尝试的人使用。

这是工作原型中更新后的表单主题块:

{%- block collection_widget -%}
    {% if prototype is defined and prototype %}
        {% set prototypeVars = {} %}

        {% set prototypeHtml = '<div class="prototype-row" data-row="__name__">' %}

        {% set prototypeHtml = prototypeHtml ~ form_widget(prototype, prototypeVars) %}

        {% if allow_delete is defined and allow_delete %}
            {% set prototypeHtml = prototypeHtml ~ '<div class="input-action input-action-delete">' %}
            {% set prototypeHtml = prototypeHtml ~ '<a href="#" class="btn btn-secondary btn-destructive btn-small prototype-remove" ng-click="' ~ form.vars.id|camel_case ~ 'Prototype.remove($event)" data-field="' ~ prototype.vars.id|camel_case ~ '">' ~ deleteButtonText|trans({}, translation_domain)| raw  ~ '</a>' %}
            {% set prototypeHtml = prototypeHtml ~ '</div>' %}
        {% endif %}

        {% set prototypeHtml = prototypeHtml ~ '</div>' %}

        <div class="form-collection" prototype-control="{{ form.vars.id|camel_case }}Prototype" data-collection-prototype="{{ prototypeHtml|e('html') }}">
            <div class="prototype-container">
                {% for field in form %}
                    <div class="prototype-row" data-row="{{ field.vars.name }}">
                        {{ form_widget(field) }}
                        {{ form_errors(field) }}
                        {% if allow_delete is defined and allow_delete %}
                            <div class="input-action input-action-delete">
                                <a href="#" class="btn btn-secondary btn-destructive btn-small prototype-remove" ng-click="{{ form.vars.id|camel_case }}Prototype.remove($event)" data-field="{{ field.vars.id|camel_case }}">{{ deleteButtonText|trans({}, translation_domain)| raw }}</a>
                            </div>
                        {% endif %}
                    </div>
                {% endfor %}
            </div>
            {% if allow_add is defined and allow_add %}
                <div class="input-action input-action-add">
                    <a href="#" class="btn btn-secondary btn-small" ng-click="{{ form.vars.id|camel_case }}Prototype.add($event)" data-collection="{{ form.vars.id|camel_case }}">{{ form.vars.addButtonText|trans({}, translation_domain) }}</a>
                </div>
            {% endif %}
            {{ form_errors(form) }}
        </div>
    {% else %}
        {{- block('form_widget') -}}
    {% endif %}
{%- endblock collection_widget -%}

我在模板中所做的所有更改是我将已经存储并在页面加载时加载的原型行移动到 .prototype-container div 中。这最有意义,并允许指令上的删除按钮与两个实例一起使用。

这是更新 JS,其中包含两个相互通信的指令:

($_ => {
    $_.app.directive('formCollection', [
        () => ({
            restrict: 'C',
            require: '^form', // Grab the form controller from the parent <form> element,
            scope: {
                prototypeControl: '=',
            },
            link: function(scope, element, attr, formController) {
                scope.formController = formController;
            },
            controller: function($scope, $element, $attrs) {
                // Register the child directive scope
                this.register = (element) => {
                    $scope.prototypeContainerScope = element.scope();
                };

                // Store the prototype template from the form theme in the controller prototype variable
                this.collectionPrototype = $attrs.collectionPrototype;

                // Determine what the the next row id will be on add
                let row = $element.find('.prototype-row').last().data('row');

                // Set the nextRow $scope variable
                if (typeof row !== 'undefined') {
                    // Next number in the sequence
                    $scope.nextRow = row + 1;
                }
                else {
                    // There are no rows on page load. Setting the default to zero
                    $scope.nextRow = 0;
                }

                // Controller method to get the next row from the child directive
                this.getNextRow = () => {
                    return $scope.nextRow;
                };

                // Set next row from the child directive
                this.setNextRow = (nextRow) => {
                    $scope.nextRow = nextRow;
                };

                // Prototype control methods from the page controller
                $scope.prototypeControl = {
                    add: ($event) => {
                        $event.preventDefault();
                        $scope.prototypeContainerScope.add();
                    },
                    remove: ($event) => {
                        $event.preventDefault();
                        $scope.prototypeContainerScope.remove($event);
                    }
                };
            }
        })
    ]).directive('prototypeContainer', [
        '$compile',
        ($compile) => ({
            restrict: 'C',
            require: '^formCollection', // Grab the form controller from the parent <form> element,
            link: function(scope, element, attr, formCollectionController) {
                formCollectionController.register(element);

                scope.collectionPrototype = formCollectionController.collectionPrototype;
                scope.nextRow = formCollectionController.getNextRow();
                scope.increaseNextRow = () => {
                    let nextRow = scope.nextRow + 1;
                    scope.nextRow = nextRow;

                    // Set next row on the parent directive controller
                    formCollectionController.setNextRow(nextRow);
                };
            },
            controller: function($scope, $element, $attrs) {
                $scope.add = () => {
                    // Replace the __name__ placeholder with the row id (typically the next number in the sequence)
                    let prototype = $scope.collectionPrototype.replace(/__name__/g, $scope.nextRow);

                    // Appened the prototype form row to the end of the prototype form rows container
                    angular.element(prototype).appendTo($element);

                    // Re-compiles the entire directive $element and children to allow events like ng-click to fire on
                    // dynamically added prototype form rows
                    $compile($element)($scope);

                    // Increase the nextRow $scope variable
                    $scope.increaseNextRow();
                };

                $scope.remove = ($event) => {
                    // Get the button $element that was clicked
                    let el = angular.element($event.target);

                    // Get the entire prototype form row (for removal)
                    let prototypeRow = el.parents('.prototype-row');

                    // Remove the row from the dom (If orphan-removal is set to true on the model, the ORM will automatically
                    // delete the entity from the database)
                    prototypeRow.remove();
                };
            }
        })
    ]);
})(Unicorn);

此外,如果这对任何想在他们的项目中使用此代码的人有帮助,CollectionTypeExtension.php 内容:

<?php

namespace Unicorn\AppBundle\Form\Extension;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CollectionTypeExtension extends AbstractTypeExtension
{
    /**
     * @param FormView $view
     * @param FormInterface $form
     * @param array $options
     */
    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        $view->vars['addButtonText'] = $options['add_button_text'];
        $view->vars['deleteButtonText'] = $options['delete_button_text'];
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'add_button_text' => 'Add',
            'delete_button_text' => 'Delete',
            'prototype' => false,
        ])
        ->setAllowedTypes('add_button_text', 'string')
        ->setAllowedTypes('delete_button_text', 'string')
        ->setAllowedTypes('prototype', 'boolean');
    }

    /**
     * Returns the name of the type being extended.
     *
     * @return string The name of the type being extended
     */
    public function getExtendedType()
    {
        return CollectionType::class;
    }
}

特别感谢可能一直在尝试重现此问题以帮助解决此问题的任何人,这个问题让我几乎整个周末都在办公桌上撞头。

干杯!