+53

Bài 6: Sử dụng watcher trong VueJS

Mình đã cập nhật lại tất cả các bài với các thay đổi ở hiện tại ở năm 2024: Vue 3, Vite, Laravel 10x,...

Chào mừng các bạn quay trở lại series học VueJS với Laravel của mình. Ở bài trước mình đã hướng dẫn các bạn về Computed. Ở bài này chúng ta sẽ chuyển qua tìm hiểu về watcher, một các rất hữu hiệu để quan sát và xử lý khi có một thay đổi trên dữ liệu.

Giới thiệu và cách sử dụng

Computed properties thường dùng để tính toán các giá trị "dẫn xuất" từ các reactive state ta khai báo. Nhưng sẽ có những trường hợp mà ta cần thực hiện side effect khi reactive state thay đổi (call API, thực hiện DOM API kiểu document.findElementById.... hay update 1 reactive state khác) thì Computed không phải thứ thích hợp để làm điều đó.

Với Vue thì ta có thể dùng watcher để thực hiện 1 cái callback bất kì khi nào mà reactive state thay đổi

Chúng ta sẽ cùng làm một vài ví dụ để hiểu hơn về watcher để xem đầu đuôi nó như thế nào nhé🚀🚀.

Ta cùng xem đoạn code này nhé

<script setup>
import { ref } from 'vue'

const count = ref(0)

function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Chạy lên ta sẽ có kết quả như sau, bấm vào nút ta sẽ thấy số count tăng lên:

Screenshot 2023-12-27 at 9.31.55 PM.png

Bây giờ ta muốn watch để mỗi khi mà count thay đổi thì ta sẽ in ra console nhé:

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (current) => {
  console.log('Current value of count:', current)
})

function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Ở trên các bạn thấy ta dùng watch được import từ Vue, truyền vào 2 tham số:

  • tham số đầu tiên là cái ref count, ta gọi nó là "source" - nguồn, ý là ta muốn theo dõi sự thay đổi của cái nguồn nào
  • tham số thứ 2 là 1 cái callback, tham số trả về của callback là giá trị hiện tại của cái source mà ta đang watch

Sau đó ta lưu lại và chạy lên, bấm vào nút để tăng count ta sẽ thấy ở console in ra các giá trị của count thay đổi theo thời gian như sau:

Screenshot 2023-12-27 at 9.41.00 PM.png

Ví dụ tương tự với reactive():

<script setup>
import { reactive, watch } from 'vue'

const state = reactive({ count: 0 })

watch(state, (current) => {
  console.log('Current value of count:', current)
})

function updateCount() {
  state.count++
}
</script>

