Динамическое создание кнопок в ASP.NET Web Forms на VB.NET

РазделASP.NETУровеньСредний Чтение9 минОпубликовано ТегиASP.NETWeb FormsVB.NETЖизненный цикл

Кнопка, созданная из кода, появляется на странице, но после нажатия исчезает, а событие Click не срабатывает. Разбираем, почему так происходит и как создавать элементы правильно.

Главное. Динамические элементы в Web Forms нужно создавать при каждом запросе, рано (Init или LoadViewState) и со стабильными ID; иначе после postback они пропадут вместе с событиями.

Зачем создавать кнопки из кода

Большинство элементов страницы объявляют в разметке ASPX. Но бывает, что их количество заранее неизвестно: кнопка для каждой категории из базы, по кнопке на каждый загруженный файл, панель фильтров, собранная по настройкам пользователя. Тогда элементы создают в коде и добавляют в дерево страницы.

Добавить кнопку легко. Сложности начинаются при нажатии: после postback кнопка пропадает, а обработчик не вызывается. Причина в том, как Web Forms восстанавливает страницу, и, поняв её, вы избавитесь от целого класса ошибок с динамическими элементами.

Почему кнопка исчезает после postback

Объект Page живёт ровно один запрос. При postback ASP.NET создаёт страницу заново по разметке ASPX, и всё, что было добавлено кодом в прошлом запросе, в новом дереве отсутствует. ViewState хранит значения свойств элементов, но не сами элементы.

Событие Click определяется так: браузер присылает в форме имя нажатой кнопки (её UniqueID), а страница ищет элемент с этим именем в дереве. Если кнопку не пересоздали до этапа обработки событий, искать не в чем, и событие теряется без всякой ошибки.

Отсюда главное правило: динамические элементы нужно создавать при каждом запросе, в том числе при postback, с теми же ID и в том же порядке, и делать это как можно раньше. Лучшее место для этого Page_Init или переопределённый метод CreateChildControls.

Элемент, добавленный позже, например в Page_Load, тоже «догонит» жизненный цикл и получит ViewState и событие. Но Init надёжнее: к Load уже могут понадобиться значения, которые элемент ещё не загрузил.

Разметка и создание кнопок

В разметке оставляем контейнер, куда будем добавлять кнопки. Для этого подходит PlaceHolder: он не выводит собственного HTML. Метка lblResult покажет, какая кнопка нажата.

Каждой кнопке задаём постоянный ID. Если его не указать, ASP.NET сгенерирует ctl00, ctl01 и так далее, и при изменении порядка создания имена перепутаются. Обработчик подключается через AddHandler, причём для всех кнопок он один.

Buttons.aspxASPX
<%@ Page Language="VB" AutoEventWireup="false" CodeFile="Buttons.aspx.vb" Inherits="Buttons" %>
<html>
<body>
  <form id="form1" runat="server">
    <asp:PlaceHolder ID="phButtons" runat="server" />
    <p><asp:Label ID="lblResult" runat="server" /></p>
  </form>
</body>
</html>

Код страницы на VB.NET

Список категорий для примера возвращает функция GetCategories, в реальном проекте это запрос к базе. Важно, чтобы при postback он возвращал тот же набор, что и при первом показе, иначе ID разойдутся. Если данные могут меняться между запросами, сохраните список ключей в ViewState или Session и пересоздавайте кнопки по нему.

Вместо разбора ID в обработчике удобнее пользоваться свойствами CommandName и CommandArgument. Тогда кнопка сама несёт свою полезную нагрузку, например ключ записи, а обработчик подключается к событию Command.

Buttons.aspx.vbVB.NET
Partial Class Buttons
    Inherits System.Web.UI.Page

    Protected Sub Page_Init(sender As Object, e As EventArgs) Handles Me.Init
        ' создаём при КАЖДОМ запросе, включая postback
        For Each cat In GetCategories()
            Dim btn As New Button()
            btn.ID = "btnCat" & cat.Key          ' стабильный ID
            btn.Text = cat.Value
            btn.CommandName = "Select"
            btn.CommandArgument = cat.Key.ToString()
            btn.CssClass = "cat-btn"
            AddHandler btn.Command, AddressOf CategoryButton_Command
            phButtons.Controls.Add(btn)
        Next
    End Sub

    Private Sub CategoryButton_Command(sender As Object, e As CommandEventArgs)
        Dim id As Integer = CInt(e.CommandArgument)
        lblResult.Text = "Выбрана категория № " & id.ToString()
    End Sub

    Private Function GetCategories() As Dictionary(Of Integer, String)
        ' в реальном проекте: запрос к базе
        Return New Dictionary(Of Integer, String) From {
            {1, "Книги"}, {2, "Музыка"}, {3, "Фильмы"}}
    End Function
End Class

Количество кнопок меняется по действию пользователя

Сложнее, когда кнопки добавляются по нажатию другой кнопки, например «Добавить поле». Обработчик Click выполняется после Load, и созданную в нём кнопку нужно запомнить, чтобы пересоздать при следующем запросе. Счётчик удобно хранить в ViewState.

Но ViewState загружается между Init и Load, поэтому в Page_Init прочитать его ещё нельзя. Решение: переопределить LoadViewState. Сначала вызываем базовую реализацию, после неё значение счётчика уже доступно, и в этом же методе пересоздаём нужное число кнопок. Они успеют получить своё состояние и события.

  • в обработчике «Добавить» увеличиваем ViewState(«Count») и создаём одну новую кнопку;
  • в LoadViewState после MyBase.LoadViewState создаём кнопки с индексами от 0 до Count минус 1;
  • ID формируем из индекса, чтобы при каждом запросе они совпадали.

Типичные ошибки

Если событие не срабатывает, первым делом откройте исходный HTML страницы и сравните атрибуты name кнопки до и после postback. Расхождение имён почти всегда указывает на одну из ошибок выше.

  • кнопки создаются внутри If Not IsPostBack: при первом показе они есть, после нажатия исчезают;
  • не задан ID, и при изменении порядка создания событие уходит другой кнопке или не приходит вовсе;
  • набор данных для кнопок между запросами меняется, и пересозданное дерево не совпадает с прежним;
  • элемент добавлен в коллекцию Controls до того, как ему задан ID, из-за чего ViewState привязывается к автоматическому имени;
  • обработчик подключён через Handles к несуществующей на этапе компиляции переменной вместо AddHandler.
Когда кнопки строятся по набору записей, проще и надёжнее использовать Repeater с Button в ItemTemplate и событием ItemCommand. Repeater сам пересоздаёт элементы из ViewState.

Как это делают сегодня

В Blazor такой проблемы нет: разметка перерисовывается из состояния компонента, и кнопки по списку выводятся обычным @foreach с @onclick, куда замыкается ключ записи. В ASP.NET Core MVC и Razor Pages кнопки рендерятся из модели, а нажатие приходит обычным POST с name и value кнопки.

Вопросы и ответы

Почему динамическая кнопка пропадает после нажатия?

Страница пересоздаётся на каждый запрос, и элементы из кода прошлого запроса в ней отсутствуют. Создавайте их заново при каждом запросе, в Page_Init.

Как узнать, какая из динамических кнопок нажата?

Задайте каждой кнопке CommandArgument с ключом и подключите один обработчик к событию Command. Ключ придёт в e.CommandArgument.

Можно ли читать ViewState в Page_Init?

Нет, ViewState загружается после Init. Если число элементов хранится в ViewState, пересоздавайте их в переопределённом LoadViewState после вызова базового метода.

Читайте также