65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET 中的双向数据绑定

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.77/5 (9投票s)

2005年3月16日

15分钟阅读

viewsIcon

134716

downloadIcon

1251

演示了如何在 ASP.NET 中实现双向数据绑定,而无需继承所有控件。

前言

就在我完成这篇文章时,Manuel Abadia 发布了一篇同名(《ASP.NET 中的双向数据绑定》)的文章,主题相同,并且(毫不奇怪地)包含了一些相同的类名。我被抢先了(或者只是被超越了)!这让我感到震惊,但我还是决定发布这篇文章,因为我们的方法略有不同,希望我们两个人中的任何一个都能找到一些有用的东西。不幸的是,这篇文章的提交被推迟了大约八个月,因为我当时正在旅行(我正在澳大利亚北部某小溪旁露营的笔记本电脑上完成它),所以它不像最初那样“时效性强”了,而且几乎已经过时了(因为 VS 2005 支持双向绑定)。唉。

不过,不管它有什么价值,以下是实现“ASP.NET 中的双向数据绑定”的方法。

注意:在整篇文章中,当我提到“数据绑定”而不指定类型(简单或复杂)时,我指的是简单数据绑定。

引言

本文描述了一种 ASP.NET 的双向数据绑定方案,它扩展了内置的简单数据绑定支持,允许自动更新回原始数据源。目的是支持一种方案,这种方案——就像内置的简单数据绑定一样——不需要被绑定控件本身的支持,而是一个通过 `Page` 类可用的“框架”功能。此外,我希望设计一个方案,使其工作方式类似于即将发布的 VS 2005 中的双向数据绑定(我还没有验证过),这样页面就可以最小化迁移工作。

背景

在最初的 ASP.NET 蜜月期过后,缺乏双向绑定是我开始更加批判性地看待各种“功能”的第一个原因,但我从未真正采取行动。我最终被各种文章所刺激,这些文章声称“答案”是继承你所有的控件以内在支持数据绑定。如果不是因为我最终在一家公司工作,那里的开发人员正是这样做的,而且那场面并不好看,这本来是可以忍受的。

现在,万一有人想知道“继承所有内容”的方法有什么问题:

  • 它劳动强度非常大。
  • 它预设了你想使用的控件不是 sealed 的,并且可以以这种方式继承。如果不是,你将不得不 组合它们并委托大部分功能。
  • 它没有与设计器中内置的绑定支持集成。

第一点,我肯定认为是致命的。对于你想要在双向绑定中使用的一种控件,你都要继承它吗?你在开玩笑吧。这违背了首先拥有可重用控件的全部意义。

真正的解决方案,肯定应该是提出一个方案,能够利用现有的数据绑定信息,并反向执行其功能,这就是我着手进行的。我的解决方案必须遵循我认为微软如果有多余时间可能会采取的方式,尤其是在我得知 Whidbey 将(应该)支持双向数据绑定时,这一点变得尤为重要——我希望使用此系统构建的页面在 Whidbey 发布时能够“即插即用”,只需少量调整。

在我看来,解决方案主要有三个部分:

首先,我需要确保我理解了正常情况下会发生什么。在此过程中,我学习到了一些关于简单数据绑定的知识,这些知识我之前不知道或已经忘记了,所以即使是专家也可能在下一节中学到一些东西。

简单数据绑定 - (简短)回顾

简单数据绑定是通过在控件的 ASPX 标签的相应属性中输入数据绑定表达式来设置的。因此,要将 `TextBox`“`txtName`”的 `Text` 属性绑定到 `DataSet`“`demoData1`”、表“`Table1`”、列“`Name`”,你可以这样做:

   <asp:TextBox id="txtName" Runat="server" 
     Text='<%# DataBinder.Eval(demoData1, 
       "Tables[Table1].DefaultView.[0].Name") %>'/>

这些数据绑定表达式适用于所有 `System.UI.Web.Control`,但仅在设计器中支持派生自 `System.UI.Web.WebControls.WebControl` 的控件。除了不将数据绑定表达式显示为控件内容之外,设计器支持还提供了创建和编辑数据绑定表达式的能力,而无需进入 ASPX 源代码——只需在控件的属性网格中选择 (DataBindings) 旁边的省略号 (“...”)。

