Ngắm và bắn với thuộc tính watch trong Vue instance

2099

Bài viết được sự cho phép của tác giả Kien Dang Chung

Trong lập trình chúng ta thấy các ứng dụng là sự tác động qua lại giữa dữ liệu và các phương thức xử lý dữ liệu, với góc nhìn này chúng ta thấy được lý do tại sao có các thành phần trong Vue instance như data, methods, computed. Từ đây một câu hỏi được đặt ra, còn thành phần nào liên quan đến dữ liệu và phương thức xử lý dữ liệu? Để trả lời câu hỏi này, bạn hãy cùng tôi rà soát các vấn đề cần làm xung quanh dữ liệu:

  10 kinh nghiệm khi làm việc với các dự án lớn viết bằng Vue.js
  3 phút làm quen với Vue.js

Vue instance

  • Dữ liệu và tự bản thân nó thể hiện trong phần data, trong hệ thống reactivity.
  • Tính toán, thao tác với dữ liệu đã có các computed property.
  • Với mỗi dữ liệu, thực hiện các phương thức riêng đã có methods.
  • Sự thay đổi của dữ liệu dẫn đến các thao tác đặc biệt thì xử lý thế nào?

Vấn đề cuối cùng có thể xử lý bằng computed property nhưng đây là các thuộc tính nên nó không có tham số đầu vào, chính vì vậy có một khái niệm nữa là watcher là những “đơn vị giám sát” dữ liệu trong thành phần data của Vue instance và thực hiện những công việc cần thiết để bổ sung công cụ cho chúng ta khi viết ứng dụng.

1. Watcher là gì trong framework Vue.js?

Đúng như tên gọi của nó, watcher giám sát các thay đổi trong một đối tượng, sử dụng watcher có thể có cùng kết quả với các giá trị được tính toán trước trong computed property nhưng với watcher nó phức tạp hơn. Chúng ta thường sử dụng watcher với những tình huống đòi hỏi phải xử lý phức tạp như:

  • Các hoạt động bất đồng bộ đáp ứng lại việc thay đổi dữ liệu
  • Các thiết lập giá trị ngay lập tức
  • Hạn chế số lần thực hiện phương thức khi dữ liệu thay đổi.

Watcher có thể được khai báo trong thành phần watch của Vue instance, cú pháp như sau:

  new Vue({
    el: '#app',
    data: {
      // Dữ liệu gán với view
    },
    watch: {
      // Các hoạt động bất đồng bộ muốn thực hiện khi dữ liệu thay đổi
    }
  });

Trong ví dụ tiếp theo, chúng ta xây dựng một ứng dụng giả lập công cụ tìm kiếm trực tuyến mà mỗi khi gõ vào một chuỗi truy vấn tìm kiếm ngay lập tức có câu trả lời. Hoạt động của ứng dụng này như sau, ứng dụng liên tục kiểm tra xem có sự thay đổi trong ô nhập truy vấn tìm kiếm hay không, nếu có nó sẽ gửi từ khóa đến một API để lấy câu trả lời. Chúng ta sẽ khai báo một phương thức giám sát (watcher) ô tìm kiếm.

<!DOCTYPE html>
<html>
<head>
    <title>Watcher Vue.js - allaravel.com</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body>
    <div class="container" id="app">
        <input class="form-control" type="text" @input="isTyping = true" v-model="searchQuery">
        <p v-if="isTyping">Đang tìm kiếm...</p>
        <div v-else>
            <ul class="list-group">
                <li class="list-group-item" v-for="result in results">{{ result }}</li>
            </ul>
        </div>
    </div>
    <script src="https://unpkg.com/vue@2.5.16/dist/vue.min.js"></script>
    <script type="text/javascript">
        new Vue({
            el: '#app',
            data: {
                searchQuery: '',
                results: [],
                isTyping: false
            },
            watch: {
                searchQuery: function(query) {
                    console.log(query);
                    var vm = this;
                    setTimeout(function() {
                        console.log('setTimeout call');
                        vm.results = ['Vue.js', 'React', 'Angular 2'];
                        vm.isTyping = false;
                    }, 1000);
                } 
            }
        });
    </script>
</body>
</html>

