Bài viết này bổ sung cho phiên chia sẻ “Svelte, Javascript, và nền tảng Web” tại sự kiện ”Gặp mặt Xuân Giáp Thìn 2024 - Hồ Chí Minh”. Video quay lại sẽ sớm được chia sẻ tại kênh Youtube của Svelte Việt Nam.

Svelte là một framework để xây dựng giao diện người dùng trên nền tảng web, so sánh được với React, Vue, Angular, … Nếu bạn chưa từng nghe về Svelte hay sử dụng nó, mình khuyên bạn nên dành tí thời gian xem qua phần chia sẻ ”Rethinking Reactivity” do Rich Harris, tác giả của Svelte, trình bày. Video này sẽ cho bạn một cái nhìn tổng quát nhất về Svelte và điểm khác biệt nền tảng của nó so với các framework khác.

Trong bài viết này, mình sẽ giới thiệu hai ví dụ cụ thể, thông qua đó có thể phần nào giúp ta so sánh một số điểm khác biệt trong thiết kế API của Svelte so với những framework khác. Mình không có ý định thuyết phục bạn bỏ hết những framework bạn đang dùng và chuyển qua Svelte, nhưng hy vọng sau bài viết này, bạn sẽ đồng ý rằng Svelte là một công nghệ đáng để quan tâm.

Web chính là framework

Hãy tưởng tương rằng bạn là một lập trình viên web chưa biết đến bất cứ framework nào, và nhiệm vụ của bạn là: bắt sự kiện click chuột xảy ra bên ngoài một phần tử:

minh họa: khi nhấn vào vùng ngoài element, thể hiện bằng hình chữ nhật, một sự kiện clickoutside được phát ra
Minh họa 1: sự kiện "clickoutside" - click chuột bên ngoài một phần tử

Tính năng này có ứng dụng khá phổ biến. Hãy nhớ lại lần cuối bạn bắt gặp một cửa số thông báo hay hộp hội thoại tạm thời, khi nhấn vào bên ngoài nó, cửa số sẽ đóng lại: đó chính là “clickoutside”. Vậy, bạn sẽ làm điều này với vanilla Javascript như thế nào? Dưới đây là một ví dụ đơn giản nhất:

export function clickoutside(node) {
  window.addEventLister('click', (event) => {
    if (!node.contains(event.target)) {
      node.dispatchEvent(new CustomEvent('clickoutside'));
    }
  });
}

Chú ý rằng, ta phát ra một CustomEvent với tên là clickoutside khi click chuột xảy ra ngoài phần tử node. CustomEvent là một web API tiêu chuẩn chứ không riêng của framework nào. Ví dụ trên cho phép ta tái sử dụng với bất cứ phần tử HTML nào:

<script type="module">
  import { clickoutside } from 'clickoutside.js';

  // tham chiếu đến phần tử cần thiết
  const node = document.getElementById('my-element');

  // thiết lập sự kiện clickoutside
  clickoutside(node);

  // thao tác khi clickoutside xảy ra
  node.addEventListner('clickoutside', () => { /* ... */ });
</script>

<div id="my-element">...</div>

Khá đơn giản đúng ko nào. Vậy còn đối với framework yêu thích của bạn thì sao? Bạn sẽ thực hiện tính năng này như thế nào? Dưới đây là phiên bản tương ứng của clickoutside trong Svelte:

<script>
  import { clickoutside } from 'clickoutside.js';

  const handleClickOutside = () => { /* ... */ };
</script>

<div
  id="my-element"
  use:clickoutside
  on:clickoutside={handleClickOutside}
>...</div>

Khoan đã… clickoutside.js là gì? clickoutside.js ở đây cũng chính là phiên bản vanilla JS đã thấy trong ví dụ trước. Chú ý cú pháp use:clickoutside: hàm clickoutside của ta được sử dụng như một Svelte action. Khi sử dụng cú pháp này, Svelte sẽ tự động truyền vào action tham số node chính là phần tử HTML mà action được đặt lên. Cuối cùng, vì node được thiết lập để phát ra sự kiện clickoutside, ta chỉ cần gắn sự kiện on:clickoutside với hàm xử lý handleClickOutside.