设计器之所以能够做到这一点,是因为所有 `Control` 都实现了 `IDataBindingsAccessor`,它允许访问 `DataBindingCollection`,该集合详细说明了该控件的哪些属性绑定到哪些表达式。不幸的是(否则这篇文章会非常短),这个集合只在设计时可用,它是在页面在设计器中加载时从 ASPX 源动态构建的。数据绑定信息的实际持久化格式是嵌入在 ASPX 源中的,如上所示。

这一点很重要,因为它使得数据绑定可以使用简单的声明性语法进行,而无需使用 Visual Studio .NET。然而,从重用这些绑定信息的角度来看,它还有很多不足之处。

那么运行时会发生什么?如果你希望所有这些绑定信息都会被页面构建器解析并存储在某个地方,那你就错了。最终的目的地是动态生成的 `Page` 类,其中每个控件的绑定都用于生成一个方法,该方法会挂接到相关控件的 `DataBind` 事件。

[你的 ASPX 页面在运行时生成的自动代码示例:]

private System.Web.UI.Control __BuildControltxtName() {
// Other contents removed for clarity


#line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
__ctrl.DataBinding += 
        new System.EventHandler(this.__DataBindtxtName);

return __ctrl;
}

public void __DataBindtxtName(object sender, System.EventArgs e) {
   System.Web.UI.Control Container;
   System.Web.UI.WebControls.TextBox target;
    
   #line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
   target = ((System.Web.UI.WebControls.TextBox)(sender));
    
   #line default
    
   #line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
   Container = ((System.Web.UI.Control)(target.BindingContainer));
    
   #line default
    
   #line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
    target.Text = System.Convert.ToString(DataBinder.Eval(demoData1, 
                               "Tables[Table1].DefaultView.[0].Name"));
   #line default
}

这里有两点值得注意:

  1. 我们无法对此做任何有用的事情。绑定信息隐藏在源代码中,提取它将非常麻烦。
  2. 绑定时发生的内联类型转换并不聪明。除了 `Convert.ToString()` 之外,它几乎假设数据值可以直接转换为控件属性类型。在为获得更好的设计时支持而实现 `TypeConverter`s 花费了大量时间后,我本希望在数据绑定方面能做得更好——这主要是在遇到 `DBNull` 时会失败,而 `DBNull` 并不罕见。
  3. 大部分工作仍然委托给 `Databinder.Eval()`。

检索绑定信息

基于以上情况,很明显,无论如何,绑定信息都必须加载回页面。由于它存储在 ASPX 源中,我将解析源并检索它。幸运的是,我之前已经编写了一个使这项工作容易得多的类——`HtmlTag`——所以我只需要:

  • 加载页面的 ASPX 源。
  • 为文档源中的每个标签创建 `HtmlTag` 实例。

(作者注:我一直不太喜欢这个,ManuDev 使用自定义控件在设计时将绑定信息持久化到页面上的其他位置,以便在运行时可用,这是一个更简洁的解决方案。但是,它有一个缺点,就是需要 Visual Studio .NET 并且重复了绑定信息(所以如果你不通过设计器重新操作,HTML 级别的代码编辑需要在两个地方进行)。尽管如此,它通常更易于接受。)

为了存储绑定信息,我创建了一个名为 `DataBindingInfo` 的类,它表示给定服务器端控件上的单个属性绑定。`DataBindingInfo` 存储已“拆分”的绑定表达式,所以我们的示例绑定

   <asp:TextBox id="txtName" Runat="server" 
       Text='<%# DataBinder.Eval(demoData1, 
         "Tables[Table1].DefaultView.[0].Name") %>'/>

将用于生成如下 `DataBindingInfo`:

   new DataBindingInfo(txtName.ID, "Text", "demoData1", 
             "Tables[Table1].DefaultView.[0].Name", "");

其中 `txtName` 是运行时从标签生成的服务器端控件,“`demoData1`”稍后将解析为 `Page` 类上的属性 `demoData1`,而最终的空 string 参数表示 `DataBinder.Eval()` 的可选格式 string 参数(实际上在代码示例中你不会看到构造函数像这样被显式调用,因为 `DataBindingInfoCollection` 有一个重载的 `Add()` 方法,它接受相同的参数并为你调用构造函数)。

