
Рекурсивный поиск элемента в ASP.NET: FindControl по всему дереву
Стандартный FindControl ищет только внутри ближайшего контейнера имён и часто возвращает Nothing там, где элемент очевидно есть. Разберём, почему так происходит, и напишем корректный рекурсивный поиск.
Почему 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, если ничего не нашла, и продолжает цикл после успешного поиска во вложенной ветке, теряя результат.
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Обобщённая версия с проверкой типа
Найденный элемент почти всегда приходится приводить к конкретному типу. Обобщённая функция делает это сама и заодно отсекает совпадения по ID с элементом другого типа. Её удобно оформить методом расширения, тогда вызов выглядит естественно: Me.FindRecursive(Of TextBox)(«txtName»).
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 не восстановится и поиск найдёт пустой элемент без введённых значений.
Как это делают сегодня
Вопросы и ответы
Почему FindControl не находит TextBox на странице с мастер-страницей?
Элемент лежит внутри ContentPlaceHolder, который является отдельным контейнером имён. Ищите через этот контейнер или рекурсивной функцией.
Что вернёт рекурсивный поиск, если ID повторяется?
Первый элемент, встреченный при обходе. Для элементов в строках GridView или Repeater ищите внутри конкретной строки.
Не замедлит ли рекурсия страницу?
Одиночный вызов обходится дёшево. Но не вызывайте его в цикле по строкам большой таблицы: там используйте FindControl у строки.