高级AJAX ListBox组件 v0.2






4.45/5 (6投票s)
2007年6月22日
14分钟阅读

49230

457
分离水平滚动功能和客户端滚动状态的保留。
引言
在我的上一篇文章中,我们创建了一个启用了AJAX的ASP.NET ListBox
控件,并支持客户端事件。然而,该控件离发布和投入生产还有很长的距离。
背景
我们已经讨论过的一些缺失功能包括:
- 使用SHIFT和CONTROL键一次选择多个项目很困难或不可能,因为它们的
onkeydown
事件会导致滚动条根据选定的第一个项目/项目进行重新定位。我们应该读取事件的keyCode
,并在按下类似“元键”的按键时绕过我们的滚动代码。 - 使用鼠标滚轮滚动项目列表的功能取决于加载页面的浏览器和版本。我们希望使鼠标滚动成为一个可配置的选项,并在所有目标浏览器上支持。
- 垂直和水平滚动条的位置仅在控件的
HorizontalScrollEnabled
属性显式设置为true
时在回发之间保留。即使HorizontalScrollEnabled
为false
,我们也应该能够保留滚动状态。 - 我们的事件处理策略并未完全支持Firefox 1.0,因为一些改变滚动位置的用户交互不会执行
_onContainerScroll
事件处理程序。此外,如果您尝试调试此处理程序,您会注意到IE在一个鼠标单击或按键操作中最多会执行它四次。拖动滚动条会导致我的CPU短暂占用30%或更高,具体取决于滚动速度。如此多次更新隐藏字段会给客户端计算机的处理器带来不必要的负载。
除了这些问题之外,您可能还发现使用FireBug运行此控件会导致大量“event is not defined”错误。这是我们在ListBox.js文件中使用的一些糟糕编码实践中的一种,我们将在将此控件推广到版本0.2之前对其进行纠正。
一次处理一件事
评估这四个障碍中的哪一个首先处理可以为我们节省大量的时间和精力。从可用性的角度来看,最大的问题是#1,但我们不应该以此为决策依据。对客户端和服务器代码影响最大的问题是#3。我们需要为#2添加一个新的服务器控件属性,而#1和#4可以完全在JS文件中处理。但#3需要在这两个文件中进行最多的更改,因此我们将在这篇文章中解决该要求。首先,让我们摆脱FireBug中那些讨厌的红色消息,好吗?
内存泄漏(在我脑海中)
为了在开发0.1版本时获得一个可工作的控件,我忽略了许多良好的编码实践。我将Zivros的代码大量复制粘贴到JS文件中,并且因为它在IE7中工作,所以就理所当然了。然而,window.event
对象(在客户端处理程序中简称为event
)在Firefox中不可用。相反,Firefox使用传递给处理程序的“e
”参数。如果您曾经需要在跨浏览器脚本中处理客户端事件,您可能会将window.event
与undefined
进行比较以确保一致性。幸运的是,微软已经考虑到了这一点,并为我们在客户端控件原型中处理大多数事件提供了一种一致的方式。我只是……嗯……忘记了。
我们在客户端控件中有两个地方错误地使用了event
对象。我们在_onKeyDown
处理程序中这样做了一次,然后在_onChange
处理程序中再次这样做,以防止ListBox
允许其事件冒泡到DIV
容器。使用ASP.NET AJAX Extensions框架执行此操作的正确方法是调用传递到处理程序函数中的事件参数“e
”的stopPropagation()
方法,如下所示:
_onKeyDown : function(e)
{
if (this.get_element() && !this.get_element().disabled)
{
// cancel bubble to prevent listbox from re-scrolling
// back to the top
e.stopPropagation();
return true;
}
}
,
_onChange : function(e)
{
if (this.get_element() && !this.get_element().disabled)
{
updateListBoxScrollPosition(
this.get_elementContainer(), this.get_element(), null);
e.stopPropagation();
return true;
}
}
,
不用谢我,谢微软。事实证明,stopPropagation()
是官方的W3C方法,所以很高兴看到他们开始遵循一些行业标准,而不是凭空捏造一切。现在,FireBug应该在处理这些事件时显示一个漂亮的绿色对勾,这意味着我们的脚本不会在Firefox中引起任何客户端错误(目前)。
兑现用户的承诺……
暂时,请设身处地为页面设计者着想。想象一下,您不在乎这个控件如何工作,只要它能工作。您只想在服务器控件的XML标签上设置几个简单的属性,然后它就能以跨浏览器的方式自行处理。如果这就是页面设计者想要的,那么他们就应该得到。让我们从提供控件属性开始。
public virtual bool ScrollStateEnabled
{
set
{
this.ViewState["ScrollStateEnabled"] = value;
}
get
{
object output = this.ViewState["ScrollStateEnabled"];
if (output == null)
output = false;
return (bool)output;
}
}
protected virtual IEnumerable<ScriptDescriptor> GetScriptDescriptors()
{
ScriptControlDescriptor descriptor = new ScriptControlDescriptor(
"DanLudwig.Controls.Client.ListBox", this.ClientID);
descriptor.AddProperty("horizontalScrollEnabled",
this.HorizontalScrollEnabled);
descriptor.AddProperty("scrollTop", this.ScrollTop);
descriptor.AddProperty("scrollLeft", this.ScrollLeft);
descriptor.AddProperty("scrollStateEnabled",
this.ScrollStateEnabled);
return new ScriptDescriptor[] { descriptor };
}
好的,很好。现在回到现实!我们的控件目前处理4种可能情况中的2种:
HorizontalScrollEnabled==<code>true
&&ScrollStateEnabled
==true
<code><code>HorizontalScrollEnabled
==false
&&ScrollStateEnabled
==false
这些属性具有相同的值,因为我们在0.1版本中将它们视为相同的选项。现在,我们必须重构服务器和客户端代码,以适应另外2种可能的配置:
<code><code>HorizontalScrollEnabled
==true
&&ScrollStateEnabled
==false
<code><code>HorizontalScrollEnabled
==false
&&ScrollStateEnabled
==true
我们必须问自己的第一个问题是,何时应该渲染DIV
容器?好吧,当HorizontalScrollEnabled
等于true
时,肯定需要它,但当ScrollStateEnabled
等于true
时,拥有它也会很好。这样,在客户端代码中查找隐藏表单字段会很容易,而无需知道客户端控件的id
;我们可以通过DIV
的childNodes
集合来查找它,就像当前实现那样。事实上,我们唯一不渲染DIV
的情况是当这两个属性都设置为false
时。
有条件地渲染隐藏表单字段也可以节省一些带宽,当页面在网络上传输时。诚然,这只是一点点,但每一字节都很重要。通过忽略添加属性到编写器和渲染HTML所需的步骤,我们还可以节省一些服务器CPU周期。
protected override void Render(HtmlTextWriter writer)
{
if (!this.DesignMode)
ScriptManager.RegisterScriptDescriptors(this);
if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
{
// wrap this control in a DIV container
}
base.Render(writer);
if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
{
if (this.ScrollStateEnabled)
{
// add a hidden field to store client scroll state
}
// close the container
}
}
值得庆幸的是,我们已经编写的大部分属性渲染代码也不会有太大改变。重写的AddAttributesToRender()
方法根本不会改变,我们只需要将不同的代码块包装在不同的条件检查中,以便另外两个帮助方法。
protected virtual void AddContainerAttributesToRender(HtmlTextWriter writer)
{
// the container now depends on 2 different property values
if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
{
// add required container attributes
if (this.HorizontalScrollEnabled)
{
// add optional container attributes
// move style declarations from the Style attribute
// into the DIV container.
}
}
}
protected virtual void AddScrollStateAttributesToRender(HtmlTextWriter writer)
{
if (ScrollStateEnabled)
{
// the hidden field should have an id,
// name, type, and default value
}
}
又一次,我们快完成了服务器控件的代码。而且又一次,我们可以将之前的方法包装在一个条件块中,以节省不必要的服务器指令。
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (this.ScrollStateEnabled)
{
// update the server control's scroll position state
}
}
……稍后处理细节
现在,如果您在测试的ASPX页面中将ScrollStateEnabled
设置为true
并尝试运行示例,您会发现我们把代码搞砸了。如果您启用了IE脚本错误通知,您会看到一个大大的错误,告诉我们我们正在尝试注册一个不存在的客户端属性。所以,让我们来创建它。既然我们正在处理属性部分的代码,我们还需要更新用于检索DIV
容器和隐藏字段的辅助属性。
// 2.)
// Define the client control's class
//
DanLudwig.Controls.Client.ListBox = function(element)
{
// initialize base (Sys.UI.Control)
// declare fields for use by properties
this._scrollStateEnabled = null;
// declare our original 3 fields
}
// 3d)
// Define the property get and set methods.
//
set_scrollStateEnabled : function(value)
{
if (this._scrollStateEnabled !== value)
{
this._scrollStateEnabled = value;
this.raisePropertyChanged('_scrollStateEnabled');
}
}
,
get_scrollStateEnabled : function()
{
return this._scrollStateEnabled;
}
,
// helper method for retrieving the ListBox's DIV container
get_elementContainer : function()
{
// only return the container if it has been rendered
if (this.get_horizontalScrollEnabled()
|| this.get_scrollStateEnabled())
{
return this.get_element().parentNode;
}
else
{
return null;
}
}
,
get_elementState : function()
{
// function locates and returns the hidden form field which
// stores the scroll state data.
if (this.get_scrollStateEnabled())
{
// same v0.1 logic used to locate and return the
// hidden form field
}
else
{
return null;
}
}
,
再一次,我们的控件应该可以工作了,但它仍然只在HorizontalScrollEnabled
和ScrollStateEnabled
相等时(即,当它们都为true
或都为false
时)按预期运行。为了处理它们不同的两种情况,我们必须重新评估我们的事件和客户端逻辑。
如果您仔细查看我们的客户端代码,有一段“应该”像一个拇指疮一样突出:“set_scrollState()
”。您注意到它有什么奇怪的地方吗?当然,它没有get
方法,这通常表明一种糟糕的代码设计策略。再仔细看看,您会发现它不接受参数。所以,它实际上并不是一个属性,它只是一个辅助函数。此外,您还记得我在0.1版本演示的代码后台添加的Page_Load
代码片段吗?我禁用了页面缓存,因为Firefox与服务器的状态不同步。这是因为我们在set_scrollState()
中调用了set_scrollTop()
和set_scrollLeft()
。我们实际上不需要从客户端代码设置这些属性。它们只用于从服务器控件捕获滚动位置以恢复先前的客户端状态,而不是设置它。如果这些属性能够更新服务器上的相应属性,我们就无需隐藏字段。
此外,当HorizontalScrollEnabled
为false
时,此函数将无法工作,因为我们需要访问ListBox
的scrollTop
和scrollLeft
属性,而不是DIV
的属性。无论您怎么看,这段代码都需要进行一些严肃的重构。它应该看起来像这样:
_onContainerScroll : function(e)
{
// when the container is scrolled, update this control
//OBSOLETE this.set_scrollState();
this._updateScrollState();
}
,
// return the client scroll state data that will go in the hidden field
get_scrollState : function()
{
var scrollingElement = this.get_elementContainer();
if (!this.get_horizontalScrollEnabled())
scrollingElement = this.get_element();
return scrollingElement.scrollTop + ':' + scrollingElement.scrollLeft;
}
,
// set the hidden field if it is out of sync with the current client state
_updateScrollState : function()
{
var scrollState = this.get_scrollState();
var hiddenField = this.get_elementState();
if (hiddenField.value !== scrollState)
hiddenField.value = scrollState;
}
,
这样就好多了!这更具可重用性。现在,当HorizontalScrollEnabled
为false
时,我们需要处理ListBox
的onscroll
事件。不过,我们首先将所有事件和UI初始化逻辑整合到单独的帮助方法中。
// 3a)
// Override / implement the initialize method
//
initialize : function()
{
// call base initialize
DanLudwig.Controls.Client.ListBox.callBaseMethod(this, 'initialize');
// initialize the events
this._initializeEvents();
// initialize the control user interface
this._initializeUI();
}
,
_initializeEvents : function()
{
// when horizontal scroll is enabled, use 3 zivros events
if (this.get_horizontalScrollEnabled())
{
// create & add ListBox events
this._onkeydownHandler = Function.createDelegate(
this, this._onKeyDown);
this._onchangeHandler = Function.createDelegate(
this, this._onChange);
$addHandlers(this.get_element(),
{
'keydown' : this._onKeyDown
,'change' : this._onChange
}, this);
// create & add DIV container event(s)
this._onkeydownContainerHandler = Function.createDelegate(
this, this._onContainerKeyDown);
$addHandlers(this.get_elementContainer(),
{
'keydown' : this._onContainerKeyDown
}, this);
// need event to update hidden field when DIV is used
if (this.get_scrollStateEnabled())
{
this._onscrollContainerHandler = Function.createDelegate(
this, this._onContainerScroll);
$addHandlers(this.get_elementContainer(),
{
'scroll' : this._onContainerScroll
}, this);
}
}
// from here, it's safe to assert that horizontal scrolling
// is not enabled
else if (this.get_scrollStateEnabled())
{
this._onscrollHandler = Function.createDelegate(
this, this._onScroll);
$addHandlers(this.get_element(),
{
'scroll' : this._onScroll
}, this);
}
}
,
// 3c)
// Define the event handlers
//
_onScroll : function(e)
{
if (this.get_element() && !this.get_element().disabled)
{
this._updateScrollState();
e.stopPropagation();
return true;
}
}
,
请注意用于初始化事件的策略。您可能已经注意到,我们新创建的_updateScrollState()
方法没有检查滚动状态是否已启用,然后才设置隐藏字段的值。这是因为我们在添加实际调用_updateScrollState()
的事件处理程序之前,就完成了所有属性的检查。随着我们添加更多事件处理程序或使用现有事件处理程序执行其他任务,以后可能需要再次重构。但是请记住,一次只处理一件事!以下是事件初始化的表格表示。粗体显示的事件将更新隐藏字段,而斜体显示的事件对于从DIV
容器内部滚动是必需的。
ScrollStateEnabled |
HorizontalScrollEnabled |
|
True |
假 |
|
True |
|
|
假 |
|
|
所有这一切都很好,但用这些更改进行测试仍然会产生一个大的错误,因为我们还没有编写_initializeUI()
方法。如果我们也能摆脱在编写它时使用的一些技巧就更好了,但事实是,为了让它按预期工作,我们实际上需要“添加”技巧。本着一次处理一个问题的精神,让我们看看使用这段代码会发生什么:
_initializeUI : function()
{
var listBox = this.get_element();
var container = this.get_elementContainer();
if (this.get_horizontalScrollEnabled())
{
// before changing the listbox's size,
// store the original size in the container.
container.originalListBoxSize = listBox.size;
// this should be done regardless of how many items there are
container.style.height = this.get_correctContainerHeight() + 'px';
if (this.get_element().options.length > this.get_element().size)
{
// change the listbox's size to eliminate internal scrolling
listBox.size = listBox.options.length;
}
}
if (this.get_scrollStateEnabled())
{
this._restoreScrollState();
}
if (this.get_horizontalScrollEnabled())
{
if (container.scrollWidth <= parseInt(container.style.width))
{
listBox.style.width = '100%';
listBox.style.height = container.scrollHeight + 'px';
container.style.height = this.get_correctContainerHeight()
+ 'px';
// the Firefox hack discussed in the previous article
// should not be dealt with here. If an application
// wants to hack a FF bug, it should be done on the
// server before this control is rendered.
}
}
}
,
get_correctContainerHeight : function()
{
var container = this.get_elementContainer();
var listBox = this.get_element();
var itemHeight = container.scrollHeight / listBox.size;
var correctHeight = itemHeight * container.originalListBoxSize;
return correctHeight;
}
,
_restoreScrollState : function()
{
var scrollingElement = this.get_elementContainer();
if (!this.get_horizontalScrollEnabled())
scrollingElement = this.get_element();
scrollingElement.scrollTop = this.get_scrollTop();
scrollingElement.scrollLeft = this.get_scrollLeft();
}
,
尽管这段代码块仍然不必要地臃肿,但我们实际上已经对其进行了一些改进。至少现在滚动位置可以在回发后正确设置,独立于HorizontalScrollEnabled
属性……对吗?继续尝试一下。在Firefox 1.5中,这段代码运行得很好,初看起来在IE7中也运行良好。您可以滚动,触发回发,并且scrollTop
属性会正确恢复。但是,回发后再次尝试滚动ListBox
会导致IE自动滚动到顶部。该死!
事实证明,这实际上是因为IE和我们一样,都在努力帮助用户。在我的演示中,有4个回发LinkButton
。在回发期间,其中任何一个都会将每个ListBox
中所有项目的ListItem.Selected
属性设置为false
。在客户端,这会导致selectedIndex
设置为-1
。即使我们在回发后恢复了ListBox
的scrollTop
属性,IE也会否决我们提出的更改,并使用selectedIndex
来设置自己的滚动位置。幸运的是,知道了这一点,我们很容易找到解决办法。
我们需要回顾并重新关注导致此错误的_onScroll
事件处理程序。从该块内部,可以(甚至很容易!)计算出与当前scrollTop
值匹配的selectedIndex
值。通过这样做,我们可以利用(甚至可以说,破解)IE试图搞砸我们迄今为止所有努力的企图。如果我们设置selectedIndex
为一个特定的计算值,然后将其设置回-1
,IE7就会屈服于我们的权威。请记住,只有当selectedIndex
等于-1
时,IE才会挑战我们。如果我们有任何选定的项目,我们不想干扰用户,所以我们应该只在selectedIndex
等于-1
时执行此技巧。
在一个浏览器中修复,在另一个浏览器中损坏
然而,这样做会激怒Firefox。将selectedIndex
设置回-1
现在会导致它滚动回顶部。此外,只要selectedIndex
保持等于-1
,它将阻止任何滚动。如果我们比较设置selectedIndex
等于-1
之前和之后ListBox
的scrollTop
值,我们会发现之后它总是零,无论之前是什么。我们不能简单地重置scrollTop
值,因为这样做会导致事件处理程序再次触发,并且由于selectedIndex
保持等于-1
,我们会陷入无限循环。
我绝不声称自己是JavaScript专家,所以我不太确定在事件处理程序中防止代码通过更改元素属性值(如上述场景)来重新触发自身是否存在最佳实践。所以,既然我们已经写了一个技巧,我们就再写两个吧:
_onScroll : function(e)
{
if (this.get_element() && !this.get_element().disabled
&& !this._supressNextScroll)
{
this._updateScrollState();
e.stopPropagation();
//TODO -- what an ugly hack!!!!!
var listBox = this.get_element();
var itemSize = listBox.scrollHeight / listBox.options.length;
if (listBox.selectedIndex == -1)
{
var oldScrollTop = listBox.scrollTop;
listBox.selectedIndex = Math.round(
listBox.scrollTop / itemSize);
this._supressNextScroll = true;
listBox.selectedIndex = -1;
listBox.scrollTop = oldScrollTop;
}
return true;
}
else
{
this._supressNextScroll = false;
}
}
,
好消息,坏消息
好消息是,这是我们第一次可以说,我们已经实现了这个升级最初期望的行为……在两个最常见的浏览器中。值得庆幸的是,由于onscroll
即使在一次鼠标单击中也会被执行多次(在两个浏览器中),所以跳过一次不会给我们带来麻烦。我们可以检测IE7,测试document.all
或window.event
,但那会比我们现在的技巧更难看。
坏消息是,这段代码在IE6或FF 1.0中效果不佳。在IE6中,我最初认为这是因为ListBox
的onscroll
事件没有被触发。它确实没有,但这已经是次要问题了,因为IE6总是返回零作为我们ListBox
的scrollTop
值。这意味着我们甚至无法确定要发送回服务器的正确滚动状态。IE6总是将“0:0”发送到隐藏字段。此时,在IE6中实现滚动状态工作的唯一方法是启用水平滚动,因为DIV
容器会发送正确的值。
一如既往,Firefox的情况稍微不那么严峻。当用户点击滚动条导航项目时,我们的控件将正常工作,因为这些操作会触发ListBox
的onscroll
事件。只有在使用按键和鼠标滚轮导航项目时,滚动状态才会记录不正确。我们将在另一篇文章中尝试解决鼠标滚轮的问题,但我们可以在这里通过在ScrollStateEnabled
为true
时,在ListBox
的onchange
事件期间执行_onScroll
处理程序(或_onContainerScroll
处理程序)来解决按键问题。
// from here, it's safe to assert that horizontal scrolling
// is not enabled
else if (this.get_scrollStateEnabled())
{
this._onscrollHandler = Function.createDelegate(
this, this._onScroll);
$addHandlers(this.get_element(),
{
'scroll' : this._onScroll
,'change' : this._onScroll
}, this);
}
_onChange : function(e)
{
if (this.get_element() && !this.get_element().disabled)
{
updateListBoxScrollPosition(
this.get_elementContainer(), this.get_element(), null);
e.stopPropagation();
if (this.get_scrollStateEnabled())
{
this._onContainerScroll(e);
}
return true;
}
}
,
由于ListBox
中的按键会强制更改selectedIndex
,因此当我们将_onScroll
处理程序(或_onContainerScroll
)附加到onchange
事件时,可以使用它。尽管我们现在可以说这个ListBox
支持Firefox 1.0,但本文的演示使用UpdatePanel
和异步回发。不幸的是,微软表示ASP.NET AJAX不支持FF 1.0。我实际上有过混合成功……一台机器上的FF 1.0可以很好地处理演示,而另一台机器上的FF 1.0则会抱怨UpdatePanel
。您可以尝试注释掉演示代码后台的Page_Init
处理程序,但无论是否有UpdatePanel
,我们的ListBox
在FF 1.0中肯定可以使用。
在Safari上试试
我知道我承诺过关于这个系列的后续文章会更短,但我现在真心承诺您,我们几乎完成了。令我惊讶的是,我们的ListBox
版本0.2在Safari 2.0.4(Mac OS X)上运行得比预期的要好。当HorizontalScrollEnabled
为true
时,用户可以横向滚动以查看长文本。不幸的是,无论如何,在回发之间似乎都没有保留滚动状态。如果您能容忍,我也能容忍,特别是考虑到Safari(至少是2.0.4版本)的另一个怪癖是,它甚至不允许用户使用键盘上的字母或数字键导航项目。如果Safari用户能容忍这一点,那么他们一定很喜欢费力地滚动……对吧?
我也简要地在Safari Public Beta 3.0.1版本上进行了测试。这个版本的Safari允许键盘的其他部分导航ListBox
项目,但该功能仍然与我们的ListBox
不兼容。为什么?我很高兴你问了。这是因为当使用键盘更改ListBox
选择时,它的onchange
事件不会被触发!因此,我不会尝试让这个控件与任何beta版本的浏览器(如Safari)一起工作。我们将在Safari发布正式版本后再来讨论这个问题。在下一篇文章中,我们将回到之前的地方,更深入地探讨如何强制浏览器兼容性。