Чудеса случаются — это вам любой программист скажет. (вместо эпиграфа)

Добрый день!

Пример будет простой.

Есть вьюха:

<mvc:View 
    controllerName="MyController" 
    xmlns="sap.m" 
    xmlns:core="sap.ui.core" 
    xmlns:mvc="sap.ui.core.mvc">
    <Button text="Поехали!" press="handlePress" />
    <Table 
        items="{view>/list/items}" 
        mode="MultiSelect" 
        selectionChange="handleTableSelection">
        <columns>
            <Column>
                <Text text="Ид" />
            </Column>
            <Column>
                <Text text="Описание" />
            </Column>
        </columns>
        <items>
            <ColumnListItem>
                <cells>
                    <ObjectIdentifier title="{view>id}" />
                    <Text text="{view>description}" />
                </cells>
            </ColumnListItem>
        </items>
    </Table>
</mvc:View>

и настолько же «навороченный» контроллер:

sap.ui.controller("MyController", {
        
    onInit: function () {
        this.__oViewModel = new sap.ui.model.json.JSONModel({
            list: {
                items: [
                    { id: 1, description: 'one' },
                    { id: 2, description: 'two' },
                    { id: 3, description: 'three' }
                ],
                selected: []
            }
        });
        this.getView().setModel(this.__oViewModel, "view");
    },

    handlePress: function (oEvent) {
        sap.m.MessageToast.show(this.__oViewModel.getProperty('/list/selected').join(', '));
    },

    handleTableSelection: function (oEvent) {
        const aSelectedCtx = oEvent.getSource().getSelectedContexts(),
            aSelected = aSelectedCtx.map(o => o.getObject().id);
        this.__oViewModel.setProperty('/list/selected', aSelected);
    }

});

Выглядеть и вести себя всё это будет вполне ожидаемо:

image

По нажатии на кнопку будут всплывать перечисленные через запятую выделенные айдишники (проверки на непустоту массива и прочие ui-мелочи оставим за рамками статьи).

Но представьте, что по нажатию на кнопку «Поехали!» нужно не просто вывести идентификаторы выделенных элементов, как это происходит сейчас, а удалить их из базы данных.

Такие вещи, разумеется, нужно делать только после дополнительного подтверждения со стороны пользователя.

Что ж, плёвое дело — добавляем фрагмент-вьюху для диалога-подтверждения:

ConfirmDialog.fragment.xml

<core:FragmentDefinition
    xmlns='sap.m'
    xmlns:core='sap.ui.core' >
    <Dialog
        id='confirmDialog'
        title='Подтверждение'
        type='Message'
        state='Warning'>
        <content>
            <Label text='Удалить выбранные записи?' />
        </content>
        <beginButton>
            <Button
                text='Удалить'
                press='handleConfirmBtn'/>
        </beginButton>
        <endButton>
            <Button
                text='Отмена'
                press='handleCancelBtn'/>
        </endButton>
    </Dialog>
</core:FragmentDefinition>

И контроллер к нему:

ConfirmDialog.controller.js

sap.ui.define([
    "sap/ui/base/ManagedObject"
], function (ManagedObject) {
    "use strict";

    return ManagedObject.extend("project.ConfirmDialog", {

        constructor: function (oView, fnConfirmBtn) {
            this.__oView = oView;
            this.__fnConfirmBtn = fnConfirmBtn;
        },

        exit: function () {
            delete this.__oView;
            delete this.__fnConfirmBtn;
        },

        open: function () {
            const oView = this.__oView;
            let oDialog = oView.byId("confirmDialog");

            if (!oDialog) {
                const oFragmentController = {
                    handleConfirmBtn: () => {
                        this.__fnConfirmBtn();
                        oDialog.close();
                    },
                    handleCancelBtn: () => {
                        oDialog.close();
                    }
                };
                oDialog = sap.ui.xmlfragment(oView.getId(), "project.view.fragment.ConfirmDialog", oFragmentController);
                oView.addDependent(oDialog);
            }
            oDialog.open();
        }

    });
});

Как видите, всё вполне классически, на уровне примеров SAP UI5 SDK.
Теперь чтобы вызвать это диалоговое окно, переопределяем обработчик нашей главной кнопки следующим образом:

