Instant AJAX Search với Laravel và Vuejs

Tính năng tìm kiếm tức thì (instant search) hiện là một tính năng khá phổ biến trong web và app. Trong bài đăng này, mình cố gắng trình bày các điểm chính của việc xây dựng một component tìm kiếm theo thời gian thực có các tính năng như debounce* hoặc highlighting các kết quả.

* : Các sự kiện input từ người dùng (thông qua mouse, keyboard,…) thường diễn ra với tần suất rất nhanh. Điều này khiến hàm xử lý sự kiện được thực thi rất nhiều và có thể ảnh hưởng đến performance cũng như user experience (UX), đặc biệt khi việc input tác động đến giao diện trang web. Để khắc phục điều này, ta hãy tìm hiểu và ứng dụng phương pháp “Debounce” trong javascript. Để hiểu rõ hơn bạn vào link tại đây để xem.

Mở đầu:

Tiến trình cũng như kết quả của 1 process như sau: chúng ta gõ bất kỳ vào input field và dữ liệu được truyền đến server side bằng AJAX request. Sau đó chúng ta bắt được keyword tại back end và load những dữ liệu phù hợp với truy vấn đã cho.

Tại đây như ta biết là nó đang diễn ra cả 1 tiến trình ngầm bên dưới front lẫn back-end. Vì vậy chúng ta nên làm 1 chút gì đó để giúp cho người dùng cảm thấy sự phản hồi tức thì, khi đó họ sẽ không cảm thấy confuse nữa.

Dựng back-end:

Tạo controller cái nha

<?php // SearchController.php

public function search(Request $request)
{
    $posts = Post::where('name', $request->keywords)->get();

    return response()->json($posts);
}

Như bạn thấy, nó thực sự đơn giản, nhưng chúng ta nên lưu ý 2 điều sau:

– Đầu tiên, chúng ta trả về với một JSON response, sau đó front-end sẽ nhận và xử lý chúng. Chúng ta nên xuất dữ liệu thông qua API cho dễ làm việc với front-end

– Điều thứ hai, vì chúng ta sử dụng $request->keywords nên chuỗi truy vấn sẽ trong như thế này ?keywords=Some+search+query.

Kết quả là chúng ta get được một tập những dữ liệu phù hợp, convert sang JSON và có thể xử lý tại front-end. Ở đây mình sẽ không nói quá chi tiết việc build API nha mà chỉ hướng dẫn anh em giải pháp.

Thực hiện tìm kiếm với Vue

Để đơn giản hóa mọi thứ, chúng ta sẽ chỉ có một đầu vào và một danh sách các kết quả. Bước đầu tiên, chúng ta tạo 1 Vue instance*, sau đó chúng ta thêm một số hành động khi giá trị của đầu vào thay đổi. Hãy xem nó trông như thế nào:

<template>
    <div>
        <input type="text" v-model="keywords">
        <ul v-if="results.length > 0">
            <li v-for="result in results" :key="result.id" v-text="result.name"></li>
        </ul>
    </div>
</template>

<script>
export default {
    data() {
        return {
            keywords: null,
            results: []
        };
    },

    watch: {
        keywords(after, before) {
            this.fetch();
        }
    },

    methods: {
        fetch() {
            axios.get('/api/search', { params: { keywords: this.keywords } })
                .then(response => this.results = reponse.data)
                .catch(error => {});
        }
    }
}
</script>

Rồi có những gì xảy ra với đoạn code trên? Đầu tiên, chúng ta có 1 khúc template, nơi mà ta gắn Vue model và lặp những kết quả. Ở khúc script, ta set up data mà mình muốn sử dụng, cũng như định nghĩa phương thức nạp dữ liệu (fetch method) và wrapper bao quanh một axios request.

Khi giá trị của keywords thay đổi, chúng ta khởi tạo phương thức nạp dữ liệu lại, với keywords mới và list kết quả mới.

Note*: Instance trong lập trình chính là thể hiện của một class. Nếu bạn khai báo một class Phim và khi bạn tạo một object phim thì đối tượng đó chính là một thể hiện của class Phim. Vậy Vue instance là gì? Đơn giản chúng ta có thể coi Vue là một class có tên gọi là Vue, để khởi tạo một Instance thì sử dụng cú pháp var app = new Vue({});

Debounce cho v-model

Có 1 problem trong đoạn code trên, đó là chúng ta fetch data ngay tức thì sau khi người gõ 1 ký tự. Thường thì đa số người dùng gõ nhiều từ hoặc đôi khi cả 1 đoạn văn bản, nhưng vậy thì sẽ request quá nhiều lên server. Cách tốt nhất là chúng ta thực thi phương thức nạp dữ liệu sau khi họ dừng gõ.