Đoạn mã trên là rất dễ hiểu với một View có một ô nhập liệu được gán dữ liệu hai chiều với một Model là searchQuery thông qua v-model (Xem lại Gán dữ liệu hai chiều với v-model nếu bạn chưa biết về v-model). Trên ô nhập liệu này chúng ta cũng kiểm tra sự kiện nhập liệu để có thông báo lên “Đang tìm kiếm…” thông qua v-on:input và viết tắt thành @input (Xem thêm Quản lý sự kiện với v-on).

    <div class="container" id="app">
        <input class="form-control" type="text" @input="isTyping = true" v-model="searchQuery">
        <p v-if="isTyping">Đang tìm kiếm...</p>
        <div v-else>
            <ul class="list-group">
                <li class="list-group-item" v-for="result in results">{{ result }}</li>
            </ul>
        </div>
    </div>

Kết quả tìm kiếm được hiển thị trong một danh sách được đưa vào thẻ <ul> với directive v-for duyệt qua các kết quả và hiển thị từng kết quả trong thành phần danh sách <li>.

Tiếp đến là phần quan trọng nhất trong đoạn code này, giám sát sự thay đổi của searchQuery và truyền chuỗi truy vấn người dùng nhập vào đến API xử lý.

watch: {
    searchQuery: function(query) {
        var vm = this;
        setTimeout(function() {
            vm.results = ['Vue.js', 'React', 'Angular 2'];
            vm.isSearching = false;
        }, 1000);
    }
}

Ở đây chúng ta đưa vào hàm setTimeout với thời gian chờ là 1 giây để giả lập thời gian xử lý truy vấn từ API, sau 1 giây kết quả trả về là một mảng tên các framework Javascript. Khi sử dụng hàm setTimeout có một chú ý là biến this sẽ trỏ đến đối tượng window chứ không phải trỏ đến Vue instance như chúng ta mong muốn. Như vậy chúng ta cần sao chép Vue instance sang một biến khác để tham chiếu bên trong hàm setTimeout().

Chương trình chạy ok nhưng có một vấn đề xảy ra, có quá nhiều lời gọi đến setTimeout không cần thiết khi mà người dùng gõ quá nhanh. Chúng ta cùng tìm hiểu vấn đề này:

Watcher Vuejs

Có quá nhiều lời gọi setTimeout trong một khoảng thời gian quá ngắn khi mà người dùng đang gõ chuỗi truy vấn, làm thế nào để khắc phục vấn đề này? Đây là một vấn đề rất hay gặp phải khi xử lý các đoạn mã bất đồng bộ. Chúng ta sử dụng đến hàm _.debound() là một hàm trong bộ thư viện lodash, nó cho phép ngừng một thời gian và chỉ gọi phương thức xử lý một lần sau khoảng thời gian đó. Code khi sử dụng lodash như sau:

<!DOCTYPE html>
<html>
<head>
    <title>Watcher Vue.js - allaravel.com</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body>
    <div class="container" id="app">
        <input class="form-control" type="text" @input="isSearching = true" v-model="searchQuery">
        <p v-if="isSearching">Searching...</p>
        <div v-else>
            <ul class="list-group">
                <li class="list-group-item" v-for="result in results">{{ result }}</li>
            </ul>
        </div>
    </div>
    <script src="https://unpkg.com/vue@2.5.16/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.5/lodash.min.js"></script>
    <script type="text/javascript">
        new Vue({
            el: '#app',
            data: {
                searchQuery: '',
                results: [],
                isSearching: false
            },
            watch: {
                searchQuery: _.debounce(function (query) {
                    console.log(query);
                    this.results = ['Vue.js', 'React', 'Angular 2'];
                    this.isSearching = false;
                }, 1000) 
            }
        });
    </script>
</body>
</html>

Watcher Vuejs sử dụng hàm debound trong lodash

Bạn thấy đấy, watcher này vẫn giám sát sự thay đổi của searchQuery nhưng chỉ thực sự xử lý khi phương thức này tạm dừng một giây, tức là khi nào người dùng ngừng gõ 1 giây thì mới gửi chuỗi truy vấn đến API để xử lý. Với cách xử lý này, nó giúp cho hạn chế số lượng lời gọi đến API mà vẫn cho kết quả cuối cùng, các lời gọi khác do xử lý quá nhanh nên chưa kịp cho ra kết quả đã phải xử lý tiếp theo gây dư thừa xử lý trên phía máy chủ cung cấp API.

2. Tham số đầu vào của watcher

