6

Consider this document fragment:

<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's CV</a>.</p>
    <div class="comments">
        <h2>Comments to John's article</h2>
        <ul>
            <li>Some user asks John a question.</li>
            <li>John responds.</li>
        </ul>
    </div>
</div>

I would like to replace every occurrence of the string "John" with the string "Peter". This could be done via HTML rewriting:

$('#test').html(function(i, v) {
    return v.replace(/John/g, 'Peter');    
});

Working demo: http://jsfiddle.net/v2yp5/

The above jQuery code looks simple and straight-forward, but this is deceiving because it is a lousy solution. HTML rewriting recreates all the DOM nodes inside the #test DIV. Subsequently, changes made on that DOM subtree programmatically (for instance "onevent" handlers), or by the user (entered form fields) are not preserved.

So what would be an appropriate way to perform this task?

Šime Vidas
  • 182,163
  • 62
  • 281
  • 385
  • How about using .text instead of .html. – James Black May 16 '11 at 00:18
  • What about [something like this](http://stackoverflow.com/questions/3899343/javascript-for-replacing-text-in-the-body-tag-of-pages-loaded-into-an-open-source) ? – alex May 16 '11 at 00:18
  • @alex - I haven't looked, but .text should use document.createTextNode for changes, so any event handlers, for example, should be untouched. – James Black May 16 '11 at 00:33
  • @James `text()` rewriting destroys all child elements. [See here](http://jsfiddle.net/v2yp5/1/). – Šime Vidas May 16 '11 at 00:37
  • @James I assumed `text()` used `innerText` or `textContent`. – alex May 16 '11 at 00:38
  • @Šime Vidas - I would just do this using javascript, to ensure that nodes are untouched, but that is simply because I don't care to spend a lot of time working around the tool, if it is too much work, find a solution that works. – James Black May 16 '11 at 00:42

8 Answers8

4

How about a jQuery plugin version for a little code reduction?

http://jsfiddle.net/v2yp5/4/

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 do a little duck typing, and have an option to pass a couple strings for the replace:

http://jsfiddle.net/v2yp5/5/

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' );
user113716
  • 318,772
  • 63
  • 451
  • 440
  • I wonder why DOM walking functionality is not offered in jQuery... sounds pretty basic to me. – Šime Vidas May 16 '11 at 01:06
  • @Šime: Good question. With the `*` selector, you can get all elements, but not text nodes. I suppose because jQuery methods aren't generally meant to operate on text nodes, and it may confuse people. Still a set of text specific functions would seem to make sense. – user113716 May 16 '11 at 01:09
  • Thanks for this excellent bit of code. I needed to make a few modifications for my case. I've posted the details as a new answer (giving credit to your original bit here). – Colin Young Jul 20 '12 at 15:59
3

You want to loop through all child nodes and only replace the text nodes. Otherwise, you may match HTML, attributes or anything else that is serialised. When replacing text, you want to work with the text nodes only, not the entire HTML serialised.

I think you already know that though :)

Bobince has a great piece of JavaScript for doing that.

Community
  • 1
  • 1
alex
  • 479,566
  • 201
  • 878
  • 984
  • @alex I was thinking about utilizing [Crockford's walkTheDOM function](http://vidasp.net/crockford-walkthedom.gif), but I'll look into bobince's code, since it appears to be more sophisticated. – Šime Vidas May 16 '11 at 00:32
  • @Šime Bobince's one is a little better suited to this, handling text nodes only. You'd need to adapt Crockford's to handle text nodes only. Also, what happened to his original name `walk_the_DOM()`? :P – alex May 16 '11 at 00:36
  • @alex Hm, when was that? I think I was still a JavaScript noob at that time `:)` – Šime Vidas May 16 '11 at 00:42
  • @alex So, DOM walking it is... `:)` – Šime Vidas May 16 '11 at 00:54
2

I needed to do something similar, but I needed to insert HTML markup. I started from the answer by @user113716 and made a couple modifications:

$.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 a few implicit assumptions:

  • you are always inserting HTML. If not, you'd want to add a check to avoid manipulating the DOM when not necessary.
  • removing the original text elements isn't going to cause any side effects.
Colin Young
  • 3,018
  • 1
  • 22
  • 46
1

Slightly less intrusive, but not necessarily any more performant, is to select elements which you know only contain text nodes, and use .text(). In this case (not a general-purpose solution, obviously):

$('#test').find('h1, p, li').text(function(i, v) {
    return v.replace(/John/g, 'Peter');
});

Demo: http://jsfiddle.net/mattball/jdc87/ (type something in the <input> before clicking the button)

Matt Ball
  • 354,903
  • 100
  • 647
  • 710
1

This is how I would 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 no recursion.

Ry-
  • 218,210
  • 55
  • 464
  • 476
  • @minitech Looks interesting. Could to implement this on [my demo](http://jsfiddle.net/v2yp5/)? – Šime Vidas May 16 '11 at 00:34
  • @minitech +1 imaginative solution. But is it really necessary to replace the text nodes altogether? Shouldn't setting `textNode.nodeValue` work just as fine? – Šime Vidas May 16 '11 at 01:04
  • I wasn't quite sure of that (isn't nodeValue read-only?), but replacing text nodes should be efficient enough. – Ry- May 16 '11 at 01:14
  • @minitech According to DOM Core, setting `nodeValue` throws only if the node is read-only. I'm not sure what a read-only node is, but [it works for text nodes](http://jsfiddle.net/TsxdM/). – Šime Vidas May 16 '11 at 01:27
1

You could wrap every textual instance that is variable (e.g. "John") in a span with a certain CSS class, and then do a .text('..') update on all those spans. Seems less intrusive to me, as the DOM isn't really manipulated.

<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>'s CV</a>.</p>
    <div class="comments">
        <h2>Comments to <span class="name">John</span>'s 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. It's definitely intrusive, as it has its way with the DOM and makes no apologies for it. But I see nothing wrong with that... I mean you're basically doing client-side data binding. So that's what the templates plugin is for.

Kon
  • 27,113
  • 11
  • 60
  • 86
0

This seems to work (demo):

$('#test :not(:has(*))').text(function(i, v) {
  return v.replace(/John/g, 'Peter');    
});
Mottie
  • 84,355
  • 30
  • 126
  • 241
  • Unfortunately not. It deletes all descendants of #test that are not his children (ANCHOR, H2, UL, and LI in my demo). – Šime Vidas May 16 '11 at 01:29
  • I realize that it wouldn't work if the text to replace was in an element that had child element, like this: `
  • Check out John's blog
  • `, but it works in the sample you provided. – Mottie May 16 '11 at 02:40
  • LOL sorry to spam my comments, but simply wrapping the name in a span and the selector I posted above would work again: `
  • Check out John's blog
  • ` – Mottie May 16 '11 at 03:14