Pages

Advertisement

Friday, July 13, 2007

ASP.NET Custom Controls -- Client Script Generation

The new state management and postback features of ASP.NET are indeed very exciting. They provide developers with a whole new range of mechanisms for producing dynamic Web pages. The ability to write your own custom controls takes that ability to a whole new level, allowing you to write a control with custom functionality that can easily be reused in multiple pages by simply defining custom tags, similar to any other HTML element. The person implementing the layout for a page no longer needs to know all the details of how to write client-side code to get the dynamic behavior that has become so popular. However, there are some pitfalls that developers need to be aware of. ASP.NET promotes server-heavy designs. Network traffic can be dramatically increased as each client-side event can potentially cause a round trip to the server. Many of the effects that result from these frequent trips to the server can easily be accomlished with a few simple JavaScript functions. Calls to the server should be kept to a minimum, with as much being done on the client as possible. By using custom controls to generate client-side script, we can take advantage of Dynamic HTML on the client while still providing a measure of separation between the layout and the logic.

Client-Side Script Generation

 

One of the goals of generating script from a custom control is to allow a developer to create the control and specify its behavior, and then publish it for others to use without having to know how the code works. We want to encapsulate the implementation of the control and tightly couple the HTML rendering to the script that works with it to reduce the possible points of failure associated with more traditional methods of Web component reuse (such as Cut & Paste, and include files). The most straightforward approach to script generation is to write the script along with the control in the Render method of your control (see Listing 1).

namespace Spotu
{
public class HelloWorld : Control
{
protected override void Render (
HtmlTextWriter writer
)
{
writer.Write(@"
<script>
function HelloWorld()
{
document.all('_msg').innerText = 'Hello World';
}
</script>");

writer.Write("<button onclick='javascript:HelloWorld()'>"
+ "Click Me</button>");

writer.Write("<div id=_msg></div>");
}
}
}

Listing 1: Example of script generation using Render

Listing 2 shows an example of a page using the HelloWorld class with client script generation.

<%@ Page language="c#" %>
<%@ Register Namespace='Spotu'
TagPrefix='spotu'
Assembly ='helloworld' %>

<html>
<body>
<form runat='server'>
<spotu:HelloWorld runat='server'/>
</form>
</body>
</html>

Listing 2: Page using the HelloWorld class.

This approach works, and does solve the initial problem of allowing a developer to write a custom control that someone else can use in their page to provide dynamic capabilities without having to post back to the server. However, it is not very elegant, and it does have some shortcomings. Most notably, we cannot include this control in a page multiple times; doing so would cause mutiple divisions to be created with the same ID. Even if we do uniquely name the <div> elements in this example, it is still inneficient because the JavaScript gets written out with every reference to this control. This can produce a lot of overhead, transmitting the same script down to the client for each instance of the control.

We need some way to have a control generate script, but generate it only once, even if multiple instances of the control are used on the same page. Fortunately for us, the developers at Microsoft thought of this and provided a way to register a script block to ensure we only write out a section of script once by using the Page.RegisterClientScriptBlock method. This method takes two parameters, an ID that identifies the script block so the Page class will know to ignore any other requests to register the same clock of code, and a string containing the script to be registered. The best place to register the script block is in the Init Event Handler for the control. To take advantage of this event, override the OnInit method of the Control class. With this in mind, the HelloWorld example could be rewritten as shown in Listing 3.

using System;
using System.Web;
using System.Web.UI;

namespace Spotu
{
public class HelloWorld : Control
{
protected override void OnInit(EventArgs e)
{
string strCode = @"
<script>
function HelloWorld(id)
{
document.all(id).innerText = 'Hello World';
}
</script>";

Page.RegisterClientScriptBlock("Spotu_HelloWorld",
strCode);
}

protected override void Render(HtmlTextWriter writer)
{
writer.Write("<button onclick='javascript:HelloWorld(\""
+ this.UniqueID + "\")'>"
+ "Click Me</button>");

writer.Write("<div id='" + this.UniqueID
+ "'></div>");
}
}
}

Listing 3: HelloWorld rewritten to use the register script block.

This approach is much better but there is still a problem. If the script that is being register is lengthy, or if there are a lot of calculations, data accesses, and so forth in generating our script, we will still take a performance hit when the page loads because the control creates this huge block of script that ends up being tossed out because it is already registered. Once a block of script is registered, we can test for it using the Page.IsClientScriptBlockRegistered method. To improve the performance of the HelloWorld control, we would include the call in out Init handler as shown in Listing 4.

