Search code examples
javascriptfunctionsettimeoutdom-manipulation

Why does my function that type words out produce garbled output?


In the following code, I expect that it gets the words in all of the .auto-type elements as input, then starts typing them out character-by-character automatically. However, the code I have doesn't work, but it also doesn't produce any errors in the console. What have I missed?

window.onload = () => {
  let elements = document.getElementsByClassName("auto-type");
  for (element of elements) {
    const text = element.innerHTML.toString();
    element.innerHTML = "";
    let charIndex = 0;
    const typing = () => {
      element.innerHTML += text.charAt(charIndex);
      if (charIndex < text.length) {
        setTimeout(typing, 100);
        charIndex++;
      }
    };
    typing();
  }
};
body {
  background-color: black;
  color: lime;
  font-family: consolas, monospace;
}
<span class="auto-type code">hello!</span>
<br />
<span class="auto-type code">some text</span>
<br />
<div class="auto-type">some other text</div>
<span class="auto-type code">here is a better text</span>


Solution

  • The code works but because it's asynchronous, it just happens to be trying to type it all out at the same time. The other reason that it isn't making sense is because it is also trying to type it into the same element.

    Investigating the problem

    Once rendered, you see this in the HTML of the page:

    <span class="auto-type code">h</span>
    <br>
    <span class="auto-type code">s</span>
    <br>
    <div class="auto-type">s</div>
    <span class="auto-type code">heooelmmrleeeo   !toietsxh tear  bteetxtter text</span>
    

    Focussing on that last line, you can see that each message has been typed into the last element. This can be made more obvious by replacing each character in your original messages with a number:

    <span class="auto-type code">111111</span>
    <br />
    <span class="auto-type code">222222222</span>
    <br />
    <div class="auto-type">333333333333333</div>
    <span class="auto-type code">444444444444444444444</span>
    

    Which renders:

    <span class="auto-type code">1</span>
    <br>
    <span class="auto-type code">2</span>
    <br>
    <div class="auto-type">3</div>
    <span class="auto-type code">412341234123412341234234234234343434343434444444</span>
    

    So that last line is built up like this:

    heooelmmrleeeo   !toietsxh tear  bteetxtter text
     │││└ the 'e' from "here" in the fourth element
     ││└ the 'o' from "some" in the third element
     │└ the 'o' from "some" in the second element
     └ the 'e' from "hello" in the first element
    

    The reason this happens is because of this for-loop, where the variable element is not scoped to the for-loop, meaning that it gets shared by each typing function, and will refer to the last element in elements:

    for (element of elements) { // <- this element variable is a global!
        /* ... */
    }
    

    You can solve your primary issue by simply redefining that element variable with let or const instead:

    for (const element of elements) { // <- this element variable is now scoped to this for-loop
        /* ... */
    }
    

    The effect of this change can be seen in the below StackSnippet:

    window.onload = () => {
      let elements = document.getElementsByClassName("auto-type");
      for (const element of elements) {
        const text = element.innerHTML.toString();
        element.innerHTML = "";
        let charIndex = 0;
        const typing = () => {
          element.innerHTML += text.charAt(charIndex);
          if (charIndex < text.length) {
            setTimeout(typing, 100);
            charIndex++;
          }
        };
        typing();
      }
    };
    body {
      background-color: black;
      color: lime;
      font-family: consolas, monospace;
    }
    <span class="auto-type code">hello!</span>
    <br />
    <span class="auto-type code">some text</span>
    <br />
    <div class="auto-type">some other text</div>
    <span class="auto-type code">here is a better text</span>

    Other problems

    Because of the nature of JavaScript, it can take a while to load your page and all your dependencies. This means your messages would be visible to the user while the page is loading which probably isn't what you are looking for. So you should hide the messages with CSS and then reveal them when you are ready.

    .auto-type {
      visibility: hidden; /* like display:none; but still consumes space on the screen */
    }
    

    Then when you empty the text out, make the element visible again:

    const text = element.innerHTML.toString();
    element.innerHTML = "";
    element.style.visibility = "visible";
    

    Correcting the code

    You need to rework your "type into" function to not fire off until the previous message has been typed out. We can do this by updating each "type into" function to return a Promise and then by chaining those Promises together.

    /**
     * Types text into the given element, returning a Promise
     * that resolves when all the text has been typed out.
     */
    function typeTextInto(element, text, typeDelayMs = 100) {
        return new Promise(resolve => {
            let charIndex = 0;
            const typeNextChar = () => {
                element.innerHTML += text.charAt(charIndex);
                if (charIndex < text.length) {
                    setTimeout(typeNextChar, typeDelayMs);
                    charIndex++;
                } else {
                    resolve(); // finished typing
                }
            };
            typeNextChar(); // start typing
        });
    }
    
    window.onload = () => {
        const elements = document.getElementsByClassName("auto-type");
        const elementTextPairs = [];
        
        // pass 1: empty out the contents
        for (element of elements) {
            const text = element.innerHTML.toString();
            element.innerHTML = "";
            element.style.visibility = "visible";
            elementTextPairs.push({ element, text });
        }
    
        // pass 2: type text into each element
        let chain = Promise.resolve(); // <- empty Promise to start the chain
        for (elementTextPair of elementTextPairs) {
            const { element, text } = elementTextPair; // unwrap pair
            chain = chain // <- add onto the chain
                .then(() => typeTextInto(element, text));
        }
    
        // basic error handling
        chain.catch(err => console.error("failed to type all messages:", err));
    };
    

    This can be futher cleaned up using async/await and splitting the typing logic out into its own function. This is shown in the working StackSnippet below:

    /**
     * Types text into the given element, returning a Promise
     * that resolves when all the text has been typed out.
     */
    function typeTextInto(element, text, typeDelayMs = 100) {
        return new Promise(resolve => {
            let charIndex = 0;
            const typeNextChar = () => {
                element.innerHTML += text.charAt(charIndex);
                if (charIndex < text.length) {
                    setTimeout(typeNextChar, typeDelayMs);
                    charIndex++;
                } else {
                    resolve(); // finished typing
                }
            };
            typeNextChar(); // start typing
        });
    }
    
    window.onload = async () => {
        try {
            const elements = document.getElementsByClassName("auto-type");
            const elementTextPairs = [];
        
            // pass 1: empty out the contents
            for (element of elements) {
                const text = element.innerHTML.toString();
                element.innerHTML = "";
                element.style.visibility = "visible";
                elementTextPairs.push({ element, text });
            }
    
            // pass 2: type text into each element
            for (elementTextPair of elementTextPairs) {
                const { element, text } = elementTextPair; // unwrap pair
                await typeTextInto(element, text); // await in a for-loop makes the asynchronous work run one-after-another
            }
        } catch (err) {
            // basic error handling
            console.error("failed to type all messages:", err);
        }
    };
    body {
      background-color: black;
      color: lime;
      font-family: consolas, monospace;
    }
    
    .auto-type {
      visibility: hidden;
    }
    <span class="auto-type code">hello!</span>
    <br />
    <span class="auto-type code">some text</span>
    <br />
    <div class="auto-type">some other text</div>
    <span class="auto-type code">here is a better text</span>