四、生成复杂的ID难以使用JavaScript操作

  我在上一篇文章的最后提到了,虽然使用WebForms我们能够对于页面上的HTML属性和样式等进行自由的定制和控制,但是有一点是毋庸置疑的,我们没有办法(正常的办法吧,Hack不算)让服务器端控件在客户端生成一个简单的ID。例如,一个TextBox控件,在服务器端的ID是txtUserName,但是最终在客户端生成的ID可能是LoginForm_txtUserName,因为它被放在一个ID为LoginForm的NamingContainer中。
  有了组件模型,就出现了大量控件。控件最主要的目的之一就是复用,而复用的一个特点就是应该高度内聚,而不依赖于外部环境。因此,为了使组件内部的服务器控件最终生成的客户端ID能够在页面上唯一,WebForms引入了NamingContainer这个概念。在NamingContainer中的服务器端控件最终在客户端生成的ID,会使用NamingContainer的“客户端ID”作为前缀。如此“递归”的做法保证了服务器控件在客户端的ID唯一。
  Web 2.0在业界风卷残云般的势头至今还未停歇,与其有密切相关的AJAX技术也被广泛使用。AJAX技术从根本上讲,是一种在浏览器中使用JavaScript实现的技术,因此使用JavaScript操作DOM元素的情况非常多见。在非WebForms的页面中我们可以编写如下的代码:
<input type="text" id="textBox" />

<script language="javascript" type="text/javascript">
    document.getElementById("textBox").value = "Hello World!";
</script>
  但是由于NamingContainer的缘故,我们在使用WebForms的服务器端的控件时就可能无法通过textBox在客户端获得文本框(生成的<input />元素)。为了解决这个问题,服务器端的控件模型提供了一个ClientID属性,通过这个属性,我们就可以在服务器端得到控件最终在客户端的ID。例如,如果上面的代码放在一个用户控件里的话,就一定必须写成如下形式:
<%@ Control Language="C#" AutoEventWireup="true" %>

<asp:TextBox runat="server" ID="textBox" />

<script language="javascript" type="text/javascript">
    document.getElementById("<%= this.textBox.ClientID %>").value = "Hello World!";
</script>
  此时,当控件被放到页面上之后,它在客户端生成的代码则会是:
<input name="DemoControl1$textBox" type="text" id="DemoControl1_textBox" />

<script language="javascript" type="text/javascript">
    document.getElementById("DemoControl1_textBox").value = "Hello World"!;
</script>
  请注意<input />元素的name和id,它们都留下了NamingContainer的痕迹。由于我们在页面上使用了<%= %>标记直接输出了服务器控件的ID,这样在客户端的JavaScript代码也就可以正确访问到服务器端<asp:TextBox />对应的客户端<input />元素了。
  这种在设计器很难预测的客户端ID,就是使用WebForms时所谓的“客户端ID污染”。
  接下来我们不妨来看一个略为复杂点的例子:
<%@ Control Language="C#" AutoEventWireup="true" %>

<asp:TextBox runat="server" ID="textBox" />

<script language="javascript" type="text/javascript">
    var counter = 0;

    function increase()
    {
        document.getElementById("<%= this.textBox.ClientID %>").value = (counter++);

        window.setTimeout(increase, 500);
    }

    increase();