<template>
  <h1>{{ state.count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Screenshot 2023-12-27 at 9.52.03 PM.png

Ta cũng có thể watch nhiều source cùng một lúc bằng cách truyền vào 1 array chứa nhiều source, callback trả về sẽ tương ứng là 1 mảng chứa giá trị hiện tại của các source (theo thứ tự ta khai báo):

<script setup>
import { ref, watch } from 'vue'

const count1 = ref(0)
const count2 = ref(0)

watch([count1, count2], (current) => {
  console.log('Current value:', current)
})

function updateCounts() {
  count1.value++
  count2.value += 2
}
</script>

<template>
  <h1>{{ count1 }} - {{ count2 }}</h1>
  <button @click="updateCounts">
    Update Count
  </button>
</template>

Chạy lên sẽ cho kết quả như sau:

Screenshot 2023-12-27 at 9.53.04 PM.png

Chú ý rằng watch sẽ chạy khi bất kì source nào thay đổi

Vậy giờ muốn watch tổng giá trị của count1count2 thì có được không nhỉ??? 🤔🤔🤔

Lại chả được quá ấy chớ, Vue support hết 😎😎, ta sửa lại chút nhé:

<script setup>
import { ref, watch } from 'vue'

const count1 = ref(0)
const count2 = ref(0)

watch(() => count1.value + count2.value, (current) => {
  console.log('Current value:', current)
})

function updateCounts() {
  count1.value++
  count2.value += 2
}
</script>

<template>
  <h1>{{ count1 }} - {{ count2 }}</h1>
  <button @click="updateCounts">
    Update Count
  </button>
</template>

Ở trên, ta để ý rằng ở tham số đầu tiên truyền vào watch ta đã sửa thành 1 cái callback, callback này trả về giá trị là tổng value của count1count2, và vì tổng này là number nên current cũng sẽ cho ta number, chạy lên ta thấy như sau:

Screenshot 2023-12-27 at 9.58.51 PM.png

Ta cũng có thể mix chúng với nhau như sau nhé:

<script setup>
import { ref, watch } from 'vue'

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)

watch([() => count1.value + count2.value, count3], (current) => {
  console.log('Current value:', current)
})

function updateCounts() {
  count1.value++
  count2.value += 2
  count3.value += 3
}
</script>

<template>
  <h1>{{ count1 }} - {{ count2 }} - {{ count3 }}</h1>
  <button @click="updateCounts">
    Update Count
  </button>
</template>

Các bạn tự chạy lên và test coi sao nhé 😉

Có 1 chú ý quan trọng là chúng ta không thể watch trực tiếp giá trị của reactive state kiểu như sau:

const count = ref(0)

watch(count.value, (current) => {
  console.log('Current value of count:', current)
})

const obj = reactive({ count: 0 })

watch(obj.count, (count) => {
  console.log(`count is: ${count}`)
})

Khi chạy lên ta sẽ thấy warning như sau:

Screenshot 2023-12-27 at 10.06.50 PM.png

Lí do bởi vì: watcher chỉ chạy với reactive state, khi ta truyền thẳng giá trị của nó vào thì nó chỉ là giá trị JS thường và không reactive gì cả, ở ví dụ trên thì ta đang đơn giản là watch mỗi số 0, nó không reactive. Do vậy các bạn chú ý điều này cho mình thật kĩ nhé.

Và cách giải quyết cho vấn đề trên là ta sẽ dùng callback như sau:

const count = ref(0)

watch(() => count.value, (current) => {
  console.log('Current value of count:', current)
})

const obj = reactive({ count: 0 })

watch(() => obj.count, (count) => {
  console.log(`count is: ${count}`)
})

Ta muốn vừa watch vừa so sánh giá trị hiện tại và giá trị cũ thì làm như sau nhé:

const count = ref(0)

watch(() => count.value, (current, old) => {
  console.log('Current value of count:', current, old)
})

Tham số thứ 2 của callback trả về sẽ là giá trị cũ. Di dỉ dì di cái gì Vue cũng có 😂😂

Và bởi vì watcher dành cho reactive state, nên computed ta cũng watch được luôn 😎😎:

<script setup>
import { ref, watch, computed } from 'vue'

const count = ref(0)
const double = computed(() => {
  return count.value * 2
})

watch(double, (current) => {
  console.log('Current value of double:', current)
})

function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Các bạn chạy lên và xem kết quả nhé:

Screenshot 2023-12-27 at 11.44.52 PM.png

Deep watch, Eager watch

Deep watch

Bây giờ ta có đoạn code sau:

<script setup>
import { ref, watch } from 'vue'

const state = ref({
  a: {
    b: {
      c: {
        d: 1
      }
    }
  }
})

watch(state, (current) => {
  console.log(current)
})

function updateState() {
  state.value.a = 1
}
</script>

<template>
  <h1>{{ JSON.stringify(state) }}</h1>
  <button @click="updateState">
    Update
  </button>
</template>

Lưu lại, F5 trình duyệt và bấm nút để cập nhật state ta sẽ thấy rằng UI đã cập nhật, nhưng không thấy watch chạy 😮😮😮

Lí do là bởi vì ở đây ta đang watch cả cái value của ref, tức là chỉ khi nào ta gán lại value bằng giá trị khác thì watch mới biết, ví dụ như sau sẽ chạy:

function updateState() {
  state.value = {} // ==>> gán lại giá trị cho cả state.value
}

Còn nếu ta chỉ update 1 thuộc tính nào đó mãi bên trong thì Vue không biết được, ví dụ state.value.a = 1.

Và để nói với Vue rằng "ê cu, cu phải theo dõi mọi thay đổi ở mọi level của state của anh", thì ta cần truyền thêm option deep: true, ta sửa lại code như sau nhé:

<script setup>
import { ref, watch } from 'vue'

const state = ref({
  a: {
    b: {
      c: {
        d: 1
      }
    }
  }
})

watch(state, (current) => {
  console.log(current)
}, { deep: true })

function updateState() {
  state.value.a = 1
}
</script>

<template>
  <h1>{{ JSON.stringify(state) }}</h1>
  <button @click="updateState">
    Update
  </button>
</template>

Sau đó ta chạy lên sẽ thấy như sau:

Screenshot 2023-12-27 at 10.30.03 PM.png

Khi ta watch deep thì Vue sẽ theo dõi mọi thay đổi ở bất kì thuộc tính ở bất kì level nào trong state của chúng ta

Nhưng các bạn cần đặc biệt chú ý rằng, khi watch deep thì Vue sẽ duyệt qua toàn bộ state của chúng ta để xem cái nào thay đổi, do vậy nếu bạn có 1 cái state mà nó là object rất lớn kiểu vài lồng cả trăm/nghìn level thì có thể sẽ ảnh hưởng tới performance đó nhé

Eager watch (Immediate watch)

Mặc định thì watch nó sẽ chỉ chạy khi reactive state thay đổi, nhưng cũng có những trường hợp ta muốn vừa vô phát là watch chạy ngay phát đầu kể cả state chưa đổi, kiểu fetch data từ server on load chẳng hạn. Trong trường hợp đó ta sẽ dùng tới option immediate nhé:

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (current) => {
  console.log('Current value of count:', current)
}, { immediate: true })

