vue.jsscrolltop

Vue3: Why when I scroll to element's scrollHeight, the last element is not visible?


I'm trying to make a chat window, where when I send/get a message, the window scrolls to the very bottom. That's how I make it:

template:

<ul class="chat-window">
  <li v-for="message in messages">
      <span>{{ message.from }}</span>{{ message.message }}
  </li>
</ul>

script:

const messages = ref()

socket.on('chat-message', (data) => { 
  messages.value.push(data)
  const chatWindow = document.querySelector('.chat-window')
  chatWindow.scrollTop = chatWindow.scrollHeight
})

But when coded this way, the last message is never seen (you need to scroll to it).
I found out that when I use setTimeout, like this:

setTimeout(() => {
  const chatWindow = document.querySelector('.chat-window')
  chatWindow.scrollTop = chatWindow.offsetHeight
}, 10)

then it works fine. So I know how to make it work, but I don't know why I need to use setTimeout. Can anybody please explain? Is there a better way to do it?


Solution

  • The reason why it works with setTimeout is the lifecycle of a vue component. You should read the documentation page to understand the how the lifecycle works.

    So if you set a variable like you do

    messages.value.push(data)
    

    Vue needs to run a new lifecycle to update the component. If you access the DOM directly after the change of the value, Vue might not have been updated the component yet.

    But, you can actively say, you want to wait for the update to be done with the nextTick function: https://vuejs.org/api/general.html#nexttick

    // import nextTick from vue
    import { nextTick } from 'vue';
    
    // callback needs to be async
    socket.on('chat-message', async (data) => { 
      messages.value.push(data)
      
      await nextTick();
    
      const chatWindow = document.querySelector('.chat-window')
      chatWindow.scrollTop = chatWindow.scrollHeight
    })
    
    // depending on the weight of your overall page,
    // sometimes you also need to request the animation frame first
    // this is better then using setTimeout. But you should try without first.
    
    socket.on('chat-message', async (data) => { 
      messages.value.push(data)
      
      await nextTick();
      
      requestAnimationFrame(() => {
        const chatWindow = document.querySelector('.chat-window')
        chatWindow.scrollTop = chatWindow.scrollHeight
      });
    })
    
    

    Instead of using querySelector you should use a template ref for the html element as described here: https://vuejs.org/guide/essentials/template-refs.html

    <ul ref="chatWindow" class="chat-window">
      <li v-for="message in messages">
          <span>{{ message.from }}</span>{{ message.message }}
      </li>
    </ul>
    
    // setup script
    
    const messages = ref([]) // don’t forget the initial empty array here
    const chatWindow = ref(null) // template refs are always initialized with null
    
    socket.on('chat-message', async (data) => { 
      messages.value.push(data)
      
      await nextTick();
    
      // you need to make sure, the chatWindow is not null
      if (!chatWindow.value) return;
      chatWindow.value.scrollTop = chatWindow.value.scrollHeight
    })