handlePress: function (oEvent) {
    const aSelected = this.__oViewModel.getProperty('/list/selected');
    this.__confirmDialog = new ConfirmDialog(this.getView(), () => {
        aSelected.forEach(o => {
            // здесь шлём запросы бэкэнду
        });
        this.__confirmDialog.exit();
    });
    this.__confirmDialog.open();
}

То есть здесь мы создаём наш диалог, передавая в качестве параметра «главную» вьюху, на которой он будет располагаться, и функцию-обработчик подтверждения.

Вот тут-то мы и подошли к самому интересному.

Прежде чем листать дальше, просмотрите-ка ещё разок код контроллера диалога.

Видите что-то странное?

Нет?

Ну тогда включаем режим трассировки и, выделив первые два элемента, смело жмём кнопку.

        handlePress: function (oEvent) {
            const aSelected = this.__oViewModel.getProperty('/list/selected');  
            // aSelected = [1,2], ага, всё хорошо
            this.__confirmDialog = new ConfirmDialog(this.getView(), () => {
                aSelected.forEach(o => {
                    // шлём бэкэнду aSelected = [1,2]
                });
                this.__confirmDialog.exit();
            });
            this.__confirmDialog.open();
        }

Ура, всё работает! Казалось бы, в чём подвох? Возможно в том, что пользователь попадётся ненасытный и захочет удалить что-нибудь ещё. Попробуем предсказать его действия: выделим третий элемент и нажмём «Поехали!»:

        handlePress: function (oEvent) {
            const aSelected = this.__oViewModel.getProperty('/list/selected');  
            // aSelected = [3]
            this.__confirmDialog = new ConfirmDialog(this.getView(), () => {
                aSelected.forEach(o => {
                    // шлём бэкэнду aSelected = [1,2] ЧТОООО???

Почему [1,2], спросите у меня вы. Вам-то хорошо, есть у кого спросить.

А я, впервые увидев такое безобразное поведение интерпретатора, стал молча сомневаться в себе. Давно работаю с различными фреймворками, но с подобным не сталкивался: оно-то известно, что const не гарантирует неизменность объектов и массивов в течение своего существования — но нигде больше aSelected даже не упоминается. Вот он объявился-присвоился, и вот передался в коллбек.

Но не буду вас долго мучить.

Всё дело в любимых всеми js-программистами контекстах и замыканиях.

То есть фактически при первом выполнении handleConfirmBtn у нас остаётся ссылка на её обработчик вместе со всем контекстом (в том числе aSelected). И при последующих подтверждениях удаления вызывается именно она.

Путь исправления ошибки оказался не таким уж простым и однозначным. Было недостаточно просто передвинуть объявление oFragmentController (после 1го вызова терялся контекст). И наиболее лаконичным способом оказался следующий (приведу код только метода open):

open: function () {
    const oView = this.__oView;
    let oDialog = oView.byId("confirmDialog");

    if (!oDialog) {
        const oFragmentController = {
            handleConfirmBtn: function () {
                this.__fnConfirm();
                oDialog.close();
            },
            handleCancelBtn: function () {
                oDialog.close();
            }
        };
        oDialog = sap.ui.xmlfragment(oView.getId(), "project.view.fragment.ConfirmDialog", oFragmentController);
        oDialog.controller = oFragmentController;
        oView.addDependent(oDialog);
    }
    oDialog.controller.__fnConfirm = this.__fnConfirmBtn.bind(this);
    oDialog.open();
}

Обратите внимание на последние 4 исполняемые строки: таким образом я «прокидываю» указатель на актуальный обработчик вместе с правильным контекстом.

По большому счёту, к изначальному варианту пришлось добавить всего пару строк.

Можно было уложиться и в одну —
oDialog.setBeginButton(new sap.m.Button({ text: 'Подтвердить', press: this.__fnConfirmBtn }));

но тут, сами понимаете, при каждом открытии диалога создавалась бы новая кнопка, это не ок.

Ещё я рассматривал вариант актуализации коллбека через oDialog.getBeginButton().attachPress, но он просто навешивает дополнительный обработчик, а снимать все имеющиеся через .detachPress в одну строчку было противно.

Вот такое приключение вышло практически на ровном месте (причём далеко не первое… ах, ui5!)

P.S. Передача функции в функцию-конструктор объекта, в котором в одном из методов есть объект-контроллер, в котором содержатся ссылки на функции-обработчики. Казалось бы, что может пойти не так?

По большому счёту описанная ситуация — это особенность именно языка, а фреймворк UI5 просто не даёт её красиво решать.