然后,工作就只是从源中解析出的绑定信息填充这些对象的集合。

我不会详细介绍这一点,因为我实际上只是重用了我编写的一个现有类——`HtmlTag`——来查找源中具有与数据绑定语法匹配的属性的标签(在 Regex `dataBoundAttributeMatcher` 中表示)。对于这些,我使用 `HtmlTag` 的 `ID` 属性通过 `page.FindControl()` 查找控件实例,并从控件、正在绑定的属性名称(映射到控件上的属性)以及用于绑定的绑定表达式(如上所述拆分)的组合创建了一个新的 `DataBindingInfo`。

代码最终看起来像这样(请注意,在这个示例中 `System.Web.UI` 已被别名为 `WebUI` 命名空间)。

/// <summary>

/// Generate at DataBindings dictionary by parsing the ASPX source

/// for the hosting page

/// </summary>

private DataBindingInfoCollection CreateDataBindings() {
    DataBindingInfoCollection bindings = new DataBindingInfoCollection();

    // Load the page's source ASPX into a big fat string

    string pageSource =GetFileContents(page.Request.PhysicalPath);

    // Attack the string looking for Html tags

    // TODO: Want to clean this up. Should just 

    // enumerate through HtmlTag.FindTags() or something...

    MatchCollection matches =new Regex(HtmlParsing.RegExPatterns.HtmlTag, 
      RegexOptions.Compiled | RegexOptions.IgnoreCase).Matches(pageSource);
    HtmlParsing.HtmlTag tag;

    foreach(Match tagMatch in matches){
        tag =new HtmlParsing.HtmlTag(tagMatch.Value);

        // Defer most of the real work to a helper routine

        AddBindingsForTag(tag, bindings);
    }

    return bindings;
}

/// <summary>

/// If we can resolve a <see cref="Control="/> for this tag,

/// loop through all its attributes, and create bindings for

/// any that have contain binding expressions

/// </summary>

/// <param name="bindings">A <see cref="DataBindingCollection"/>

/// to add the newly created bindings too</param>

private void AddBindingsForTag(HtmlParsing.HtmlTag tag, 
                           DataBindingInfoCollection bindings){
    string attribName, attribValue;
    BindingExpression attribExpression;
    DataBindingInfo bindingInfo;

    // If we can actually determine the control for this tag...

    if (tag.ID!=String.Empty){
        Control control    =page.FindControl(tag.ID);
        if (control!=null){

            // Then we loop through the attributes looking 

            // for databinding expressions

            foreach(System.Collections.DictionaryEntry item in tag.Attributes){
                attribName        =item.Key.ToString();
                attribValue        =item.Value.ToString();

                // If it's a <%# ... %> type attribute

                if (attribValue!=String.Empty && 
                      attribValue.StartsWith(webBindingPrefix) && 
                      attribValue.EndsWith(webBindingSuffix)){

                    // Trim off the start/end delimiters

                    attribValue = attribValue.Substring(webBindingPrefix.Length, 
                                  attribValue.Length - webBindingPrefix.Length - 
                                                       webBindingSuffix.Length);
                    
                    // Now see if it's still a valid binding expression

                    attribExpression    =BindingExpression.Parse(attribValue);
                    if (attribExpression!=null){

                        // ...and if so add it to the bindings 

                        // collection for this control

                        bindingInfo =bindings.Add(control.ID, attribName, 
                                            attribExpression.DataSource, 
                                            attribExpression.DataExpression, 
                                            attribExpression.FormatString);
                        bindingInfo.IsTwoWay = 
                                      IsTwoWayProperty(attribName, control);
                    }
                }
            }
        }
    }
}

(如果你有兴趣,`HtmlTag` 的源代码包含在演示项目中——它只是一个包装了几个 RegEx。)

出于性能原因,任何给定页面的 `DataBindingInfoCollection` 都存储在 ASP `Cache` 对象中,因此它不会被重复计算。

/// <summary>

/// Generate at DataBindings dictionary by parsing the ASPX source

/// for the hosting page, or retrieving from the <see cref="Cache"/>

/// if possible

/// </summary>

