What is a good alternative to rewriting HTML?
Consider this piece of paper:
<div id="test"> <h1>An article about John</h1> <p>The frist paragraph is about John.</p> <p>The second paragraph contains a <a href="#">link to John CV</a>.</p> <div class="comments"> <h2>Comments to John article</h2> <ul> <li>Some user asks John a question.</li> <li>John responds.</li> </ul> </div> </div> I would like to replace each occurrence of the string “John” with the string “Peter”. This can be done by rewriting HTML:
$('#test').html(function(i, v) { return v.replace(/John/g, 'Peter'); }); Working demo: http://jsfiddle.net/v2yp5/
The above jQuery code looks simple and straightforward, but it is deceiving because it is a lousy solution. HTML rewriting recreates all the DOM nodes inside the #test DIV. Subsequently, changes made to this DOM subtree programmatically (for example, "onevent" handlers) or by the user (entered form fields) are not saved.
So, what would be the appropriate way to accomplish this task?
You want to iterate over all child nodes and only replace text nodes. Otherwise, you can map HTML, attributes, or anything else serialized. When replacing text, you want to work only with text nodes, and not with the entire HTML series.
I think you already know this though :)
Bobins has a great piece of JavaScript for this .
What about the jQuery plugin version for a little code reduction?
jQuery.fn.textWalk = function( fn ) { this.contents().each( jwalk ); function jwalk() { var nn = this.nodeName.toLowerCase(); if( nn === '#text' ) { fn.call( this ); } else if( this.nodeType === 1 && this.childNodes && this.childNodes[0] && nn !== 'script' && nn !== 'textarea' ) { $(this).contents().each( jwalk ); } } return this; }; $('#test').textWalk(function() { this.data = this.data.replace('John','Peter'); }); Or make a small duck print, and you have the opportunity to pass a couple of lines for replacement:
jQuery.fn.textWalk = function( fn, str ) { var func = jQuery.isFunction( fn ); this.contents().each( jwalk ); function jwalk() { var nn = this.nodeName.toLowerCase(); if( nn === '#text' ) { if( func ) { fn.call( this ); } else { this.data = this.data.replace( fn, str ); } } else if( this.nodeType === 1 && this.childNodes && this.childNodes[0] && nn !== 'script' && nn !== 'textarea' ) { $(this).contents().each( jwalk ); } } return this; }; $('#test').textWalk(function() { this.data = this.data.replace('John','Peter'); }); $('#test').textWalk( 'Peter', 'Bob' ); I needed to do something similar, but I needed to insert HTML markup. I started with @ user113716 answer and made a few changes:
$.fn.textWalk = function (fn, str) { var func = jQuery.isFunction(fn); var remove = []; this.contents().each(jwalk); // remove the replaced elements remove.length && $(remove).remove(); function jwalk() { var nn = this.nodeName.toLowerCase(); if (nn === '#text') { var newValue; if (func) { newValue = fn.call(this); } else { newValue = this.data.replace(fn, str); } $(this).before(newValue); remove.push(this) } else if (this.nodeType === 1 && this.childNodes && this.childNodes[0] && nn !== 'script' && nn !== 'textarea') { $(this).contents().each(jwalk); } } return this; }; There are several implicit assumptions:
- you always embed HTML. If not, you want to add validation to avoid manipulating the DOM when it is not necessary.
- deleting the original text elements will not cause any side effects.
A little less intrusive, but not necessarily more perfect, is to select elements that, as you know, contain only text nodes, and use .text() . In this case (obviously not a universal solution):
$('#test').find('h1, p, li').text(function(i, v) { return v.replace(/John/g, 'Peter'); }); Demo: http://jsfiddle.net/mattball/jdc87/ (enter something in the <input> before clicking the button)
Here's how I do it:
var textNodes = [], stack = [elementWhoseNodesToReplace], c; while(c = stack.pop()) { for(var i = 0; i < c.childNodes.length; i++) { var n = c.childNodes[i]; if(n.nodeType === 1) { stack.push(n); } else if(n.nodeType === 3) { textNodes.push(n); } } } for(var i = 0; i < textNodes.length; i++) textNodes[i].parentNode.replaceChild(document.createTextNode(textNodes[i].nodeValue.replace(/John/g, 'Peter')), textNodes[i]); Pure JavaScript and lack of recursion.
You can wrap each text instance that is a variable (for example, "John") in the gap with a particular CSS class, and then update .text ('..') on all of these spaces. It seems less intrusive to me, since the DOM is not actually managed.
<div id="test"> <h1>An article about <span class="name">John</span></h1> <p>The frist paragraph is about <span class="name">John</span>.</p> <p>The second paragraph contains a <a href="#">link to <span class="name">John</span> CV</a>.</p> <div class="comments"> <h2>Comments to <span class="name">John</span> article</h2> <ul> <li>Some user asks <span class="name">John</span> a question.</li> <li><span class="name">John</span> responds.</li> </ul> </div> </div> $('#test .name').text(function(i, v) { return v.replace(/John/g, 'Peter'); }); Another idea is to use jQuery Templates . This is definitely intrusive as it has its way with the DOM and does not apologize for it. But I don’t see anything wrong with that ... I mean, you basically do data binding on the client side. So for the template plugin.
The proposed POJS solution is fine, but I don't understand why to avoid recursion. DOM nodes are usually not nested too deep, so I think. I also think that it is much better to build one regular expression than to use a literal, and build an expression for each call to replace.
// Repalce all instances of t0 in text descendents of // root with t1 // function replaceText(t0, t1, root) { root = root || document; var node, nodes = root.childNodes; if (typeof t0 == 'string') { t0 = new RegExp(t0, 'g'); } for (var i=0, iLen=nodes.length; i<iLen; i++) { node = nodes[i]; if (node.nodeType == 1) { arguments.callee(t0, t1, node); } else if (node.nodeType == 3) { node.data = node.data.replace(t0, t1); } } }