Modify the Html Output of any of your Page Before Rendering
Posted on: 2014-11-04
In some situation, you may want to alter the html output that the Asp.Net MVC render. An interesting use case is that you may have several user controls that inject directly into the html some JavaScript or CSS. To keep your page loading fast, you want to have everything at the bottom of the html. Of course, other method exist but one can be to let Asp.Net MVC render everthing and just before sending back to the client the Html output to remove those JavaScript and CSS tag of the Html markup and to add them at the bottom of this Html. This article describe how to modify the Asp.Net MVC default rendering pipeline to inject your own hook that will be placed between the end of the Asp.Net MVC rendering engine and the sending of this one to the client. It will also explains how to have this option in an atomic scenario of only allowing this alteration for a specific action up to all requests.
The first class to create is the class that will play with the content produced. I create a small filter called MyCustomStream that remove all Script tag and replace them by a comment and then add all Script tag before the closing Html tag. This way, all Script are set at the end of the page.
public class MyCustomStream : Stream { private readonly Stream filter;
public MyCustomStream(Stream filter) { this.filter = filter; }
public override void Write(byte[] buffer, int offset, int count) { var allScripts = new StringBuilder(); string wholeHtmlDocument = Encoding.UTF8.GetString(buffer, offset, count); var regex = new Regex(@"<script[^>]*>(?<script>([^<]|<[^/])*)</script>", RegexOptions.IgnoreCase | RegexOptions.Multiline); //Remove all Script Tag wholeHtmlDocument = regex.Replace(wholeHtmlDocument, m => { allScripts.Append(m.Groups["script"].Value); return "<!-- Removed Script -->"; });
//Put all Script at the end if (allScripts.Length > 0) { wholeHtmlDocument = wholeHtmlDocument.Replace("</html>", "<script type='text/javascript'>" + allScripts.ToString() + "</script></html>"); } buffer = Encoding.UTF8.GetBytes(wholeHtmlDocument); this.filter.Write(buffer, 0, buffer.Length); }
public override void Flush() { this.filter.Flush(); }
public override long Seek(long offset, SeekOrigin origin) { return this.filter.Seek(offset, origin); }
public override void SetLength(long value) { this.filter.SetLength(value); }
public override int Read(byte[] buffer, int offset, int count) { return this.filter.Read(buffer, offset, count); }
public override bool CanRead { get { return this.filter.CanRead; } }
public override bool CanSeek { get { return this.filter.CanSeek; } }
public override bool CanWrite { get { return this.filter.CanWrite; } }
public override long Length { get { return this.filter.Length; } }
public override long Position { get { return this.filter.Position; } set { this.filter.Position = value; } } }
To make it works for controller or action, you must create an attribute
. When the action is executed and this one has the attribute (or if the controller of the action has the attribute) the filter is applied.
public class MyCustomAttribute: ActionFilterAttribute { public override void OnActionExecuted(ActionExecutedContextfilterContext) { var response = filterContext.HttpContext.Response;
if (response.ContentType == "text/html") { response.Filter = new MyCustomStream(filterContext.HttpContext.Response.Filter); } } }
You can also set it to all your controller by setting the attribute to the Global.Asax.cs
protected void Application_Start() { GlobalFilters.Filters.Add(new MyCustomAttribute()); }
But, so far something is wrong. The filter is called multiple time because the stream is outputed in chunk of several bytes. Since we are playing with the Html rendering, we must replace html element when the whole document is loaded. This require us to modify a little bit the implementation above. The filter class must have a buffer. We will append all chunk into our buffer and when this one is full, we will act our transformation on this buffer and use this memory buffer to output into the filter stream.
The first step is to have a Stream
to buffer. I choose to use the MemoryStream
because it has some method like ToArray()
that simplify our life when it is the time to read the whole buffer. The Flush
method needs modification to accumulate all bytes of the page before hooking the filter and write back the modified buffer.
public class MyCustomStream : Stream {
private readonly Stream filter; private readonly MemoryStream cacheStream = new MemoryStream();
public MyCustomStream(Stream filter) { this.filter = filter; }
public override void Write(byte[] buffer, int offset, int count) { cacheStream.Write(buffer, 0, count); }
public override void Flush() { if (cacheStream.Length > 0) { var allScripts = new StringBuilder(); string wholeHtmlDocument = Encoding.UTF8.GetString(cacheStream.ToArray(), 0, (int)cacheStream.Length); var regex = new Regex(@"<script[^>]*>(?<script>([^<]|<[^/])*)</script>", RegexOptions.IgnoreCase | RegexOptions.Multiline); //Remove all Script Tag wholeHtmlDocument = regex.Replace(wholeHtmlDocument, m => { allScripts.Append(m.Groups[0].Value); return "<!-- Removed Script -->"; });
//Put all Script at the end if (allScripts.Length > 0) { wholeHtmlDocument = wholeHtmlDocument.Replace("</html>", "<script type='text/javascript'>" + allScripts.ToString() + "</script></html>"); } var buffer = Encoding.UTF8.GetBytes(wholeHtmlDocument); this.filter.Write(buffer, 0, buffer.Length); cacheStream.SetLength(0); } this.filter.Flush(); }
public override long Seek(long offset, SeekOrigin origin) { return this.filter.Seek(offset, origin); }
public override void SetLength(long value) { this.filter.SetLength(value); }
public override int Read(byte[] buffer, int offset, int count) { return this.filter.Read(buffer, offset, count); }
public override bool CanRead { get { return this.filter.CanRead; } }
public override bool CanSeek { get { return this.filter.CanSeek; } }
public override bool CanWrite { get { return this.filter.CanWrite; } }
public override long Length { get { return this.filter.Length; } }
public override long Position { get { return this.filter.Position; } set { this.filter.Position = value; } } }
You can put what ever you want inside the if statement of the Flush
method. In my case, I remove all script of the file, replace them with a comment and finally put all scripts at the end of the file, just before the closing Html tag.
The result can be seen if you show the source in any browser. This method is efficient but as a cost that we are playing with the output result and indeed add some overhead in the rendering pipeline. This kind of filter must be used only in specific cases where it is the only way to accomplish a transformation. The case of JavaScript or CSS are two cases where it is logic to do if you are developing in an older oriented way where "control/component/usercontrol" inject their own JavaScript and CSS. However, in new system, you should not rely on this kind of replacement. It tends to develop bad habit to throw code everywhere without checking the consequence. It also add some performance penalty by having to pass through all the code instead of initially setting at the right place the code. This can be efficiently done by using section
with ASP.net MVC. Finally, this kind of replacement can cause problem because of dependencies. In this small example, nothing really is changed, but in bigger code base some JavaScript may need to be before specific Html elements or have dependencies to other JavaScript files. Moving with automatic process may require more code than the one shown in this article.
You can find the source code of this example in GitHub or download the zip file.