public DataBindingInfoCollection GetDataBindings(){
    DataBindingInfoCollection bindings = 
                          page.Cache[CacheKey] as DataBindingInfoCollection;
    if (bindings==null){
        // No bindings in the cache, create new and add

        // There's an argument for having a lock{} statement here,

        // but there's no harm in having an overlap

        // Object identity of the binding collection in the cache is not

        // a big issue, but the locking could cause concurrency issues

        bindings    =CreateDataBindings();
        CacheDependency depends =new CacheDependency(page.Request.PhysicalPath);
        page.Cache.Add(CacheKey, bindings, depends, Cache.NoAbsoluteExpiration, 
               Cache.NoSlidingExpiration, CacheItemPriority.BelowNormal, null);
    }
    return bindings;
}

现在,我对必须这样做一直有些保留,所以实际上这只是 `DataBoundPage` 可以用来检索其 `DataBindingInfoCollection` 的一种可能策略。具体来说,派生类可以重写 `BindingInfoProvider` 属性以返回其选择的 `IDataBindingInfoProvider`。我一直在尝试一个在 IDE 中收集绑定信息的提供程序——`DesignerBindingInfoProvider`——它包含在内,但尚未完全正常工作。这被实现为一个可运行的 `IExtenderProvider`,这要归功于 Wouter van Vugt 的文章 “ASPExtenderSerializer”,它提供了一个自定义 `CodeDomSerializer` 来替换 VS 2002/3 有缺陷的序列化器。

不幸的是,这在 VS 2005 中可能会停止工作,因为 ASP.NET 设计器不再正确支持 `IComponents` 了。这是一个比这篇文章(即将过时)更大的问题,所以开始抱怨吧。我想要我的 `IExtenderProviders`……

恢复 DataSource

通常,原始 DataSource 会在消耗之前以一种相当临时的 D方式创建,通常如下所示:

private void Page_Load(object sender, System.EventArgs e)
{
    if (!IsPostBack){

        // This assumes your controls are bound to dataSet1 in some way

        dataAdapter.Fill(dataSet1);
        DataBind();
    }
}

绑定后,控件会保留绑定到它们的数据(用于回发),但原始 `DataSet` 不会自动持久化。这是故意的——`DataSet` 可以是相当大的结构,包含大量元数据和版本信息,并且持久化它(无论是存储在 ViewState 还是 Session 中)都会影响性能(更不用说没有简单的方法来确定要持久化哪些 `DataSet` 以及哪些是临时的)。

这意味着,在我们解除绑定数据之前,我们必须恢复 `DataSet`。我在 `DataBoundPage` 类中为此提供了一个框架。它定义了一个模板方法 `DataUnbind`,用于执行反向数据绑定所需的步骤。特别是,实现页面应该重写 `EnsureDataSource()` 以重新创建 `DataSet`(如果需要),因为在执行“解除绑定”之前会调用此方法。你负责恢复 `DataSet`,剩下的交给我们。

/// <summary>

/// Updates the datasource from the bound controls, firing the relevant events

/// </summary>

protected virtual void DataUnbind(){
    EnsureDataBindings();
    EnsureDataSource();
    OnDataUnbinding();
    AutoDataUnbind();
}

不幸的是,没有了原始的 `DataSet`,你就失去了所有的并发保护。如果你只是实现 `EnsureDataSource()` 来再次运行原始查询,并且数据已经更改,那么我们新数据集中“原始”行将包含新更改的数据,因此并发冲突不会自动捕获,我们会覆盖别人的更改。

所以我们需要一种节省空间的方式来持久化原始 `DataSet`,包括更改行的原始/修改副本,为此基本上有两种选择:

  • 将原始 `DataSet` 保存到 ViewState 中,但先 `Remove()` 除已绑定行之外的所有行以节省空间。
  • 保存已绑定行的 XML `DiffGram`,并稍后使用它来在 `DataSet` 中重新创建这些行。
  • 从 DataSource 重新生成 `DataSet`,但在加载数据时手动检查并发问题。如果你的数据库支持某种时间戳列,可以用作给定行的版本标记,这样最简单。
  • 将行的版本标记(或哈希)存储在 ViewState 中并实现自己的并发处理。

你选择哪种取决于你的具体需求,以及你是否真的想去修改数据库中的那些 IDE 生成的存储过程。