function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

F5 lại và ta sẽ thấy ngay từ ban đầu watch đã chạy và in ra console:

Screenshot 2023-12-27 at 10.34.49 PM.png

Người ta hay gọi đây là eager watch, ý là watch 1 cách "háo hức, chủ động" 😄, mà mình thấy lúc dịch ra tiếng Việt nó kì quá, trong khi option của Vue để là immediate, nên mình cứ dùng từ Immediate cho dễ liên tưởng

watchEffect()

Tiếp theo ta cùng xem đoạn code sau nhé:

<script setup>
import { ref, watch } from 'vue'

const count = ref(1)

watch(count, async (current) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${current}`
  )
  const json = await response.json()
  console.log(json)
}, { immediate: true })

function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Ở trên các bạn thấy là mình watch count, khi value của count thay đổi thì ta sẽ gọi 1 cái API và in ra response ở console. Mình dùng option immediate để cho nó chạy ngay lúc đầu 1 phát luôn.

Các bạn để ý rằng ta đang watch count, và ở trong callback ta cũng dùng tới giá trị của count luôn (đoạn ${current})

Vue cho chúng ta một giải pháp để đơn giản hoá việc này với watchEffect. Ta sửa lại code một chút như sau nhé:

<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(1)

watchEffect(async () => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${count.value}`
  )
  const json = await response.json()
  console.log(json)
})

function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Với watchEffect() ta để ý rằng không có source nữa, mà chỉ còn callback thôi, và callback này chạy ngay lập tức giống như option immediate: true vậy. Và Vue sẽ tự theo dõi tất cả những reactive state mà ta có ở trong cái callback luôn.

Vi diệu thế nhỉ, tự biết trong callback có gì mà track luôn 🤩🤩🤩🤩

Ở đây ta cần chú ý là, Vue sẽ theo dõi tất cả các reactive state mà ở callback ta truyền vào watchEffect() trong quá trình thực thi đồng bộ (sync), do vậy với các reactive state mà được truy cập sau await đầu tiên thì Vue sẽ không track được.

Nghe khó hiểu thế nhỉ 🤔🤔🤔🤔

Âu câu ta cùng xem ví dụ để hiểu hơn nhé:

// Case 1: count2 thay đổi watchEffect sẽ không chạy
// vì count2 được truy cập sau await đầu tiên
const count1 = ref(1)
const count2 = ref(1)

watchEffect(async () => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${count1.value}`
  )
  const json = await response.json()
  console.log(json)
  console.log(count2.value, '++++')
})

// Case 2: tương tự trường hợp này cũng sẽ không chạy khi count2 thay đổi
const count1 = ref(1)
const count2 = ref(1)

watchEffect(async () => {
  fetch(
    `https://jsonplaceholder.typicode.com/todos/${count1.value}`
  ).then(res => res.json())
  .then(json => {
    console.log(json)
    console.log(count2.value, '++++')
  })
})

// Case 3: ✅ Chạy khi count2 thay đổi vì ta truy cập count2 trước await đầu tiên
const count1 = ref(1)
const count2 = ref(1)