Trong ví dụ trên chúng ta thấy watcher cho searchQuery có tham số đầu vào là query chính là chuỗi truy vấn người dùng nhập vào, đây là giá trị mới nhất của searchQuery. Chúng ta cũng có thể lấy lại giá trị trước đó của searchQuery thông qua tham số thứ hai của watcher.

watch: {
    searchQuery: function(newVal, oldVal) {
        console.log(newVal);
        console.log(oldVal);
    }
}

Tham số đầu tiên của watcher là newVal sẽ chứa giá trị mới nhất của thuộc tính đang được “giám sát”, tham số thứ hai oldVal chứa giá trị trước khi có thay đổi của thuộc tính được giám sát. Watcher cũng có thể chỉ có một tham số đầu vào.

watch: {
    searchQuery: function(newVal) {
        console.log(newVal);
    }
}

3. Giám sát thuộc tính được lồng bên trong đối tượng

Đối tượng trong thành phần data của Vue instance có thể rất phức tạp với các thuộc tính được lồng ở cấp sâu hơn, ví dụ:

data: {
    person: {
        name: 'Nguyễn Văn A',
        drivingLicense: {
            id: 'GPLX0393928282',
            issueDate: '20180417',
            issueBy: 'Bộ GTVT'
        }
    }
}

Khi đó nếu chúng ta muốn giám sát thuộc tính issueDate thì sử dụng cú pháp tham chiếu đến thuộc tính thông qua dấu chấm.

watch: {
    'person.drivingLicense.issueDate': function(newVal, oldVal) {
        alert('Giấy phép được gia hạn từ ' + oldVal + ' sang ' + newVal);
    }
}

Nếu bạn không muốn sử dụng cách tham chiếu với dấu chấm và dấu nháy đơn có thể gói nó vào trong một computed property:

computed: {
    licenseIsUpgrade() {
        return this.person.drivingLicense.issueDate;
    }
},
watch: {
    licenseIsUpgrade: function(newVal, oldVal) {
        alert('Giấy phép được gia hạn từ ' + oldVal + ' sang ' + newVal);
    }
}

Trên đây chúng ta mới chỉ giám sát sự thay đổi của các thuộc tính trong một đối tượng, nếu giám sát cả đối tượng thì thế nào? Trong watcher có thể khai báo mức độ giám sát thông qua thuộc tính deep.

watch: {
    'person': {
        handler: function (newVal, oldVal) {
            console.log('Giám sát đối tượng', ' giá trị cũ: ', newVal, ' giá trị cũ:', oldVal)
        },
        deep: true
    }
}

Khi đó bất kể thành phần nào của đối tượng person thay đổi thì watcher đều nhìn thấy. Chú ý, với ví dụ này newVal và oldVal sẽ cùng giá trị bởi vì khi thay đổi đối tượng hoặc mảng, Vue không giữ bản sao của giá trị trước đó. Cả hai tham số sẽ cùng tham chiếu đến cùng một đối tượng hoặc mảng, bởi vậy khi viết code liên quan đến giá trị cũ và mới của đối tượng hoặc mảng chúng ta phải có thêm phần xử lý.

data: {
    person: {
        name: 'Nguyễn Văn A',
        drivingLicense: {
            id: 'GPLX0393928282',
            issueDate: '20180417',
            issueBy: 'Bộ GTVT'
        }
    }
}
computed: {
    clonePerson: function() {
        return JSON.parse(JSON.stringify(this.person));
    }
},
watch: {
    clonePerson: function(newVal, oldVal) {
        alert(JSON.stringify(newVal));
        alert(JSON.stringify(oldVal));
    }
}

4. Thêm hoặc hủy bỏ các watcher trong khi chạy ứng dụng

Khi khai báo các watcher chúng ta khai báo trong Vue Instance, vậy nếu chúng ta muốn thêm các watcher hoặc hủy bỏ chúng đi ở bên ngoài thực thể Vue thì sao? Bạn hoàn toàn yên tâm, Vue instance có biến $watch giúp bạn thực hiện công việc này.

<!DOCTYPE html>
<html>
<head>
    <title>Watcher Vue.js - allaravel.com</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
</head>
<body>
    <div class="container" id="app">
        <p>Counter: {{ counter }}</p>
        <button @click="counter++">Tăng bộ đếm</button>
    </div>
    <script src="https://unpkg.com/vue@2.5.16/dist/vue.min.js"></script>
    <script type="text/javascript">
        var vm = new Vue({
            el: '#app',
            data: {
                counter: 1
            }
        });
        vm.$watch('counter', function(newValue, oldValue) {
            alert('Bộ đếm tăng từ ' + oldValue + ' lên ' + newValue + '!');
        });
    </script>