So sánh với Vue và React

Để so sánh một cách trực quan nhất, sau đây là clickoutside với mã nguồn được viết bằng Svelte từ thư viện @svelte-put/clickoutside, React từ thư viện usehooks-ts, và Vue từ thư viện @vueuse.

Thư viện @svelte-put/clickoutside là do mình viết ra, nếu tham khảo trang documentation của thư viện, bạn sẽ thấy rằng nó cung cấp nhiều tính năng hơn các phiên bản còn lại, với số lượng dòng code tương đương.

// mã nguồn: https://github.com/vnphanquang/svelte-put/blob/9cedde8c33ecce7b1a4058425bf29b6f7a292b91/packages/clickoutside/src/clickoutside.js
export function clickoutside(node, param = { enabled: true }) {
	let { enabled, eventType, nodeForEvent, options, capture } = resolveConfig(param);

	/**
	 * @param {Event} event
	 */
	function handle(event) {
		if (!event.target) return;
		if (node && !node.contains(/** @type {Node} */ (event.target)) && !event.defaultPrevented) {
			node.dispatchEvent(new CustomEvent('clickoutside', { detail: event }));
		}
	}

	if (param.enabled !== false) {
		nodeForEvent.addEventListener(eventType, handle, options);
	}

	return {
		update(update) {
			nodeForEvent.removeEventListener(eventType, handle, capture);
			({ enabled, eventType, nodeForEvent, options, capture } = resolveConfig(update));
			if (enabled) nodeForEvent.addEventListener(eventType, handle, options);
		},
		destroy() {
			nodeForEvent.removeEventListener(eventType, handle, capture);
		},
	};
}

export function resolveConfig(param = {}) {
	return {
		enabled: param.enabled ?? true,
		nodeForEvent: param.limit?.parent ?? document,
		eventType: param.event ?? 'click',
		options: param.options,
		capture: typeof param.options === 'object' ? param.options?.capture : param.options,
	};
}
// mã nguồn: https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useOnClickOutside/useOnClickOutside.ts
import type { RefObject } from 'react'
import { useEventListener } from '../useEventListener'

/** Supported event types. */
type EventType =
  | 'mousedown'
  | 'mouseup'
  | 'touchstart'
  | 'touchend'
  | 'focusin'
  | 'focusout'

export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
  ref: RefObject<T> | RefObject<T>[],
  handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
  eventType: EventType = 'mousedown',
  eventListenerOptions: AddEventListenerOptions = {},
): void {
  useEventListener(
    eventType,
    event => {
      const target = event.target as Node

      // Do nothing if the target is not connected element with document
      if (!target || !target.isConnected) {
        return
      }

      const isOutside = Array.isArray(ref)
        ? ref
            .filter(r => Boolean(r.current))
            .every(r => r.current && !r.current.contains(target))
        : ref.current && !ref.current.contains(target)

      if (isOutside) {
        handler(event)
      }
    },
    undefined,
    eventListenerOptions,
  )
}
// mã nguồn: https://github.com/vueuse/vueuse/blob/1558cd2b5b019abc1feda6d702caa1053a182903/packages/core/onClickOutside/directive.ts
import { directiveHooks } from '@vueuse/shared'
import type { ObjectDirective } from 'vue-demi'
import { onClickOutside } from '.'
import type { OnClickOutsideHandler, OnClickOutsideOptions } from '.'

export const vOnClickOutside: ObjectDirective<
HTMLElement,
OnClickOutsideHandler | [(evt: any) => void, OnClickOutsideOptions]
> = {
  [directiveHooks.mounted](el, binding) {
    const capture = !binding.modifiers.bubble
    if (typeof binding.value === 'function') {
      (el as any).__onClickOutside_stop = onClickOutside(el, binding.value, { capture })
    }
    else {
      const [handler, options] = binding.value
      ;(el as any).__onClickOutside_stop = onClickOutside(el, handler, Object.assign({ capture }, options))
    }
  },
  [directiveHooks.unmounted](el) {
    (el as any).__onClickOutside_stop()
  },
}

