Včera jsem psal jednu dosti šílenou komponentu v ASP.NET a narazil jsem na zajímavý problém. Budu kvůli němu muset ještě trochu doplnit poslední článek o vývoji hierarchického repeateru, aby neprováděl data-binding pokaždé, ale uměl si položky obnovit z ViewState.
Ale zpátky k tématu – moje komponenta si při prvním načtení vytáhne z komponenty DataSource data, která zobrazí. Pro každý řádek vygeneruje nějakou komponentu, kterážto uvnitř obsahuje spoustu dalších komponent. Při postbacku se dotaz do DataSource znovu nedělá, hodnoty se natáhnou z ViewState (pokud bychom databinding udělali znovu, ztratily by se v nich totiž změny provedené uživatelem). Jak to vlastně funguje?
ViewState obsahuje pouze změny hodnot vlastností komponent (těch, které ho podporují, což je ale po hříchu většina) provedené od fáze PreLoad, kdy se ViewState načítá a začíná sledovat. Pokud tedy vlastnost komponenty nastavíme v události Init nebo dříve, do ViewState se neuloží. Toho se využívá v případě, že hodnotu vlastnosti uvedeme natvrdo do ASPX stránky – strom komponent se vytváří velmi brzy a vlastnosti se hned nastaví. Protože ještě nemáme ViewState, tyto vlastnosti se ale do něj neukládají. Díky tomu ve ViewState nemáme zbytečné hodnoty, které jsou natvrdo zapsány v ASPX stránce a server si je může kdykoliv obnovit. Pokud hodnotu vlastnosti po události PreLoad změníme v code-behindu, tato změna se už do ViewState ukládá.
Šablony (např. vlastnosti ItemTemplate) se samozřejmě do ViewState neukládají. Když natáhnu nějaká data třeba do Repeateru, on podle nich vygeneruje komponenty a při příštím požadavku je může obnovit z ViewState. Jediné, co potřebuje vědět, je to, kolik jich bylo, a jakého byly typu. Repeater si tedy při bindingu zapamatuje počet položek a při postbacku se neptá datového zdroje znovu, ale prostě jen vytvoří tolik položek, kolik tam bylo před tím, a všechny jejich vlastnosti, které se bindovaly přes vazby Eval, se obnoví z ViewStatu – bindujeme totiž vždy do vlastností komponent.
Pokud máme komponentu, která dynamicky generuje komponenty jiné, k zajištění, aby se správně ve fázi PreLoad obnovil ViewState, potřebujeme udělat jednu důležitou věc – zachovat stejný strom komponent. Repeater musí mít v sobě stejný počet komponent typu RepeaterItem, aby se do nich hodnoty nahrály. Důležité je také to, aby seděla ID komponent.
Je nutné si dále uvědomit, že Post data jsou něco jiného než ViewState – i když ViewState vypneme a do stránky dáme TextBox, CheckBox, RadioButton či jinou formulářovou komponentu, bude si tato komponenta pamatovat, co v ní bylo vyplněno, i když uděláme PostBack. Ve fázi PreLoad se dělají totiž dvě věci – jednak se obnovuje ViewState, a druhak se obnovují i Post data.
Co se stane, když si ale vzpomeneme, že chceme vytvořit komponentu, až po fázi PreLoad? To by nemělo vadit, pokud taková komponenta byla ve stránce na stejném místě i před postbackem. V okamžiku přidání komponenty do kolekce Controls se ViewState pro danou komponentu dodatečně načte. A načtou se i Post data. Tedy jen někdy.
Udělejme následující test (kód píšu tentokrát ve VB, abych o sobě nemohl říct, že od té doby, co jsem Visual Basic MVP, jsem ve Visual Basicu nenapsal jediný řádek kódu):
<%@ Page Language="VB" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<script runat="server">
Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
'vytvořit komponentu až ve fázi Load
PH.Controls.Add(New TextBox() With {.ID = "TextBox1"})
'při prvním načtení stránky změnit ViewState
If Not IsPostBack Then
DirectCast(PH.Controls(0), TextBox).BackColor = System.Drawing.Color.Red
End If
End Sub
</script>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<div>
<h1>ViewState test</h1>
<asp:PlaceHolder ID="PH" runat="server"></asp:PlaceHolder>
<asp:Button ID="Button1" runat="server" Text="Button" />
</div>
</form>
</body>
</html>
Jednoduchá stránka s PlaceHolderem a tlačítkem. Tlačítko nemá žádné události, slouží jen k vyvolání PostBacku.
Ve fázi Load vytvoříme uvnitř placeholderu komponentu TextBox a nastavíme jí ID. Pokud není postback, nastavíme komponentě barvu pozadí. Tato změna se pochopitelně uloží do ViewState, děláme ji po až události PreLoad.
Pokud stránku spustím, do textového pole něco napíšu a kliknu na tlačítko, obnoví se jak ViewState (textové pole bude červené), ale i Post data – v TextBoxu zůstane napsaná hodnota, která tam byla. Vše se provedlo při volání Add na placeholderu, který už ve stránce přidán je – pro přidávanou komponentu se načetl ViewState i Post data.
Vtip je v tom, že pokud komponentu vytvoříme moc pozdě, např. v události PreRender, pak se sice obnoví ViewState, ale Post data už ne. Docela mě to překvapilo a příčinu tohoto chování přesně neznám.
Co je horší, i kdybych o tom věděl, asi bych si to hned neuvědomil – psal jsem totiž komponentu dědící z CompositeControl. To je třída, která slouží pro vývoj jednoduchých komponent poskládaných z komponent jiných – například obrázek a odkaz atd. Tato třída má důležitou metodu CreateChildControls, v níž máte vytvořit vnitřní komponenty a nastavit je.
Potíž je v tom, že metoda CreateChildControls se volá až v okamžiku, kdy je potřeba sáhnout na vnitřní komponenty. Protože jsem ale na komponentě při některém PostBacku nic nevolal, vytvořily se komponenty až ve chvíli, kdy je ASP.NET chtělo vyrenderovat, což je až ve fázi Render. ViewState se nakrásně obnovil, takže texty v CheckBoxech tam byly, ale jejich zatržení už se ztratilo – nenačetla se totiž Post data.
Pokud se někdy budete divit, proč komponenta uvnitř UpdatePanelu s nastaveným ChildrenAsTriggers na true dělá plné PostBacky z vnitřních komponent, přestože by díky tomuto nastavení měla dělat PostBack přes AJAX, je to také pozdním voláním CreateChildControls. Strom komponent se ještě nevytvořil, protože není zavolána CreateChildControls, a v okamžiku, kdy se zavolá, má již UpdatePanel zřejmě komponenty pro asynchronní postbacky někde zaregistrované a nevšímá si jich.
Řešení místo strašení? Stačí ideálně ve fázi Init zavolat uvnitř komponenty metodu EnsureChildControls. Ta se podívá, jestli již vnitřní komponenty jsou vytvořené, a pokud ne, vytvoří je. Rozhodně nevolejte CreateChildControls přímo, tato metoda by se měla volat jen jednou. Vývojáři na její začátek rádi dávají Controls.Clear, díky čemuž si pak nevšimnou, že jim někdo tu metodu zavolá dvakrát, a stav komponent se jim taky ztrácí – nastavíte si jednou vytvořené komponenty k obrazu svému, a vzápětí je někdo smaže a vytvoří znovu. CreateChildControls tedy rozhodně nikdy nevolejte sami!