TwoWayDataBinder.Eval()

这当然是有趣的部分。我们已经检索了所有绑定信息并恢复了 DataSource,但如果没有一种方法能够将绑定控件的数据重新映射回其来源,我们就一无所获。我们需要能够复制 `DataBinder.Eval()` 的行为,但反向操作。

重申一下,`DataBinder.Eval()` 方法执行一项便捷的任务,它接受一个 `object` 引用和一个 `string` 表达式,然后“遍历”该表达式,将每个部分解析为对象上的属性(或索引器),检索其值,然后使用该值进一步解析表达式的下一部分。这是一种复杂的解释方式,用于解释当你看似直观地理解它所做的事情时(伪代码):

DataBinder.Eval(obj1, "property1.property2")

// value of 'property1' evaluated on obj

// value of 'property2' evaluated on above value (obj.property1)

// result of above returned (obj.property1.property2)

实际上,编写代码来完成所有这些(使用反射)相当容易,因此编写代码来完成完全相同的操作,当它到达表达式的末尾时,它会设置属性为传递的额外参数的值,而不是获取并返回属性的值,这也是相当容易的。假设我们调用了该方法 `DataBinder.UnEval()`,那么反转数据绑定就只需要遍历 `DataBindingInfoCollection`,并对每个项目执行以下步骤:

  • 解析控件上由属性名称指定的属性,并检索其值(例如,对于 `Textbox`,通常会绑定到 `Text` 属性)。
  • 将该值传递给 `DataBinder.UnEval()` 方法,连同控件引用和绑定表达式。它将遍历绑定表达式,并将数据源中的相应值设置为 `Text` 被设置到的值。

然而,这会在类型转换方面产生一个鸡生蛋还是蛋生鸡的问题。假设 `property2` 是一个 `Int32`。你可以很好地将其绑定到 `textBox.Text`(因为内置数据绑定可以扩展到将整数转换为字符串),但要反转绑定,你必须将其转换回整数。但是,你事先不知道 `property2` 是一个整数,因为只有当你解析表达式时,你才会获得 `property2` 的引用,那么你怎么知道要将其转换为什么?你可以使用 `DataBinder.Eval()` 来获取 `property2` 的当前值,然后使用其 `type` 将 `Text`(如果需要)转换为该类型,然后运行 `DataBinder.UnEval()`,但必须两次遍历表达式(以及如果初始值为 `null` 时会出现的问题)似乎有点浪费。

你需要一种方法来评估表达式并返回表达式末尾属性的引用,而不是其值。这样,表达式解析代码只运行一次,结果可以用于获取设置和查看类型信息。但是,由于 .NET 的反射属性处理器(`PropertyInfo` 和 `PropertyDescriptor`)是无实例的(也就是说,一旦我们有了 `property2` 的引用,我们仍然需要 `property1` 的值才能实际获取/设置 `property2` 的值),所以我们必须同时返回属性和它所属的 `object`。

为此,我创建了 `IMemberInstance`。`IMemberInstance` 表示一个属性引用,该引用与可以找到该属性的原始 `object` 实例绑定在一起。因此,`IMemberInstance` 可用于获取/设置属性的值,而无需提供要操作的对象实例,这使其成为我们 `TwoWayDataBinder.Eval()` 方法的理想返回值。此外,由于 `IMemberInstance` 非常通用,它可以作为不同类型的“属性实例”的通用基础——也就是说,`IMemberInstance` 的实现可以在内部使用 `PropertyInfo`、`PropertyDescriptor` 或其他机制,而客户对此并不关心。这无疑是双赢的,我们稍后会看到。我在一篇关于 使用 IMemberInstance 进行状态化反射 的独立文章中更详细地介绍了这个主题。

/// <summary>

/// Interface for a 'property' on a specific object instance.

/// The interface allows get/set of the property value without

/// having to re-suppy the object instance, or knowlege of

/// the underlying implementation of the 'property'

/// </summary>

public interface IMemberInstance
{
    /// <summary>

    /// Retrieve the object instance that this 

    /// IMemberInstance manipulates

    /// </summary>

    object Instance{
        get;
    }

    /// <summary>

    /// Retrieves the name of the property

    /// </summary>

    string Name {
        get;
    }