// alias
export { vOnClickOutside as VOnClickOutside }

Chú ý rằng phiên bản Svelte là hoàn toàn độc lập, trong khi Vue và React có sử dụng thêm code từ các tệp khác được nhập (import) vào. Về mặc cú pháp, mình sẽ để bạn tự quyết định rằng cái nào phù hợp hơn với phong cách của bạn. Nhưng hy vọng chúng ta đều có thể đồng ý với quan sát sau đây:

clickoutside viết bằng Svelte là vanilla Javascript, nó không phụ thuộc vào bất cứ API nào từ Svelte. Thay vào đó, React và Vue đòi hỏi một lớp abstraction để có thể hoạt động được trong ngữ cảnh của framework.

Vì sao điều này quan trọng? Svelte gỡ bỏ abstraction và giúp ta tiếp cận gần hơn với các cộng nghệ nền tảng, cụ thể là HTML, CSS, Javascript, và các web API tiêu chuẩn. Một lập trình viên mới, nếu có kiến thức nền tảng về web, sẽ dễ dàng tiếp cận và sử dụng Svelte. Một ngày nào đó, khi Svelte không còn nữa (và ngày đó sẽ đến), những kiến thức này sẽ vẫn còn giá trị, vì nó là kiến thức về web, chứ không phải là kiến thức đặc hữu của riêng Svelte.

Nói cách khác, trong ví dụ này, Svelte không đóng vai trò gì to tát lắm, nền tảng web mới chính là framework mà ta cần quan tâm.

Compiler chính là framework

Điểm khác biệt lớn nhất của Svelte là cách nó sử dụng trình biên dịch (compiler) để phân tích mã nguồn tại thời gian biên dịch (compile time) nhằm giảm thiểu mã nguồn tại thời gian trang web hoạt động trong browser (runtime). Svelte không sử dụng virtual DOM như React hay Vue mà đưa mô hình reactivity vào ngay lúc biên dịch. Nếu những gì mình vừa nói nghe lạ lẫm, bạn có thể nhìn một cách tóm tắt như sau:

Svelte thay ta tối ưu hóa mã nguồn trước khi nó được chạy ở máy của người dùng cuối. Kết quả thường thấy là một trang web nhẹ và nhanh hơn!

Tuy nhiên, trong bài viết này, ta sẽ không tập trung vào khía cạnh tối ưu hóa này, mà là việc Svelte cho phép ta mở rộng trình biên dịch để tạo ra những tính năng tùy biến, thông qua một API gọi là Svelte Preprocessor. Nói ngắn gọn, Svelte preprocessor là một hàm số nhận vào mã nguồn Svelte, và trả về một mã nguồn Svelte mới, với thay đổi tùy ý miễn là tại cuối chu trình biên dịch, mã nguồn vẫn hợp lệ với những gì Svelte cho phép.

minh họa: bên trái thể hiện mã nguồn với thẻ fire trong nội dung, sau khi biến đổi thông qua preprocessor, bên phải thể hiện mã nguồn với thẻ fire được thay đổi thành emoji ngọn lửa
Minh họa 2: một Svelte preprocessor biến thẻ "fire" thành emoji tương ứng

Nhiệm vụ lần này của ta là: thiết lập một trang blog tĩnh viết bằng markdown, tượng tự như trang sveltevietnam.dev mà bạn đang đọc. Cụ thể hơn, làm sao để biến đoạn mã markdown dưới đây:

```javascript
/// title=example.js
function hello() {
  console.log('hello world');
}
```

thành giao diện:

function hello() {
  console.log('hello world');
}

Để làm được điều này, mã nguồn sẽ phải đi qua một chuỗi xử lý tương đối phức tạp:

