Рекурсивный поиск элемента в ASP.NET: FindControl по всему дереву

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

Стандартный FindControl ищет только внутри ближайшего контейнера имён и часто возвращает Nothing там, где элемент очевидно есть. Разберём, почему так происходит, и напишем корректный рекурсивный поиск.

Главное. FindControl ищет только в своём контейнере имён. Для поиска по всему дереву нужна рекурсия с немедленным возвратом результата, но в GridView и мастер-страницах точнее использовать e.Row.FindControl и типизированные свойства.

Почему FindControl возвращает Nothing

Страница Web Forms это дерево серверных элементов. В его корне Page, ниже форма, панели, пользовательские элементы, шаблоны GridView и Repeater. Метод FindControl ищет элемент по ID, но не во всём дереве, а только в пределах текущего контейнера имён (naming container). Контейнер имён это элемент, реализующий интерфейс INamingContainer: ContentPlaceHolder мастер-страницы, UserControl, строка GridView, элемент Repeater, шаблон LoginView.

Именно контейнеры имён гарантируют уникальность клиентских идентификаторов. Внутри каждой строки GridView может быть свой TextBox с ID txtQty, и все они получают разные ClientID. Поэтому FindControl и не спускается внутрь вложенных контейнеров: на странице может быть десять элементов с одинаковым ID, и метод не знает, какой из них вам нужен.

На практике это выглядит так: на странице с мастер-страницей вызов Page.FindControl(«txtName») возвращает Nothing, хотя TextBox лежит прямо в разметке. Он находится внутри ContentPlaceHolder, а это уже другой контейнер.

Цепочки Controls(0).Controls(2) и почему от них стоит отказаться

Первое решение, которое приходит в голову, это добраться до элемента по индексам: Page.Controls(0).Controls(3).Controls(1). Такой код работает ровно до первой правки разметки. Достаточно добавить на страницу литерал или убрать пробел между тегами, и индексы сдвигаются, ведь пробелы и текст между серверными элементами тоже превращаются в LiteralControl.

Вторая альтернатива точнее: вызывать FindControl по цепочке у каждого контейнера, например Master.FindControl(«MainContent»).FindControl(«txtName»). Это надёжно, но требует знать всю иерархию. Рекурсивный поиск избавляет от этого знания.

Корректная рекурсивная функция

Идея простая: проверяем каждый дочерний элемент, и если у него есть свои дочерние, спускаемся в них. Важно вернуть результат сразу, как только элемент найден, и не затереть найденное значение при дальнейшем обходе. В распространённых примерах из интернета встречаются две ошибки: функция возвращает New Control вместо Nothing, если ничего не нашла, и продолжает цикл после успешного поиска во вложенной ветке, теряя результат.

ControlFinder.vbVB.NET
Imports System.Web.UI

Public Module ControlFinder
    ' Ищет элемент по ID во всём поддереве, включая вложенные
    ' контейнеры имён. Возвращает Nothing, если не найден.
    Public Function FindControlRecursive(root As Control, id As String) As Control
        If root Is Nothing OrElse String.IsNullOrEmpty(id) Then Return Nothing
        ' Сначала быстрый штатный поиск в текущем контейнере
        Dim direct = root.FindControl(id)
        If direct IsNot Nothing Then Return direct

        For Each child As Control In root.Controls
            If child.HasControls() Then
                Dim found = FindControlRecursive(child, id)
                If found IsNot Nothing Then Return found ' сразу выходим
            End If
        Next
        Return Nothing
    End Function
End Module
Штатный FindControl в начале экономит время: внутри своего контейнера он находит элемент по словарю, без обхода. Рекурсия нужна только для вложенных контейнеров.

Обобщённая версия с проверкой типа

Найденный элемент почти всегда приходится приводить к конкретному типу. Обобщённая функция делает это сама и заодно отсекает совпадения по ID с элементом другого типа. Её удобно оформить методом расширения, тогда вызов выглядит естественно: Me.FindRecursive(Of TextBox)(«txtName»).

ControlExtensions.vbVB.NET
Imports System.Runtime.CompilerServices
Imports System.Web.UI

Public Module ControlExtensions
    <Extension()>
    Public Function FindRecursive(Of T As Control)(root As Control,
                                                  id As String) As T
        For Each child As Control In root.Controls
            If child.ID = id AndAlso TypeOf child Is T Then
                Return DirectCast(child, T)
            End If
            If child.HasControls() Then
                Dim found = child.FindRecursive(Of T)(id)
                If found IsNot Nothing Then Return found
            End If
        Next
        Return Nothing
    End Function
End Module

' Использование на странице:
' Dim box = Me.FindRecursive(Of TextBox)("txtName")
' If box IsNot Nothing Then box.Text = "Иван"

Когда рекурсия не нужна

Рекурсивный поиск удобен, но обходит всё дерево, а на сложной странице это сотни элементов. Кроме того, если одинаковых ID несколько, он вернёт первый попавшийся, что для строк GridView почти всегда неверно. Во многих типовых ситуациях есть более прямой путь:

  • в событиях RowDataBound и RowCommand у GridView используйте e.Row.FindControl: строка сама является контейнером имён, и поиск будет точным;
  • для элементов мастер-страницы объявите на ней публичное свойство и директиву MasterType на странице, тогда доступ будет типизированным и проверяется компилятором;
  • в пользовательском элементе управления открывайте нужные дочерние элементы через свойства, а не ищите их снаружи;
  • у FindControl есть поддержка пути через символ $, например FindControl(«MainContent$txtName»), если иерархия контейнеров известна.

Когда вызывать поиск

Даже правильная функция вернёт Nothing, если вызвать её слишком рано. Элементы из разметки существуют уже к событию Init, а вот строки GridView, элементы Repeater и динамически добавленные элементы появляются только после привязки данных или после их создания в коде. Поиск в шаблонах выполняйте в обработчиках привязки или после вызова DataBind.

Для динамических элементов, созданных в коде, важна ещё и повторная загрузка: их нужно создавать заново при каждом постбэке, причём не позже Page_Load, иначе ViewState не восстановится и поиск найдёт пустой элемент без введённых значений.

Для отладки полезно включить трассировку Trace=True в директиве Page. В таблице Control Tree видны все элементы, их UniqueID и вложенность, и сразу понятно, в каком контейнере искать.

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

В ASP.NET Core MVC и Razor Pages серверного дерева элементов нет вовсе: страница рендерится из модели, а значения полей приходят через привязку модели, поэтому искать элементы не нужно. В Blazor ссылку на компонент получают атрибутом @ref, а данные передают через параметры и привязку @bind. При миграции с Web Forms код с FindControl обычно превращается в свойства модели.

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

Почему FindControl не находит TextBox на странице с мастер-страницей?

Элемент лежит внутри ContentPlaceHolder, который является отдельным контейнером имён. Ищите через этот контейнер или рекурсивной функцией.

Что вернёт рекурсивный поиск, если ID повторяется?

Первый элемент, встреченный при обходе. Для элементов в строках GridView или Repeater ищите внутри конкретной строки.

Не замедлит ли рекурсия страницу?

Одиночный вызов обходится дёшево. Но не вызывайте его в цикле по строкам большой таблицы: там используйте FindControl у строки.

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