    /// <summary>

    /// Gets/sets the value of <c>Name</c> on <c>Instance</c>

    /// </summary>

    object Value {
        get;
        set;
    }

    /// <summary>

    /// Retrieves the type of the property

    /// </summary>

    Type Type {
        get;
    }

    /// <summary>

    /// Retrieves an appropriate TypeConverter for 

    /// the property, or null if none retrieved

    /// </summary>

    TypeConverter Converter {
        get;
    }
}

所以现在,我们不编写 `UnEval()` 方法,而是编写一个替代的 `Eval()` 方法——`TwoWayDataBinder.Eval()`——它返回 `IMemberInstance` 而不是 `object`。

执行绑定/解除绑定就像:

  • 获取 `DataBound` 控件上已绑定成员的 `IMemberInstance`(例如 `myTextBox.Text`)。
  • 获取数据源中由我们 `DataBindingInfo` 中存储的绑定表达式指向的成员的 `IMemberInstance`(这就是 `TwoWayDataBinder.Eval()` 的作用)。
  • 将一个的值赋给另一个,并加入一些类型转换。
/// <summary>

/// Perform the actual work of unbinding the datasource

/// from the bound controls. It is recommended to execute

/// this via calls to <see cref="DataUnbind"/> to ensure

/// the relevant events fire to resurrect the datasource

/// (as neccessary)

/// </summary>

protected void AutoDataUnbind()
{
    Debug.WriteLine("Unbinding data...");
    foreach(DataBindingInfo info in DataBindings)
    {
        try
        {
          if (!info.IsTwoWay)
              continue;

          object boundObject = FindControl(info.BoundObject);
          object bindingContainer = 
                             GetBindingContainer(boundObject);
          IMemberInstance boundProp = 
                     TwoWayDataBinder.GetProperty(boundObject, 
                                            info.BoundMember);
          object dataSource = 
                    TwoWayDataBinder.GetField(bindingContainer, 
                                        info.DataSource).Value;
          IMemberInstance dataMember = TwoWayDataBinder.Eval(dataSource, 
                                               info.Expression);
          object typedValue;

          // We use a custom converter if supplied with the bindings

          if (info.HasConverter && info.Converter.CanConvertTo(dataMember.Type))
              typedValue =
                     info.Converter.ConvertTo(boundProp.Value, dataMember.Type);

              // For blanks being assigned to non-string types we set DBNull

          else if (dataMember.Type!=typeof(string) && (boundProp.Value==null || 
                                         boundProp.Value.Equals(string.Empty)))
              typedValue =info.EmptyValue;

              // For enums we attempt to use a converter from the control

          else if (boundProp.Type.IsEnum && boundProp.Converter!=null && 
                            boundProp.Converter.CanConvertTo(dataMember.Type))
              typedValue =boundProp.Converter.ConvertTo(boundProp.Value, 
                                                             dataMember.Type);

              // Otherwise we use some generic type conversion code

          else
              typedValue =TwoWayDataBinder.ConvertType(boundProp.Value, 
                                    dataMember.Type, info.FormatString);
        
              dataMember.Value =typedValue;

              TraceBindingAssignment("Unbinding", typedValue, info);
        }
        catch(Exception err)
        {
          throw new ApplicationException(String.Format("Failed to set " + 
                     "column {0} ({1})", info.Expression, err.Message), err);
        }
    }
}

关注点

当你深入研究 .NET 中的绑定框架时,你会发现一些非常巧妙的东西。也有一些严重的遗漏。

  • 那些声明性绑定表达式很好——因为它们不迫使你使用 VS——但它们应该在运行时去一个有用的地方,而不仅仅是塞进一个生成的类中。
  • `ICustomTypeDescriptor` 非常棒,但 ASP.NET 数据绑定完全忽略了控件/DataSource 通过它提供自定义 `TypeConverter` 的能力。
  • `System.Web.UI.Control` 公开一个属性 `BindingContainer`,该属性指定了它们在绑定方面的上下文;也就是说,嵌套在 DataBound 控件中的控件将其父控件引用为绑定容器。不幸的是,这 nowhere 被记录(它是 MSDN 中那些“这是给我们的,不是给你们的”条目之一)。
© . All rights reserved.