Có nhiều cách, cách đầu tiên là lazy modifier. Với v-model modifier, chúng ta có thể thay đổi sync event từ input. Có nghĩa là dữ liệu sẽ được update giá trị mới nếu người dùng thoát focus trong trường input (blur event) hoặc nhấn enter.  Có cách khác là implement 1 debouncer cho v-model, tuy nhiên Vue ver 2 đã không hỗ trợ nó rồi. Không hỗ trợ nên chúng ta nếu lưới thì có thể sử dụng thư viện bên thứ ba là lodash’s (_) debounce , nhưng theo quan điểm mình, nên tự làm 1 giải pháp debounce để tránh phụ thuộc vào thư viện hen.

Vừa rồi mình có tìm được 1 giải pháp khá ngon, bạn vào post này và repo này để xem thêm và lấy code về xài thôi.

Để em nó chạy được, bạn phải add .lazy modifier vào model nha, đừng quên!

Rồi, già sử chúng ta đã tích hợp debounce. Bây giờ chúng ta có thể delay bất kỳ thay đổi nào trên Vue model nếu muốn. Tưởng tượng xem trong quá trình người dùng gõ nhiều ký tự và họ tạm dừng sau đó, nếu không có bất kỳ thay đổi nào trong khoảng thời gian nhất định, chúng ta commit trạng thái cuối cùng cho v-model. Watch method sẽ được kích hoạt và fetch dữ liệu mới.Tất nhiên là chúng ta chỉ phải thực hiện nó 1 lần thay vì hàng chục lần.

Rồi mình thử add delay 300ms nhé:

<input type="text" v-model.lazy="keywords" v-debounce="300">

Highlight kết quả

Từ khía cạnh UX, phần này rất quan trọng. Nếu chúng ta có thể làm nổi bật các kết quả phù hợp với từ khóa nhất định thì đó sẽ là một cách hay để giúp người sử dụng tìm thấy những gì mình muốn.

highlight(text) {
    return text.replace(new RegExp(this.keywords, 'gi'), '<span class="highlighted">$&</span>');
}

Tóm lược

Nói chung là cũng không quá khó mà còn mang lại nhiều lợi ích, nó cho cảm giác phản hồi nhanh cho người dùng, giảm thiểu request lên server tránh ngốn resource. Dưới đây là ví dụ đầy đủ nhưng là dữ liệu tĩnh nhé, không có back-end.

Html:

<div id="app">
	<input type="text" v-model.lazy="keywords" v-debounce="500" placeholder="Tìm kiếm (VD: gõ Vue...)">
	<ul v-if="results.length > 0">
		<li v-for="result in results" :key="result.id" v-html="highlight(result.title)"></li>
	</ul>
</div>

Css:

.highlighted { color: red }

JS:

function debounce(fn, delay = 300) {
	var timeoutID = null;

    return function () {
		clearTimeout(timeoutID);

        var args = arguments;
        var that = this;

        timeoutID = setTimeout(function () {
        	fn.apply(that, args);
        }, delay);
    }
};

// this is where we integrate the v-debounce directive!
// We can add it globally (like now) or locally!
Vue.directive('debounce', (el, binding) => {
	if (binding.value !== binding.oldValue) {
		// window.debounce is our global function what we defined at the very top!
		el.oninput = debounce(ev => {
			el.dispatchEvent(new Event('change'));
		}, parseInt(binding.value) || 300);
	}
});

new Vue({
	el: '#app',
	
	data() {
		return {
			keywords: null,
			posts: [
				{id: 1, title: 'Front-end Performance – Where should we start?'},
				{id: 2, title: 'Vue Calendar Component with Laravel API'},
				{id: 3, title: 'Optimise Your Front-end Workflow with Prepros'},
				{id: 4, title: 'Affinity Designer vs. Adobe Illustrator – Which One is Better for You?'},
				{id: 5, title: 'Implementing Laravel’s Authorization on the Front-End'},
				{id: 6, title: 'Using CodePen Can Boost Your Front-end Development Workflow'},
				{id: 7, title: 'Connecting GitLab, Codeship and Laravel Forge'},
				{id: 8, title: 'Dynamic Author Email with Contact Form 7'},
				{id: 9, title: 'Impersonating Users in Laravel'},
				{id: 10, title: 'Introduction to Affinity Designer'},
				{id: 11, title: 'Using Contenteditable Attribute'},
				{id: 12, title: 'Using Laravel’s Localization in JS'},
				{id: 13, title: 'CSS Gradient Basics'},
			]
		}
	},
	
	computed: {
		results() {
			return this.keywords ? this.posts.filter(result => result.title.includes(this.keywords)) : [];
		}
	},
	
	methods: {
		highlight(text) {
			return text.replace(new RegExp(this.keywords, 'gi'), '<span class="highlighted">$&</span>');
		}
	}
})

Thêm cái jsfiddle run xem sao:

Tham khảo thêm vị trí tuyển dụng kỹ sư Laravel lương cao cho bạn