  protected override void OnInit(EventArgs e)
{
if (!Page.IsClientScriptBlockRegistered("Spotu_HelloWorld"))
{
string strCode = @"
<script>
function HelloWorld(id)
{
document.all(id).innerText = 'Hello World';
}
</script>";

Page.RegisterClientScriptBlock("Spotu_HelloWorld",
strCode);
}
}

Figure 4: OnInit using the IsClientScriptBlockRegistered test

Using client script generation from custom controls provides a clean encapsulated method of enabling dynamic behavior in Web pages while still shielding the page designer from having to know the details of how to produce the desired effect. Developers are now free to concentrate on how to get a control to do what you want it to do without being bogged down with were to put it on the page, or being pestered by the marketing guy to move a control around, add a new one, or take one away. By combining this approach with designer integration controls, dynamic behaviors can easily be customized and reused across multiple pages with little or no developer interaction and without the pitfalls of server-side includes or cut & paste code reuse.

Caching

Some of you might be inclined to ask: How do I cache this script so it doesn't get downloaded every time? After all, client-side script tends to be fairly static, not needing to be downloaded every time a Web page is loaded.

There are a couple of options for caching the output of your control. The ASP.NET approach would be to take advantage of output caching. There are a myriad of output caching options, but most of them place the responsibility of setting up that caching on the person doing the presentation by using directives and flags in the .aspx page. Also, caching the entire page may not be the desired effect. Some pages are extremely dynamic. In such cases, the ideal would be to just cache the control, or some portion of the control. ASP.NET does have some support for this, but that support is reserved primarily for user controls (.ascx files), which doesn't provide the reuse we are looking for.

For custom controls providing generated script, we may want to consider using an external script file. As we have already noted, most script does not change often, if at all and can readily be cached on the client. Instead of writing out the script directly from our custom control, we can instead place the script in an external script file and simply write out a <script..> tag with a src="." attribute that references our script file. This allows the control and the page to fluctuate as often as necessary without incurring the network traffic of always downloading the script to the client. The primary drawback to the approach is the deployment. There are now two files that need to be deployed to use the control in a page and the .js file must be reachable from the page that is using it. Relative paths don't always work because each page that uses the control may be at a different level. One deployment solution is to create a directory at the top level of your application (example: includes) and reference it in your control using Request.ApplicationPath + "/includes/<your script file here>". Another approach might be to provide custom properties on you control so the location of the external source file can be specified in the .aspx page. Listing 5 is an example of a calculator implemented using this approach.

using System;
using System.Web;
using System.Web.UI;
using System.Collections.Specialized;

namespace Spotu
{
public class Calculator : Control, IPostBackDataHandler
{
const string sc_strStyleClass = "calcButton";

private string _strNumButton;
private string _strOpButton;
private string _strScriptSrc;
private string _strStyleHref;
private string _strSavedValue;
private int _intCalcValue = 0;


// Custom property for explicitly setting the location
// of the script file
public string ScriptSrc
{
get { return _strScriptSrc; }
set { _strScriptSrc = value; }
} // End ScriptSrc

// Custom property for explicitly setting the location
// of the stylesheet file
public string StyleSrc
{
get { return _strScriptSrc; }
set { _strScriptSrc = value; }
} // End StyleSrc

#region IPostBackDataHandler

// LoadPostData gets call when the 'save' button
// rendered by this control is clicked
public virtual bool LoadPostData (
string postDataKey,
NameValueCollection values
)
{
_strSavedValue = "Saved Value: "
+ values[UniqueID + "_display"];
return false;
} // end LoadPostData

// Needed to implement IPostBackDataHandler
public virtual void RaisePostDataChangedEvent()
{
} // End RaisePostDataChangedEvent

#endregion

// Loads the state of the control from the
// viewstate managed by .NET
protected override void LoadViewState (
object savedState
)
{
_strSavedValue = savedState as string;
} // End LoadViewState

// Saves the state of the control
protected override object SaveViewState()
{
return _strSavedValue;
} // End SaveViewState

// Init event handler, called to initialize any state
// in the object before the viewstate is restored.
protected override void OnInit (
EventArgs e
)
{
_strNumButton = string.Format("<button "
+ "onclick='javascript:g_{0}.EnterNumber(this.innerText);'"
+ " class='{1}'>", this.UniqueID, sc_strStyleClass);

_strOpButton = string.Format("<button "
+ "onclick='javascript:g_{0}.OnOperator(this.innerText);' "
+ "class='{1}'>", this.UniqueID, sc_strStyleClass);

if (_strScriptSrc == null)
{
_strScriptSrc = Context.Request.ApplicationPath
+ "/includes/calc.js";
}

if (_strStyleHref == null)
{
_strStyleHref = Context.Request.ApplicationPath
+ "/includes/calcStyle.css";
}

string strScriptBlock = "<script src='"
+ _strScriptSrc
+ "'></script>";

Page.RegisterClientScriptBlock("Spotu_Calculator",
strScriptBlock);

string strStyle = "<link rel='stylesheet' "
+ "type='text/css' href='"
+ _strStyleHref
+ "'></link>";

Page.RegisterClientScriptBlock("Spotu_Calculator_Style",
strStyle);
} // End OnInit

// Load Event Handler. Retrieve the value posted in the
// display field of the calculator so we can keep the
// state of the display regardless of how the form is
// submitted.
protected override void OnLoad (
EventArgs e
)
{
if (Page.IsPostBack)
{
_intCalcValue =
Int32.Parse(Context.Request.Form[UniqueID
+ "_display"]);
}
} // End OnLoad

// Render out the control
protected override void Render (
HtmlTextWriter writer
)
{
string strHtml = string.Format(@"
<script> var g_{0} = new Calc('{0}_display'); </script>
<table>
<tr colspan='*'>
<input type='text'
name='{0}_display'
readonly=true
value={4}>
</input>
</tr>
<tr><td>{1}7</button></td>
<td>{1}8</button></td>
<td>{1}9</button></td>
<td>{2}/</button></td>
<td>
<button
class='{3}'
onclick='javascript:g_{0}.OnClear();'>
C
</button>
</td>
</tr>
<tr><td>{1}4</button></td>
<td>{1}5</button></td>
<td>{1}6</button></td>
<td>{2}*</button></td>
</tr>
<tr><td>{1}1</button></td>
<td>{1}2</button></td>
<td>{1}3</button></td>
<td>{2}-</button></td>
</tr>
<tr><td>{1}0</button></td>
<td></td>
<td>{1}.</button></td>
<td>{2}+</button></td>
<td>
<button
class='{3}'
onclick='javascript:g_{0}.OnEqual();'>
=
</button>
</td>
</tr>
</table>", UniqueID,
_strNumButton,
_strOpButton,
sc_strStyleClass,
_intCalcValue);

writer.Write(strHtml);

writer.Write("<INPUT type='submit' name='"
+ this.UniqueID + "' value='Save'></INPUT>");

writer.Write("<H3 id='" + UniqueID + "_savedVal'>"
+ _strSavedValue + "</H3>");
} // End Render
}
}

Listing 5: calculator.cs

<%@ Page %>
<%@ Register Namespace='Spotu'
TagPrefix='spotu'
Assembly ='calc' %>

<html>
<body>
<form runat='server'>
<spotu:Calculator runat='server'/>
<hr>
<spotu:Calculator runat='server'/>
</form>
</body>
</html>

Listing 6: calculator.aspx

function Calc(dispId)
{
this.intCurrentVal = 0;
this.intLastNum = 0;
this._op = "";
this.bEqual = false;
this.displayId = dispId;

this.EnterNumber = function(num)
{
if (this.bEqual)
this.OnClear()

if (this.intLastNum != 0)
this.intLastNum += num;
else
this.intLastNum = num;

document.all(this.displayId).value = this.intLastNum;
}

this.ComputeValue = function()
{
switch (this._op)
{
case '+':
this.intCurrentVal = Number(this.intCurrentVal)
+ Number(this.intLastNum);
break;
case '-':
this.intCurrentVal -= this.intLastNum;
break;
case '*':
this.intCurrentVal *= this.intLastNum;
break;
case '/':
this.intCurrentVal /= this.intLastNum;
break;
default:
this.intCurrentVal = this.intLastNum;
}
document.all(this.displayId).value = this.intCurrentVal;
}

this.OnOperator = function(op)
{
if (!this.bEqual)
this.ComputeValue();

this.bEqual = false;
this.intLastNum = 0;
this._op = op;
}

this.OnEqual = function()
{
this.ComputeValue();
this.bEqual = true;
}

this.OnClear = function()
{
this._op = "";
this.intCurrentVal = 0;
this.intLastNum = 0;
this.bEqual = false;
document.all(this.displayId).value = this.intCurrentVal;
}
}

Listing 7: JavaScript source file for calculator

.calcButton
{
width=25;
}

Figure 8: Stylesheet for calculator buttons

Examining the Code

One item to note is that the reference to the stylesheet that defines the style for the calculator buttons is located in the OnInit method along with the script block registration. Registering blocks of client-side code is not limited to "script" alone. The stylesheet here is external, allowing the designer the ability to modify the look and feel of the buttons by modifying the .css file. Another approach to allowing the page designer to change the look and feel of the calculator would be to implement custom properties, or better yet, custom properties with sub-properties to group them together (example: Font-Style, Font-Size, and so forth). This approach seems somewhat limiting in that the designer then can only change the properties you have exposed. With stylesheets, the designer has all the options available to him/her that would be there if a standard HTML element was being used, options that would otherwise be unavailable because he/she does not have direct access to the HTML elements your custom control produces and would not be able to apply a class or style to them.

There is one block of script that is written out when the control is rendered instead of beign included in the .js file. This allows multiple instances of the calculator control to be used in the same page. The UniqueID property inherited from the Control class is used to differentiate the controls from each other. The UniqueID property is a unique identifier that identifies an instance of a control within a page.

The locations of the stylesheet and the external script file default to an /includes directory located at the virtual application root. However, there are two custom properties provided that allow the designer to override where those file are located.

By using the UniqueID for the control as the name for the submit button, we make sure that the LoadPostData method for our control only gets called when the 'Save' button for that control is clicked. If we had named the text box with the UniqueID for the control, we would end up saving the calculated number for all the controls on the page, regardless of how the submit to the server was done. This example is a little contrived and if you are really serious about reducing server load, you could alter the 'Save' button so that, instead of posting the form back to the server, it does a Web Services call.

Conclusion

Using custom controls to generate client-side script can have tremendous benefits. The custom control will look and behave similarly to any other control written with ASP.NET, making it easy to reuse and shielding the page designer from needing to know the details of how the code works. By using client-side scripting to create the dynamic behaviors, you can greatly increase the responsiveness of the individual pages and the overall performance of your web site by significantly decreasing the number of calls that are made to the server. Using external files for your script has both positives and negatives. The pros include taking advantage of browser caching and easy access for customizability. The cons include a more complex deployment both in the production environment as well as the design time environment.

Downloads
Download demo project - 6 Kb
Download source - 3 Kb

No comments:

Post a Comment