</body>
</html>

Với watcher được thêm vào ở ngoài Vue instance cũng có tham số để thiết lập xem chúng ta giám sát cả đối tượng hay không?

vm.$watch('person', function(newVal, oldVal) {
    alert('Nhân sự thay đổi từ ' + oldVal.fullName + ' sang ' + newVal.fullName + '!');
}, { deep: true });

Với các watcher phức tạp, có thể thay biểu thức tham chiếu đến một thuộc tính đối tượng thông qua toán tử dấu chấm bằng một hàm trả về biểu thức cần giám sát.

vm.$watch(
    function() {
        return this.counter;
    },
    function(newValue, oldValue) {
        alert('Bộ đếm tăng từ ' + oldValue + ' lên ' + newValue + '!');
    }
);

Chúng ta đã nói đến việc thêm watcher ở ngoài Vue instance, vậy làm cách nào để Vue không giám sát tiếp các thuộc tính trong data? Rất đơn giản, phương thức $watch() trả về một phương thức mà nếu chúng ta gọi đến phương thức này, Vue sẽ không giám sát thuộc tính trong data nữa.

var vm = new Vue({
    el: '#app',
    data: {
        counter: 1
    }
});
var unwatch = vm.$watch(
    function() {
        return this.counter;
    },
    function(newValue, oldValue) {
        alert('Bộ đếm tăng từ ' + oldValue + ' lên ' + newValue + '!');
    }
);
// Sau 10 giây ngừng giám sát thuộc tính counter của data
setTimeout(function() {
    unwatch();
}, 10000);

Sau 10 giây mọi thay đổi của counter sẽ không được giám sát nữa.

5. So sánh computed, watch và methods trong Vue instance

Chúng ta cùng xem xét một ví dụ sau:

var vm = new Vue({
    el: '#welcome',
    data: {
        message: 'Hello',
        name: 'World',
        nameEdits: 0
    },
    computed: {
        welcomeMessage: function () {
            return this.message + ' ' + this.name
        }
    },
    watch: {
        name: function () {
            if (this.message.toLowerCase() === 'reset') {
                this.nameEdits = 0
            } else {
                this.nameEdits += 1 
            }
        }
    },
    methods: {
        numRenders: function () {
            console.log('Page rendered')
        }
    }
})

Đầu tiên chúng ta xem phần computed, welcomeMessage chỉ được gọi khi message hoặc name thay đổi vì nó phụ thuộc vào thuộc tính này. Vì vậy, nếu nameEdits thay đổi, welcomeMessage cũng không được gọi. Thuộc tính Computed giúp ứng dụng hoạt động hiệu quả tối ưu mà vẫn có những phản ứng.

Tiếp theo chúng ta sang phần watch, nó đang giám sát thuộc tính name, có nghĩa là bất cứ khi nào name thay đổi, hàm khai báo sẽ được gọi để cập nhật nameEdits. Chú ý, mỗi watch chỉ giám sát trên một thuộc tính, ở đây là name, do đó khi nameEdits và message thay đổi, hàm này không được kích hoạt.

Cuối cùng, chúng ta xem phần methods, numRender được gọi mỗi khi render xong (cụ thể hơn là được gọi khi các đoạn mã gọi đến hoặc sự kiện người dùng khi giao diện đã được render), vì vậy bất cứ khi nào có gì đó được cập nhật trên giao diện người dùng, numberRenders được gọi. Ví dụ, nếu bạn có một ứng dụng hiển thị đồng hồ và nó cập nhật theo từng giây, tất cả các phương thức sẽ được gọi từng giây bất kể phương thức đó làm gì.

6. Bài tập

Continue

7. Lời kết

Các khái niệm trên đây là rất cơ bản khi làm việc với Vue.js do đó bạn cần nắm rõ nó, có thể bạn chỉ mới hiểu lờ mờ nhưng đừng lo các dự án cụ thể sẽ giúp bạn nắm rõ hơn.

Bài viết gốc được đăng tải tại allaravel.com

Có thể bạn quan tâm:

Xem thêm Việc làm Developer hấp dẫn trên TopDev