</script>
  上面这段JavaScript代码的作用是每500为一个计数器加1,并且显示在文本框上。随着项目的发展,页面上复杂的JavaScript代码会越来越多,于是我们就会想办法将其转移到js文件中并且在页面上引用它们。使用js文件的好处很多,便于进行代码管理是一方面,但是最重要的好处之一还是对于性能的提高。如果JavaScript代码完全写在页面上,这样每次加载页面都需要下载这些JavaScript代码,而js文件可以缓存,这样客户端只需要在第一次加载时下载这个文件就可以了。减少了客户端与服务器之间数据通信的大小,也就加快页面加载的速度,提高了性能。
  不过问题就此出来了:为了能够正确引用到页面上的某个服务器控件生成的DOM元素,我们就必须在页面中使用<%= %>标记来输出控件的ClientID,但是<%= %>无法写在js文件中,这可怎么办?于是很多人着急了起来,我也不时会收到此类问题,似乎很难找到合适的解决办法。于是“客户端ID污染”似乎也就成了一个使用WebForms时非常严重的问题。
  有些朋友会说:“这个没有问题啊,仔细观察ClientID的组成方式能够很容易找到规律的。”服务器控件的ClientID是由自身ID和它所在的NamingContainer“树”来共同决定的,因此在理论上我们也完全可以在设计器得到“已经放置在页面中”的某个服务器控件的客户端ID,并将其写进JavaScript代码中。话虽如此,的确没错,但是这个解决方案实在不好,因为它违背了控件的重要特性:“复用”。作为一个控件来说,它可能会被放在任意的NamingContainer树下,也就是说,它的客户端ID在不同的环境中并不固定。另外,如果控件上层NamingContainer树中有任何一个的服务器端ID被修改的话,js文件中使用的ID就需要进行改变,这样实在不利于的维护,随着项目增大,此类问题会愈发明显。
  那么我们究竟该怎么做呢?
  在设法解决这个问题之前,我们先来思考一下这个问题。如果我们没有使用WebForms进行开发,就在普通的页面上编写代码,那么我们对于上面的功能会如何将其提取到js文件中呢?嗯,就直接在代码中通过textBox这个ID来获得DOM元素吧。那么好,请您先回答我以下几个疑问:
  1. 为什么要写textBox而不是其他ID呢?
  2. 如果其他页面上有个同样需要实现的功能,而那个文本框的id是txtCounter,那么该怎么作呢?
  3. 如果一张页面上有两个文本框需要显示这样的计数器,那么又该怎么做呢?
  上面的几个疑问其实只反应了一件事情,那就是这个计数器的复用性实在太差。什么叫做好的复用性呢?那么我们来看一下一个典型的示例,MaskedEditExtender。我们来看看它是怎么做的:
<ajaxToolkit:MaskedEditExtender
    TargetControlID="TextBox1"
    Mask="9,999,999.99"
    MessageValidatorTip="true"
    OnFocusCssClass="MaskedEditFocus"
    OnInvalidCssClass="MaskedEditError"
    MaskType="Number"
    InputDirection="RightToLeft"
    AcceptNegative="Left"
    DisplayMoney="Left"
    ErrorTooltipEnabled="True" />
  MaskedEditExtender的第一个属性TargetControlID,就可以决定了究竟是为哪个文本框添加效果,然后效果的样式可以由MaskType和Mask决定,获得焦点的样式和输入错误的样式可以由OnFocusCssClass和OnInvalidCssClass属性决定,连字符输入的顺序都可以定制。
  这就是复用:爱怎么用,就怎么用。爱给谁用,就给谁用。想什么时候用,就什么时候用。
  要复用,一般总需要组件化或模块化,内部实现通用的功能,而具体的信息应该由外部传入。例如我们上面的计数器就应该进行改造(用到了MS AJAX Lib里的Function.createDelegate方法):
function Counter(textBoxId, interval)
{
    this._counter = 0;
    this._textBox = document.getElementById(textBoxId);
    this._interval = interval;
}
Counter.prototype =
{
    run : function()
    {
        this._textBox.value = (this._counter ++);
        window.setTimeout(
            Function.createDelegate(this, this.run), this._interval);           
    }
};
  现在这个技术器的复用性已经有质的飞跃了,因为我们可以随意指定一个客户端的文本框进行显示,并且可以自由地设置计数器增长的间隔时间。于是我们在WebForms页面中就可以写如下的代码了:
<asp:TextBox runat="server" ID="textBox1" />
<asp:TextBox runat="server" ID="textBox2" />

<script language="javascript" type="text/javascript">
    new Counter("<%= this.textBox1.ClientID %>", 500).run();
    new Counter("<%= this.textBox2.ClientID %>", 1000).run();
</script>
  现在WebForms客户端ID污染已经不构成问题了吧!
  其实解决客户端ID污染的做法用一句话就能说清:“将不变的部分提取至js文件,将变化的部分(例如服务器控件的客户端ID)留在页面中”。但是我在这里将它上升到组件化的高度,因为它能让我们开发出更优秀的客户端程序。组件化的客户端编程方式较之传统的零散function的做法,更有利于代码的管理,并且增强了复用性和可维护性。有人说,客户端ID污染问题使脚本代码很难做到“内聚”——可能他的意思是将脚本代码提取到js文件中吧——但是我认为,这种污染“迫使”我们使用组件化的方式进行客户端开发,而这种组件化或者模块化的做法恰恰提高了代码的内聚性。
  不过,似乎组件化的编程方式会写更多的代码,不是吗?从理论上来说,可能的确是。不过需要注意的是,我上面提出的例子非常简单,简单到了其中的一半代码是用于“组件化”编程的“骨架”上。而对于一个略为复杂的功能来说,例如一个通用的表单验证组件,或者客户端级联组件,增加的这点“骨架”还算得了什么呢?
  这也算是一种因祸得福吧。