簡介
複合控件只不過是普通的 ASP.NET 控件,還不屬於要論及的另一種類型的 ASP.NET 服務器控件。既然這樣,爲什麼在各書籍和文檔中總要留出專門的章節來論述複合控件呢?ASP.NET 複合控件有什麼特別之處呢?
顧名思義,複合控件是將多個其他控件聚集在某單一頂部和單一 API 下的控件。如果某個自定義控件由一個標籤和一個文本框組成,就可以說該控件是一個複合控件。“複合”一詞表明該控件本質上是由其他構成組件在運行時組合而成。複合控件所暴露的方法集和屬性集通常(但不是必須)由構成組件的方法和屬性提供,並加入一些新成員。複合控件也可以引發自定義事件,還可以處理並激起子控件所引起的事件。
複合控件在 ASP.NET 中如此特別並不是因爲其有可能成爲服務器控件新類型的代表。更確切的說是因爲它在呈現時獲得了 ASP.NET 運行時的支持。
複合控件是一個功能強大的工具,可以生成豐富複雜的組件,這些組件產生自活動對象的相互作用而不是某些字符串生成器對象的標記輸出。複合控件以構成控件樹的形式呈現,每個構成控件都有其自己的生命週期和事件,並且所有構成控件都聯合構成一個全新的 API,並按需要儘可能地抽象化。
在本文中,我將論述複合控件的內部體系結構,以闡明它在多種情況下爲您帶來的好處。接下來,我將生成一個複合列表控件,與我在以前文章中所述控件的功能集相比,此控件的功能集更爲豐富。
複合控件的要點是什麼?
前一段時間,我曾經自己嘗試在 ASP.NET. 中研究複合控件。我從 MSDN 文檔學習理論和實踐知識,並也設計出一些不錯的控件。但是,只有當我有一次在純屬偶然的情況下看到以下示例時,我才真正領悟到複合控件的要點(和優點)。設想一下由兩個其他控件(Label 和 TextBox)的組合生成的迄今爲止最簡單(也是最常見)的控件。以下介紹了一種編寫這種控件的可行方法。我們將其命名爲 LabelTextBox。
public class LabelTextBox :WebControl, INamingContainer { public string Text { get { object o = ViewState["Text"]; if (o == null) return String.Empty; return (string) o; } set { ViewState["Text"] = value; } } public string Title { get { object o = ViewState["Title"]; if (o == null) return String.Empty; return (string) o; } set { ViewState["Title"] = value; } } protected override void CreateChildControls() { Controls.Clear(); CreateControlHierarchy(); ClearChildViewState(); } protected virtual void CreateControlHierarchy() { TextBox t = new TextBox(); Label l = new Label(); t.Text = Text; l.Text = Title; Controls.Add(l); Controls.Add(t); } }
該控件具備兩個公共屬性(Text 和 Title)以及一個呈現引擎。這兩個屬性保存在視圖狀態中,並分別表示 TextBox 和 Label 的內容。該控件對於 Render 方法沒有替換方法,並通過 CreateChildControls 替換方法來生成其自己的標記。我馬上就會詳述呈現階段的例行過程。CreateChildControls 的代碼首先清除子控件的集合,然後爲當前控件輸出的構成控件生成控件樹。CreateControlHierarchy 是一種特定於控件的方法,不要求必須標記爲受保護和虛擬。但請注意,大多數自帶複合控件(例如 DataGrid)只是通過一個類似的虛擬方法來暴露用於生成控件樹的邏輯。
CreateControlHierarchy 方法會根據需要實例化多個構成組件,然後合成最終輸出。完成之後,各控件將被添加到當前控件的 Controls 集合。如果希望控件的輸出結果是一個 HTML 表,則可以創建一個 Table 控件,並相應添加含有各自內容的行和單元格。所有行、單元格和所含控件都是最外部表的子項。這時,您只需將 Table 控件添加到 Controls 集合中即可。在上述代碼中,Label 和 TextBox 是 LabelTextBox 控件的直接子項並直接添加到集合中。控件的呈現狀態和運行狀態都很正常。
單純從性能上看,創建控件的暫態實例不如呈現一些純文本的效率高。讓我們考慮一種無需子控件就能編寫上述控件的替代方法。這次讓我們將其命名爲 TextBoxLabel。
public class LabelTextBox :WebControl, INamingContainer { : protected override void Render(HtmlTextWriter writer) { string markup = String.Format( "<span>{0}</span><input type=text value='{1}'>", Title, Text); writer.Write(markup); } }
該控件具備同樣的兩個屬性(Text 和 Title)並替換了 Render 方法。正如您所看到的那樣,其實現過程相當簡單並且代碼運行速度也略勝一籌。您可以通過在字符串生成器中合成文本併爲瀏覽器輸出最終標記來取代合成子控件的這種方法。同樣,此時控件的呈現狀態良好。但我們真的可以說它的運行狀態也同樣良好嗎?圖 1 顯示了在示例頁中運行的兩個控件。
圖 1:使用不同呈現引擎的相似控件
在頁面中啓用跟蹤功能並重新運行。當頁面顯示在瀏覽器中時,將其向下滾動並查看控件樹。它將如下所示:
圖 2:由兩個控件生成的控件樹
複合控件由構成組件的活動實例組成。ASP.NET 運行時會發現這些子控件,並可以在處理已發佈數據時同它們進行直接通信。其結果是,子控件可以自己處理視圖狀態並自動激起事件。
對於基於標記合成的控件,情況則不同。如圖中所示,該控件是一個帶有空 Controls 集合的代碼基本單位。如果標記在頁面中注入交互元素(文本框、按鈕、下拉式菜單),則 ASP.NET 在不涉及控件本身的情況下無法處理回發數據及事件。
嘗試在兩個文本框中輸入一些文本並單擊圖 1 中的“刷新”按鈕,這樣就可以發生一個回發。第一個控件(即複合控件)在經過回發後會正確保留所分配的文本。使用 Render 方法的第二個控件在經過回發後會丟失新文本。爲什麼會這樣呢?其中兼有兩個原因。
第一個原因是,在上述標記中我沒有爲 <input> 標記命名。這樣,它的內容就不會回發。請注意,必須使用 name 屬性來爲元素命名。讓我們對 Render 方法做如下修改。
protected override void Render(HtmlTextWriter writer) { string markup = String.Format( "<span>{0}</span><input type=text value='{1}' name='{2}'>", Title, Text, ClientID); writer.Write(markup); }
注入客戶端頁面的 <input> 元素現在與服務器控件使用相同的 ID。頁面回發時,ASP.NET 運行時可發現一個與已發佈字段的 ID 相匹配的服務器控件。但它並不知道如何處理該控件。要使 ASP.NET 將所有的客戶端更改都應用於服務器控件,該控件必須實現 IPostBackDataHandler 接口。
包含 TextBox 的複合控件無需擔心回發問題,因爲所嵌入的控件會使用 ASP.NET 自動解決該問題。呈現 TextBox 的控件需要與 ASP.NET 進行交互,以確保可以正確處理回發值並正常引發事件。以下代碼表明瞭如何擴展 TextBoxLabel 控件以使其完全支持回發。
bool LoadPostData(string postDataKey, NameValueCollection postCollection) { string currentText = Text; string postedText = postCollection[postDataKey]; if (!currentText.Equals(postedText, StringComparison.Ordinal)) { Text = postedText; return true; } return false; } void IPostBackDataHandler.RaisePostDataChangedEvent() { return; }
複合控件的常見方案
複合控件是適合用於構建複雜組件的工具,在複合控件中,多個子控件聚合到一起,並在彼此之間以及與外部之間進行交互。呈現控件則只用於只讀式控件聚合,其輸出不包括交互元素(例如下拉框或文本框)。
如果您對事件處理和回發數據感興趣,我強烈建議您選擇複合控件。如果使用子控件,則生成複雜的控件樹會更加輕鬆,而且最終結果也更清晰簡潔。此外,只有需要提供附加功能時才需要處理回發接口。
呈現控件不但需要實現附加接口,還要將含有屬性值的標記靜態部分縫合到一起。
複合控件的優點還表現在可以呈現多個同類項,這與在 DataGrid 控件中的情況類似。將每個構成項作爲活動對象啓用使您可以引發創建事件並以編程方式訪問它們的屬性。在 ASP.NET 2.0 中,對於要完全實現實際的數據綁定複合控件(上述控件只是隨便的舉例)所需的樣板代碼,絕大部分都隱藏在新基類的摺疊部分中:CompositeDataBoundControl。
複合控件的呈現引擎
在深入探討 ASP.NET 2.0 編碼技術之前,讓我們回顧一下複合控件的內部例行過程。我們提到過,複合控件的呈現是集中圍繞 CreateChildControls 方法進行的,該方法從 Control 基類繼承而來。您可能會認爲,要使服務器控件呈現其內容,替換 Render 方法是必不可少的一步。正如我們先前所看到的,如果 CreateChildControls 被替換,則並不總是需要執行這一步。但是,何時在控件調用棧中調用 CreateChildControls 呢?
如圖中所示,在頁面第一次顯示時,會在預呈現階段調用 CreateChildControls。
圖 3:在預呈現階段調用 CreateChildControls
特別是,請求處理代碼(在 Page 類中)在將 PreRender 事件引發至頁面和每個子控件之前會直接調用 EnsureChildControls。換言之,如果控件樹還未完全生成,則不會呈現任何控件。
以下代碼段例示了 EnsureChildControls(在 Control 基礎上定義的另一種方法)的僞代碼。
protected virtual void EnsureChildControls() { if (!ChildControlsCreated) { try { CreateChildControls(); } finally { ChildControlsCreated = true; } } }
此方法可能會在頁面和控件的生命週期內反覆調用。爲避免控件重複,ChildControlsCreated 屬性被設爲 true。如果此屬性返回 true,則該方法會立即退出。
當頁面回發時,ChildControlsCreated 會在週期前期調用。如圖 4 所示,它在已發佈數據處理階段調用。
圖 4:發生回發時在已發佈數據處理階段調用
當 ASP.NET 頁面開始處理從客戶端發佈的數據時,它會嘗試查找一個其 ID 與已發佈字段的名稱相匹配的服務器控件。在執行此步驟期間,頁面代碼會調用 Control 類中的 FindControl 方法。反之,該方法需要確保在進行操作之前控件樹已完全生成,因此它調用 EnsureChildControls 並按需要生成控件層次結構。
那麼要在 CreateChildControls 方法內部執行的代碼是怎樣的呢?儘管沒有正式的指南可供遵循,但通常認爲 CreateChildControls 至少必須完成以下任務:清除 Controls 集合,生成控件樹,並清除子控件的視圖狀態。並不嚴格要求必須從 CreateChildControls 方法內部設置 ChildControlsCreated 屬性。實際上,ASP.NET 頁面框架始終通過 EnsureChildControls(此方法可自動設置布爾標記)來調用 CreateChildControls。
用於解決設計時問題的 CompositeControl
隨 ASP.NET 2.0 一同提供了一個名爲 CompositeControl 的基類。因此,新的非數據綁定複合控件應該從該類派生而不是從 WebControl 派生。在開發控件方面,CompositeControl 的用法變動不大。您仍然需要替換 CreateChildControls 並按先前所述方式編碼。那麼 CompositeControl 的作用是什麼?讓我們先從其原型着手:
public class CompositeControl :WebControl, INamingContainer, ICompositeControlDesignerAccessor
使用該類就無需再用 INamingContainer 裝飾控件,但這實際上並不是很重要,因爲接口只是一個標記並且不包含任何方法。更爲重要的是,該類實現了一個名爲 ICompositeControlDesignerAccessor 的全新接口。
public interface ICompositeControlDesignerAccessor { void RecreateChildControls(); }
此接口由複合控件的標準設計器用於在設計時重建控件樹。以下是 CompositeControl 中方法的默認實現過程。
void ICompositeControlDesignerAccessor.RecreateChildControls() { base.ChildControlsCreated = false; EnsureChildControls(); }
簡言之,如果您從 CompositeControl 派生複合控件,就不會遇到設計時的故障,而且無需採用技巧和妙計就可以使控件在運行時和設計時都能正常運行。
要充分理解此接口的重要性,可試以寄存某 LabelTextBox 複合控件的示例頁爲例,並將其轉換爲設計模式。控件在運行時工作正常,但在設計時卻不可見。
圖 5:只有複合控件從 CompositeControl 派生纔對它們進行特殊的設計時處理
如果只是用 CompositeControl 替換 WebControl,則控件在運行時仍然保持正常工作,而在設計時也會運行良好。
圖 6:在設計時運行良好的複合控件
生成數據綁定複合控件
大多數複雜的服務器控件都已綁定數據(也可能已經模板化),並且由各種子控件構成。這些控件保留了一個構成項(通常爲表的行或單元格)的列表。該列表在經過回發後會保存在視圖狀態中,並且從綁定數據生成或從視圖狀態重建。該控件還在視圖狀態中保存其構成項的數量,以便在頁面中其他控件引起回發時可以正確重建表結構。我將用 DataGrid 控件舉例說明。
DataGrid 由一列行構成,每一行都代表綁定數據源中的一個記錄。每個網格行都通過一個 DataGridRow 對象(從 TableRow 派生的一個類)表示。在各網格行創建完成並被添加到最終網格表時,諸如 ItemCreated 和 ItemDataBound 之類的相應事件將被引發至頁面。當通過數據綁定創建 DataGrid 時,其行數由綁定項數和頁面大小決定。如果帶有 DataGrid 的頁面回發會怎樣?
這種情況下,如果是由 DataGrid 自身引起的回發(例如,用戶單擊以進行排序或標頁),則新頁面會再次通過數據綁定來呈現 DataGrid。這是顯而易見的,因爲 DataGrid 需要刷新數據進行顯示。如果是主頁回發,則情況就不同了,因爲單擊了頁面上的另一個控件(例如某按鈕)。這種情況下,DataGrid 不綁定到數據並且必須從視圖狀態進行重建。(如果禁用了視圖狀態,就是另外一種情況了,這時只能通過數據綁定顯示網格。)
數據源不保存在視圖狀態中。作爲複合控件,DataGrid 包含子控件,其中每個子控件都將自己的狀態保存到視圖狀態並從視圖狀態恢復。DataGrid 只需跟蹤在所有行和所包含控件從視圖狀態恢復之前它所必須重複執行的次數。此次數與所顯示綁定項的數量一致,並且必須作爲控件狀態的一部分存儲到視圖狀態中。在 ASP.NET 1.x 中,您必須自己學習並實現此模式。在 ASP.NET 2.0 中,從新類 CompositeDataBoundControl 派生您的複合控件就可以了。
讓我們嘗試使用一種顯示可擴展數據綁定新聞標題行的網格類控件。在此過程中,我們將再度使用在前文中論及的 Headline 控件。
public class HeadlineListEx :CompositeDataBoundControl { : }
HeadlineListEx 控件包含了一個收集了所有綁定數據項的 Items 集合屬性。該集合爲公共集合,並且可在與多數列表控件一起運行時通過編程方式填充。對典型數據綁定的支持是通過一對屬性(DataTextField 和 DataTitleField)實現的。這兩個屬性表明了數據源中將用於填充新聞標題和文本的字段。Items 集合被保存到視圖狀態中。
要將 HeadlineListEx 控件轉換爲真正的複合控件,您首先需要從 CompositeDataBoundControl 將其派生出來,然後再替換 CreateChildControls。有意思的是,你會注意到 CreateChildControls 是重載方法。
override int CreateChildControls() override int CreateChildControls(IEnumerable data, bool dataBinding)
第一個重載方法替換了在 Control 類中定義的方法。第二個重載方法是每個複合控件都必須替換的一種抽象方法。實際上,複合控件的開發工作簡化爲兩大主要任務:
• |
替換 CreateChildControls。 |
• |
實現 Rows 集合屬性以跟蹤控件的所有構成項。 |
Rows 屬性不同於 Items,因爲它不保存在視圖狀態中,且具有與請求相同的生存期,並引用幫助程序對象而不是綁定數據項。
public virtual HeadlineRowCollection Rows { get { if (_rows == null) _rows = new HeadlineRowCollection(); return _rows; } }
Rows 集合在控件生成時填充。讓我們看一下 CreateChildControls 的替換方法。該方法採用了兩個參數:綁定項和一個布爾標記,其中布爾標記用於指明該控件是通過數據綁定創建還是通過視圖狀態創建。(請注意示例程序文件中的程序員註釋使用的是英文,本文中將其譯爲中文是爲了便於參考。)
override int CreateChildControls(IEnumerable dataSource, bool dataBinding) { if (dataBinding) { string textField = DataTextField; string titleField = DataTitleField; if (dataSource != null) { foreach (object o in dataSource) { HeadlineItem elem = new HeadlineItem(); elem.Text = DataBinder.GetPropertyValue(o, textField, null); elem.Title = DataBinder.GetPropertyValue(o, titleField, null); Items.Add(elem); } } } // 開始生成控件層次結構 Table t = new Table(); Controls.Add(t); Rows.Clear(); int itemCount = 0; foreach(HeadlineItem item in Items) { HeadlineRowType type = HeadlineRowType.Simple; HeadlineRow row = CreateHeadlineRow(t, type, item, itemCount, dataBinding); _rows.Add(row); itemCount++; } return itemCount; }
在數據綁定的情況下,首先要填充 Items 集合。遍歷綁定集合,提取數據,然後填充 HeadlineItem 類的新建實例。接下來,遍歷 Items 集合(該集合中可能包含以編程方式添加的附加項),並在控件中創建行。
HeadlineRow CreateHeadlineRow(Table t, HeadlineRowType rowType, HeadlineItem dataItem, int index, bool dataBinding) { // 爲最外部表創建新行 HeadlineRow row = new HeadlineRow(rowType); // 爲子控件創建單元格 TableCell cell = new TableCell(); row.Cells.Add(cell); Headline item = new Headline(); cell.Controls.Add(item); // 此時引發 HeadlineRowCreated 事件 // 將此行添加到所創建的 HTML 表 t.Rows.Add(row); // 處理數據對象綁定 if (dataBinding) { row.DataItem = dataItem; Headline ctl = (Headline) cell.Controls[0]; ctl.Text = dataItem.Text; ctl.Title = dataItem.Title; // 此時引發 HeadlineRowDataBound 事件 } return row; }
CreateHeadlineRow 方法會創建並返回 HeadlineRow 類(從 TableRow 派生而來)的一個實例。在這種情況下,此行會包含一個由 Headline 控件填充的單元格。在其他情況下,您可以更改此部分代碼以根據需要添加多個單元格並相應填充內容。
重要的是,要將所需完成的任務分爲兩個不同的步驟:創建和數據綁定。首先,創建行的佈局,引發行創建事件(如果有),並最後將其添加到父表中。接下來,如果要將控件綁定到數據,則設置對綁定數據敏感的子控件屬性。完成操作後,則引發一個行數據綁定事件(如果有)。
請注意,該模式更準確描述了 ASP.NET 自帶複合控件的內部體系結構。
可以使用以下代碼來引發事件。
HeadlineRowEventArgs e = new HeadlineRowEventArgs(); e.DataItem = dataItem; e.RowIndex = index; e.RowType = rowType; e.Item = row; OnHeadlineRowDataBound(e);
請注意,只在要引發數據綁定事件時才設置 DataItem 屬性。事件數據結構被任意設置爲以下形式。如果您認爲有必要,儘可以對其進行更改。
public class HeadlineRowEventArgs :EventArgs { public HeadlineItem DataItem; public HeadlineRowType RowType; public int RowIndex; public HeadlineRow Item; }
若要實際引發一個事件,通常的做法是使用一個如下定義的受保護方法。
protected virtual void OnHeadlineRowDataBound(HeadlineRowEventArgs e) { if (HeadlineRowDataBound != null) HeadlineRowDataBound(this, e); }
若要聲明此事件,可在 ASP.NET 2.0 中使用新的一般事件處理程序委託。
public event EventHandler<HeadlineRowEventArgs> HeadlineRowDataBound;
在示例頁中,一切均照常執行。您可在控件標記上定義處理程序並將某方法寫入代碼文件。示例如下。
<cc1:HeadlineListEx runat="server" ID="HeadlineListEx1" DataTextField="notes" DataTitleField="lastname" DataSourceID="MySource" OnHeadlineRowDataBound="HeadlineRowCreated" />
HeadlineRowCreated 事件處理程序的代碼顯示如下。
protected void HeadlineRowCreated(object sender, HeadlineRowEventArgs e) { if (e.DataItem.Title.Contains("Doe")) e.Item.BackColor = Color.Red; }
圖 7:運行中的 HeadlineListEx 控件
通過掛接數據綁定事件,所有含有 Doe 的項都將以紅色背景呈現。
結論
複合控件是通過將其他控件聚合在某一公用 API 頂下創建而成的控件。複合控件將保留自己子控件的活動實例,並且不僅限於呈現這些實例。通過檢查頁面跟蹤輸出中的控件樹部分,您就可以很容易看到這一點。使用複合控件可以帶來幾點好處,例如可以簡化對事件和回發的處理。在 ASP.NET 1.x 中生成複雜的數據綁定控件有點棘手,需要您深入瞭解一些實現細節。在引進 CompositeDataBoundControl 基類的情況下,這種複雜性在 ASP.NET 中基本可以迎刃而解。最後,如果在 ASP.NET 2.0 中需要非數據綁定的複合控件,則可以使用 CompositeControl 基類。對於數據綁定複合控件,則可以改爲考慮 CompositeDataBoundControl。無論是哪種情況,您都必須提供一個 CreateChildControls 的有效替換方法,這是所有複合控件的核心,用於創建子控件層次結構。