watchEffect(async () => {
  console.log(count2.value, '++++')
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${count1.value}`
  )
  const json = await response.json()
  console.log(json)
})

Ủa tại sao lại phải là trước cái await đầu tiên, với cả như case 2 dùng Promise mà không có await thì ai biết lối nào mà lần?? 🙄🙄🙄

Thực tế thì đây liên quan tới JS Event loop và cách Vue implement watchEffect(), khá là thú vị, mình sẽ viết ở 1 bài riêng về vấn đề này nhé

watch() hay watchEffect()???

Okie tạm tạm hiểu watch và watchEffect rồi, nhưng vẫn không hiểu là khi nào nên dùng cái nào 🧐🧐

Gòi gòi đây, ta cùng điểm qua sự khác nhau giữa chúng và khi nào dùng cái nào nhé:

  • watch(): chỉ track những thứ ta khai báo ở source, không track có gì bên trong callback, tách biệt 2 phần rõ rệt: source và callback. Kiểu này sẽ cho ta kiểm soát được chính xác những gì ta cần watch, và khi nào thì cái callback sẽ chạy
  • watchEffect(): kiểu này thì gộp cả 2 phần bên trên của watch() lại làm một, code sẽ (có thể) gọn hơn và tiện hơn

2 điểm lợi khác của watchEffect:

  • trường hợp ta cần watch nhiều source thì dùng watchEffect code có thể sẽ nom gọn và đẹp hơn. Thậm chí với trường hợp nhiều source có khi ta lại quên mất 1 source nào đó trong mớ code loằng ngoằng, khi đó sẽ khá khó debug
  • giả sử ta có 1 object to, lồng nhiều cấp, và ta chỉ muốn watch một số thuộc tính bất kì, thì dùng watchEffect sẽ cho performance tốt hơn, vì nó không cần duyệt toàn bộ cả cái object to đấy giống như watch + { deep: true }

Với những điểm trên thì các bạn cần nhắc khi nào nên dùng cái nào nhé. Có nhiều trường hợp watch() tốt hơn, có lúc lại là watchEffect() 😃

Nâng cao chút

Thời điểm chạy callback

Giả sử giờ ta muốn kiểm tra xem là tại thời điểm callback chạy thì trên UI HTML đoạn text in ra có đúng bằng giá trị của count hay không. Ta làm như sau:

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (current) => {
  const textContent = document.getElementById('count').textContent
  const currentStr = current.toString()
  console.log(textContent === currentStr, 'textContent: ' + textContent, 'current: ' + current)
})

function updateCount() {
  count.value++
}
</script>

<template>
  <h1 id="count">{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Lưu lại và quay trở lại trình duyệt F5 test thử xem nhé:

Screenshot 2023-12-27 at 11.23.41 PM.png

Ô????😲😲😲, hiện tại trên UI hiển thị là 6 đúng, nhưng khi ta getElementById lại ra 5?????

Ủa ủa? cái gì vậy nhỉ????

Bình tĩnh các bạn ơi 😂😂

Thực tế là bởi vì callback được chạy trước khi Vue thực hiện update component, do vậy tại thời điểm chạy thì ở DOM HTML vẫn là giá trị trước đó (giá trị cũ)

Nếu ta muốn callback được chạy sau khi Vue update component thì ta chỉ cần thêm option { flush: 'post' } vào là được:

watch(count, (current) => {
  const textContent = document.getElementById('count').textContent
  const currentStr = current.toString()
  console.log(textContent === currentStr, 'textContent: ' + textContent, 'current: ' + current)
}, { flush: 'post' })

Quay trở lại trình duyệt F5 và test ta sẽ thấy rằng giá trị khi ta getElementById đã bằng với giá trị hiện tại của count ở trong callback: Screenshot 2023-12-27 at 11.25.57 PM.png

Tương tự với watchEffect:

watchEffect(callback, {
  flush: 'post'
})

Và Vue cũng cung cấp cho ta một function watchPostEffect để tiện cho watchEffect luôn:

import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* executed after Vue updates */
})

Theo trải nghiệm của mình thì ta thường dùng tới flush: 'post' trong một vài trường hợp cần thao tác với DOM khi reactive state thay đổi, ví dụ trong Chat App, sau khi có message mới -> push vào array messages -> scroll to bottom dùng JS API

Dừng watcher

Mặc định thì watcher sẽ ăn theo vòng đời của component, khi component bị destroy thì watcher cũng tiêu luôn. Và thường ta cũng chả quan tâm tới việc này 😂

Nhưng nếu watcher được khai báo async (bất đồng bộ): trong setTimeout, setInterval, Promise, API call....

Thì khi đó Vue sẽ không biết có những watcher nào, bởi vì Vue chỉ biết tới những watcher được khai báo sync (đồng bộ).

Do vậy với các watcher được khai báo async thì ta sẽ phải tự stop chúng nó nếu không ta sẽ có memory leak. Ví dụ:

<script setup>
import { watchEffect } from 'vue'

// ✅ Đồng bộ -> tự stop
watchEffect(() => {})

//  ⛔️ Bất đồng bộ -> ta phải tự stop
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

Và để stop watcher thủ công thì ta làm như sau:

const unwatch = watchEffect(() => {})

// stop thủ công, áp dụng cho tất cả các loại watcher
// watch(), watchEffect(), watchPostEffect(),...
unwatch()

Theo mình thấy thì rất rất rất ít khi và gần như không bao giờ ta cần dùng tới cách này, code production không cẩn thận bị memory leak debug thì khổ 😃

So sánh watcher và computed

Đến bước này thì ta có thể thắc mắc: vậy thay vì dùng watcher để theo dõi sự thay đổi của reactive state thì ta dùng computed property được không nhỉ? bởi vì khi reactive state thay đổi thì compute cũng chạy lại mà.

Ví dụ như sau thì có chạy không nhỉ?:

<script setup>
import { ref, watch, computed } from 'vue'

const count = ref(0)

watch(count, (current) => {
  console.log('Current value of count:', current)
})

const dummyComputed = computed(() => {
  console.log('[Computed]Current value of count:', count.value)
  return count.value * 2
})


function updateCount() {
  count.value++
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Câu trả lời là không 😃, bởi vì hiện tại dummyComputed không được sử dụng ở đâu cả nên nó sẽ không bao giờ chạy, thay vào đó ta phải sử dụng nó ở <template> (show nó ra) hoặc dùng 1 cái watch để watch nó thì nó mới chạy.

Vì vậy ta nên dùng computed và watcher cho đúng trường hợp và đúng mục đích tránh gây nhầm lẫn, khó hiểu cho đồng đội đọc code, và có thể sinh ra bug nhé 😉

Vue 2

Cách sử dụng

Tương tự với computed, để sử dụng watcher thì ta sẽ thêm watch và với mỗi reactive state mà ta muốn watch thì ta sẽ khai báo theo format tên_state() (dạng function)

Ví dụ:

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    updateCount() {
      this.count++
    }
  },
  computed: {
    double() {
      return this.count * 2
    }
  },
  watch: {
    count(newVal, oldVal) {
      console.log(newVal, oldVal)
    },
    double() {
      console.log(this.double)
    }
  }
}
</script>

<template>
  <h1>{{ count }}</h1>
  <button @click="updateCount">
    Update Count
  </button>
</template>

Nếu ta cần watch tổ hợp giá trị của 2 reactive state, ví dụ tổng của count1 và count2 thì ta sẽ dùng 1 cái computed tính tổng sau đó watch cái computed đó nhé

ta có thể khai báo dạng không tham số double() hay có tham số đều được nhé count(newVal, oldVal)

Deep watch, eager watch

Trong trường hợp ta muốn watch một cái object lồng nhiều cấp thì ta cần truyền thêm deep: true vào nhé:

<script>
export default {
  data() {
    return {
      state: {
        a: {
          b: {
            c: {
              d: 1
            }
          }
        }
      }
    }
  },
  methods: {
    updateState() {
      this.state.a = 1
    }
  },
  watch: {
    state: {
      handler: (newVal) => {
        console.log(newVal)
      },
      deep: true
    }
  }
}
</script>

<template>
  <h1>{{ state }}</h1>
  <button @click="updateState">
    Update State
  </button>
</template>

Chú ý rằng với deep: true thì Vue sẽ duyệt qua toàn bộ state, mọi cấp để check xem cái nào đã thay đổi, và với object lớn + phức tạp thì quá trình này có thể sẽ ảnh hưởng tới performance nếu phải chạy đi chạy lại

Tiếp theo, mặc định thì watcher sẽ chạy sau khi reactive state thay đổi, nếu ta muốn watcher chạy ngay 1 cái đầu tiên thì ta dùng immediate: true nhé:

<script>
export default {
  data() {
    return {
      state: {
        a: {
          b: {
            c: {
              d: 1
            }
          }
        }
      }
    }
  },
  methods: {
    updateState() {
      this.state.a = 1
    }
  },
  watch: {
    state: {
      handler: (newVal) => {
        console.log(newVal)
      },
      immediate: true
    }
  }
}
</script>

<template>
  <h1>{{ state }}</h1>
  <button @click="updateState">
    Update State
  </button>
</template>

F5 lại và test ta sẽ thấy rằng ngay từ ban đầu thì watcher đã chạy rồi

Kết bài

Qua bài này hi vọng rằng các bạn đã hiểu được phần nào đó những tác dụng bằng cách sử dụng watcher để có thể theo dõi sự biến đổi của dữ liệu, từ đó áp dụng vào công việc thực tế của chính mình.

Bài tiếp theo chúng ta sẽ cùng tìm hiểu về Conditional Rendering (giống như các toán từ điều kiện if, else, mà chúng ta thường dùng).

Cám ơn các bạn đã theo dõi, có gì thắc mắc các bạn comment bên dưới nhé ^^!


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.