minh họa: chuỗi xử lý MDSvex, ShikiJS, enhance-code-block, và vite để biến markdown thành html giao diện phù hợp
Minh họa 3: quá trình biến đổi mã nguồn markdown thành giao diện hoàn chỉnh trên trang blog
  1. Sử dụng Svelte preprocessor MDSvex để biến đổi markdown thành Svelte component tương ứng.
  2. Sử dụng Shiki để cung cấp syntax highlighting cho mã nguồn.
  3. Sử dụng Svelte preprocessor enhanced-code-block (một preprocessor do mình viết) để cung cấp thêm các tính năng như số dòng code, tiêu đề, nút sao chép, mở rộng màn hình, thu gọn.
  4. Thông qua Vite làm build tool để thể hiện thành html cuối cùng, như trang mà bạn đang đọc.

Điều quan trọng mà ta cần chú ý là: toàn bộ quy trình biến đổi trên được thực hiện tại thời gian biên dịch và thời gian build ra trang web cuối cùng. Có nghĩa là, ta không cần phải tải mã nguồn Svelte preprocessor hay Shiki lên trình duyệt của người dùng cuối. Kết hợp điều vừa rồi với một số kỹ thuật progressive enhancement, hơn 90% nội dung bài viết và các tính năng trên trang đều hoạt động mà không cần Javascript.

Mình sẽ không đi sâu vào chi tiết thực hiện của các Svelte preprocessor đã nêu trong bài viết này. Nếu bạn có hứng thú, hãy tham khảo svelte.config.js - nơi khai báo tất cả các preprocessor giúp xây dựng lên trang Blog của Svelte Việt Nam.

Nhờ có Svelte preprocessor API, công việc trên không chỉ trở nên khả thi, mà còn tương đối dễ dàng, thậm chí là với một lập trình viên có kinh nghiệm trung bình như mình. Nếu yêu cầu phải thực hiện việc tương tự bằng một framework khác, mình không chắc rằng mình có thể tự tin đảm nhận công việc này, hay thậm chí là nó có khả thi hay không.

Chẳng ai quan tâm

Thật ra, chẳng ai thật sự quan tâm đến việc framework này nọ như thế nào. Chúng ta chỉ muốn phát triển ứng dụng một cách hiệu quả nhất, dễ bảo trí nhất, và mang lại trải nghiệm tốt nhất cho người dùng. May mắn là, những người tạo ra Svelte hiểu điều này. Trong bài viết ”Tenets” (tạm dịch: “Các nguyên tắc”), Rich Harris giải thích rằng: Svelte không cố gắng trở thành framework nhanh nhất, nhẹ nhất, nhiều người dùng nhất, hay đạt điểm cao nhất trong các benchmark hay đánh giá của Lighthouse, mà hướng đến trở thành framework truyền cảm nhất (từ gốc: “best vibes”).

Sau hai ví dụ trên, ta quan sát được rằng: nếu bạn hiểu về HTML, CSS, và Javascript, bạn có thể sử dụng Svelte. Tương tự, nếu bạn có kiến thức về khoa học máy tính, về lập trình, bạn có thể mở rộng Svelte để phục vụ cho nhu cầu của bạn, giải quyết những vấn đề ngay cả những người tao ra Svelte cũng không biết đến. Tất cả mọi thứ đều quay trở lại những nền tảng cơ bản - những kiến thức bạn thật sự cần có để tạo ra một ứng dụng web tốt nhất cho người dùng.

Vì vậy, thật sự là, bạn không cần phải quan tâm đến Svelte vì nó là một framework đột phá, xuất sắc nhất, sẽ thay thế tất cả mọi công nghệ như các bài viết trên mạng xã hội thường quảng cáo. Hãy quan tâm đến Svelte vì nó là phương tiện giúp bạn tiếp cận web một cách thật sự dễ dàng.


Bạn tìm thấy lỗi chính tả hay cần đính chính nội dung? Sửa trang này tại Github